diff --git a/.env.example b/.env.example index 14f1e779290e4d374d6a30ec59deeeb0aa8d462a..b86a54713ee2f3b4174755418c35e3f593f69b5a 100644 --- a/.env.example +++ b/.env.example @@ -49,7 +49,7 @@ OPENAI_API_BASE_URL= # ENABLE_RESPONSES_IMAGE_BACKEND=true # OPENAI_RESPONSES_API_MODEL= -# 可选:给网页加一个访问密码。公网部署时建议一定要设置。 +# 可选:给网页加一个访问码。公网部署时建议一定要设置。 APP_PASSWORD= # 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。 diff --git a/.gitignore b/.gitignore index 6b6baa4f2a01c56b9c74b283d9995f62bb3b4f1b..12792ea38b2cd4ff609a8ae2932b3f17997109bb 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ generated-images/ # generated verification artifacts /artifacts/ + +# local agent/tool configuration +.antigravitycli/ diff --git a/AGENTS.md b/AGENTS.md index 6655aa93cfb633daf94ac11c573f9784e004c802..4ca91e689325fbafa0fd39723f98c1360ed40d2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ ```bash npm test npm run lint +npm run lint:scripts npm run build git diff --check ``` @@ -125,6 +126,7 @@ docker compose up -d --build ```bash npm test npm run lint +npm run lint:scripts npm run build git diff --check ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 745e10e8a42e1228a5e4904d9ae0cf371b3b8a37..2593fce471c578cf6fd971067229a9a96a5f1cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ ### 变更 - 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。 -- 图片请求构造、流式响应处理和密码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。 +- 图片请求构造、流式响应处理和访问码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。 - README 与 `.env.example` 补充流式批处理、单 credential 并发上限和渠道失败冷却配置说明。 - ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。 @@ -48,7 +48,7 @@ - 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。 - 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。 - 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。 -- 修正密码弹窗重试只保存表单数据、未保存请求模式和流式参数的问题。 +- 修正访问码弹窗重试只保存表单数据、未保存请求模式和流式参数的问题。 ## [1.2.0] - 2026-05-11 @@ -58,12 +58,12 @@ - 支持 `sticky`、`round_robin`、`random` 三种服务端凭证路由策略。 - 增加渠道解析、路由选择、有效凭证解析的单元测试。 - 增加仓库执行约束文档 `AGENTS.md`。 -- 增加服务端运行时工具测试,覆盖密码哈希校验、请求来源选择、批次 ID 和图片文件名生成。 +- 增加服务端运行时工具测试,覆盖访问码哈希校验、请求来源选择、批次 ID 和图片文件名生成。 - 增加应用日志工具测试,覆盖日志等级规范化、默认等级、无效配置回退和上下文透传。 ### 变更 - 统一本地服务默认使用 `4783` 端口启动。 -- 抽取服务端运行时工具,复用密码校验、输出目录和文件名生成逻辑。 +- 抽取服务端运行时工具,复用访问码校验、输出目录和文件名生成逻辑。 - 将服务端请求路径日志收敛为可配置日志等级,生产环境默认只输出警告和错误。 - 缓存日志等级解析结果,减少热路径重复计算。 diff --git a/README.md b/README.md index f20f1fbdf9b5da79c773fec87025195e62158350..22bf2d734606c9e2385f2efffbd65461df8decd2 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ http://localhost:4783 - 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。 - 发送到编辑:从生成结果或历史记录直接进入编辑模式。 - 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。 -- 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加入口密码。 +- 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加访问码。 - Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。 - 双语和主题:支持中文、英文、亮色、暗色。 - 两种图片存储模式:服务端文件系统或浏览器 IndexedDB。 @@ -199,6 +199,9 @@ Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统 | `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 | | `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 | | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 | +| `POST /api/agent/jobs/images/generate` | 创建文生图 job,适合 4K/high 或长耗时请求。 | +| `GET /api/agent/jobs/{id}` | 轮询 job 状态。 | +| `GET /api/agent/jobs/{id}/result` | 读取完成后的标准图片响应,运行中返回可重试错误。 | | `GET /api/agent/artifacts/{id}` | 查询产物元数据。 | | `GET /api/agent/artifacts/{id}/content` | 下载产物图片内容。 | | `DELETE /api/agent/artifacts/{id}` | 删除产物和元数据。 | @@ -209,6 +212,13 @@ Agent 请求必须带 `Idempotency-Key`,避免超时重试造成重复出图 Authorization: Bearer your-agent-token ``` +`AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token,不会回退到页面访问码哈希。只有未设置 `AGENT_API_TOKEN` 且设置了 `APP_PASSWORD` 时,Agent API 才接受 `X-App-Password-Hash`;实际可用方案以 `/api/agent/capabilities` 的 `auth.schemes` 为准。 + +同一个 `Idempotency-Key` 如果已进入终态 `failed`,再次请求只会回放该失败,不会重新执行。终态失败回放会返回 `retryable=false`,并保留错误码、上游状态和脱敏诊断字段;需要重新尝试时,应创建新的业务操作和新的 `Idempotency-Key`。 + +Job polling 当前是同一 Next.js 服务实例内的后台任务,结果和错误会写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应继续按状态端点和结构化错误处理,必要时用相同 `Idempotency-Key` 重建同一业务操作。 +运行中的 job 会定时刷新请求 lease,避免高质量长耗时上游调用仍在执行时被 recovery 误判为孤儿请求。 + 生成示例: ```bash @@ -294,7 +304,7 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case | `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 `imageBackend=responses` 时必须设置,或在请求中传 `responsesModel`。 | | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 | | `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 | -| `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问密码。 | +| `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问码。 | | `AGENT_API_TOKEN` | 否 | 无 | 设置后,`/api/agent/*` 需要 Bearer token。 | | `AGENT_STATE_BACKEND` | 否 | `sqlite` | Agent 状态后端,可选 `memory`、`sqlite` 或 `postgres`。 | | `AGENT_SQLITE_PATH` | 否 | `generated-images/.agent-state/agent.sqlite` | SQLite 状态库路径。 | @@ -305,7 +315,7 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case | `AGENT_REQUEST_LEASE_MS` | 否 | `600000` | Agent 请求运行锁租约时间。 | | `AGENT_REQUEST_TTL_SECONDS` | 否 | `86400` | 幂等请求记录保留秒数。 | | `AGENT_RECOVERY_INTERVAL_MS` | 否 | `30000` | Agent 请求触发轻量 recovery 的最小间隔。 | -| `AGENT_PUBLIC_BASE_URL` | 否 | `/` | OpenAPI `servers[0].url`,供外部 Agent 生成客户端时使用。 | +| `AGENT_PUBLIC_BASE_URL` | 否 | `/` | OpenAPI `servers[0].url`,供外部 Agent 生成客户端时使用;配置时必须是绝对 `http`/`https` URL,不能包含凭据、查询参数或片段。 | | `APP_LOG_LEVEL` | 否 | 生产环境 `warn`,其他环境 `info` | 服务端日志等级,可选 `debug`、`info`、`warn`、`error`。 | | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 否 | `fs` | 可选 `fs` 或 `indexeddb`。 | @@ -441,9 +451,47 @@ NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb 本仓库也提供 `docker-compose.memory.yml` 作为本地模拟模板;Hugging Face Docker Space 通常直接通过 Space Variables 和 Secrets 设置环境变量,不需要提交 `.env.local`。完整部署步骤见 [Hugging Face Space 免费层部署](./docs/deployment/huggingface-space-free.md)。 -免费 CPU Basic 会在长时间无访问后休眠。本仓库提供 `.github/workflows/hf-space-keepalive.yml`,默认每 6 小时访问一次 `/api/auth-status`,只做只读 keepalive,不携带密码或 token,不触发生图。若 Space 地址变化,在 GitHub 仓库 Variables 中设置 `HF_SPACE_KEEPALIVE_URL`。 +免费 CPU Basic 会在长时间无访问后休眠。本仓库提供 `.github/workflows/hf-space-keepalive.yml`,默认每 6 小时访问一次 `/api/auth-status`,只做只读 keepalive,不携带访问码或 token,不触发生图。若 Space 地址变化,在 GitHub 仓库 Variables 中设置 `HF_SPACE_KEEPALIVE_URL`。 + +全新电脑从 0 开始时,先完成系统级前置条件: + +```bash +node --version +npm --version +hf --help +hf auth login +npm install +``` + +要求 Node.js 20 或更高版本。Hugging Face CLI 安装方式以官方文档为准;当前官方入口是 `hf` 命令,登录使用 Hugging Face Access Token。 + +不同用户首次接手自己的 Space 时,先生成本机访问记录文件: + +```bash +npm run init-access:hf-space -- \ + --space-id / \ + --space-url https://-.hf.space +``` + +脚本会写入 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,生成访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`,并记录 `HF_SPACE_ID`、`HF_SPACE_URL` 和默认同步 key。脚本不会打印 Secret 值,且默认不覆盖已有文件。 +`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。 + +创建本机访问记录文件不需要 Hugging Face 账号密码。同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token: + +```bash +hf auth whoami +hf auth login +``` + +如果不确定当前机器缺什么,先运行只读诊断: + +```bash +npm run doctor:hf-space +``` + +该命令会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置;不会写远端 Secret、不会重启 Space、不会打印 Secret 值。 -如果修改了本机访问记录文件里的 `APP_PASSWORD`,可执行 `npm run sync-secret:hf-space` 同步到 Hugging Face Space Secret、重启并验证新密码。脚本默认读取 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,不会回显 Secret 值。 +如果修改了本机访问记录文件里的 `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 值。 `memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。 @@ -494,10 +542,13 @@ docker logs -f gpt-image-playground-customer | `npm run dev` | 启动本地开发服务。 | | `npm run build` | 执行生产构建。 | | `npm run start` | 启动生产模式服务。 | +| `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、本机 access 文件和远端配置。 | +| `npm run init-access:hf-space` | 为当前用户生成本机 HF Space 访问记录、随机访问码和 Agent token。 | | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 | -| `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret,并验证页面密码。 | +| `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret,并验证页面访问码。 | | `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约。 | | `npm run lint` | 检查 `src/` 代码。 | +| `npm run lint:scripts` | 跨平台检查仓库脚本和 skill 脚本语法。 | | `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 | ## 常见问题 diff --git a/docs/deployment/huggingface-space-free.md b/docs/deployment/huggingface-space-free.md index 5a6ade83760bf5f2b35065aa40f3993836c113f9..e9392fff62bbd5c0b5953c1cca3ba89fe91a11f6 100644 --- a/docs/deployment/huggingface-space-free.md +++ b/docs/deployment/huggingface-space-free.md @@ -24,6 +24,44 @@ app_port: 4783 - Docker Space 配置、Variables/Secrets 和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker - 免费 CPU Basic 规格说明:https://huggingface.co/docs/hub/main/spaces-gpus +- Hugging Face CLI 安装和登录说明:https://huggingface.co/docs/huggingface_hub/en/guides/cli + +## 全新电脑前置条件 + +全新用户、全新电脑需要先准备系统级工具。没有 Node.js 和 npm 时,仓库内 npm 脚本无法运行;没有 HF CLI 登录时,脚本无法把 Secret 写到远端 Space。 + +先检查: + +```bash +node --version +npm --version +hf --help +hf auth whoami +``` + +要求: + +- Node.js 20 或更高版本。 +- npm 随 Node.js 一起可用。 +- Hugging Face CLI 使用当前官方 `hf` 命令。 +- `hf auth login` 使用 Hugging Face Access Token,不是账号密码。 +- Docker 只对 `npm run smoke:hf-space` 和本地容器验证必需;只创建 txt 文件和同步 Secret 不需要 Docker。 + +安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。 + +第一次拉取仓库后安装依赖: + +```bash +npm install +``` + +如果不确定当前机器缺什么,运行只读诊断: + +```bash +npm run doctor:hf-space +``` + +`doctor:hf-space` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。 ## Space Variables @@ -31,23 +69,30 @@ app_port: 4783 ```dotenv AGENT_STATE_BACKEND=memory -AGENT_SQLITE_PATH= -AGENT_DATABASE_URL= -AGENT_DB_PASSWORD= -AGENT_DB_PASSWORD_FILE= NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb APP_LOG_LEVEL=warn ``` `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明 build arg,Hugging Face Docker Space 会把同名 Variable 作为 build arg 传入构建,并在运行期注入环境变量。 +如果不使用 `memory`,再按实际状态后端追加可选变量: + +```dotenv +AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite +AGENT_DATABASE_URL=postgres://... +AGENT_DB_PASSWORD= +AGENT_DB_PASSWORD_FILE=/path/to/password-file +``` + +`AGENT_DATABASE_URL`、`AGENT_DB_PASSWORD` 和 `AGENT_DB_PASSWORD_FILE` 是 PostgreSQL 配置路径,不需要在 `memory` 模式下设置为空值。 + 可选: ```dotenv AGENT_PUBLIC_BASE_URL=https://-.hf.space ``` -`AGENT_PUBLIC_BASE_URL` 只影响 OpenAPI `servers[0].url`,Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。 +`AGENT_PUBLIC_BASE_URL` 只影响 OpenAPI `servers[0].url`,必须填写绝对 `http`/`https` URL,不能包含凭据、查询参数或片段;Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。 ## Space Secrets @@ -56,11 +101,11 @@ AGENT_PUBLIC_BASE_URL=https://-.hf.space ```dotenv OPENAI_API_KEY= OPENAI_API_BASE_URL=https://api.openai.com/v1 -APP_PASSWORD= +APP_PASSWORD= AGENT_API_TOKEN= ``` -公网部署建议至少设置 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。 +公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。 如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets: @@ -74,7 +119,7 @@ OPENAI_CHANNEL_1_API_KEYS=, ## 手机网页使用 1. 打开 Space 地址,例如 `https://-.hf.space`。 -2. 如果配置了 `APP_PASSWORD`,输入页面访问密码。 +2. 如果配置了 `APP_PASSWORD`,输入页面访问码。 3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。 4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。 @@ -95,6 +140,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs GPT_IMAGE_PLAYGROUND_URL=https://-.hf.space \ GPT_IMAGE_AGENT_TOKEN= \ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ + --allow-billable \ "a product photo of a ceramic mug on a wooden table" ``` @@ -149,9 +195,77 @@ npm run keepalive:hf-space 注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。 -## 同步本机访问密码到 Space +## 初始化本机访问记录 + +不同用户首次接手自己的 Space 时,先在本机生成访问记录文件。该文件保存在用户 home 目录下,不应提交到仓库: + +```bash +npm run init-access:hf-space -- \ + --space-id / \ + --space-url https://-.hf.space +``` + +默认写入: + +```text +~/.cache/gpt-image-playground-customer/hf-space-access.txt +``` + +文件会包含: + +```dotenv +HF_SPACE_ID=/ +HF_SPACE_URL=https://-.hf.space +HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN +APP_PASSWORD= +AGENT_API_TOKEN= +``` + +`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。 + +脚本不会在输出中回显 `APP_PASSWORD` 或 `AGENT_API_TOKEN`。如果文件已存在,默认拒绝覆盖;确认要重置时使用: + +```bash +npm run init-access:hf-space -- \ + --space-id / \ + --space-url https://-.hf.space \ + --force +``` + +创建这个 txt 文件不需要 Hugging Face 账号密码。它只保存本项目的访问码、Agent token 和 Space 目标信息。 + +同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token。先检查登录状态: + +```bash +hf auth whoami +``` + +如果未登录,执行: + +```bash +hf auth login +``` + +`hf auth login` 使用的是 Hugging Face Access Token,不是账号密码。不要把 HF 账号密码或 HF Access Token 写入 `hf-space-access.txt`。 + +生成后可先做本机只读诊断,不写远端: + +```bash +npm run doctor:hf-space -- --skip-remote +``` + +如果诊断提示 access 文件缺少 `HF_SPACE_ID`、`HF_SPACE_URL` 或 `HF_SPACE_SECRET_KEYS`,说明本机可能已有旧格式文件。可手工补齐这些字段,或确认重置后重新生成: -如果本机访问记录文件里的 `APP_PASSWORD` 已更新,可以用脚本同步到 HF Space Secret、重启服务并验证新密码: +```bash +npm run init-access:hf-space -- \ + --space-id / \ + --space-url https://-.hf.space \ + --force +``` + +## 同步本机访问码到 Space + +如果本机访问记录文件里的 `APP_PASSWORD` 已更新,可以用脚本同步到 HF Space Secret、重启服务并验证新访问码: ```bash npm run sync-secret:hf-space @@ -163,7 +277,7 @@ npm run sync-secret:hf-space ~/.cache/gpt-image-playground-customer/hf-space-access.txt ``` -默认同步 `APP_PASSWORD`,不会在输出中回显密码值。可通过环境变量覆盖目标或同步多个 key: +由 `init-access:hf-space` 生成的文件会让同步脚本同时同步 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。旧格式文件默认只同步 `APP_PASSWORD`,不会在输出中回显访问码值。可通过环境变量覆盖目标或同步多个 key: ```bash HF_SPACE_ID=misonL/gpt-image-playground-customer \ @@ -173,10 +287,17 @@ HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN \ npm run sync-secret:hf-space ``` +同步脚本默认要求 `HF_SPACE_ID` 和 `HF_SPACE_URL` 来自 access 文件或环境变量,避免不同用户误写到仓库示例 Space。只有维护默认示例 Space 时才使用: + +```bash +npm run sync-secret:hf-space -- --use-default-target +``` + 可选参数: - `npm run sync-secret:hf-space -- --no-restart`:只写 Secret,不重启 Space。 -- `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify` 密码验证。 +- `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify` 访问码验证。 +- `npm run sync-secret:hf-space -- --use-default-target`:允许使用脚本内置默认 Space 目标。 ## 验证门禁 @@ -185,6 +306,7 @@ npm run sync-secret:hf-space ```bash npm test npm run lint +npm run lint:scripts npm run build npm run keepalive:hf-space npm run smoke:hf-space diff --git a/docs/superpowers/plans/2026-05-15-image-download-share.md b/docs/superpowers/plans/2026-05-15-image-download-share.md index 8b70de639a3077b51f87eeab085935eba2a0348e..74481eeb6f88d7c090dd31d34a4ba8844806b339 100644 --- a/docs/superpowers/plans/2026-05-15-image-download-share.md +++ b/docs/superpowers/plans/2026-05-15-image-download-share.md @@ -25,10 +25,10 @@ - 最终验证运行 `npm test`、`npm run lint`、`npm run build`、`git diff --check`;如果本分支完成实现,还要执行 Docker 冒烟验证。 **护栏指标:** -- 不得把受密码保护的 `/api/image/{filename}` 直接暴露为分享机制。 -- 不得把 API Key、密码、访问码或原始提示词写入分享 URL。 +- 不得把受访问码保护的 `/api/image/{filename}` 直接暴露为分享机制。 +- 不得把 API Key、访问码或原始提示词写入分享 URL。 - 不得破坏 Agent 产物内容路由。 -- 不得破坏 `http://localhost:4783` 下无密码本地部署的既有行为。 +- 不得破坏 `http://localhost:4783` 下无访问码本地部署的既有行为。 - 不得静默回退到 mock 图片或伪造分享成功。 **采样计划:** @@ -49,7 +49,7 @@ **约束:** - 当前 `AGENTS.md` 要求中文沟通、基于事实下结论、禁止静默降级,并执行最小充分验证。 -- 当前脏工作区包含密码和 cookie 适配相关改动。不要回滚或覆盖无关的用户改动或前序代理改动。 +- 当前脏工作区包含访问码和 cookie 适配相关改动。不要回滚或覆盖无关的用户改动或前序代理改动。 - 保持当前 `node:test` 布局;不要引入第二套测试框架。 - 代码和文档尽量使用 ASCII;不使用 Emoji 或装饰性 Unicode。 @@ -61,7 +61,7 @@ **耦合说明:** - 分享创建依赖发送到编辑所使用的同一图片字节读取路径:IndexedDB blob 或 `/api/image/{filename}`。 -- 分享内容必须独立于页面密码 cookie;否则外部接收者无法访问有效分享。 +- 分享内容必须独立于页面访问码 cookie;否则外部接收者无法访问有效分享。 - 下载是纯浏览器行为,不应要求新增服务端状态。 - 分享 token 必须由 `crypto.randomBytes` 生成,不得使用 `Math.random`、时间戳或可预测输入派生。 - 访问码为空或纯空白时必须按“无访问码分享”处理;非空访问码必须满足最小长度,避免弱访问码被误认为受保护分享。 @@ -102,7 +102,7 @@ | --- | --- | | 复杂性原位置 | 用户目前依赖受保护图片 URL 或本地浏览器 blob 做临时下载/分享。 | | 新位置 | 分享产物移动到 `generated-images/.shares`,包含元数据、复制字节、访问码哈希和有效期。 | -| 收益 | 外部分享访问不再依赖页面密码 cookie 或浏览器本地 IndexedDB 状态。 | +| 收益 | 外部分享访问不再依赖页面访问码 cookie 或浏览器本地 IndexedDB 状态。 | | 新成本 | 后续必须考虑分享清理和生命周期;分享元数据成为新的文件系统状态面。 | | 失效模式 | 在后续新增清理任务前,孤立分享文件或过期分享可能持续积累。 | @@ -112,7 +112,7 @@ - `src/lib/share-store.ts` 已实现分享元数据、复制内容、访问码哈希、有效期和路径限制。 - `src/lib/share-store.test.ts` 已覆盖存储模块,包括受保护/公开分享、有效期、不安全 token、当前工作目录和内容路径限制。 - `src/lib/server-runtime.ts` 已导出 `createAccessToken(serverPassword)` 和 `verifyAccessToken(clientAccessToken, serverPassword)`。 -- `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面密码错误码。 +- `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面访问码错误码。 - `src/components/image-output.tsx` 当前导入 `Grid`、`Loader2`、`Send`、`Terminal` 和 `Trash2`;没有下载/分享图标或 props。 - `src/components/image-output.tsx` 的动作行当前只渲染轮播控制、日志和发送到编辑。 - `src/components/image-output.tsx` 当前已有 `isSingleImageView`,定义为 `typeof viewMode === 'number'`。 @@ -162,7 +162,7 @@ | --- | --- | --- | --- | | 使用浏览器 blob URL 添加下载按钮 | 用户可以保存已选图片 | 提升本地导出可用性 | blob 来源错误可能下载到过期或缺失图片 | | 添加分享创建 API | 用户可以创建分享 URL | 提升外部分享能力 | 如果认证绕过不当,可能暴露受保护图片字节 | -| 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面密码 cookie | +| 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面访问码 cookie | | 添加访问码和有效期检查 | 无效接收者无法查看字节 | 降低未授权暴露 | 如果元数据和内容结果不一致,UI 可能变得困惑 | ## 状态模型 @@ -231,7 +231,7 @@ afterEach(async () => { function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) { const headers = new Headers(); - const accessToken = options.accessToken === undefined ? createAccessToken(['customer', 'password'].join('-')) : options.accessToken; + const accessToken = options.accessToken === undefined ? createAccessToken(['customer', 'access', 'code'].join('-')) : options.accessToken; if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`); return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form }); } @@ -239,7 +239,7 @@ function createShareRequest(form: FormData, options: { accessToken?: string | nu describe('POST /api/shares', () => { it('creates a share from an uploaded image without returning secrets', async () => { await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'password'].join('-'); + process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); const form = new FormData(); form.set('sourceFilename', 'result.png'); form.set('accessCode', '12345678'); @@ -258,9 +258,9 @@ describe('POST /api/shares', () => { assert.equal('accessCodeSalt' in body, false); }); - it('rejects unauthenticated share creation when a page password is configured', async () => { + it('rejects unauthenticated share creation when a page access code is configured', async () => { await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'password'].join('-'); + process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); const form = new FormData(); form.set('sourceFilename', 'result.png'); form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); @@ -273,7 +273,7 @@ describe('POST /api/shares', () => { it('rejects share creation with an invalid page access token', async () => { await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'password'].join('-'); + process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); const form = new FormData(); form.set('sourceFilename', 'result.png'); form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); @@ -284,7 +284,7 @@ describe('POST /api/shares', () => { assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid); }); - it('allows share creation when no page password is configured', async () => { + it('allows share creation when no page access code is configured', async () => { await withTempCwd(); delete process.env.APP_PASSWORD; const form = new FormData(); @@ -297,7 +297,7 @@ describe('POST /api/shares', () => { it('treats blank access codes as public shares', async () => { await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'password'].join('-'); + process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); const form = new FormData(); form.set('sourceFilename', 'result.png'); form.set('accessCode', ' '); @@ -311,7 +311,7 @@ describe('POST /api/shares', () => { it('rejects short access codes', async () => { await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'password'].join('-'); + process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); const form = new FormData(); form.set('sourceFilename', 'result.png'); form.set('accessCode', '1234567'); @@ -325,7 +325,7 @@ describe('POST /api/shares', () => { it('rejects missing image uploads', async () => { await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'password'].join('-'); + process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); const form = new FormData(); form.set('sourceFilename', 'result.png'); @@ -337,7 +337,7 @@ describe('POST /api/shares', () => { it('rejects invalid expiry values', async () => { await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'password'].join('-'); + process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); const form = new FormData(); form.set('sourceFilename', 'result.png'); form.set('expiresInMinutes', '-1'); @@ -1338,7 +1338,7 @@ onShareImage={handleOpenShareImage} 'share.copyLink': '复制分享链接', 'share.create': '创建分享', 'share.createFailed': '创建分享失败。', -'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入密码后再试。', +'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。', ``` 英文: @@ -1359,7 +1359,7 @@ onShareImage={handleOpenShareImage} 'share.copyLink': 'Copy share link', 'share.create': 'Create Share', 'share.createFailed': 'Failed to create share.', -'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the password again and retry.', +'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.', ``` - [x] **步骤 5:运行前端验证** diff --git a/package.json b/package.json index ea3269e4a147a454eb0134467621033fc322aeef..aa977a3e4be6eb75995412381c204403e13b56cf 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,17 @@ "prebuild": "node scripts/clean-standalone.mjs", "build": "next build", "postbuild": "node scripts/patch-standalone-runtime.mjs", - "test": "node --test --import tsx \"src/**/*.test.ts\"", + "test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"", + "test:scripts": "node --test \"scripts/**/*.test.mjs\"", "test:postgres": "node scripts/test-postgres-live.mjs", + "doctor:hf-space": "node scripts/doctor-hf-space.mjs", + "init-access:hf-space": "node scripts/init-hf-space-access.mjs", "keepalive:hf-space": "node scripts/keepalive-hf-space.mjs", "sync-secret:hf-space": "node scripts/sync-hf-space-secret.mjs", "smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs", "start": "node scripts/start-standalone.mjs", "lint": "eslint src", + "lint:scripts": "node scripts/check-node-syntax.mjs", "format": "prettier --write \"src/**/*.{ts,tsx}\"" }, "dependencies": { diff --git a/scripts/agent-skill-scripts.test.mjs b/scripts/agent-skill-scripts.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..f299a2683e2c137df18f8b57ceeaceb04511d5a8 --- /dev/null +++ b/scripts/agent-skill-scripts.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createServer } from 'node:http'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { parseRetryAfterValue, resolveSameOriginUrl } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts'); + +describe('Agent skill script argument validation', () => { + it('rejects invalid generate numeric options before dry-run output', () => { + const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']); + + assert.equal(result.status, 2); + assert.match(result.stderr, /--n 必须是正整数/); + assert.equal(result.stdout.trim(), ''); + }); + + it('rejects invalid edit timeout before reading the image path', () => { + const result = runSkillScript('edit-image.mjs', ['--timeout-ms', 'abc', '/tmp/missing.png', 'prompt']); + + assert.equal(result.status, 2); + assert.match(result.stderr, /--timeout-ms 必须是正整数/); + assert.equal(result.stdout.trim(), ''); + }); + + it('rejects invalid upstream probe timeout before network checks', () => { + const result = runSkillScript('probe-upstream-image.mjs', ['--timeout-ms', 'abc']); + + assert.equal(result.status, 2); + assert.match(result.stderr, /--timeout-ms 必须是正整数/); + assert.equal(result.stdout.trim(), ''); + }); + + it('rejects invalid retry attempt env values', () => { + const result = runSkillScript('generate-image.mjs', ['prompt'], { + GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc' + }); + + assert.equal(result.status, 2); + assert.match(result.stderr, /GPT_IMAGE_AGENT_MAX_ATTEMPTS 必须是正整数/); + assert.equal(result.stdout.trim(), ''); + }); + + it('rejects service base URLs with embedded credentials before dry-run output', () => { + const result = runSkillScript('generate-image.mjs', ['prompt'], { + GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test' + }); + + assert.equal(result.status, 2); + assert.match(result.stderr, /base URL/); + assert.match(result.stderr, /不能包含凭据/); + assert.doesNotMatch(result.stderr, /secret/); + assert.equal(result.stdout.trim(), ''); + }); + + it('rejects upstream probe base URLs with embedded credentials before network checks', () => { + const result = runSkillScript('probe-upstream-image.mjs', ['--base-url', 'https://user:secret@example.test/v1']); + + assert.equal(result.status, 2); + assert.match(result.stderr, /base URL/); + assert.match(result.stderr, /不能包含凭据/); + assert.doesNotMatch(result.stderr, /secret/); + assert.equal(result.stdout.trim(), ''); + }); + + it('does not read prompt files during default generate dry-run', () => { + const result = runSkillScript('generate-image.mjs', ['--prompt-file', '/tmp/missing-agent-prompt.txt']); + + assert.equal(result.status, 0); + const body = JSON.parse(result.stdout); + assert.equal(body.dry_run, true); + assert.equal(body.billable, false); + assert.equal(result.stderr.trim(), ''); + }); + + it('shows edit help without validating unrelated env values', () => { + const result = runSkillScript('edit-image.mjs', ['--help'], { + GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc' + }); + + assert.equal(result.status, 0); + assert.match(result.stderr, /用法:edit-image\.mjs/); + assert.equal(result.stdout.trim(), ''); + }); + + it('shows skill script help without validating service URL env values', () => { + const env = { GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test' }; + const generateHelp = runSkillScript('generate-image.mjs', ['--help'], env); + const editHelp = runSkillScript('edit-image.mjs', ['--help'], env); + + assert.equal(generateHelp.status, 0); + assert.match(generateHelp.stderr, /用法:generate-image\.mjs/); + assert.equal(generateHelp.stdout.trim(), ''); + assert.equal(editHelp.status, 0); + assert.match(editHelp.stderr, /用法:edit-image\.mjs/); + assert.equal(editHelp.stdout.trim(), ''); + }); + + it('rejects cross-origin job result URLs before sending auth headers', () => { + assert.throws( + () => resolveSameOriginUrl('https://space.example.test', 'https://evil.example.test/result', 'job.result_url'), + /不同 origin/ + ); + assert.equal( + resolveSameOriginUrl('https://space.example.test', '/api/agent/jobs/abc/result', 'job.result_url'), + 'https://space.example.test/api/agent/jobs/abc/result' + ); + }); + + it('caps retry-after values before sleeping', () => { + assert.equal(parseRetryAfterValue('5'), 5); + assert.equal(parseRetryAfterValue('0'), 1); + assert.equal(parseRetryAfterValue('999999999999999999999'), 60); + assert.equal(parseRetryAfterValue('not-a-number', 7), 7); + }); + + it('preserves non-JSON capabilities status and body in generate failures', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(503, { 'content-type': 'text/plain' }); + response.end('maintenance window'); + return; + } + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end('missing'); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync('generate-image.mjs', ['--allow-billable', 'prompt'], { + GPT_IMAGE_PLAYGROUND_URL: baseUrl + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /capabilities 请求失败,状态码 503:maintenance window/); + } + ); + }); +}); + +function runSkillScript(filename, args, env = {}) { + return spawnSync(process.execPath, [join(skillScriptsRoot, filename), ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, ...env } + }); +} + +function runSkillScriptAsync(filename, args, env = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('close', (status) => { + resolve({ status, stdout, stderr }); + }); + }); +} + +async function withServer(handler, run) { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + try { + assert.equal(typeof address, 'object'); + assert.ok(address); + await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} diff --git a/scripts/check-node-syntax.mjs b/scripts/check-node-syntax.mjs new file mode 100644 index 0000000000000000000000000000000000000000..eddf7106d77f801338ecf09aacb236c2f6beea79 --- /dev/null +++ b/scripts/check-node-syntax.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const CHECK_DIRS = ['scripts', 'skills']; + +function listMjsFiles() { + return CHECK_DIRS.flatMap((dir) => listMjsFilesUnder(join(REPO_ROOT, dir))).sort((a, b) => a.label.localeCompare(b.label)); +} + +function listMjsFilesUnder(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return listMjsFilesUnder(path); + if (!entry.isFile() || !entry.name.endsWith('.mjs')) return []; + return { + path, + label: relative(REPO_ROOT, path).replaceAll('\\', '/') + }; + }); +} + +let failed = false; +for (const file of listMjsFiles()) { + const result = spawnSync(process.execPath, ['--check', file.path], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + if (result.status === 0) continue; + failed = true; + const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + console.error(output || `Syntax check failed: ${file.label}`); +} + +if (failed) process.exit(1); diff --git a/scripts/doctor-hf-space.mjs b/scripts/doctor-hf-space.mjs new file mode 100644 index 0000000000000000000000000000000000000000..8be964c0586297a51c7bccee61b1a7e1440fd924 --- /dev/null +++ b/scripts/doctor-hf-space.mjs @@ -0,0 +1,299 @@ +#!/usr/bin/env node + +import { existsSync, statSync } from 'node:fs'; +import { + assertKnownOptions, + buildNextActions, + classifyRequiredAndRecommendedNames, + DEFAULT_ACCESS_FILE, + getJsonNames, + isMainModule, + missingKeys, + parseAccessFile, + readEnvValue, + readOptionValue, + runCommand, + validateSpaceId, + validateSpaceUrl +} from './hf-space-doctor-utils.mjs'; + +const MIN_NODE_MAJOR = 20; +const REQUIRED_ACCESS_KEYS = ['HF_SPACE_ID', 'HF_SPACE_URL', 'HF_SPACE_SECRET_KEYS', 'APP_PASSWORD', 'AGENT_API_TOKEN']; +const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD']; +const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE']; +const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL']; +const REQUIRED_SPACE_SECRETS = ['APP_PASSWORD', 'AGENT_API_TOKEN']; +const OPTIONAL_GENERATION_SECRETS = ['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS']; + +function parseArgs(argv) { + assertKnownOptions(argv, ['--access-file', '--help', '-h', '--skip-remote']); + return { + accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE, + help: argv.includes('--help') || argv.includes('-h'), + skipRemote: argv.includes('--skip-remote') + }; +} + +function printHelp() { + console.log(`Usage: + npm run doctor:hf-space + +Options: + --access-file Override the access file path. + --skip-remote Skip read-only Hugging Face remote checks. + --help Show this help. + +Environment overrides: + HF_SPACE_ACCESS_FILE`); +} + +function addCheck(checks, status, name, message, details = {}) { + checks.push({ status, name, message, ...details }); +} + +function checkNode(checks) { + const major = Number.parseInt(process.versions.node.split('.')[0], 10); + if (major >= MIN_NODE_MAJOR) { + addCheck(checks, 'pass', 'node', `Node.js ${process.version} is supported.`); + return; + } + addCheck(checks, 'fail', 'node', `Node.js ${process.version} is too old. Install Node.js ${MIN_NODE_MAJOR} or newer.`); +} + +function checkCommand(checks, name, command, args, failureAction) { + const result = runCommand(command, args); + if (result.ok) { + addCheck(checks, 'pass', name, `${command} is available.`, { version: result.stdout.split(/\r?\n/)[0] }); + return true; + } + addCheck(checks, 'fail', name, failureAction, { error: result.error }); + return false; +} + +function checkAccessFile(checks, accessFile) { + if (!existsSync(accessFile)) { + addCheck(checks, 'fail', 'access-file', `Access file is missing: ${accessFile}`, { + action: 'Run npm run init-access:hf-space -- --space-id / --space-url https://-.hf.space' + }); + return undefined; + } + + let values; + try { + values = parseAccessFile(accessFile); + } catch (error) { + addCheck(checks, 'fail', 'access-file', 'Access file cannot be read.', { + error: error instanceof Error ? error.message : String(error) + }); + return undefined; + } + + addCheck(checks, 'pass', 'access-file', `Access file exists: ${accessFile}`); + + if (process.platform !== 'win32') { + const mode = statSync(accessFile).mode & 0o777; + if ((mode & 0o077) === 0) { + addCheck(checks, 'pass', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}.`); + } else { + addCheck(checks, 'fail', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}; expected 600.`, { + action: `chmod 600 ${accessFile}` + }); + } + } + + const missing = missingKeys(REQUIRED_ACCESS_KEYS, values); + if (missing.length) { + addCheck(checks, 'fail', 'access-file-keys', `Access file is missing required keys: ${missing.join(', ')}.`); + } else { + addCheck(checks, 'pass', 'access-file-keys', 'Access file contains all required non-empty keys.'); + } + + const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => values.has(key)); + if (forbidden.length) { + addCheck(checks, 'fail', 'access-file-forbidden-keys', `Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`); + } else { + addCheck(checks, 'pass', 'access-file-forbidden-keys', 'Access file does not contain Hugging Face account credentials.'); + } + + validateAccessValues(checks, values); + return values; +} + +function validateAccessValues(checks, values) { + const spaceId = values.get('HF_SPACE_ID')?.trim(); + const spaceIdError = validateSpaceId(spaceId); + if (!spaceIdError) { + addCheck(checks, 'pass', 'space-id', 'HF_SPACE_ID has namespace/space format.'); + } else if (spaceId) { + addCheck(checks, 'fail', 'space-id', spaceIdError); + } + + const spaceUrl = values.get('HF_SPACE_URL')?.trim(); + if (spaceUrl) { + const spaceUrlError = validateSpaceUrl(spaceUrl); + if (spaceUrlError) { + addCheck(checks, 'fail', 'space-url', spaceUrlError); + } else { + addCheck(checks, 'pass', 'space-url', 'HF_SPACE_URL looks like a Hugging Face Space URL.'); + } + } + + const appPassword = values.get('APP_PASSWORD') || ''; + const agentToken = values.get('AGENT_API_TOKEN') || ''; + if (appPassword.length >= 16 && agentToken.length >= 24) { + addCheck( + checks, + 'pass', + 'generated-secrets', + 'APP_PASSWORD access code and AGENT_API_TOKEN meet the minimum length checks.' + ); + } else { + addCheck( + checks, + 'fail', + 'generated-secrets', + 'APP_PASSWORD access code must be at least 16 chars and AGENT_API_TOKEN at least 24 chars.' + ); + } +} + +function checkRemote(checks, values, skipRemote, hfAvailable, hfAuthenticated) { + if (skipRemote) { + addCheck(checks, 'skip', 'remote-space', 'Remote checks were skipped by --skip-remote.'); + return; + } + if (!hfAvailable || !hfAuthenticated || !values) { + addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI, hf auth login, and a valid access file.'); + return; + } + + const spaceId = values.get('HF_SPACE_ID')?.trim(); + if (!spaceId) { + addCheck(checks, 'skip', 'remote-space', 'Remote checks require HF_SPACE_ID in the access file.'); + return; + } + const info = runCommand('hf', ['spaces', 'info', spaceId, '--format', 'json']); + if (!info.ok) { + addCheck(checks, 'fail', 'remote-space', `Cannot read Space info for ${spaceId}.`, { error: info.error }); + return; + } + addCheck(checks, 'pass', 'remote-space', `Space ${spaceId} is accessible.`); + checkRemoteNames( + checks, + spaceId, + 'remote-variables', + ['spaces', 'variables', 'list', spaceId, '--json'], + REQUIRED_SPACE_VARIABLES, + RECOMMENDED_SPACE_VARIABLES + ); + checkRemoteSecrets(checks, spaceId); +} + +function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommendedNames = []) { + const result = runCommand('hf', args); + if (!result.ok) { + addCheck(checks, 'warn', name, `Cannot list ${name} for ${spaceId}.`, { error: result.error }); + return; + } + try { + const names = getJsonNames(result.stdout); + const { missingRequired, missingRecommended } = classifyRequiredAndRecommendedNames( + names, + requiredNames, + recommendedNames + ); + if (missingRequired.length) { + addCheck(checks, 'fail', name, `${name} missing required names: ${missingRequired.join(', ')}.`); + } else { + addCheck(checks, 'pass', name, `${name} contains required names.`); + } + if (missingRecommended.length) { + addCheck(checks, 'warn', name, `${name} missing recommended names: ${missingRecommended.join(', ')}.`); + } + } catch (error) { + addCheck(checks, 'warn', name, `Cannot parse ${name} JSON output.`, { + error: error instanceof Error ? error.message : String(error) + }); + } +} + +function checkRemoteSecrets(checks, spaceId) { + const result = runCommand('hf', ['spaces', 'secrets', 'list', spaceId, '--json']); + if (!result.ok) { + addCheck(checks, 'warn', 'remote-secrets', `Cannot list remote secrets for ${spaceId}.`, { error: result.error }); + return; + } + try { + const names = getJsonNames(result.stdout); + const missing = REQUIRED_SPACE_SECRETS.filter((key) => !names.has(key)); + const hasGenerationSecret = OPTIONAL_GENERATION_SECRETS.some((key) => names.has(key)); + if (missing.length) { + addCheck(checks, 'fail', 'remote-secrets', `Remote secrets missing: ${missing.join(', ')}.`); + } else { + addCheck(checks, 'pass', 'remote-secrets', 'Remote secrets contain APP_PASSWORD and AGENT_API_TOKEN.'); + } + if (hasGenerationSecret) { + addCheck(checks, 'pass', 'remote-generation-secret', 'Remote generation credential is configured.'); + } else { + addCheck(checks, 'warn', 'remote-generation-secret', 'No OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS secret found; server-side generation may be unavailable.'); + } + } catch (error) { + addCheck(checks, 'warn', 'remote-secrets', 'Cannot parse remote secrets JSON output.', { + error: error instanceof Error ? error.message : String(error) + }); + } +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const checks = []; + checkNode(checks); + checkCommand(checks, 'npm', 'npm', ['--version'], 'npm is missing. Install Node.js 20 or newer with npm.'); + const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.'); + const hfAuth = hfAvailable ? runCommand('hf', ['auth', 'whoami']) : { ok: false }; + if (hfAvailable && hfAuth.ok) { + addCheck(checks, 'pass', 'hf-auth', 'hf CLI is authenticated.'); + } else if (hfAvailable) { + addCheck(checks, 'fail', 'hf-auth', 'hf CLI auth check failed.', { + action: 'hf auth login', + error: hfAuth.error + }); + } + if (existsSync('node_modules')) { + addCheck(checks, 'pass', 'node-modules', 'node_modules exists.'); + } else { + addCheck(checks, 'warn', 'node-modules', 'node_modules is missing; build, lint, test, and smoke commands require npm install.'); + } + checkCommand(checks, 'git', 'git', ['--version'], 'git is missing; install git before cloning or pushing Space repos.'); + const docker = runCommand('docker', ['version', '--format', '{{.Server.Version}}']); + if (docker.ok) { + addCheck(checks, 'pass', 'docker', 'docker is available.', { version: docker.stdout.split(/\r?\n/)[0] }); + } else { + addCheck(checks, 'warn', 'docker', 'Docker is unavailable; npm run smoke:hf-space will not work.', { + error: docker.error + }); + const dockerCli = runCommand('docker', ['--version']); + if (dockerCli.ok) addCheck(checks, 'warn', 'docker-daemon', 'Docker CLI exists but the daemon is not reachable.'); + } + + const values = checkAccessFile(checks, options.accessFile); + checkRemote(checks, values, options.skipRemote, hfAvailable, Boolean(hfAuth.ok)); + + const failed = checks.some((check) => check.status === 'fail'); + console.log(JSON.stringify({ ok: !failed, checks, nextActions: buildNextActions(checks) }, null, 2)); + if (failed) process.exit(1); +} + +try { + if (isMainModule(import.meta.url, process.argv[1])) { + main(); + } +} catch (error) { + console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2)); + process.exit(1); +} diff --git a/scripts/hf-space-doctor-utils.mjs b/scripts/hf-space-doctor-utils.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ab471f480f9678929d3c095adf04f27b6f9b9e7e --- /dev/null +++ b/scripts/hf-space-doctor-utils.mjs @@ -0,0 +1,166 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt'); + +export function readEnvValue(name) { + return process.env[name]?.trim() || undefined; +} + +export function readOptionValue(argv, name) { + const prefix = `${name}=`; + const inlineValue = argv.find((arg) => arg.startsWith(prefix)); + if (inlineValue !== undefined) { + const value = inlineValue.slice(prefix.length).trim(); + if (!value) throw new Error(`${name} requires a value`); + return value; + } + + const index = argv.indexOf(name); + if (index === -1) return undefined; + const value = argv[index + 1]?.trim(); + if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`); + return value; +} + +export function assertKnownOptions(argv, knownOptions) { + const known = new Set(knownOptions); + for (const arg of argv) { + if (!arg.startsWith('-')) continue; + const name = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg; + if (!known.has(name)) throw new Error(`Unknown option: ${name}`); + } +} + +export function runCommand(command, args = []) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + if (result.error) return { ok: false, error: result.error.message }; + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + return { ok: false, error: output || `${command} ${args.join(' ')} failed` }; + } + return { ok: true, stdout: result.stdout.trim() }; +} + +export function parseAccessFile(accessFile) { + const values = new Map(); + const text = readFileSync(accessFile, 'utf8'); + for (const rawLine of text.split(/\r?\n/)) { + if (!rawLine || rawLine.startsWith('#')) continue; + const separatorIndex = rawLine.indexOf('='); + if (separatorIndex <= 0) continue; + const key = rawLine.slice(0, separatorIndex).trim(); + const value = rawLine.slice(separatorIndex + 1); + if (key) values.set(key, value); + } + return values; +} + +export function getJsonNames(text) { + const parsed = JSON.parse(extractJsonPayload(text)); + const names = new Set(); + const visit = (value) => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!value || typeof value !== 'object') return; + for (const key of ['name', 'key', 'id']) { + if (typeof value[key] === 'string') names.add(value[key]); + } + }; + visit(parsed); + return names; +} + +function extractJsonPayload(text) { + const trimmedText = text.trim(); + if (!trimmedText) return '[]'; + + const lines = trimmedText.split(/\r?\n/); + const jsonLineIndex = lines.findIndex((line) => { + const trimmedLine = line.trimStart(); + return trimmedLine.startsWith('{') || trimmedLine.startsWith('['); + }); + if (jsonLineIndex === -1) return trimmedText; + return lines.slice(jsonLineIndex).join('\n'); +} + +export function missingKeys(keys, values) { + return keys.filter((key) => !values.has(key) || !String(values.get(key)).trim()); +} + +export function classifyRequiredAndRecommendedNames(names, requiredNames, recommendedNames = []) { + return { + missingRequired: requiredNames.filter((key) => !names.has(key)), + missingRecommended: recommendedNames.filter((key) => !names.has(key)) + }; +} + +const NEXT_ACTIONS = new Map([ + ['node', 'Install Node.js 20 or newer, then reopen the terminal.'], + ['npm', 'Install Node.js 20 or newer with npm.'], + ['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'], + ['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'], + ['node-modules', 'Run npm install.'], + [ + 'access-file-keys', + 'Regenerate or update the access file with npm run init-access:hf-space -- --space-id / --space-url https://-.hf.space' + ], + [ + 'generated-secrets', + 'Regenerate weak or blank project secrets with npm run init-access:hf-space -- --space-id / --space-url https://-.hf.space --force' + ], + ['remote-variables', 'Configure the required and recommended Space Variables in Hugging Face Settings before syncing secrets.'], + ['remote-secrets', 'Run npm run sync-secret:hf-space after hf auth login succeeds.'], + ['remote-generation-secret', 'Configure OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS in Space Secrets before real image generation.'] +]); + +export function buildNextActions(checks) { + const actions = new Set(); + for (const check of checks) { + if (check.status === 'pass' || check.status === 'skip') continue; + if (check.action) actions.add(check.action); + const mappedAction = NEXT_ACTIONS.get(check.name); + if (mappedAction) actions.add(mappedAction); + } + return [...actions]; +} + +export function validateSpaceId(spaceId) { + if (!spaceId?.trim()) return 'HF Space id is required.'; + if (!/^[^/\s]+\/[^/\s]+$/.test(spaceId)) return 'HF Space id must use namespace/space format.'; + return undefined; +} + +export function validateSpaceUrl(spaceUrl) { + if (!spaceUrl?.trim()) return 'HF Space URL is required.'; + let parsedUrl; + try { + parsedUrl = new URL(spaceUrl); + } catch { + return 'HF Space URL is not a valid URL.'; + } + if (parsedUrl.protocol !== 'https:') return 'HF Space URL must use https.'; + if (!parsedUrl.hostname.endsWith('.hf.space')) return 'HF Space URL must be a Hugging Face .hf.space URL.'; + if (parsedUrl.username || parsedUrl.password || parsedUrl.pathname !== '/' || parsedUrl.search || parsedUrl.hash) { + return 'HF Space URL must be a plain Space origin without credentials, path, query, or fragment.'; + } + return undefined; +} + +export function assertSpaceTargetConfig({ spaceId, spaceUrl }) { + const spaceIdError = validateSpaceId(spaceId); + if (spaceIdError) throw new Error(spaceIdError); + const spaceUrlError = validateSpaceUrl(spaceUrl); + if (spaceUrlError) throw new Error(spaceUrlError); +} + +export function isMainModule(moduleUrl, argvPath) { + return Boolean(argvPath) && moduleUrl === pathToFileURL(resolve(argvPath)).href; +} diff --git a/scripts/hf-space-doctor-utils.test.mjs b/scripts/hf-space-doctor-utils.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..624b11239f3bfe2de194dd37924491497ccc4066 --- /dev/null +++ b/scripts/hf-space-doctor-utils.test.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { + assertKnownOptions, + assertSpaceTargetConfig, + buildNextActions, + classifyRequiredAndRecommendedNames, + getJsonNames, + isMainModule, + readOptionValue, + validateSpaceId, + validateSpaceUrl +} from './hf-space-doctor-utils.mjs'; + +describe('HF Space doctor utilities', () => { + it('parses hf CLI JSON output after hint lines', () => { + const names = getJsonNames( + [ + 'Hint: Use `hf spaces variables add user/space -e KEY=VALUE` to add variables.', + '[{"key":"APP_PASSWORD"},{"key":"AGENT_API_TOKEN"}]' + ].join('\n') + ); + + assert.deepEqual([...names], ['APP_PASSWORD', 'AGENT_API_TOKEN']); + }); + + it('does not treat square brackets in hint text as JSON', () => { + const names = getJsonNames( + [ + 'Hint: Usage: hf spaces secrets list [OPTIONS] SPACE_ID', + '[{"key":"OPENAI_API_KEY"}]' + ].join('\n') + ); + + assert.deepEqual([...names], ['OPENAI_API_KEY']); + }); + + it('keeps auth remediation broad enough for network and token failures', () => { + const actions = buildNextActions([ + { + status: 'fail', + name: 'hf-auth', + message: 'hf CLI auth check failed.', + error: 'TLS failed while checking auth' + } + ]); + + assert.equal(actions.length, 1); + assert.match(actions[0], /network\/proxy/); + assert.match(actions[0], /hf auth login/); + }); + + it('classifies required and recommended remote variables separately', () => { + const result = classifyRequiredAndRecommendedNames( + new Set(['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE']), + ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'], + ['APP_LOG_LEVEL'] + ); + + assert.deepEqual(result, { + missingRequired: [], + missingRecommended: ['APP_LOG_LEVEL'] + }); + }); + + it('detects the main module with file URL encoding', () => { + const argvPath = 'scripts/path with space.mjs'; + const moduleUrl = pathToFileURL(resolve(argvPath)).href; + + assert.equal(isMainModule(moduleUrl, argvPath), true); + assert.equal(isMainModule(moduleUrl, undefined), false); + }); + + it('validates Hugging Face Space target config consistently', () => { + assert.equal(validateSpaceId('example/demo'), undefined); + assert.equal(validateSpaceUrl('https://example-demo.hf.space'), undefined); + assert.match(validateSpaceId('bad'), /namespace\/space/); + assert.match(validateSpaceUrl('https://example.com'), /\.hf\.space/); + assert.match(validateSpaceUrl('https://user:pass@example-demo.hf.space'), /plain Space origin/); + assert.match(validateSpaceUrl('https://example-demo.hf.space/share/abc'), /plain Space origin/); + assert.match(validateSpaceUrl('https://example-demo.hf.space?token=secret'), /plain Space origin/); + assert.throws( + () => + assertSpaceTargetConfig({ + spaceId: 'example/demo', + spaceUrl: 'https://example.com' + }), + /\.hf\.space/ + ); + }); + + it('rejects unknown CLI options and blank inline option values', () => { + assert.doesNotThrow(() => assertKnownOptions(['--access-file', 'tmp.txt'], ['--access-file'])); + assert.doesNotThrow(() => assertKnownOptions(['--access-file=tmp.txt'], ['--access-file'])); + assert.throws(() => assertKnownOptions(['--unknown'], ['--access-file']), /Unknown option/); + assert.throws(() => readOptionValue(['--space-id='], '--space-id'), /requires a value/); + }); +}); diff --git a/scripts/init-hf-space-access.mjs b/scripts/init-hf-space-access.mjs new file mode 100644 index 0000000000000000000000000000000000000000..3a128b07c1e42a32059de3ad5355f3646997fb18 --- /dev/null +++ b/scripts/init-hf-space-access.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import { randomBytes } from 'node:crypto'; +import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { + assertKnownOptions, + assertSpaceTargetConfig, + DEFAULT_ACCESS_FILE, + readEnvValue, + readOptionValue, + isMainModule +} from './hf-space-doctor-utils.mjs'; + +const DEFAULT_SECRET_KEYS = ['APP_PASSWORD', 'AGENT_API_TOKEN']; +const APP_PASSWORD_BYTES = 24; +const AGENT_TOKEN_BYTES = 32; + +function parseArgs(argv) { + assertKnownOptions(argv, ['--access-file', '--force', '--help', '-h', '--space-id', '--space-url']); + return { + accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE, + force: argv.includes('--force'), + help: argv.includes('--help') || argv.includes('-h'), + spaceId: readOptionValue(argv, '--space-id') || readEnvValue('HF_SPACE_ID'), + spaceUrl: readOptionValue(argv, '--space-url') || readEnvValue('HF_SPACE_URL') + }; +} + +function printHelp() { + console.log(`Usage: + npm run init-access:hf-space -- --space-id --space-url https://-.hf.space + +Options: + --access-file Override the access file path. + --force Overwrite an existing access file. + --help Show this help. + +Environment overrides: + HF_SPACE_ID + HF_SPACE_URL + HF_SPACE_ACCESS_FILE`); +} + +function createSecret(bytes) { + return randomBytes(bytes).toString('base64url'); +} + +function buildAccessFileContent(options) { + return [ + '# Hugging Face Space access for gpt-image-playground-customer', + '# Generated by npm run init-access:hf-space. Do not commit this file.', + `HF_SPACE_ID=${options.spaceId}`, + `HF_SPACE_URL=${options.spaceUrl}`, + `HF_SPACE_SECRET_KEYS=${DEFAULT_SECRET_KEYS.join(',')}`, + `APP_PASSWORD=${createSecret(APP_PASSWORD_BYTES)}`, + `AGENT_API_TOKEN=${createSecret(AGENT_TOKEN_BYTES)}`, + '' + ].join('\n'); +} + +function writeAccessFile(options) { + if (existsSync(options.accessFile) && !options.force) { + throw new Error(`Access file already exists: ${options.accessFile}. Use --force to overwrite.`); + } + mkdirSync(dirname(options.accessFile), { recursive: true }); + writeFileSync(options.accessFile, buildAccessFileContent(options), { encoding: 'utf8', mode: 0o600 }); + chmodSync(options.accessFile, 0o600); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + assertSpaceTargetConfig(options); + writeAccessFile(options); + + console.log( + JSON.stringify( + { + ok: true, + accessFile: options.accessFile, + spaceId: options.spaceId, + spaceUrl: options.spaceUrl, + secretKeys: DEFAULT_SECRET_KEYS, + nextCommand: 'npm run sync-secret:hf-space' + }, + null, + 2 + ) + ); +} + +try { + if (isMainModule(import.meta.url, process.argv[1])) { + main(); + } +} catch (error) { + console.error( + JSON.stringify( + { + ok: false, + error: error instanceof Error ? error.message : String(error) + }, + null, + 2 + ) + ); + process.exit(1); +} diff --git a/scripts/init-hf-space-access.test.mjs b/scripts/init-hf-space-access.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bb184daf124c232ae04e0f64916b96c1b0128eb8 --- /dev/null +++ b/scripts/init-hf-space-access.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_PATH = fileURLToPath(new URL('./init-hf-space-access.mjs', import.meta.url)); +const SCRIPT_URL = new URL('./init-hf-space-access.mjs', import.meta.url).href; + +function runInit(args) { + return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); +} + +function makeTempDir() { + return mkdtempSync(join(tmpdir(), 'gpt-image-playground-access-')); +} + +describe('HF Space access initializer', () => { + it('can be imported without executing the CLI', async () => { + await assert.doesNotReject(import(`${SCRIPT_URL}?import-smoke=${Date.now()}`)); + }); + + it('rejects unknown options before writing an access file', () => { + const dir = makeTempDir(); + const accessFile = join(dir, 'access.txt'); + const result = runInit([ + '--space-id', + 'example/demo', + '--space-url', + 'https://example-demo.hf.space', + '--unknown', + '--access-file', + accessFile + ]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unknown option/); + assert.equal(existsSync(accessFile), false); + rmSync(dir, { force: true, recursive: true }); + }); + + it('rejects invalid Space ids before writing an access file', () => { + const dir = makeTempDir(); + const accessFile = join(dir, 'access.txt'); + const result = runInit([ + '--space-id', + 'bad', + '--space-url', + 'https://example-demo.hf.space', + '--access-file', + accessFile + ]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /namespace\/space/); + assert.equal(existsSync(accessFile), false); + rmSync(dir, { force: true, recursive: true }); + }); + + it('rejects non-Hugging Face Space URLs before writing an access file', () => { + const dir = makeTempDir(); + const accessFile = join(dir, 'access.txt'); + const result = runInit([ + '--space-id', + 'example/demo', + '--space-url', + 'https://example.com', + '--access-file', + accessFile + ]); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\.hf\.space/); + assert.equal(existsSync(accessFile), false); + rmSync(dir, { force: true, recursive: true }); + }); + + it('writes an access file without leaking generated secrets to stdout', () => { + const dir = makeTempDir(); + const accessFile = join(dir, 'access.txt'); + const result = runInit([ + '--space-id', + 'example/demo', + '--space-url', + 'https://example-demo.hf.space', + '--access-file', + accessFile + ]); + + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(result.stdout, /APP_PASSWORD=|AGENT_API_TOKEN=/); + + const content = readFileSync(accessFile, 'utf8'); + assert.match(content, /^HF_SPACE_ID=example\/demo$/m); + assert.match(content, /^HF_SPACE_URL=https:\/\/example-demo\.hf\.space$/m); + assert.match(content, /^HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN$/m); + if (process.platform !== 'win32') { + assert.equal(statSync(accessFile).mode & 0o777, 0o600); + } + + rmSync(dir, { force: true, recursive: true }); + }); +}); diff --git a/scripts/keepalive-hf-space.mjs b/scripts/keepalive-hf-space.mjs index 426c2bc8b06955319b85f812a1e17df7cc784d58..f0f946cde621dcc03661f563a630738268cdcc50 100644 --- a/scripts/keepalive-hf-space.mjs +++ b/scripts/keepalive-hf-space.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { validateSpaceUrl } from './hf-space-doctor-utils.mjs'; + const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space'; const DEFAULT_KEEPALIVE_PATH = '/api/auth-status'; const DEFAULT_TIMEOUT_MS = 30_000; @@ -8,7 +10,10 @@ const MIN_TIMEOUT_MS = 1_000; function readPositiveIntegerEnv(name, fallback) { const rawValue = process.env[name]?.trim(); if (!rawValue) return fallback; - const value = Number.parseInt(rawValue, 10); + if (!/^\d+$/.test(rawValue)) { + throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`); + } + const value = Number(rawValue); if (!Number.isSafeInteger(value) || value < MIN_TIMEOUT_MS) { throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`); } @@ -16,6 +21,10 @@ function readPositiveIntegerEnv(name, fallback) { } function normalizeUrl(rawUrl, path) { + const urlError = validateSpaceUrl(rawUrl); + if (urlError) { + throw new Error(urlError.replace('HF Space URL', 'HF_SPACE_KEEPALIVE_URL')); + } const baseUrl = new URL(rawUrl); const normalizedPath = path.startsWith('/') ? path : `/${path}`; return new URL(normalizedPath, baseUrl).toString(); diff --git a/scripts/keepalive-hf-space.test.mjs b/scripts/keepalive-hf-space.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1b608aac639c720a37991be74081efa23e4f15e7 --- /dev/null +++ b/scripts/keepalive-hf-space.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const scriptPath = join(repoRoot, 'scripts/keepalive-hf-space.mjs'); + +describe('HF Space keepalive script validation', () => { + it('rejects non-integer timeout env values before network access', () => { + const result = runKeepalive({ HF_SPACE_KEEPALIVE_TIMEOUT_MS: '1000abc' }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /HF_SPACE_KEEPALIVE_TIMEOUT_MS/); + assert.match(result.stderr, /integer/); + assert.equal(result.stdout.trim(), ''); + }); + + it('rejects keepalive URLs with embedded credentials before network access', () => { + const result = runKeepalive({ HF_SPACE_KEEPALIVE_URL: 'https://user:secret@example-demo.hf.space' }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /HF_SPACE_KEEPALIVE_URL/); + assert.match(result.stderr, /plain Space origin/); + assert.doesNotMatch(result.stderr, /secret/); + assert.equal(result.stdout.trim(), ''); + }); +}); + +function runKeepalive(env) { + return spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, ...env } + }); +} diff --git a/scripts/smoke-hf-space-memory.mjs b/scripts/smoke-hf-space-memory.mjs index d0c0dcf97bd04971acb69260751c795cfdc8ed2a..e4b59777312e3a31023a3228e5be96aa8026c30b 100644 --- a/scripts/smoke-hf-space-memory.mjs +++ b/scripts/smoke-hf-space-memory.mjs @@ -65,6 +65,14 @@ function assertEqual(actual, expected, label) { } } +function assertJsonEqual(actual, expected, label) { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`); + } +} + cleanup(); try { @@ -106,6 +114,7 @@ try { const capabilities = await fetchJson('/api/agent/capabilities'); assertEqual(capabilities.auth?.required, true, 'Agent auth required flag'); + assertJsonEqual(capabilities.auth?.schemes, ['bearer'], 'Agent auth schemes'); assertEqual(capabilities.defaults?.state_backend, 'memory', 'Agent state backend'); assertEqual(capabilities.storage?.image_storage_mode, 'indexeddb', 'Image storage mode'); assertEqual(capabilities.storage?.postgres_configured, false, 'PostgreSQL configured flag'); diff --git a/scripts/sync-hf-space-secret.mjs b/scripts/sync-hf-space-secret.mjs index 4638b61e5d9c40671936c89f552190f8d1574dd4..0bad8c2c6172d4453a42edc8434ec2a5b364d273 100644 --- a/scripts/sync-hf-space-secret.mjs +++ b/scripts/sync-hf-space-secret.mjs @@ -2,34 +2,68 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; +import { + assertKnownOptions, + assertSpaceTargetConfig, + DEFAULT_ACCESS_FILE, + isMainModule, + parseAccessFile +} from './hf-space-doctor-utils.mjs'; + const DEFAULT_SPACE_ID = 'misonL/gpt-image-playground-customer'; const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space'; -const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt'); const DEFAULT_SECRET_KEYS = ['APP_PASSWORD']; +const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD']; const STATUS_POLL_ATTEMPTS = 20; const STATUS_POLL_INTERVAL_MS = 5_000; const VERIFY_ATTEMPTS = 6; const VERIFY_INTERVAL_MS = 3_000; +class NonRetryableHfError extends Error {} + function parseArgs(argv) { + assertKnownOptions(argv, ['--help', '-h', '--no-restart', '--skip-verify', '--use-default-target']); return { + help: argv.includes('--help') || argv.includes('-h'), restart: !argv.includes('--no-restart'), + useDefaultTarget: argv.includes('--use-default-target'), verify: !argv.includes('--skip-verify') }; } +function printHelp() { + console.log(`Usage: + npm run sync-secret:hf-space + +Options: + --no-restart Sync secrets without restarting the Space. + --skip-verify Skip APP_PASSWORD access-code verification. + --use-default-target Allow the built-in default Space target. + --help Show this help. + +Environment overrides: + HF_SPACE_ACCESS_FILE + HF_SPACE_ID + HF_SPACE_URL + HF_SPACE_SECRET_KEYS`); +} + function readRequiredEnv(name, fallback) { const value = process.env[name]?.trim() || fallback; if (!value) throw new Error(`${name} is required`); return value; } -function readSecretKeys() { - const rawValue = process.env.HF_SPACE_SECRET_KEYS?.trim(); +function readConfigValue(name, secrets, fallback) { + const value = process.env[name]?.trim() || secrets.get(name)?.trim() || fallback; + if (!value) throw new Error(`${name} is required`); + return value; +} + +function readSecretKeys(secrets) { + const rawValue = process.env.HF_SPACE_SECRET_KEYS?.trim() || secrets.get('HF_SPACE_SECRET_KEYS')?.trim(); if (!rawValue) return DEFAULT_SECRET_KEYS; const keys = rawValue .split(',') @@ -39,18 +73,18 @@ function readSecretKeys() { return keys; } -function readAccessFileSecrets(accessFile) { - const secrets = new Map(); - const text = readFileSync(accessFile, 'utf8'); - for (const rawLine of text.split(/\r?\n/)) { - if (!rawLine || rawLine.startsWith('#')) continue; - const separatorIndex = rawLine.indexOf('='); - if (separatorIndex <= 0) continue; - const key = rawLine.slice(0, separatorIndex).trim(); - const value = rawLine.slice(separatorIndex + 1); - if (key) secrets.set(key, value); +function validateSecretValues(secretKeys, secrets) { + const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => secrets.has(key)); + if (forbidden.length) { + throw new Error(`Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`); } - return secrets; + const secretValues = new Map(); + for (const key of secretKeys) { + const value = secrets.get(key); + if (!value?.trim()) throw new Error(`${key} is missing or blank in access file`); + secretValues.set(key, value); + } + return secretValues; } function redactSensitiveText(text, secretValues = []) { @@ -62,18 +96,40 @@ function redactSensitiveText(text, secretValues = []) { } function runHf(args, options = {}) { + const commandLabel = redactSensitiveText(`hf ${args.join(' ')}`, options.secretValues); const result = spawnSync('hf', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + if (result.error) { + throw new NonRetryableHfError(`${commandLabel} failed: ${result.error.message}`); + } if (result.status !== 0) { const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); - const safeOutput = redactSensitiveText(output || `hf ${args.join(' ')} failed`, options.secretValues); + const safeOutput = redactSensitiveText(output || `${commandLabel} failed`, options.secretValues); throw new Error(safeOutput); } return result.stdout || ''; } +function assertHfAuthenticated() { + try { + runHf(['auth', 'whoami']); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + 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}`); + } +} + +function assertSpaceReadable(spaceId) { + try { + runHf(['spaces', 'info', spaceId, '--format', 'json']); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + throw new Error(`Cannot read Hugging Face Space "${spaceId}". Check HF_SPACE_ID and the logged-in token permissions. Cause: ${cause}`); + } +} + async function syncSecret({ spaceId, key, value }) { let lastError; for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt += 1) { @@ -83,6 +139,7 @@ async function syncSecret({ spaceId, key, value }) { }); return; } catch (error) { + if (error instanceof NonRetryableHfError) throw error; lastError = error; await delay(VERIFY_INTERVAL_MS); } @@ -144,9 +201,9 @@ async function verifyAppPassword({ spaceUrl, appPassword }) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passwordHash }) }); - const body = await response.json().catch(async () => ({ raw: await response.text() })); + const body = await readJsonResponseBody(response); if (!response.ok || body?.authenticated !== true) { - throw new Error(`APP_PASSWORD verification failed with HTTP ${response.status}`); + throw new Error(`APP_PASSWORD access-code verification failed with HTTP ${response.status}`); } return { status: response.status, authenticated: body.authenticated }; } catch (error) { @@ -157,18 +214,39 @@ async function verifyAppPassword({ spaceUrl, appPassword }) { throw lastError; } +export async function readJsonResponseBody(response) { + const text = await response.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return { raw: text.slice(0, 200) }; + } +} + async function main() { const options = parseArgs(process.argv.slice(2)); - const spaceId = readRequiredEnv('HF_SPACE_ID', DEFAULT_SPACE_ID); - const spaceUrl = readRequiredEnv('HF_SPACE_URL', DEFAULT_SPACE_URL); + if (options.help) { + printHelp(); + return; + } + const accessFile = readRequiredEnv('HF_SPACE_ACCESS_FILE', DEFAULT_ACCESS_FILE); - const secretKeys = readSecretKeys(); - const secrets = readAccessFileSecrets(accessFile); + const secrets = parseAccessFile(accessFile); + const fallbackSpaceId = options.useDefaultTarget ? DEFAULT_SPACE_ID : undefined; + const fallbackSpaceUrl = options.useDefaultTarget ? DEFAULT_SPACE_URL : undefined; + const spaceId = readConfigValue('HF_SPACE_ID', secrets, fallbackSpaceId); + const spaceUrl = readConfigValue('HF_SPACE_URL', secrets, fallbackSpaceUrl); + assertSpaceTargetConfig({ spaceId, spaceUrl }); + const secretKeys = readSecretKeys(secrets); + const secretValues = validateSecretValues(secretKeys, secrets); const syncedKeys = []; + assertHfAuthenticated(); + assertSpaceReadable(spaceId); + for (const key of secretKeys) { - const value = secrets.get(key); - if (!value?.trim()) throw new Error(`${key} is missing or blank in access file`); + const value = secretValues.get(key); await syncSecret({ spaceId, key, value }); syncedKeys.push(key); } @@ -186,7 +264,7 @@ async function main() { if (options.verify && syncedKeys.includes('APP_PASSWORD')) { verification = await verifyAppPassword({ spaceUrl, - appPassword: secrets.get('APP_PASSWORD') + appPassword: secretValues.get('APP_PASSWORD') }); } @@ -206,16 +284,18 @@ async function main() { ); } -main().catch((error) => { - console.error( - JSON.stringify( - { - ok: false, - error: error instanceof Error ? error.message : String(error) - }, - null, - 2 - ) - ); - process.exit(1); -}); +if (isMainModule(import.meta.url, process.argv[1])) { + main().catch((error) => { + console.error( + JSON.stringify( + { + ok: false, + error: error instanceof Error ? error.message : String(error) + }, + null, + 2 + ) + ); + process.exit(1); + }); +} diff --git a/scripts/sync-hf-space-secret.test.mjs b/scripts/sync-hf-space-secret.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..d1e5ba7c54b70cba878bc6749ba7637f120e4073 --- /dev/null +++ b/scripts/sync-hf-space-secret.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_PATH = fileURLToPath(new URL('./sync-hf-space-secret.mjs', import.meta.url)); +const SCRIPT_URL = new URL('./sync-hf-space-secret.mjs', import.meta.url).href; + +function makeTempDir() { + return mkdtempSync(join(tmpdir(), 'gpt-image-playground-sync-')); +} + +function runSync(accessFile, env = {}) { + return spawnSync(process.execPath, [SCRIPT_PATH, '--no-restart', '--skip-verify'], { + encoding: 'utf8', + env: { + ...process.env, + HF_SPACE_ACCESS_FILE: accessFile, + ...env + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); +} + +describe('HF Space secret sync preflight', () => { + it('can be imported without executing the CLI', async () => { + await assert.doesNotReject(import(`${SCRIPT_URL}?import-smoke=${Date.now()}`)); + }); + + it('parses verification responses without reading non-JSON bodies twice', async () => { + const module = await import(`${SCRIPT_URL}?json-body=${Date.now()}`); + const body = await module.readJsonResponseBody(new Response('not json', { status: 502 })); + + assert.deepEqual(body, { raw: 'not json' }); + }); + + it('prints help without requiring an access file or remote auth', () => { + const result = spawnSync(process.execPath, [SCRIPT_PATH, '--help'], { + encoding: 'utf8', + env: { + ...process.env, + HF_SPACE_ACCESS_FILE: join(tmpdir(), 'missing-access-file.txt') + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage:/); + assert.match(result.stdout, /--use-default-target/); + }); + + it('rejects unknown options before remote operations', () => { + const result = spawnSync(process.execPath, [SCRIPT_PATH, '--unknown'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Unknown option/); + assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/); + }); + + it('rejects non-Hugging Face Space URLs before remote operations', () => { + const dir = makeTempDir(); + const accessFile = join(dir, 'access.txt'); + writeFileSync( + accessFile, + [ + 'HF_SPACE_ID=example/demo', + 'HF_SPACE_URL=https://example.com', + 'HF_SPACE_SECRET_KEYS=APP_PASSWORD', + 'APP_PASSWORD=abcdefghijklmnopqrstuvwxyz', + '' + ].join('\n'), + { encoding: 'utf8', mode: 0o600 } + ); + + const result = runSync(accessFile); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\.hf\.space/); + assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/); + + rmSync(dir, { force: true, recursive: true }); + }); + + it('rejects Hugging Face account credentials in the access file before remote operations', () => { + const dir = makeTempDir(); + const accessFile = join(dir, 'access.txt'); + writeFileSync( + accessFile, + [ + 'HF_SPACE_ID=example/demo', + 'HF_SPACE_URL=https://example-demo.hf.space', + 'HF_SPACE_SECRET_KEYS=APP_PASSWORD', + 'APP_PASSWORD=abcdefghijklmnopqrstuvwxyz', + 'HF_TOKEN=hf_account_token_should_not_be_synced', + '' + ].join('\n'), + { encoding: 'utf8', mode: 0o600 } + ); + + const result = runSync(accessFile); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must not contain Hugging Face credentials/); + assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/); + + rmSync(dir, { force: true, recursive: true }); + }); + + it('redacts secret values if hf fails while syncing a secret', () => { + const dir = makeTempDir(); + const accessFile = join(dir, 'access.txt'); + const hfPath = join(dir, 'hf'); + const secretValue = 'super-secret-access-code'; + writeFileSync( + accessFile, + [ + 'HF_SPACE_ID=example/demo', + 'HF_SPACE_URL=https://example-demo.hf.space', + 'HF_SPACE_SECRET_KEYS=APP_PASSWORD', + `APP_PASSWORD=${secretValue}`, + '' + ].join('\n'), + { encoding: 'utf8', mode: 0o600 } + ); + writeFileSync( + hfPath, + [ + '#!/bin/sh', + 'if [ "$1 $2" = "auth whoami" ]; then exit 0; fi', + 'if [ "$1 $2" = "spaces info" ]; then /bin/chmod 000 "$0"; echo "{}"; exit 0; fi', + 'exit 1', + '' + ].join('\n'), + { encoding: 'utf8', mode: 0o755 } + ); + + const result = runSync(accessFile, { PATH: dir }); + + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.stderr, new RegExp(secretValue)); + assert.match(result.stderr, /APP_PASSWORD=\[redacted\]/); + + rmSync(dir, { force: true, recursive: true }); + }); +}); diff --git a/skills/gpt-image-playground-agent/SKILL.md b/skills/gpt-image-playground-agent/SKILL.md index 52671fc855e20705b849724ab699e03cb2f5745e..e76d3bb5f4f46141d71697b6eef386c9e72326d5 100644 --- a/skills/gpt-image-playground-agent/SKILL.md +++ b/skills/gpt-image-playground-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: gpt-image-playground-agent -description: 调用已部署的 GPT Image Playground Agent API,面向 Codex、Claude Code、Gemini 等自动化 Agent 的图片生成与图片编辑任务。适用于用户部署了本项目的任意实例,包括本机一键脚本、Docker、局域网服务器、云服务器、域名或自定义端口,并需要先定位服务地址,再通过 /api/agent/* 处理 Idempotency-Key、结构化 AgentError、可重试错误、产物 metadata/content URL、Bearer token 或 password-hash 鉴权,以及 response_mode path/base64/both 的场景。 +description: 当用户需要通过 API 调用已部署的 GPT Image Playground 批量请求图片生成时使用;支持文字生成图片、文字加图片生成图片,并返回可下载图片产物、metadata、base64 或 job 结果。 --- # GPT Image Playground Agent @@ -11,13 +11,15 @@ description: 调用已部署的 GPT Image Playground Agent API,面向 Codex、 1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。 2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。 -3. 读取 capabilities 中的认证方式、模型、限制、状态后端和端点路径;不要硬编码假设部署方式。 +3. 读取 capabilities 中的认证方式、模型、模型级限制、Agent 流式边界、状态后端和端点路径;不要硬编码假设部署方式。 4. 为每个业务操作生成稳定的 `Idempotency-Key`。同一操作重试时复用原 key;不同操作不要复用。 -5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。 -6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。 +5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点是非流式端点,当前固定以 `stream: false` 调上游。 +6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点。 7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。 -8. 处理失败时读取结构化 `error.code`、`error.retryable` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。 -9. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。 +8. 不要把页面端 `POST /api/images` 当成 Agent 默认路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明。 +9. 读取 `agent_jobs`。若 `supported=true` 且 `mode=job_polling`,4K/high 或长耗时任务优先走 job/polling。 +10. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。 +11. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。 ## 鉴权 @@ -27,30 +29,85 @@ description: 调用已部署的 GPT Image Playground Agent API,面向 Codex、 Authorization: Bearer ``` -如果服务端使用 `APP_PASSWORD`,发送 `X-App-Password-Hash`。下载或删除产物时必须复用同一鉴权方式。 +此时服务端只接受 Bearer token,不会回退到访问码哈希。如果未配置 `AGENT_API_TOKEN` 但配置了页面访问码 `APP_PASSWORD`,发送 `X-App-Password-Hash`。下载或删除产物时必须复用 capabilities 声明的同一鉴权方式。 ## 调用约束 -- 不要把 API Key、token 或密码写入源码、文档示例、日志或测试快照。 +- 不要把 API Key、token 或访问码写入源码、文档示例、日志或测试快照。 - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。 - 不要在模型上下文中展开大体积 base64,除非用户明确要求。 - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。 - 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。 +- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。 +- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 支持流式;Agent generate/edit 当前以 `non_streaming_only` 声明。 +- 不要调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`。 +- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。 + +## Job Polling + +当 `agent_jobs.supported=true` 时,长耗时文生图可使用: + +1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。 +2. `GET /api/agent/jobs/{id}` 轮询状态。 +3. `GET /api/agent/jobs/{id}/result` 在 `state=succeeded` 后读取标准 `AgentImageResponse`。 + +`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。 + +当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新创建同一 `Idempotency-Key` 的 job。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。 ## 可用脚本 -- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。 -- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。 +- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实生图。 +- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。 +- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。 + +生成脚本常用参数: + +```bash +node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ + --size 2048x2048 \ + --quality high \ + --response-mode path \ + --idempotency-key stable-operation-key \ + "a product photo of a ceramic mug" +``` + +真实生图必须显式开启: + +```bash +node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ + --allow-billable \ + --timeout-ms 420000 \ + --size 2048x2048 \ + "a product photo of a ceramic mug" +``` + +生成脚本会在 capabilities 声明 `agent_jobs.supported=true` 后,对 `quality=high` 且最大边不小于 3072 的请求自动使用 job polling。也可以用 `--job` 强制 job polling,或用 `--no-job` 强制同步 Agent generate。 + +编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` 和 `--allow-billable`。 + +直连上游诊断: + +```bash +OPENAI_API_KEY=... node skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs \ + --base-url https://api.openai.com/v1 +``` + +诊断脚本只输出状态、耗时、脱敏错误摘要、白名单响应头和 base64 长度,不输出 API key 或完整图片数据。 + +上游探针脚本支持 `--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。 脚本读取以下环境变量: - `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。 - `GPT_IMAGE_AGENT_TOKEN`:Bearer token。 -- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 部署时发送的 `X-App-Password-Hash`。 +- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时发送的 `X-App-Password-Hash`。 - `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。 - `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。 - `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。 +`GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。 + 脚本会把服务返回的相对产物路径补充为绝对 URL,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。 ## 参考 diff --git a/skills/gpt-image-playground-agent/references/api.md b/skills/gpt-image-playground-agent/references/api.md index e025ceb1d8f137dbf64de9b60d14ca02ed1d6643..87f1a29c57888d9aeb97bf91177868cf3e91dd51 100644 --- a/skills/gpt-image-playground-agent/references/api.md +++ b/skills/gpt-image-playground-agent/references/api.md @@ -4,10 +4,52 @@ - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。 - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。 +- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。 -脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 做只读契约检查,不触发真实生图或编辑。 -鉴权支持 `GPT_IMAGE_AGENT_TOKEN` 或 `GPT_IMAGE_APP_PASSWORD_HASH`。 +生成和编辑脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`。 +上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。 +脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。 +鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。 当服务返回相对 `content_url` 或 `metadata_url` 时,辅助脚本会额外输出 `absolute_content_url` 和 `absolute_metadata_url`。 +同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。 + +生成脚本参数: + +- `--model`:默认 `gpt-image-2`。 +- `--size`:默认 `1024x1024`。 +- `--quality`:默认 `high`。 +- `--n`:默认 `1`。 +- `--format`:默认 `png`,`jpg` 会规范化为 `jpeg`。 +- `--response-mode`:默认 `path`。 +- `--timeout-ms`:默认 `420000`。 +- `--prompt-file`:从文本文件读取 prompt。 +- `--idempotency-key`:指定稳定幂等键。 +- `--dry-run`:只输出将要发送的 JSON。 +- `--allow-billable`:允许真实调用生图端点。 + +编辑脚本参数: + +- `--model` +- `--size` +- `--quality` +- `--response-mode` +- `--timeout-ms` +- `--idempotency-key` +- `--dry-run` +- `--allow-billable` + +上游探针脚本参数: + +- `--base-url` +- `--model` +- `--prompt` +- `--size` +- `--quality` +- `--format` +- `--timeout-ms` +- `--allow-billable` + +上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。输出不会包含 key,也不会输出完整 base64。 ## 能力查询 @@ -15,7 +57,74 @@ GET /api/agent/capabilities ``` -返回 API 版本、支持的模型、限制、鉴权方式、存储模式、状态后端、幂等设置和端点路径。响应不会公开服务端本地 SQLite 文件路径。 +返回 API 版本、支持的模型、通用限制、模型级限制、Agent 流式边界、鉴权方式、存储模式、状态后端、幂等设置和端点路径。响应不会公开服务端本地 SQLite 文件路径。 + +关键字段: + +- `auth.required`:是否需要鉴权。 +- `auth.schemes`:当前部署实际接受的鉴权方案。`AGENT_API_TOKEN` 优先于 `APP_PASSWORD`,两者同时配置时只返回 `bearer`。 +- `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。 +- `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。 +- `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。 +- `model_limits.gpt-image-2.max_aspect`:最大长短边比例,当前为 `3`。 +- `model_limits.gpt-image-2.min_pixels`:最小总像素,当前为 `655360`。 +- `model_limits.gpt-image-2.recommended_presets`:推荐尺寸预设。 +- `model_limits.gpt-image-2.high_4k_risk`:高质量 4K 级请求的长耗时风险说明。 +- `agent_streaming.generate.mode`:当前为 `non_streaming_only`。 +- `agent_streaming.edit.mode`:当前为 `non_streaming_only`。 +- `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。 +- `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。 +- `agent_jobs.mode`:当前为 `job_polling`。 +- `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。 +- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。 + +当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,4K/high 长耗时请求优先创建 job 并轮询结果;同步 Agent generate 仍适用于普通非流式请求。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。 + +## Job Polling + +```http +POST /api/agent/jobs/images/generate +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +``` + +请求体与 `POST /api/agent/images/generate` 相同。创建成功后返回: + +```json +{ + "job": { + "id": "job-request-uuid", + "request_id": "job-request-uuid", + "idempotency_key": "stable-key", + "mode": "generate", + "state": "running", + "created_at": "2026-05-20T00:00:00.000Z", + "updated_at": "2026-05-20T00:00:00.000Z", + "expires_at": "2026-05-21T00:00:00.000Z", + "result_url": "/api/agent/jobs/job-request-uuid/result", + "retry_after_seconds": 5 + } +} +``` + +轮询状态: + +```http +GET /api/agent/jobs/{id} +``` + +读取结果: + +```http +GET /api/agent/jobs/{id}/result +``` + +`/result` 在运行中返回 `request_in_progress` 和 `Retry-After`;成功后返回标准 `AgentImageResponse`;失败时返回结构化 `AgentError`。失败 job 是终态,`error.retryable` 固定为 `false`,但保留原始错误的 `code`、`message`、`upstream_status` 和 `diagnostics` 用于排查。不存在返回 `job_not_found`,过期返回 `job_expired`。 + +`GET /api/agent/jobs/{id}` 在 `state=failed` 时,`job.error` 也会返回 `retryable=false`,并携带同样的 `code`、`message`、`upstream_status` 和 `diagnostics` 排障字段;`request_id` 已在 `job.request_id` 中提供。 + +如果服务进程在 job 结束前重启,客户端应按 `GET /api/agent/jobs/{id}` 返回的状态继续处理;必要时使用相同 `Idempotency-Key` 重新创建同一 job,避免重复业务操作。同一个 key 命中终态 failed job 时只会返回该失败状态,不会触发新执行;需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。 ## 生成图片 @@ -42,6 +151,8 @@ Content-Type: application/json } ``` +Agent 生成端点当前只支持非流式 JSON 响应。不要向该端点发送 `stream: true`;页面 SSE 使用独立的 `POST /api/images` form-data 路径。 + 响应: ```json @@ -112,11 +223,26 @@ DELETE /api/agent/artifacts/{id} "n": "必须是 1 到 10 之间的整数" } }, + "diagnostics": { + "elapsed_ms": 1234, + "selected_channel_id": "default", + "upstream_host": "api.example.test", + "upstream_status": 524, + "transport_error": false, + "retry_after_seconds": 15, + "channel_cooldown_scope": "channel", + "response_headers": { + "date": "Wed, 20 May 2026 00:00:00 GMT", + "cf-ray": "example" + } + }, "request_id": "uuid" } } ``` +`diagnostics` 只包含脱敏诊断字段和白名单响应头,不包含 API key、token、完整上游响应体或图片 base64。SDK/网络层只有 `Connection error.` 时,`transport_error` 会是 `true`,但不会伪造 `upstream_status`。 + 常见错误码: - `validation_error` @@ -126,6 +252,8 @@ DELETE /api/agent/artifacts/{id} - `idempotency_conflict` - `request_in_progress` - `artifact_not_found` +- `job_not_found` +- `job_expired` - `upstream_rate_limited` - `upstream_auth_failed` - `upstream_unavailable` diff --git a/skills/gpt-image-playground-agent/scripts/edit-image.mjs b/skills/gpt-image-playground-agent/scripts/edit-image.mjs index 9710eab2f960a64d65f214b5bb736d35ac2f0912..181146c0973a14ed9f8be81f87ab12fdd06b2a6e 100644 --- a/skills/gpt-image-playground-agent/scripts/edit-image.mjs +++ b/skills/gpt-image-playground-agent/scripts/edit-image.mjs @@ -2,33 +2,123 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; +import { + errorMessage, + normalizeBaseUrl, + parseRetryAfterValue, + readConfiguredPositiveInteger, + readOptionValue, + sleep +} from './lib/script-utils.mjs'; -const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783'); const token = process.env.GPT_IMAGE_AGENT_TOKEN || ''; const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || ''; -const [imagePath, ...promptParts] = process.argv.slice(2); -const prompt = promptParts.join(' '); -const parsedMaxAttempts = parseInt(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS || '3', 10); -const maxAttempts = Number.isInteger(parsedMaxAttempts) && parsedMaxAttempts > 0 ? parsedMaxAttempts : 3; -const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1'; -const idempotencyKey = process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-edit-${crypto.randomUUID()}`; +const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check'); +let options; +try { + options = parseArgs(process.argv.slice(2)); +} catch (error) { + console.error(errorMessage(error)); + printUsage(); + process.exit(2); +} +const imagePath = options.imagePath; +const prompt = options.promptParts.join(' '); +if (options.help) { + printUsage(); + process.exit(0); +} + +let maxAttempts; +let timeoutMs; +try { + maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3); + timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000); +} catch (error) { + console.error(errorMessage(error)); + printUsage(); + process.exit(2); +} +const idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-edit-${crypto.randomUUID()}`; if ((!imagePath || !prompt) && !contractCheck) { - console.error('用法:edit-image.mjs '); - console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs'); + printUsage(); + process.exit(2); +} + +let baseUrl; +try { + baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783'); +} catch (error) { + console.error(errorMessage(error)); process.exit(2); } +if (options.dryRun || (!contractCheck && !options.allowBillable)) { + console.log( + JSON.stringify( + { + ok: true, + billable: false, + dry_run: true, + endpoint: `${baseUrl}/api/agent/images/edit`, + idempotency_key: idempotencyKey, + request: { + image_path: imagePath, + prompt, + model: options.model, + size: options.size, + quality: options.quality, + response_mode: options.responseMode + }, + next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。' + }, + null, + 2 + ) + ); + process.exit(0); +} + +function parseArgs(argv) { + const parsed = { + model: 'gpt-image-2', + size: 'auto', + quality: 'auto', + responseMode: 'path', + timeoutMs: undefined, + idempotencyKey: undefined, + imagePath: undefined, + dryRun: false, + allowBillable: false, + help: false, + promptParts: [] + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--dry-run') parsed.dryRun = true; + else if (arg === '--allow-billable') parsed.allowBillable = true; + else if (arg === '--help' || arg === '-h') parsed.help = true; + else if (arg === '--contract-check') continue; + else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg); + else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg); + else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg); + else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg); + else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); + else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg); + else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`); + else if (!parsed.imagePath) parsed.imagePath = arg; + else parsed.promptParts.push(arg); + } + return parsed; +} + function authHeaders() { if (token) return { Authorization: `Bearer ${token}` }; if (passwordHash) return { 'X-App-Password-Hash': passwordHash }; return {}; } -function normalizeBaseUrl(value) { - return value.replace(/\/+$/, ''); -} - function absoluteUrl(value) { if (typeof value !== 'string' || !value) return undefined; return new URL(value, `${baseUrl}/`).toString(); @@ -49,11 +139,11 @@ function enrichImageUrls(result) { async function readCapabilities() { let response; try { - response = await fetch(`${baseUrl}/api/agent/capabilities`, { + response = await fetchWithTimeout(`${baseUrl}/api/agent/capabilities`, { headers: authHeaders() }); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = errorMessage(error); throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`); } if (!response.ok) { @@ -63,28 +153,36 @@ async function readCapabilities() { return response.json(); } -function parseRetryAfterValue(value) { - if (!value || !/^\d+$/.test(value)) return 1; - return Math.max(1, Number(value)); -} - function shouldRetry(result) { return Boolean(result?.error?.retryable); } -function sleep(seconds) { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +async function fetchWithTimeout(url, init) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +function printUsage() { + console.error('用法:edit-image.mjs [options] '); + console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。'); + console.error('常用参数:--model --size --quality --response-mode --timeout-ms --idempotency-key --dry-run --allow-billable'); + console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check'); } try { await readCapabilities(); } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + console.error(errorMessage(error)); process.exit(1); } if (contractCheck) { - const response = await fetch(`${baseUrl}/api/agent/images/edit`, { + const response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, { method: 'POST', headers: { 'Idempotency-Key': idempotencyKey, @@ -95,10 +193,10 @@ if (contractCheck) { }); const result = await response.json(); if (response.status === 415 && result?.error?.code === 'validation_error') { - console.log(JSON.stringify({ ok: true, status: response.status, error_code: result.error.code }, null, 2)); + console.log(JSON.stringify({ ok: true, billable: false, status: response.status, error_code: result.error.code }, null, 2)); process.exit(0); } - console.error(JSON.stringify({ ok: false, status: response.status, result }, null, 2)); + console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2)); process.exit(1); } @@ -109,7 +207,7 @@ try { process.exit(2); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = errorMessage(error); console.error(`无法读取图片文件:${imagePath}。${message}`); process.exit(2); } @@ -120,8 +218,10 @@ const imageType = mimeTypeForPath(imagePath); function buildFormData() { const formData = new FormData(); formData.append('prompt', prompt); - formData.append('model', 'gpt-image-2'); - formData.append('response_mode', 'path'); + formData.append('model', options.model); + formData.append('size', options.size); + formData.append('quality', options.quality); + formData.append('response_mode', options.responseMode); formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath)); return formData; } @@ -140,7 +240,7 @@ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { let response; let result; try { - response = await fetch(`${baseUrl}/api/agent/images/edit`, { + response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, { method: 'POST', headers: { 'Idempotency-Key': idempotencyKey, @@ -150,7 +250,7 @@ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { }); result = await response.json(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = errorMessage(error); result = { error: { code: 'network_error', message, retryable: true } }; lastResult = result; lastRetryAfter = 1; diff --git a/skills/gpt-image-playground-agent/scripts/generate-image.mjs b/skills/gpt-image-playground-agent/scripts/generate-image.mjs index a0450e0bb3651137e7050ae181e758987555e66c..1a153d1cf04fc8620a8464a89b73da5a7b7bb2b5 100644 --- a/skills/gpt-image-playground-agent/scripts/generate-image.mjs +++ b/skills/gpt-image-playground-agent/scripts/generate-image.mjs @@ -1,29 +1,205 @@ #!/usr/bin/env node import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { + errorMessage, + normalizeBaseUrl, + normalizeOutputFormat, + parseRetryAfterValue, + readConfiguredPositiveInteger, + readOptionValue, + resolveSameOriginUrl, + sleep +} from './lib/script-utils.mjs'; -const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783'); const token = process.env.GPT_IMAGE_AGENT_TOKEN || ''; const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || ''; -const prompt = process.argv.slice(2).join(' '); -const maxAttempts = Number(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS || '3'); -const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1'; +const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check'); +let options; +try { + options = parseArgs(process.argv.slice(2)); +} catch (error) { + console.error(errorMessage(error)); + printUsage(); + process.exit(2); +} +if (options.help) { + printUsage(); + process.exit(0); +} -if (!prompt && !contractCheck) { - console.error('用法:generate-image.mjs '); - console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs'); +let prompt; +let maxAttempts; +let timeoutMs; +let idempotencyKey; +let requestBody; +try { + maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3); + timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000); + idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-generate-${crypto.randomUUID()}`; + if (isNonBillableDryRun(options, contractCheck)) { + if (!hasPromptSource(options)) { + printUsage(); + process.exit(2); + } + requestBody = buildDryRunRequestBody(options); + } else { + prompt = readPrompt(options, { readPromptFile: !contractCheck }); + requestBody = buildRequestBody(prompt, options); + } +} catch (error) { + console.error(errorMessage(error)); + printUsage(); process.exit(2); } +if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) { + printUsage(); + process.exit(2); +} + +let baseUrl; +try { + baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783'); +} catch (error) { + console.error(errorMessage(error)); + process.exit(2); +} + +if (isNonBillableDryRun(options, contractCheck)) { + console.log( + JSON.stringify( + { + ok: true, + billable: false, + dry_run: true, + endpoint: dryRunEndpoint(options.jobMode), + job_mode: options.jobMode, + idempotency_key: idempotencyKey, + request: requestBody, + next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。' + }, + null, + 2 + ) + ); + process.exit(0); +} + +try { + var capabilities = await readCapabilities(); +} catch (error) { + console.error(errorMessage(error)); + process.exit(1); +} + +if (contractCheck) { + await runContractCheck(capabilities); + process.exit(0); +} + +try { + if (shouldUseJobPolling(capabilities, requestBody, options.jobMode)) { + await runGenerateJob(); + } else { + await runGenerateRequest(); + } +} catch (error) { + console.error(errorMessage(error)); + process.exit(1); +} + +function parseArgs(argv) { + const parsed = { + model: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + n: '1', + format: 'png', + responseMode: 'path', + timeoutMs: undefined, + promptFile: undefined, + idempotencyKey: undefined, + jobMode: 'auto', + dryRun: false, + allowBillable: false, + help: false, + promptParts: [] + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--dry-run') parsed.dryRun = true; + else if (arg === '--allow-billable') parsed.allowBillable = true; + else if (arg === '--job') parsed.jobMode = 'always'; + else if (arg === '--no-job') parsed.jobMode = 'never'; + else if (arg === '--help' || arg === '-h') parsed.help = true; + else if (arg === '--contract-check') continue; + else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg); + else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg); + else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg); + else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg); + else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg); + else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg); + else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); + else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg); + else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg); + else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`); + else parsed.promptParts.push(arg); + } + return parsed; +} + +function readPrompt(parsed, { readPromptFile }) { + if (parsed.promptFile) { + if (readPromptFile) { + return fs.readFileSync(parsed.promptFile, 'utf8'); + } + return parsed.promptParts.join(' ') || 'contract check'; + } + return parsed.promptParts.join(' '); +} + +function buildRequestBody(promptValue, parsed) { + return { + prompt: promptValue || 'contract check', + model: parsed.model, + n: readConfiguredPositiveInteger(parsed.n, '--n', 1), + size: parsed.size, + quality: parsed.quality, + output_format: normalizeOutputFormat(parsed.format), + response_mode: parsed.responseMode + }; +} + +function buildDryRunRequestBody(parsed) { + const body = { + model: parsed.model, + n: readConfiguredPositiveInteger(parsed.n, '--n', 1), + size: parsed.size, + quality: parsed.quality, + output_format: normalizeOutputFormat(parsed.format), + response_mode: parsed.responseMode + }; + if (parsed.promptFile) { + return { ...body, prompt_file: parsed.promptFile }; + } + return { ...body, prompt: parsed.promptParts.join(' ') }; +} + +function hasPromptSource(parsed) { + return Boolean(parsed.promptFile || parsed.promptParts.length > 0); +} + +function isNonBillableDryRun(parsed, isContractCheck) { + return parsed.dryRun || (!isContractCheck && !parsed.allowBillable); +} + function authHeaders() { if (token) return { Authorization: `Bearer ${token}` }; if (passwordHash) return { 'X-App-Password-Hash': passwordHash }; return {}; } -function normalizeBaseUrl(value) { - return value.replace(/\/+$/, ''); -} - function absoluteUrl(value) { if (typeof value !== 'string' || !value) return undefined; return new URL(value, `${baseUrl}/`).toString(); @@ -41,97 +217,218 @@ function enrichImageUrls(result) { }; } +function dryRunEndpoint(jobMode) { + if (jobMode === 'always') return `${baseUrl}/api/agent/jobs/images/generate`; + if (jobMode === 'never') return `${baseUrl}/api/agent/images/generate`; + return `${baseUrl}/api/agent/images/generate 或 ${baseUrl}/api/agent/jobs/images/generate`; +} + async function readCapabilities() { - let response; - try { - response = await fetch(`${baseUrl}/api/agent/capabilities`, { - headers: authHeaders() - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`); - } + const { response, result, text } = await fetchJson(`${baseUrl}/api/agent/capabilities`, { + headers: authHeaders(), + timeoutMs + }); if (!response.ok) { - const body = await response.text(); - throw new Error(`capabilities 请求失败,状态码 ${response.status}:${body}`); + throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`); } - return response.json(); + return result; } -function parseRetryAfter(response) { - const value = response.headers.get('retry-after'); - if (!value || !/^\d+$/.test(value)) return 1; - return Math.max(1, Number(value)); -} +async function runGenerateRequest() { + let lastResult; + let lastRetryAfter = null; -function shouldRetry(result) { - return Boolean(result?.error?.retryable); + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + ...authHeaders() + }, + body: JSON.stringify(requestBody), + timeoutMs + }); + + if (response.ok) { + console.log(JSON.stringify(enrichImageUrls(result), null, 2)); + process.exit(0); + } + + const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')); + lastResult = result; + lastRetryAfter = retryAfter; + if (!shouldRetry(result) || attempt === maxAttempts) break; + await sleep(retryAfter); + } + + console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); + process.exit(1); } -function sleep(seconds) { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +async function runGenerateJob() { + let lastResult; + let lastRetryAfter = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const { response, result } = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + ...authHeaders() + }, + body: JSON.stringify(requestBody), + timeoutMs + }); + + if (response.ok) { + const jobResult = await pollJobResult(result?.job); + console.log(JSON.stringify(enrichImageUrls(jobResult), null, 2)); + process.exit(0); + } + + const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')); + lastResult = result; + lastRetryAfter = retryAfter; + if (!shouldRetry(result) || attempt === maxAttempts) break; + await sleep(retryAfter); + } + + console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); + process.exit(1); } -try { - await readCapabilities(); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); +async function pollJobResult(job) { + if (!job || typeof job.id !== 'string') { + throw new Error('创建 job 的响应缺少 job.id。'); + } + const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || `/api/agent/jobs/${job.id}/result`, 'job.result_url'); + const deadlineMs = Date.now() + timeoutMs; + let lastResult; + let lastRetryAfter = job.retry_after_seconds || 1; + + while (Date.now() < deadlineMs) { + const { response, result } = await fetchJson(resultUrl, { + headers: authHeaders(), + timeoutMs + }); + if (response.ok) return result; + + const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')) || lastRetryAfter; + lastResult = result; + lastRetryAfter = retryAfter; + if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break; + await sleep(retryAfter); + } + + console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); process.exit(1); } -if (contractCheck) { - const response = await fetch(`${baseUrl}/api/agent/images/generate`, { +async function runContractCheck(capabilitiesValue) { + const checks = []; + const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body: JSON.stringify({ - prompt: 'contract check', - model: 'gpt-image-2', - response_mode: 'path' - }) + body: JSON.stringify(requestBody), + timeoutMs }); - const result = await response.json(); if (response.status === 400 && result?.error?.code === 'idempotency_key_required') { - console.log(JSON.stringify({ ok: true, status: response.status, error_code: result.error.code }, null, 2)); - process.exit(0); + checks.push({ endpoint: '/api/agent/images/generate', status: response.status, error_code: result.error.code }); + } else { + console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2)); + process.exit(1); } - console.error(JSON.stringify({ ok: false, status: response.status, result }, null, 2)); - process.exit(1); + + if (supportsJobPolling(capabilitiesValue)) { + const jobCheck = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify(requestBody), + timeoutMs + }); + if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') { + console.error( + JSON.stringify({ ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result }, null, 2) + ); + process.exit(1); + } + checks.push({ + endpoint: '/api/agent/jobs/images/generate', + status: jobCheck.response.status, + error_code: jobCheck.result.error.code + }); + } + + console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2)); } -const idempotencyKey = process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-generate-${crypto.randomUUID()}`; -let lastResult; -let lastRetryAfter = 0; +async function fetchJson(url, init) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), init.timeoutMs); + try { + const fetchInit = { ...init }; + delete fetchInit.timeoutMs; + const response = await fetch(url, { ...fetchInit, signal: controller.signal }); + const text = await response.text(); + const result = parseJsonResponse(text, response.ok, url); + return { response, result, text }; + } catch (error) { + const message = errorMessage(error); + throw new Error(`请求失败:${url}。${message}`); + } finally { + clearTimeout(timeout); + } +} -for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const response = await fetch(`${baseUrl}/api/agent/images/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Idempotency-Key': idempotencyKey, - ...authHeaders() - }, - body: JSON.stringify({ - prompt, - model: 'gpt-image-2', - response_mode: 'path' - }) - }); +function parseJsonResponse(text, isOk, url) { + if (!text) return null; + try { + return JSON.parse(text); + } catch (error) { + if (!isOk) return null; + const message = errorMessage(error); + throw new Error(`响应不是有效 JSON:${url}。${message}`); + } +} + +function shouldRetry(result) { + return Boolean(result?.error?.retryable); +} - const result = await response.json(); - if (response.ok) { - console.log(JSON.stringify(enrichImageUrls(result), null, 2)); - process.exit(0); +function supportsJobPolling(capabilitiesValue) { + return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling'); +} + +function shouldUseJobPolling(capabilitiesValue, request, jobMode) { + if (jobMode === 'never') return false; + if (!supportsJobPolling(capabilitiesValue)) { + if (jobMode === 'always') { + throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。'); + } + return false; } + if (jobMode === 'always') return true; + return request.quality === 'high' && readMaxImageEdge(request.size) >= 3072; +} - const retryAfter = response.headers.get('retry-after'); - lastResult = result; - lastRetryAfter = retryAfter; - if (!shouldRetry(result) || attempt === maxAttempts) break; - await sleep(parseRetryAfter(response)); +function readMaxImageEdge(size) { + if (typeof size !== 'string') return 0; + const match = size.match(/^(\d+)x(\d+)$/); + if (!match) return 0; + return Math.max(Number(match[1]), Number(match[2])); } -console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); -process.exit(1); +function printUsage() { + console.error('用法:generate-image.mjs [options] '); + console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。'); + console.error('常用参数:--model --size --quality --n --format --response-mode --timeout-ms --prompt-file --idempotency-key --job --no-job'); + console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'); +} diff --git a/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs b/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9f6eda7b8f72cfa59a61c5d0775c7d5def1ab974 --- /dev/null +++ b/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs @@ -0,0 +1,71 @@ +const MAX_RETRY_AFTER_SECONDS = 60; + +export function readOptionValue(argv, index, name) { + const value = argv[index]; + if (!value || value.startsWith('--')) { + throw new Error(`${name} 需要参数值。`); + } + return value; +} + +export function readConfiguredPositiveInteger(value, name, fallback) { + if (value === undefined || value === null || value === '') return fallback; + if (!/^\d+$/.test(String(value))) { + throw new Error(`${name} 必须是正整数。`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`${name} 必须是正整数。`); + } + return parsed; +} + +export function normalizeBaseUrl(value) { + const normalized = String(value || '').trim().replace(/\/+$/, ''); + let parsed; + try { + parsed = new URL(normalized); + } catch { + throw new Error('base URL 必须是有效的 http/https 绝对 URL。'); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('base URL 必须使用 http 或 https。'); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('base URL 不能包含凭据、查询参数或片段。'); + } + return normalized; +} + +export function normalizeOutputFormat(value) { + return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase(); +} + +export function parseRetryAfterValue(value, fallback = 1) { + if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) return MAX_RETRY_AFTER_SECONDS; + return clampRetryAfterSeconds(parsed); +} + +export function sleep(seconds) { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +function clampRetryAfterSeconds(value) { + if (!Number.isFinite(value)) return MAX_RETRY_AFTER_SECONDS; + return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.round(value))); +} + +export function resolveSameOriginUrl(baseUrl, value, label) { + const base = new URL(baseUrl); + const resolved = new URL(value, `${baseUrl}/`); + if (resolved.origin !== base.origin) { + throw new Error(`${label} 指向不同 origin,拒绝携带鉴权头访问。`); + } + return resolved.toString(); +} + +export function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs b/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a5c5a637e924b39d6b4955e90984cb00869f0022 --- /dev/null +++ b/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +import dns from 'node:dns/promises'; +import tls from 'node:tls'; +import { + errorMessage, + normalizeBaseUrl, + normalizeOutputFormat, + readConfiguredPositiveInteger, + readOptionValue +} from './lib/script-utils.mjs'; + +const HEADER_ALLOWLIST = new Set(['content-type', 'date', 'server', 'cf-ray', 'x-request-id', 'retry-after']); +let options; +try { + options = parseArgs(process.argv.slice(2)); +} catch (error) { + console.error(errorMessage(error)); + printUsage(); + process.exit(2); +} +if (options.help) { + printUsage(); + process.exit(0); +} + +let baseUrl; +try { + baseUrl = normalizeBaseUrl( + options.baseUrl || process.env.GPT_IMAGE_UPSTREAM_BASE_URL || process.env.OPENAI_API_BASE_URL || 'https://api.openai.com/v1' + ); +} catch (error) { + console.error(errorMessage(error)); + process.exit(2); +} +const apiKey = process.env.GPT_IMAGE_UPSTREAM_API_KEY || process.env.OPENAI_API_KEY || ''; +let timeoutMs; +try { + timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 30000); +} catch (error) { + console.error(errorMessage(error)); + printUsage(); + process.exit(2); +} +let upstream; +try { + upstream = new URL(baseUrl); +} catch { + console.error(`无效的上游 base URL:${baseUrl}`); + process.exit(2); +} + +const report = { + ok: false, + billable: false, + base_url: baseUrl, + upstream_host: upstream.host, + api_key_configured: Boolean(apiKey), + dns: await probeDns(upstream.hostname), + tls: await probeTls(upstream), + models: await probeModels() +}; + +if (options.allowBillable) { + report.generation = await probeGeneration(); + report.billable = true; +} + +report.ok = Boolean(report.models.ok && (!report.generation || report.generation.ok)); +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); + +function parseArgs(argv) { + const parsed = { + baseUrl: undefined, + model: 'gpt-image-2', + prompt: 'contract probe', + size: '1024x1024', + quality: 'low', + format: 'png', + timeoutMs: undefined, + allowBillable: false, + help: false + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg); + else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg); + else if (arg === '--prompt') parsed.prompt = readOptionValue(argv, (index += 1), arg); + else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg); + else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg); + else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg); + else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); + else if (arg === '--allow-billable') parsed.allowBillable = true; + else if (arg === '--help' || arg === '-h') parsed.help = true; + else throw new Error(`未知参数:${arg}`); + } + return parsed; +} + +async function probeDns(hostname) { + const startedAt = Date.now(); + try { + const result = await dns.lookup(hostname); + return { ok: true, elapsed_ms: Date.now() - startedAt, address_family: result.family }; + } catch (error) { + return { ok: false, elapsed_ms: Date.now() - startedAt, error: errorMessage(error) }; + } +} + +async function probeTls(url) { + if (url.protocol !== 'https:') { + return { ok: true, skipped: true, reason: 'non_https_base_url' }; + } + const startedAt = Date.now(); + return new Promise((resolve) => { + const socket = tls.connect({ + host: url.hostname, + port: Number(url.port || 443), + servername: url.hostname, + timeout: timeoutMs + }); + socket.once('secureConnect', () => { + const protocol = socket.getProtocol() || undefined; + socket.end(); + resolve({ ok: true, elapsed_ms: Date.now() - startedAt, authorized: socket.authorized, protocol }); + }); + socket.once('timeout', () => { + socket.destroy(); + resolve({ ok: false, elapsed_ms: Date.now() - startedAt, error: 'TLS handshake timed out.' }); + }); + socket.once('error', (error) => { + resolve({ ok: false, elapsed_ms: Date.now() - startedAt, error: errorMessage(error) }); + }); + }); +} + +async function probeModels() { + const { response, json, text, elapsedMs } = await fetchJson(`${baseUrl}/models`, { method: 'GET' }); + return { + ok: response.ok, + status: response.status, + elapsed_ms: elapsedMs, + content_type: response.headers.get('content-type') || undefined, + response_headers: readAllowedHeaders(response.headers), + ...summarizeJson(json, text) + }; +} + +async function probeGeneration() { + const { response, json, text, elapsedMs } = await fetchJson(`${baseUrl}/images/generations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: options.model, + prompt: options.prompt, + n: 1, + size: options.size, + quality: options.quality, + output_format: normalizeOutputFormat(options.format) + }) + }); + return { + ok: response.ok, + billable: true, + status: response.status, + elapsed_ms: elapsedMs, + content_type: response.headers.get('content-type') || undefined, + response_headers: readAllowedHeaders(response.headers), + ...summarizeGenerationJson(json, text) + }; +} + +async function fetchJson(url, init) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = Date.now(); + try { + const response = await fetch(url, { + ...init, + headers: { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + ...(init.headers || {}) + }, + signal: controller.signal + }); + const text = await response.text(); + return { response, text, json: parseJson(text), elapsedMs: Date.now() - startedAt }; + } catch (error) { + return { + response: new Response(JSON.stringify({ error: errorMessage(error) }), { status: 599 }), + text: '', + json: { error: errorMessage(error) }, + elapsedMs: Date.now() - startedAt + }; + } finally { + clearTimeout(timeout); + } +} + +function summarizeJson(json, text) { + if (Array.isArray(json?.data)) { + return { model_count: json.data.length }; + } + return { error: summarizeError(json, text) }; +} + +function summarizeGenerationJson(json, text) { + if (!Array.isArray(json?.data)) { + return { error: summarizeError(json, text) }; + } + const firstImage = json.data[0] || {}; + const firstB64 = typeof firstImage.b64_json === 'string' ? firstImage.b64_json : ''; + return { + image_count: json.data.length, + first_b64_length: firstB64.length, + usage: json.usage || undefined + }; +} + +function summarizeError(json, text) { + const error = json?.error; + if (typeof error === 'object' && error) { + return { + code: typeof error.code === 'string' ? error.code : undefined, + type: typeof error.type === 'string' ? error.type : undefined, + message: typeof error.message === 'string' ? error.message : undefined + }; + } + return text ? { message: text.slice(0, 500) } : undefined; +} + +function readAllowedHeaders(headers) { + const result = {}; + for (const name of HEADER_ALLOWLIST) { + const value = headers.get(name); + if (value) result[name] = value; + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function parseJson(text) { + try { + return text ? JSON.parse(text) : null; + } catch { + return null; + } +} + +function printUsage() { + console.error('用法:probe-upstream-image.mjs [options]'); + console.error('默认只请求 /models;添加 --allow-billable 才会调用 /images/generations。'); + console.error('常用参数:--base-url --model --prompt --size --quality --format --timeout-ms --allow-billable'); +} diff --git a/src/app/api/agent/agent-routes.test.ts b/src/app/api/agent/agent-routes.test.ts index a9db85a1c0301c86a59a18e3dd033079f054f912..024c85ee8f8cf1d1058e882f276c3a9e325ca37c 100644 --- a/src/app/api/agent/agent-routes.test.ts +++ b/src/app/api/agent/agent-routes.test.ts @@ -56,14 +56,15 @@ describe('Agent route integration', () => { const { getCapabilities } = await loadAgentRoutes(); process.env.AGENT_STATE_BACKEND = 'memory'; process.env.AGENT_API_TOKEN = 'capability-token'; - process.env.APP_PASSWORD = 'page-password'; + process.env.APP_PASSWORD = 'page-access-code'; const response = await getCapabilities(); assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.auth.required, true); + assert.deepEqual(body.auth.schemes, ['bearer']); assert.equal(JSON.stringify(body).includes('capability-token'), false); - assert.equal(JSON.stringify(body).includes('page-password'), false); + assert.equal(JSON.stringify(body).includes('page-access-code'), false); assert.equal(body.defaults.state_backend, 'memory'); }); @@ -76,6 +77,7 @@ describe('Agent route integration', () => { assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.auth.required, false); + assert.deepEqual(body.auth.schemes, []); }); 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', () => { const secondBody = await second.json(); assert.equal(secondBody.cached, true); assert.equal(second.headers.get('x-idempotent-replay'), 'true'); + assert.equal(second.headers.get('x-request-id'), firstBody.request_id); assert.equal(secondBody.request_id, firstBody.request_id); assert.equal(upstreamCalls, 1); @@ -220,6 +223,297 @@ describe('Agent route integration', () => { assert.equal(body.error.retryable, false); }); + it('returns sanitized upstream diagnostics for failed generate requests', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + throw new Error('upstream failed'); + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const idempotencyKey = 'route-upstream-diagnostics-key'; + const response = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' })); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error.code, 'upstream_unavailable'); + assert.equal(body.error.upstream_status, 500); + assert.equal(body.error.diagnostics.upstream_status, 500); + assert.equal(body.error.diagnostics.selected_channel_id, 'default'); + assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/); + assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel'); + assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number'); + assert.equal(JSON.stringify(body).includes('test-key'), false); + const upstreamCallsAfterFirstFailure = upstreamCalls; + + const replay = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' })); + assert.equal(replay.status, 502); + assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); + const replayBody = await replay.json(); + assert.equal(replayBody.error.code, 'upstream_unavailable'); + assert.equal(replayBody.error.retryable, false); + assert.equal(replayBody.error.request_id, body.error.request_id); + assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure); + } finally { + await upstream.close(); + } + }); + + it('creates a generate job, exposes running status, and returns the completed result', async () => { + const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); + let releaseUpstream: (() => void) | undefined; + let upstreamCalls = 0; + const upstream = await startImageUpstream(async () => { + upstreamCalls += 1; + await new Promise((resolve) => { + releaseUpstream = resolve; + }); + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const created = await createGenerateJob(agentJobJsonRequest('route-job-key', { prompt: 'agent route job' })); + assert.equal(created.status, 202); + const createdBody = await created.json(); + assert.equal(createdBody.job.state, 'running'); + assert.equal(createdBody.job.idempotency_key, 'route-job-key'); + assert.equal(createdBody.job.result_url, `/api/agent/jobs/${createdBody.job.id}/result`); + + await waitFor(() => upstreamCalls === 1); + const running = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { + params: Promise.resolve({ id: createdBody.job.id }) + }); + assert.equal(running.status, 200); + assert.equal((await running.json()).job.state, 'running'); + + releaseUpstream?.(); + const result = await waitForJobResult(getJobResult, createdBody.job.id); + assert.equal(result.status, 200); + const resultBody = await result.json(); + assert.equal(resultBody.request_id, createdBody.job.id); + assert.equal(resultBody.cached, false); + assert.equal(resultBody.images.length, 1); + assert.equal('b64_json' in resultBody.images[0], false); + } finally { + releaseUpstream?.(); + await upstream.close(); + } + }); + + it('reuses the running generate job for the same idempotency key', async () => { + const { createGenerateJob, getJobResult } = await loadAgentRoutes(); + let releaseUpstream: (() => void) | undefined; + let upstreamCalls = 0; + const upstream = await startImageUpstream(async () => { + upstreamCalls += 1; + await new Promise((resolve) => { + releaseUpstream = resolve; + }); + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const first = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' })); + assert.equal(first.status, 202); + const firstBody = await first.json(); + await waitFor(() => upstreamCalls === 1); + + const second = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' })); + assert.equal(second.status, 202); + assert.equal(second.headers.get('x-idempotent-replay'), 'true'); + const secondBody = await second.json(); + assert.equal(secondBody.job.id, firstBody.job.id); + assert.equal(secondBody.job.state, 'running'); + assert.equal(upstreamCalls, 1); + + releaseUpstream?.(); + const result = await waitForJobResult(getJobResult, firstBody.job.id); + assert.equal(result.status, 200); + } finally { + releaseUpstream?.(); + await upstream.close(); + } + }); + + it('rejects generate job idempotency keys reused with a different request body', async () => { + const { createGenerateJob, getJobResult } = await loadAgentRoutes(); + let releaseUpstream: (() => void) | undefined; + let upstreamCalls = 0; + const upstream = await startImageUpstream(async () => { + upstreamCalls += 1; + await new Promise((resolve) => { + releaseUpstream = resolve; + }); + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const first = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' })); + assert.equal(first.status, 202); + const firstBody = await first.json(); + await waitFor(() => upstreamCalls === 1); + + const conflict = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' })); + assert.equal(conflict.status, 409); + assert.equal((await conflict.json()).error.code, 'idempotency_conflict'); + assert.equal(upstreamCalls, 1); + + releaseUpstream?.(); + const result = await waitForJobResult(getJobResult, firstBody.job.id); + assert.equal(result.status, 200); + } finally { + releaseUpstream?.(); + await upstream.close(); + } + }); + + it('returns stored Agent errors for failed generate jobs', async () => { + const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + throw new Error('job upstream failed'); + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const created = await createGenerateJob(agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' })); + assert.equal(created.status, 202); + const createdBody = await created.json(); + + const result = await waitForJobResult(getJobResult, createdBody.job.id); + assert.equal(result.status, 502); + const resultBody = await result.json(); + assert.equal(resultBody.error.code, 'upstream_unavailable'); + assert.equal(resultBody.error.retryable, false); + assert.equal(resultBody.error.upstream_status, 500); + assert.equal(resultBody.error.diagnostics.upstream_status, 500); + assert.equal(resultBody.error.request_id, createdBody.job.id); + assert.equal(JSON.stringify(resultBody).includes('test-key'), false); + + const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { + params: Promise.resolve({ id: createdBody.job.id }) + }); + assert.equal(status.status, 200); + const statusBody = await status.json(); + assert.equal(statusBody.job.state, 'failed'); + assert.equal(statusBody.job.error.code, 'upstream_unavailable'); + assert.equal(statusBody.job.error.retryable, false); + assert.equal(statusBody.job.error.upstream_status, 500); + assert.equal(statusBody.job.error.diagnostics.upstream_status, 500); + assert.equal(upstreamCalls > 0, true); + } finally { + await upstream.close(); + } + }); + + it('keeps a long-running generate job leased while the upstream call is still active', async () => { + process.env.AGENT_REQUEST_LEASE_MS = '200'; + process.env.AGENT_RECOVERY_INTERVAL_MS = '50'; + const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); + let releaseUpstream: (() => void) | undefined; + let upstreamCalls = 0; + const upstream = await startImageUpstream(async () => { + upstreamCalls += 1; + await new Promise((resolve) => { + releaseUpstream = resolve; + }); + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const created = await createGenerateJob(agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' })); + assert.equal(created.status, 202); + const createdBody = await created.json(); + await waitFor(() => upstreamCalls === 1); + await new Promise((resolve) => setTimeout(resolve, 350)); + + const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { + params: Promise.resolve({ id: createdBody.job.id }) + }); + assert.equal(status.status, 200); + const statusBody = await status.json(); + assert.equal(statusBody.job.state, 'running'); + + releaseUpstream?.(); + const result = await waitForJobResult(getJobResult, createdBody.job.id); + assert.equal(result.status, 200); + } finally { + releaseUpstream?.(); + await upstream.close(); + } + }); + + it('returns structured errors for missing and expired jobs', async () => { + const { getJob, getJobResult } = await loadAgentRoutes(); + const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); + + const missing = await getJob(new Request('http://localhost/api/agent/jobs/missing-job'), { + params: Promise.resolve({ id: 'missing-job' }) + }); + assert.equal(missing.status, 404); + assert.equal((await missing.json()).error.code, 'job_not_found'); + + setAgentStateStoreFactoryForTests(() => ({ + async init() {}, + async recoverExpiredRequests() { + return 0; + }, + async purgeExpiredRequests() { + return 0; + }, + async beginRequest() { + throw new Error('not used'); + }, + async refreshRequestLease() { + return false; + }, + async saveArtifacts() {}, + async completeRequest() {}, + async failRequest() {}, + async getRequest() { + return { + requestId: 'expired-job', + idempotencyKey: 'expired-key', + requestHash: 'hash', + mode: 'generate', + status: 'running', + requestJson: { prompt: 'expired' }, + createdAt: '2026-05-12T00:00:00.000Z', + updatedAt: '2026-05-12T00:00:00.000Z', + expiresAt: '2026-05-12T00:00:01.000Z' + }; + }, + async getArtifact() { + return undefined; + }, + async listArtifactsForRequest() { + return []; + }, + async deleteArtifact() { + return false; + } + })); + resetAgentStateStoreForTests(); + + const expired = await getJobResult(new Request('http://localhost/api/agent/jobs/expired-job/result'), { + params: Promise.resolve({ id: 'expired-job' }) + }); + assert.equal(expired.status, 410); + assert.equal((await expired.json()).error.code, 'job_expired'); + }); + it('edits through multipart input and replays the cached response for the same idempotency key', async () => { const { editImage } = await loadAgentRoutes(); let upstreamCalls = 0; @@ -242,6 +536,7 @@ describe('Agent route integration', () => { const secondBody = await second.json(); assert.equal(secondBody.cached, true); assert.equal(second.headers.get('x-idempotent-replay'), 'true'); + assert.equal(second.headers.get('x-request-id'), firstBody.request_id); assert.equal(secondBody.request_id, firstBody.request_id); assert.equal(upstreamCalls, 1); @@ -304,6 +599,126 @@ describe('Agent route integration', () => { assert.equal(body.error.retryable, false); }); + it('returns sanitized upstream diagnostics for failed edit requests', async () => { + const { editImage } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + throw new Error('edit upstream failed'); + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const idempotencyKey = 'route-edit-upstream-diagnostics-key'; + const response = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics')); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error.code, 'upstream_unavailable'); + assert.equal(body.error.upstream_status, 500); + assert.equal(body.error.diagnostics.upstream_status, 500); + assert.equal(body.error.diagnostics.selected_channel_id, 'default'); + assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/); + assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel'); + assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number'); + assert.equal(JSON.stringify(body).includes('test-key'), false); + const upstreamCallsAfterFirstFailure = upstreamCalls; + + const replay = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics')); + assert.equal(replay.status, 502); + assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); + const replayBody = await replay.json(); + assert.equal(replayBody.error.code, 'upstream_unavailable'); + assert.equal(replayBody.error.retryable, false); + assert.equal(replayBody.error.request_id, body.error.request_id); + assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure); + } finally { + await upstream.close(); + } + }); + + it('does not mark a real upstream success as failed when edit state completion fails', async () => { + const { editImage } = await loadAgentRoutes(); + const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); + let upstreamCalls = 0; + let failCalls = 0; + let saveCalls = 0; + const requestId = 'edit-completion-failure-request'; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + setAgentStateStoreFactoryForTests(() => ({ + async init() {}, + async recoverExpiredRequests() { + return 0; + }, + async purgeExpiredRequests() { + return 0; + }, + async beginRequest() { + return { + type: 'acquired', + record: { + requestId, + idempotencyKey: 'edit-completion-failure-key', + requestHash: 'hash', + mode: 'edit', + status: 'running', + requestJson: { fields: { prompt: 'state completion failure' } }, + createdAt: '2026-05-12T00:00:00.000Z', + updatedAt: '2026-05-12T00:00:00.000Z', + expiresAt: '2026-05-13T00:00:00.000Z' + } + }; + }, + async refreshRequestLease() { + return false; + }, + async saveArtifacts() { + saveCalls += 1; + }, + async completeRequest() { + throw new Error('state completion failed'); + }, + async failRequest() { + failCalls += 1; + }, + async getRequest() { + return undefined; + }, + async getArtifact() { + return undefined; + }, + async listArtifactsForRequest() { + return []; + }, + async deleteArtifact() { + return false; + } + })); + + const originalConsoleError = console.error; + console.error = () => {}; + try { + const response = await editImage(agentEditRequest('edit-completion-failure-key', 'state completion failure')); + + assert.equal(response.status, 500); + const body = await response.json(); + assert.equal(body.error.code, 'unexpected_error'); + assert.equal(body.error.retryable, true); + assert.equal(body.error.request_id, requestId); + assert.equal(upstreamCalls, 1); + assert.equal(saveCalls, 1); + assert.equal(failCalls, 0); + } finally { + console.error = originalConsoleError; + await upstream.close(); + } + }); + it('requires artifact content authorization and returns image bytes when authorized', async () => { const { generateImage, getArtifact, getArtifactContent, deleteArtifact } = await loadAgentRoutes(); const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); @@ -424,6 +839,9 @@ describe('Agent route integration', () => { } }; }, + async refreshRequestLease() { + return false; + }, async saveArtifacts() { saveCalls += 1; }, @@ -433,6 +851,9 @@ describe('Agent route integration', () => { async failRequest() { failCalls += 1; }, + async getRequest() { + return undefined; + }, async getArtifact() { return undefined; }, @@ -495,6 +916,9 @@ describe('Agent route integration', () => { } }; }, + async refreshRequestLease() { + return false; + }, async saveArtifacts() { throw new Error('artifact metadata save failed'); }, @@ -503,6 +927,9 @@ describe('Agent route integration', () => { assert.equal(input.requestId, requestId); assert.equal(input.error.error.retryable, true); }, + async getRequest() { + return undefined; + }, async getArtifact() { return undefined; }, @@ -596,10 +1023,16 @@ async function loadAgentRoutes() { const artifactRoute = await import('./artifacts/[id]/route'); const artifactContentRoute = await import('./artifacts/[id]/content/route'); const capabilitiesRoute = await import('./capabilities/route'); + const createGenerateJobRoute = await import('./jobs/images/generate/route'); + const jobRoute = await import('./jobs/[id]/route'); + const jobResultRoute = await import('./jobs/[id]/result/route'); return { getCapabilities: capabilitiesRoute.GET, generateImage: generateRoute.POST, editImage: editRoute.POST, + createGenerateJob: createGenerateJobRoute.POST, + getJob: jobRoute.GET, + getJobResult: jobResultRoute.GET, getArtifact: artifactRoute.GET, deleteArtifact: artifactRoute.DELETE, getArtifactContent: artifactContentRoute.GET @@ -618,6 +1051,18 @@ function agentJsonRequest(idempotencyKey: string, body: Record, }); } +function agentJobJsonRequest(idempotencyKey: string, body: Record, headers: Record = {}) { + return new Request('http://localhost/api/agent/jobs/images/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + ...headers + }, + body: JSON.stringify(body) + }); +} + function agentEditRequest( idempotencyKey: string, prompt: string, @@ -716,3 +1161,22 @@ async function waitFor(predicate: () => boolean): Promise { } throw new Error('等待条件超时'); } + +async function waitForJobResult( + getJobResult: ( + request: Request, + context: { params: Promise<{ id: string }> } + ) => Promise, + id: string +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const response = await getJobResult(new Request(`http://localhost/api/agent/jobs/${id}/result`), { + params: Promise.resolve({ id }) + }); + if (response.status !== 409) { + return response; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error('等待 job result 超时'); +} diff --git a/src/app/api/agent/images/edit/route.ts b/src/app/api/agent/images/edit/route.ts index c384259428b36f580ada400ee057a26dec31e222..c1868a1a8234b838e169e237c5249942d1ee5927 100644 --- a/src/app/api/agent/images/edit/route.ts +++ b/src/app/api/agent/images/edit/route.ts @@ -39,11 +39,13 @@ export async function POST(request: NextRequest) { if (beginResult.type === 'replay') { const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response); - return NextResponse.json(response, { headers: { 'X-Idempotent-Replay': 'true' } }); + return NextResponse.json(response, { + headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': beginResult.record.requestId } + }); } if (beginResult.type === 'failed') { requestId = beginResult.record.requestId; - return storedAgentErrorResponse(beginResult.error); + return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' }); } if (beginResult.type === 'conflict') { throw new AgentApiError({ @@ -93,9 +95,7 @@ export async function POST(request: NextRequest) { await completeAgentExecutionState(store, execution); } catch (error) { appLogger.error('保存 Agent 编辑完成状态失败。', error); - const persistenceError = createCompletionPersistenceError(); - await store.failRequest({ requestId, error: errorToAgentErrorBody(persistenceError, requestId) }); - throw persistenceError; + throw createCompletionPersistenceError(); } return NextResponse.json(execution.response, { headers }); } catch (error) { diff --git a/src/app/api/agent/images/generate/route.ts b/src/app/api/agent/images/generate/route.ts index fed2af814e6e65c09fbce978b0610fcf3385fad1..2374fea65a8e3a28e34bcd89cf87a3e2e1f80173 100644 --- a/src/app/api/agent/images/generate/route.ts +++ b/src/app/api/agent/images/generate/route.ts @@ -37,11 +37,13 @@ export async function POST(request: NextRequest) { if (beginResult.type === 'replay') { const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response); - return NextResponse.json(response, { headers: { 'X-Idempotent-Replay': 'true' } }); + return NextResponse.json(response, { + headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': beginResult.record.requestId } + }); } if (beginResult.type === 'failed') { requestId = beginResult.record.requestId; - return storedAgentErrorResponse(beginResult.error); + return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' }); } if (beginResult.type === 'conflict') { throw new AgentApiError({ diff --git a/src/app/api/agent/jobs/[id]/result/route.ts b/src/app/api/agent/jobs/[id]/result/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc3e91092a34ba3248a639a8b3fc1f13ff55eace --- /dev/null +++ b/src/app/api/agent/jobs/[id]/result/route.ts @@ -0,0 +1,51 @@ +import { + assertReadableJobRecord, + readAgentJobState, + readCompletedJobResult +} from '@/lib/agent-job-service'; +import { + AgentApiError, + agentErrorResponse, + normalizeAgentError, + storedAgentErrorResponse +} from '@/lib/api-error-response'; +import { assertAgentAuthorized } from '@/lib/agent-auth'; +import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime'; +import { computeRetryAfterSeconds, createRequestId } from '@/lib/agent-state-store'; +import { NextRequest, NextResponse } from 'next/server'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + let requestId = createRequestId(); + try { + assertAgentAuthorized(request.headers); + const { id } = await context.params; + const store = await ensureAgentStateStoreReady(); + const record = assertReadableJobRecord(await store.getRequest(id), id); + requestId = record.requestId; + const result = await readCompletedJobResult(store, record); + if (result.type === 'response') { + return NextResponse.json(result.response, { headers: { 'X-Request-Id': requestId } }); + } + if (result.type === 'stored_error') { + return storedAgentErrorResponse(result.error); + } + throw createJobInProgressError(record); + } catch (error) { + return agentErrorResponse(normalizeAgentError(error), requestId); + } +} + +function createJobInProgressError(record: Parameters[0]): AgentApiError { + const retryAfterSeconds = computeRetryAfterSeconds(record.lockedUntil, new Date()); + return new AgentApiError({ + code: 'request_in_progress', + message: 'Agent job 仍在运行。', + status: 409, + retryable: true, + retryAfterSeconds + }); +} diff --git a/src/app/api/agent/jobs/[id]/route.ts b/src/app/api/agent/jobs/[id]/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..b39a3ad2db0695ae56e09b6f66b27870c4d89620 --- /dev/null +++ b/src/app/api/agent/jobs/[id]/route.ts @@ -0,0 +1,32 @@ +import { assertReadableJobRecord, buildAgentJobStatusResponse, readAgentJobState } from '@/lib/agent-job-service'; +import { agentErrorResponse, normalizeAgentError } from '@/lib/api-error-response'; +import { assertAgentAuthorized } from '@/lib/agent-auth'; +import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime'; +import { createRequestId, computeRetryAfterSeconds } from '@/lib/agent-state-store'; +import { NextRequest, NextResponse } from 'next/server'; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + let requestId = createRequestId(); + try { + assertAgentAuthorized(request.headers); + const { id } = await context.params; + const store = await ensureAgentStateStoreReady(); + const record = assertReadableJobRecord(await store.getRequest(id), id); + requestId = record.requestId; + const retryAfterSeconds = readRetryAfterSeconds(record); + return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), { + headers: { 'X-Request-Id': requestId } + }); + } catch (error) { + return agentErrorResponse(normalizeAgentError(error), requestId); + } +} + +function readRetryAfterSeconds(record: Parameters[0]): number | undefined { + if (readAgentJobState(record) !== 'running') return undefined; + return computeRetryAfterSeconds(record.lockedUntil, new Date()); +} diff --git a/src/app/api/agent/jobs/images/generate/route.ts b/src/app/api/agent/jobs/images/generate/route.ts new file mode 100644 index 0000000000000000000000000000000000000000..33cabf8c5ed5b6ca42ee4f2679b304c2dbf8429e --- /dev/null +++ b/src/app/api/agent/jobs/images/generate/route.ts @@ -0,0 +1,78 @@ +import { buildAgentJobStatusResponse, startAgentGenerateJob } from '@/lib/agent-job-service'; +import { + buildGenerateRequestHash, + parseAgentGenerateRequest, + readIdempotencyKey +} from '@/lib/agent-image-service'; +import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts'; +import { AgentApiError, agentErrorResponse, normalizeAgentError } from '@/lib/api-error-response'; +import { assertAgentAuthorized } from '@/lib/agent-auth'; +import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime'; +import { createRequestId } from '@/lib/agent-state-store'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + let requestId = createRequestId(); + try { + assertAgentAuthorized(request.headers); + const imageRequest = await parseAgentGenerateRequest(request); + const idempotencyKey = readIdempotencyKey(request.headers); + const store = await ensureAgentStateStoreReady(); + const leaseMs = readAgentLeaseMs(process.env); + const beginResult = await store.beginRequest({ + idempotencyKey, + requestHash: buildGenerateRequestHash(imageRequest), + mode: 'generate', + requestJson: imageRequest, + leaseMs, + ttlSeconds: readAgentRequestTtlSeconds(process.env) + }); + + if (beginResult.type === 'conflict') { + throw createIdempotencyConflictError(); + } + if (beginResult.type === 'replay' || beginResult.type === 'failed') { + requestId = beginResult.record.requestId; + return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), { + headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': requestId } + }); + } + if (beginResult.type === 'in_progress') { + requestId = beginResult.record.requestId; + return runningJobResponse(beginResult.record, beginResult.retryAfterSeconds, true); + } + + requestId = beginResult.record.requestId; + startAgentGenerateJob({ + store, + request: imageRequest, + headers: new Headers(request.headers), + requestId, + idempotencyKey, + leaseMs + }); + return runningJobResponse(beginResult.record, 5, false); + } catch (error) { + return agentErrorResponse(normalizeAgentError(error), requestId); + } +} + +function createIdempotencyConflictError(): AgentApiError { + return new AgentApiError({ + code: 'idempotency_conflict', + message: 'Idempotency-Key 已被不同请求正文使用。', + status: 409, + retryable: false + }); +} + +function runningJobResponse(record: Parameters[0], retryAfterSeconds: number, replay: boolean) { + return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), { + status: 202, + headers: { + 'Retry-After': String(retryAfterSeconds), + 'X-Request-Id': record.requestId, + ...(replay ? { 'X-Idempotent-Replay': 'true' } : {}) + } + }); +} diff --git a/src/app/api/agent/openapi.json/route.ts b/src/app/api/agent/openapi.json/route.ts index b4ada686d954ce61c0718b06f9427eec1626bbae..110921507d8230127e717c8d52a5f6a59b19e37b 100644 --- a/src/app/api/agent/openapi.json/route.ts +++ b/src/app/api/agent/openapi.json/route.ts @@ -1,4 +1,4 @@ -import { buildAgentOpenApiDocument } from '@/lib/agent-api-contracts'; +import { buildAgentOpenApiDocument } from '@/lib/agent-openapi'; import { NextResponse } from 'next/server'; export async function GET() { diff --git a/src/app/api/auth-verify/route.test.ts b/src/app/api/auth-verify/route.test.ts index dbbc3f815e428695e0803e9a8e564c0c33f9f6d6..2822c82b9adcfc0f586349e7b3e03980f7c7b176 100644 --- a/src/app/api/auth-verify/route.test.ts +++ b/src/app/api/auth-verify/route.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, it } from 'node:test'; import { NextRequest } from 'next/server'; const originalAppPassword = process.env.APP_PASSWORD; -const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-'); +const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-'); afterEach(() => { if (originalAppPassword === undefined) { @@ -16,7 +16,7 @@ afterEach(() => { }); describe('POST /api/auth-verify', () => { - it('returns a page password error code for invalid password hashes', async () => { + it('returns a page access code error code for invalid access-code hashes', async () => { process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; const request = new NextRequest('http://localhost/api/auth-verify', { method: 'POST', diff --git a/src/app/api/auth-verify/route.ts b/src/app/api/auth-verify/route.ts index f63bde15124587164896e09c2ad585aad9597920..38727b1d5c54843fe9e63a17ad432c505cb47fd1 100644 --- a/src/app/api/auth-verify/route.ts +++ b/src/app/api/auth-verify/route.ts @@ -24,7 +24,7 @@ export async function POST(request: NextRequest) { return NextResponse.json( { authenticated: false, - error: 'Unauthorized: Invalid password.', + error: 'Unauthorized: Invalid access code.', code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid }, { status: 401 } diff --git a/src/app/api/image-delete/route.ts b/src/app/api/image-delete/route.ts index 7e0f7725dbff41f69d676f92bcc3645b36e258fb..b13a95ca764075fbf64433575426566c7c0ef90d 100644 --- a/src/app/api/image-delete/route.ts +++ b/src/app/api/image-delete/route.ts @@ -26,12 +26,12 @@ export async function POST(request: NextRequest) { const clientPasswordHash = requestBody.passwordHash; if (!clientPasswordHash) { - appLogger.error('删除操作缺少密码哈希。'); - return NextResponse.json({ error: '未授权:缺少密码哈希。' }, { status: 401 }); + appLogger.error('删除操作缺少访问码哈希。'); + return NextResponse.json({ error: '未授权:缺少访问码哈希。' }, { status: 401 }); } if (!verifyPasswordHash(clientPasswordHash, appPassword)) { - appLogger.error('删除操作的密码哈希无效。'); - return NextResponse.json({ error: '未授权:密码无效。' }, { status: 401 }); + appLogger.error('删除操作的访问码哈希无效。'); + return NextResponse.json({ error: '未授权:访问码无效。' }, { status: 401 }); } } } catch (e) { diff --git a/src/app/api/image-route.test.ts b/src/app/api/image-route.test.ts index 2d397d3760eaed67de28e5b7111a399409cecd1d..0af69c009bb56a90ce41cbb062344c11aadf4336 100644 --- a/src/app/api/image-route.test.ts +++ b/src/app/api/image-route.test.ts @@ -6,8 +6,8 @@ import { afterEach, describe, it } from 'node:test'; import { NextRequest } from 'next/server'; const originalAppPassword = process.env.APP_PASSWORD; -const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-'); -const OTHER_PAGE_PASSWORD_FIXTURE = ['other', 'password'].join('-'); +const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-'); +const OTHER_PAGE_PASSWORD_FIXTURE = ['other', 'access', 'code'].join('-'); afterEach(() => { if (originalAppPassword === undefined) { @@ -18,7 +18,7 @@ afterEach(() => { }); describe('GET /api/image/[filename]', () => { - it('returns a missing page password code when the access cookie is absent', async () => { + it('returns a missing page access code error when the access cookie is absent', async () => { process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; const request = new NextRequest('http://localhost/api/image/sample.png'); @@ -29,7 +29,7 @@ describe('GET /api/image/[filename]', () => { assert.equal(result.code, PAGE_PASSWORD_AUTH_ERROR_CODES.missing); }); - it('returns an invalid page password code when the access cookie is wrong', async () => { + it('returns an invalid page access code error when the access cookie is wrong', async () => { process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; const request = new NextRequest('http://localhost/api/image/sample.png', { headers: { diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts index 3af68d7df901965cd68b74b764e22da4509c5b4f..533565a94a91a8729ccdca02f68c87cdef341514 100644 --- a/src/app/api/images/route.ts +++ b/src/app/api/images/route.ts @@ -101,16 +101,16 @@ export async function POST(request: NextRequest) { if (appPassword) { const clientPasswordHash = formData.get('passwordHash'); if (typeof clientPasswordHash !== 'string' || !clientPasswordHash) { - appLogger.error('缺少密码哈希。', requestLogContext); + appLogger.error('缺少访问码哈希。', requestLogContext); return NextResponse.json( - { error: '未授权:缺少密码哈希。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.missing }, + { error: '未授权:缺少访问码哈希。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.missing }, { status: 401 } ); } if (!verifyPasswordHash(clientPasswordHash, appPassword)) { - appLogger.error('密码哈希无效。', requestLogContext); + appLogger.error('访问码哈希无效。', requestLogContext); return NextResponse.json( - { error: '未授权:密码无效。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid }, + { error: '未授权:访问码无效。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid }, { status: 401 } ); } diff --git a/src/app/api/logs/route.test.ts b/src/app/api/logs/route.test.ts index c562bbdf0597f910707cd88e02d42ff4b2d7dc80..909be428f31760185fe36c0e8481eb7583059993 100644 --- a/src/app/api/logs/route.test.ts +++ b/src/app/api/logs/route.test.ts @@ -6,7 +6,7 @@ import { NextRequest } from 'next/server'; const originalAppPassword = process.env.APP_PASSWORD; const originalAppLogLevel = process.env.APP_LOG_LEVEL; -const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-'); +const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-'); afterEach(() => { if (originalAppPassword === undefined) { @@ -35,7 +35,7 @@ describe('GET /api/logs', () => { assert.equal(response.status, 403); }); - it('rejects password hashes sent in the query string', async () => { + it('rejects access-code hashes sent in the query string', async () => { process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; const request = new NextRequest(`http://localhost/api/logs?passwordHash=${sha256(PAGE_PASSWORD_FIXTURE)}`); @@ -44,7 +44,7 @@ describe('GET /api/logs', () => { assert.equal(response.status, 401); }); - it('accepts password hashes sent as a bearer token', async () => { + it('accepts access-code hashes sent as a bearer token', async () => { process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; process.env.APP_LOG_LEVEL = 'warn'; const request = new NextRequest('http://localhost/api/logs', { diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts index b212889b9a2164347772479712fd59ebc7a42cd5..2397708a35937de311c3de483e0df7ba49c6f1fa 100644 --- a/src/app/api/logs/route.ts +++ b/src/app/api/logs/route.ts @@ -28,7 +28,7 @@ export async function GET(request: NextRequest) { } if (!isAuthorized(request)) { - return NextResponse.json({ error: '未授权:缺少或无效的密码哈希。' }, { status: 401 }); + return NextResponse.json({ error: '未授权:缺少或无效的访问码哈希。' }, { status: 401 }); } appLogger.info('日志查看器已连接。'); diff --git a/src/app/api/shares/[token]/content/route.ts b/src/app/api/shares/[token]/content/route.ts index 23a1b75481e1984ea496b09085dc090d946f7dd6..577bfe12ba6dc74c67072a11ffc678acd3cc91c5 100644 --- a/src/app/api/shares/[token]/content/route.ts +++ b/src/app/api/shares/[token]/content/route.ts @@ -62,7 +62,9 @@ function contentDispositionFilename(record: { accessCodeRequired: boolean; sourc if (record.accessCodeRequired) { return `shared-image${mimeExtension(record.mimeType)}`; } - return sanitizeHeaderFilename(record.sourceFilename); + const sanitized = sanitizeHeaderFilename(record.sourceFilename); + const stem = sanitized.replace(/\.[^.]+$/, '') || 'shared-image'; + return `${stem}${mimeExtension(record.mimeType)}`; } function pruneExpiredAccessFailures(now: number) { @@ -122,6 +124,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok Pragma: 'no-cache', Expires: '0', 'Surrogate-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', 'Content-Disposition': `inline; filename="${contentDispositionFilename(record)}"` } }); diff --git a/src/app/api/shares/route.test.ts b/src/app/api/shares/route.test.ts index ca26d419d56565c54f00cfc134fa54218cf55253..47306313451ca7f6d989d7331d5e843e7b31dab0 100644 --- a/src/app/api/shares/route.test.ts +++ b/src/app/api/shares/route.test.ts @@ -13,7 +13,7 @@ import { GET as getShare } from './[token]/route'; import { POST } from './route'; const originalAppPassword = process.env.APP_PASSWORD; -const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-'); +const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-'); const VALID_PNG_BYTES = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR42mP8z8AABQMBgADeT7UAAAAASUVORK5CYII=', 'base64' @@ -73,7 +73,7 @@ describe('POST /api/shares', { concurrency: false }, () => { assert.equal('accessCodeSalt' in body, false); }); - it('rejects unauthenticated share creation when a page password is configured', async () => { + it('rejects unauthenticated share creation when a page access code is configured', async () => { await withTempCwd(); process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; const form = new FormData(); @@ -99,7 +99,7 @@ describe('POST /api/shares', { concurrency: false }, () => { assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid); }); - it('allows share creation when no page password is configured', async () => { + it('allows share creation when no page access code is configured', async () => { await withTempCwd(); delete process.env.APP_PASSWORD; const form = new FormData(); @@ -193,6 +193,45 @@ describe('POST /api/shares', { concurrency: false }, () => { const body = await response.json(); assert.equal(body.code, 'invalid_expiry'); }); + + it('rejects unsafe source filenames before storing share metadata', async () => { + await withTempCwd(); + process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; + const form = new FormData(); + form.set('sourceFilename', '../secret.png'); + form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' })); + + const response = await POST(createShareRequest(form)); + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.code, 'invalid_source_filename'); + }); + + it('rejects non-string source filenames before storing share metadata', async () => { + await withTempCwd(); + process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; + const form = new FormData(); + form.set('sourceFilename', new File([Buffer.from('not-a-name')], 'name.txt', { type: 'text/plain' })); + form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' })); + + const response = await POST(createShareRequest(form)); + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.code, 'invalid_source_filename'); + }); + + it('rejects overlong source filenames before storing share metadata', async () => { + await withTempCwd(); + process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; + const form = new FormData(); + form.set('sourceFilename', `${'a'.repeat(201)}.png`); + form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' })); + + const response = await POST(createShareRequest(form)); + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body.code, 'invalid_source_filename'); + }); }); describe('share metadata and content routes', { concurrency: false }, () => { @@ -272,10 +311,36 @@ describe('share metadata and content routes', { concurrency: false }, () => { assert.equal(ok.headers.get('content-type'), 'image/png'); assert.match(ok.headers.get('cache-control') || '', /no-store/); assert.equal(ok.headers.get('surrogate-control'), 'no-store'); + assert.equal(ok.headers.get('x-content-type-options'), 'nosniff'); assert.equal(ok.headers.get('content-disposition'), 'inline; filename="shared-image.png"'); assert.equal(await ok.text(), 'protected-image'); }); + it('uses the detected image MIME type for public content filenames', async () => { + await withTempCwd(); + const record = await createImageShare({ + imageBuffer: Buffer.from('public-image'), + sourceFilename: 'invoice.html', + mimeType: 'image/png', + accessCode: undefined, + expiresInMinutes: null + }); + + const response = await getShareContent( + new Request(`http://localhost/api/shares/${record.token}/content`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }), + params(record.token) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'image/png'); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.equal(response.headers.get('content-disposition'), 'inline; filename="invoice.png"'); + }); + it('rate limits repeated wrong access codes', async () => { await withTempCwd(); const record = await createImageShare({ diff --git a/src/app/api/shares/route.ts b/src/app/api/shares/route.ts index 1624b5f4f9d0d71bc0fa62c43624313fca7e1f3d..4dd22bc7782f825017b77481800a122a6dd3e6f7 100644 --- a/src/app/api/shares/route.ts +++ b/src/app/api/shares/route.ts @@ -6,6 +6,8 @@ import { NextRequest, NextResponse } from 'next/server'; const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024; const MIN_ACCESS_CODE_LENGTH = 8; const MAX_ACCESS_CODE_LENGTH = 128; +const MAX_SOURCE_FILENAME_LENGTH = 200; +const SOURCE_FILENAME_PATTERN = /^[^\x00-\x1f\x7f\\/]+$/u; function detectImageMimeType(buffer: Buffer): string | undefined { if ( @@ -60,6 +62,15 @@ function parseAccessCode(value: FormDataEntryValue | null): string | undefined | return trimmed; } +function parseSourceFilename(value: FormDataEntryValue | null, fallback: string): string | null { + if (value !== null && typeof value !== 'string') return null; + const candidate = value?.trim() || fallback.trim(); + if (!candidate || candidate.length > MAX_SOURCE_FILENAME_LENGTH || !SOURCE_FILENAME_PATTERN.test(candidate)) { + return null; + } + return candidate; +} + function verifyShareCreator(request: NextRequest) { if (!process.env.APP_PASSWORD) return undefined; const accessToken = request.cookies.get('gptImageAccess')?.value; @@ -89,7 +100,10 @@ export async function POST(request: NextRequest) { const sourceFilenameValue = form.get('sourceFilename'); const fallbackFilename = typeof image.name === 'string' && image.name.trim() ? image.name : 'shared-image.png'; - const sourceFilename = typeof sourceFilenameValue === 'string' && sourceFilenameValue.trim() ? sourceFilenameValue.trim() : fallbackFilename; + const sourceFilename = parseSourceFilename(sourceFilenameValue, fallbackFilename); + if (sourceFilename === null) { + return jsonError('invalid_source_filename', '分享文件名无效。', 400); + } const expiresInMinutes = parseExpiry(form.get('expiresInMinutes')); if (expiresInMinutes === undefined) { return jsonError('invalid_expiry', '分享有效期无效。', 400); diff --git a/src/app/page.tsx b/src/app/page.tsx index a5b5cf53972f9be9a3a5bf5d4ce9151f55348f76..cac58b10556901c7c8ae6602cff1cc0188c7ea98 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -471,7 +471,7 @@ export default function HomePage() { } return 'unavailable'; } catch (error) { - console.error('验证入口密码失败:', error); + console.error('验证入口访问码失败:', error); return 'unavailable'; } }, []); @@ -683,7 +683,7 @@ export default function HomePage() { await handleApiCall(...retryArgs, hash); } } catch (e) { - console.error('计算密码哈希失败:', e); + console.error('计算访问码哈希失败:', e); setError(createErrorNotice(t('password.hashError'))); } }; diff --git a/src/components/password-dialog.tsx b/src/components/password-dialog.tsx index e6de02234a0790380f659fb64167f563f0481cec..d4e7c68bb6498aba263bf3e72bb7300d92e38c7e 100644 --- a/src/components/password-dialog.tsx +++ b/src/components/password-dialog.tsx @@ -26,7 +26,7 @@ export function PasswordDialog({ isOpen, onOpenChange, onSave, - title = 'Configure Password', + title = 'Configure Access Code', description }: PasswordDialogProps) { const { t } = useI18n(); diff --git a/src/lib/agent-api-contracts.test.ts b/src/lib/agent-api-contracts.test.ts index 76085a26d36fe3df80358af38c612879a96d87f3..b0322d8cfd256f04932082a593440748caed1ff7 100644 --- a/src/lib/agent-api-contracts.test.ts +++ b/src/lib/agent-api-contracts.test.ts @@ -1,8 +1,13 @@ import { + buildAgentAuthCapabilities, buildAgentCapabilities, - buildAgentOpenApiDocument, + readAgentLeaseMs, + readAgentPublicBaseUrl, + readAgentRecoveryIntervalMs, + readAgentRequestTtlSeconds, validateAgentGenerateRequest } from './agent-api-contracts'; +import { buildAgentOpenApiDocument } from './agent-openapi'; import { RequestValidationError } from './image-request-utils'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; @@ -75,6 +80,30 @@ describe('validateAgentGenerateRequest', () => { }); }); +describe('Agent numeric configuration', () => { + it('uses defaults when optional numeric env values are absent', () => { + assert.equal(readAgentRequestTtlSeconds({}), 86400); + assert.equal(readAgentLeaseMs({}), 600000); + assert.equal(readAgentRecoveryIntervalMs({}), 30000); + }); + + it('fails explicitly when numeric env values are invalid', () => { + assert.throws(() => readAgentRequestTtlSeconds({ AGENT_REQUEST_TTL_SECONDS: 'abc' }), /AGENT_REQUEST_TTL_SECONDS/); + assert.throws(() => readAgentLeaseMs({ AGENT_REQUEST_LEASE_MS: '0' }), /AGENT_REQUEST_LEASE_MS/); + assert.throws(() => readAgentRecoveryIntervalMs({ AGENT_RECOVERY_INTERVAL_MS: '-1' }), /AGENT_RECOVERY_INTERVAL_MS/); + }); + + it('validates the public OpenAPI server URL', () => { + assert.equal(readAgentPublicBaseUrl({}), '/'); + assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test/' }), 'https://images.example.test'); + assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'http://localhost:4783' }), 'http://localhost:4783'); + assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'not a url' }), /AGENT_PUBLIC_BASE_URL/); + assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'javascript:alert(1)' }), /AGENT_PUBLIC_BASE_URL/); + assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://user:pass@images.example.test' }), /AGENT_PUBLIC_BASE_URL/); + assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test?token=secret' }), /AGENT_PUBLIC_BASE_URL/); + }); +}); + describe('buildAgentCapabilities', () => { it('exposes machine-readable defaults, limits, auth, and storage metadata', () => { const capabilities = buildAgentCapabilities({ @@ -86,10 +115,50 @@ describe('buildAgentCapabilities', () => { assert.equal(capabilities.defaults.state_backend, 'postgres'); assert.equal(capabilities.auth.required, true); + assert.deepEqual(capabilities.auth.schemes, ['bearer']); assert.equal(capabilities.storage.postgres_configured, true); assert.equal('sqlite_path' in capabilities.storage, false); assert.equal(capabilities.idempotency.header, 'Idempotency-Key'); assert.ok(capabilities.supported.models.includes('gpt-image-2')); + assert.equal(capabilities.model_limits['gpt-image-2'].max_edge, 3840); + assert.equal(capabilities.model_limits['gpt-image-2'].edge_multiple, 16); + assert.equal(capabilities.model_limits['gpt-image-2'].max_pixels, 8294400); + assert.equal(capabilities.agent_streaming.generate.supported, false); + assert.equal(capabilities.agent_streaming.generate.mode, 'non_streaming_only'); + assert.equal(capabilities.agent_streaming.page_sse.endpoint, '/api/images'); + assert.equal(capabilities.endpoints.create_generate_job, '/api/agent/jobs/images/generate'); + assert.equal(capabilities.agent_jobs.supported, true); + assert.equal(capabilities.agent_jobs.mode, 'job_polling'); + assert.equal(capabilities.agent_jobs.endpoints.create_generate_job, '/api/agent/jobs/images/generate'); + assert.deepEqual(capabilities.agent_jobs.states, ['queued', 'running', 'succeeded', 'failed', 'expired']); + assert.match(capabilities.agent_jobs.current_guidance, /poll/i); + }); + + it('exposes only the runtime-accepted bearer auth scheme when Agent token is configured', () => { + assert.deepEqual( + buildAgentAuthCapabilities({ + AGENT_API_TOKEN: 'token', + APP_PASSWORD: 'page-access-code' + }), + { required: true, schemes: ['bearer'] } + ); + }); + + it('exposes access-code hash auth only when no Agent token is configured', () => { + assert.deepEqual(buildAgentAuthCapabilities({ APP_PASSWORD: 'page-access-code' }), { + required: true, + schemes: ['x-app-password-hash'] + }); + }); + + it('marks Agent auth as optional when no auth env is configured', () => { + assert.deepEqual( + buildAgentAuthCapabilities({ + AGENT_API_TOKEN: ' ', + APP_PASSWORD: ' ' + }), + { required: false, schemes: [] } + ); }); it('exposes memory state backend for ephemeral deployments', () => { @@ -108,13 +177,29 @@ describe('buildAgentCapabilities', () => { assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]); assert.ok('/api/agent/openapi.json' in document.paths); assert.ok('/api/agent/images/generate' in document.paths); + assert.ok('/api/agent/jobs/images/generate' in document.paths); + assert.ok('/api/agent/jobs/{id}' in document.paths); + assert.ok('/api/agent/jobs/{id}/result' in document.paths); assert.ok('AgentCapabilities' in document.components.schemas); assert.ok('AgentImageResponse' in document.components.schemas); + assert.ok('AgentJobStatusResponse' in document.components.schemas); assert.ok('AgentArtifact' in document.components.schemas); assert.ok('EditRequest' in document.components.schemas); assert.ok('AgentError' in document.components.schemas); + assert.ok('AgentModelLimits' in document.components.schemas); + assert.ok('AgentStreamingCapabilities' in document.components.schemas); + assert.ok('AgentJobCapabilities' in document.components.schemas); + assert.ok('AgentErrorDiagnostics' in document.components.schemas); assert.ok(document.paths['/api/agent/images/generate'].post.responses['200']); + assert.ok(document.paths['/api/agent/images/generate'].post.responses['403']); + assert.ok(document.paths['/api/agent/images/generate'].post.responses['429']); assert.ok(document.paths['/api/agent/images/generate'].post.responses['422']); + assert.ok(document.paths['/api/agent/jobs/images/generate'].post.responses['202']); + assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['200']); + assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['409']); + assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['422']); + assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['429']); + assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['502']); }); it('describes public capabilities without server-local SQLite paths', () => { @@ -126,26 +211,47 @@ describe('buildAgentCapabilities', () => { assert.equal('sqlite_path' in storageSchema.properties, false); }); - it('describes Agent authentication and common runtime failures in OpenAPI', () => { - const document = buildAgentOpenApiDocument({}); + it('describes bearer authentication and common runtime failures in OpenAPI', () => { + const document = buildAgentOpenApiDocument({ + AGENT_API_TOKEN: 'token', + APP_PASSWORD: 'page-access-code' + }); assert.ok(document.components.securitySchemes.BearerAuth); - assert.ok(document.components.securitySchemes.AppPasswordHash); - assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [ - { BearerAuth: [] }, - { AppPasswordHash: [] } - ]); + assert.equal('AppPasswordHash' in document.components.securitySchemes, false); + assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, ['bearer']); + assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [{ BearerAuth: [] }]); assert.ok(document.paths['/api/agent/images/generate'].post.responses['401']); assert.ok(document.paths['/api/agent/images/generate'].post.responses['415']); assert.ok(document.paths['/api/agent/images/generate'].post.responses['502']); assert.ok(document.paths['/api/agent/images/edit'].post.responses['401']); + assert.ok(document.paths['/api/agent/images/edit'].post.responses['409'].headers['Retry-After']); assert.ok(document.paths['/api/agent/images/edit'].post.responses['415']); assert.ok(document.paths['/api/agent/images/edit'].post.responses['502']); }); - it('marks artifact routes as authenticated in OpenAPI', () => { + it('describes access-code hash authentication in OpenAPI when no Agent token is configured', () => { + const document = buildAgentOpenApiDocument({ APP_PASSWORD: 'page-access-code' }); + + assert.equal('BearerAuth' in document.components.securitySchemes, false); + assert.ok(document.components.securitySchemes.AppPasswordHash); + assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, [ + 'x-app-password-hash' + ]); + assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [{ AppPasswordHash: [] }]); + }); + + it('does not require Agent authentication in OpenAPI when no auth env is configured', () => { const document = buildAgentOpenApiDocument({}); - const expectedSecurity = [{ BearerAuth: [] }, { AppPasswordHash: [] }]; + + assert.deepEqual(document.components.securitySchemes, {}); + assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, []); + assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, []); + }); + + it('marks artifact routes as authenticated in OpenAPI', () => { + const document = buildAgentOpenApiDocument({ AGENT_API_TOKEN: 'token' }); + const expectedSecurity = [{ BearerAuth: [] }]; assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].get.security, expectedSecurity); assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].delete.security, expectedSecurity); @@ -158,6 +264,7 @@ describe('buildAgentCapabilities', () => { const schema = document.components.schemas.AgentArtifact; assert.equal(responseSchema.properties.artifact.$ref, '#/components/schemas/AgentArtifact'); + assert.equal('AgentArtifactRecord' in document.components.schemas, false); assert.equal('filepath' in schema.properties, false); assert.ok('content_url' in schema.properties); assert.ok('metadata_url' in schema.properties); diff --git a/src/lib/agent-api-contracts.ts b/src/lib/agent-api-contracts.ts index c24bfa1a9682dd325b94f663dc1424fec0fc6da3..476298e39b04a08d5491686595dcc66e4d7b136b 100644 --- a/src/lib/agent-api-contracts.ts +++ b/src/lib/agent-api-contracts.ts @@ -7,10 +7,18 @@ import { type GptImageModel, type ValidOutputFormat } from './image-request-utils'; -import { validateGptImage2Size } from './size-utils'; +import { + GPT_IMAGE_2_EDGE_MULTIPLE, + GPT_IMAGE_2_MAX_ASPECT, + GPT_IMAGE_2_MAX_EDGE, + GPT_IMAGE_2_MAX_PIXELS, + GPT_IMAGE_2_MIN_PIXELS, + validateGptImage2Size +} from './size-utils'; +import type { AgentErrorDiagnostics } from './api-error-response'; export const AGENT_API_VERSION = '1.0.0'; -export const AGENT_SCHEMA_VERSION = '2026-05-12'; +export const AGENT_SCHEMA_VERSION = '2026-05-20'; export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite'; export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000; export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60; @@ -23,12 +31,15 @@ export const AGENT_QUALITIES = ['low', 'medium', 'high', 'auto'] as const; export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const; export const AGENT_MODERATIONS = ['low', 'auto'] as const; export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const; +export const AGENT_JOB_STATES = ['queued', 'running', 'succeeded', 'failed', 'expired'] as const; export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres'; +export type AgentAuthScheme = 'bearer' | 'x-app-password-hash'; export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number]; export type AgentQuality = (typeof AGENT_QUALITIES)[number]; export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number]; export type AgentModeration = (typeof AGENT_MODERATIONS)[number]; +export type AgentJobState = (typeof AGENT_JOB_STATES)[number]; export type AgentGenerateRequest = { model: GptImageModel; @@ -65,12 +76,35 @@ export type AgentImageResponse = { created_at: string; }; +export type AgentJobStatusResponse = { + job: { + id: string; + request_id: string; + idempotency_key: string; + mode: 'generate' | 'edit'; + state: AgentJobState; + created_at: string; + updated_at: string; + expires_at: string; + result_url?: string; + retry_after_seconds?: number; + error?: { + code: string; + message: string; + retryable: boolean; + details?: Record; + upstream_status?: number; + diagnostics?: AgentErrorDiagnostics; + }; + }; +}; + export type AgentCapabilities = { api_version: string; schema_version: string; auth: { required: boolean; - schemes: string[]; + schemes: AgentAuthScheme[]; }; endpoints: Record; defaults: { @@ -84,6 +118,50 @@ export type AgentCapabilities = { max_upload_mb: number; partial_images: { min: number; max: number }; }; + model_limits: { + 'gpt-image-2': { + max_edge: number; + max_pixels: number; + edge_multiple: number; + max_aspect: number; + min_pixels: number; + recommended_presets: Array<{ name: string; size: string; purpose: string }>; + high_4k_risk: { + applies_to: string[]; + guidance: string; + }; + }; + }; + agent_streaming: { + generate: { + supported: false; + mode: 'non_streaming_only'; + endpoint: string; + }; + edit: { + supported: false; + mode: 'non_streaming_only'; + endpoint: string; + }; + page_sse: { + supported: true; + mode: 'form_data_sse'; + endpoint: string; + contract: 'page_ui_only'; + }; + }; + agent_jobs: { + supported: true; + mode: 'job_polling'; + intended_for: string[]; + endpoints: { + create_generate_job: string; + get_job: string; + get_job_result: string; + }; + states: readonly string[]; + current_guidance: string; + }; supported: { models: readonly string[]; output_formats: readonly string[]; @@ -289,24 +367,15 @@ export function readAgentStateBackend(env: Record): } export function readAgentRequestTtlSeconds(env: Record): number { - const value = env.AGENT_REQUEST_TTL_SECONDS; - if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_REQUEST_TTL_SECONDS; - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_REQUEST_TTL_SECONDS; + return readPositiveIntegerEnv(env, 'AGENT_REQUEST_TTL_SECONDS', AGENT_DEFAULT_REQUEST_TTL_SECONDS); } export function readAgentLeaseMs(env: Record): number { - const value = env.AGENT_REQUEST_LEASE_MS; - if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_LEASE_MS; - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_LEASE_MS; + return readPositiveIntegerEnv(env, 'AGENT_REQUEST_LEASE_MS', AGENT_DEFAULT_LEASE_MS); } export function readAgentRecoveryIntervalMs(env: Record): number { - const value = env.AGENT_RECOVERY_INTERVAL_MS; - if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_RECOVERY_INTERVAL_MS; - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_RECOVERY_INTERVAL_MS; + return readPositiveIntegerEnv(env, 'AGENT_RECOVERY_INTERVAL_MS', AGENT_DEFAULT_RECOVERY_INTERVAL_MS); } export function readAgentSqlitePath(env: Record): string { @@ -314,26 +383,63 @@ export function readAgentSqlitePath(env: Record): st } export function readAgentPublicBaseUrl(env: Record): string { - return env.AGENT_PUBLIC_BASE_URL?.trim() || '/'; + const value = env.AGENT_PUBLIC_BASE_URL?.trim(); + if (!value) return '/'; + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 格式无效。', 500); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 必须是 http 或 https URL。', 500); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 不能包含凭据、查询参数或片段。', 500); + } + return parsed.toString().replace(/\/$/, ''); } export function validateOptionalAgentApiBaseUrl(baseUrl: string | undefined): void { if (baseUrl) validateApiBaseUrl(baseUrl); } +export function buildAgentAuthCapabilities(env: Record): AgentCapabilities['auth'] { + if (env.AGENT_API_TOKEN?.trim()) { + return { required: true, schemes: ['bearer'] }; + } + if (env.APP_PASSWORD?.trim()) { + return { required: true, schemes: ['x-app-password-hash'] }; + } + return { required: false, schemes: [] }; +} + +function readPositiveIntegerEnv(env: Record, fieldName: string, fallback: number): number { + const value = env[fieldName]?.trim(); + if (!value) return fallback; + if (!/^\d+$/.test(value)) { + throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); + } + return parsed; +} + export function buildAgentCapabilities(env: Record): AgentCapabilities { return { api_version: AGENT_API_VERSION, schema_version: AGENT_SCHEMA_VERSION, - auth: { - required: Boolean(env.AGENT_API_TOKEN || env.APP_PASSWORD), - schemes: ['bearer', 'x-app-password-hash'] - }, + auth: buildAgentAuthCapabilities(env), endpoints: { capabilities: '/api/agent/capabilities', openapi: '/api/agent/openapi.json', generate: '/api/agent/images/generate', edit: '/api/agent/images/edit', + create_generate_job: '/api/agent/jobs/images/generate', + job: '/api/agent/jobs/{id}', + job_result: '/api/agent/jobs/{id}/result', artifact_metadata: '/api/agent/artifacts/{id}', artifact_content: '/api/agent/artifacts/{id}/content', artifact_delete: '/api/agent/artifacts/{id}' @@ -349,6 +455,55 @@ export function buildAgentCapabilities(env: Record): max_upload_mb: MAX_UPLOAD_BYTES / 1024 / 1024, partial_images: { min: 1, max: 3 } }, + model_limits: { + 'gpt-image-2': { + max_edge: GPT_IMAGE_2_MAX_EDGE, + max_pixels: GPT_IMAGE_2_MAX_PIXELS, + edge_multiple: GPT_IMAGE_2_EDGE_MULTIPLE, + max_aspect: GPT_IMAGE_2_MAX_ASPECT, + min_pixels: GPT_IMAGE_2_MIN_PIXELS, + recommended_presets: [ + { name: 'square', size: '2048x2048', purpose: '通用正方形构图' }, + { name: 'landscape', size: '3072x2048', purpose: '横向宽幅构图' }, + { name: 'portrait', size: '2048x3072', purpose: '纵向主体构图' } + ], + high_4k_risk: { + applies_to: ['quality=high', 'max_edge>=3072', 'long_running_upstream'], + guidance: '高质量 4K 级请求可能耗时数分钟;失败应归类为上游长耗时风险,不代表低负载路径不可用。' + } + } + }, + agent_streaming: { + generate: { + supported: false, + mode: 'non_streaming_only', + endpoint: '/api/agent/images/generate' + }, + edit: { + supported: false, + mode: 'non_streaming_only', + endpoint: '/api/agent/images/edit' + }, + page_sse: { + supported: true, + mode: 'form_data_sse', + endpoint: '/api/images', + contract: 'page_ui_only' + } + }, + agent_jobs: { + supported: true, + mode: 'job_polling', + intended_for: ['quality=high', 'max_edge>=3072', 'long_running_upstream', 'manual_billable_gate'], + endpoints: { + create_generate_job: '/api/agent/jobs/images/generate', + get_job: '/api/agent/jobs/{id}', + get_job_result: '/api/agent/jobs/{id}/result' + }, + states: AGENT_JOB_STATES, + current_guidance: + '对 4K/high 或长耗时请求优先使用 job polling:先创建 generate job,再轮询状态,最后读取 result。运行中 job 会刷新 lease。当前执行模型为同实例后台任务,不是跨实例持久队列。' + }, supported: { models: AGENT_MODELS, output_formats: AGENT_OUTPUT_FORMATS, @@ -369,314 +524,3 @@ export function buildAgentCapabilities(env: Record): } }; } - -export function buildAgentOpenApiDocument(env: Record) { - const capabilities = buildAgentCapabilities(env); - const jsonContent = (schemaRef: string) => ({ - content: { - 'application/json': { - schema: { $ref: schemaRef } - } - } - }); - const agentSecurity = [{ BearerAuth: [] }, { AppPasswordHash: [] }]; - const commonAgentErrors = { - '401': jsonContent('#/components/schemas/AgentError'), - '415': jsonContent('#/components/schemas/AgentError'), - '502': jsonContent('#/components/schemas/AgentError') - }; - return { - openapi: '3.1.0', - info: { - title: 'GPT Image Playground Agent API', - version: capabilities.api_version - }, - servers: [{ url: readAgentPublicBaseUrl(env) }], - paths: { - '/api/agent/capabilities': { - get: { - summary: '获取机器可读的 Agent API 能力信息', - responses: { - '200': jsonContent('#/components/schemas/AgentCapabilities') - } - } - }, - '/api/agent/openapi.json': { - get: { - summary: '获取 Agent API 的 OpenAPI 文档', - responses: { - '200': { description: 'OpenAPI 文档' } - } - } - }, - '/api/agent/images/generate': { - post: { - summary: '为 Agent 生成图片', - security: agentSecurity, - parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }], - requestBody: { - required: true, - ...jsonContent('#/components/schemas/GenerateRequest') - }, - responses: { - '200': jsonContent('#/components/schemas/AgentImageResponse'), - '400': jsonContent('#/components/schemas/AgentError'), - '409': { - ...jsonContent('#/components/schemas/AgentError'), - headers: { - 'Retry-After': { schema: { type: 'integer', minimum: 1 } } - } - }, - ...commonAgentErrors, - '422': jsonContent('#/components/schemas/AgentError'), - '500': jsonContent('#/components/schemas/AgentError') - } - } - }, - '/api/agent/images/edit': { - post: { - summary: '为 Agent 编辑图片', - security: agentSecurity, - parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }], - requestBody: { - required: true, - content: { - 'multipart/form-data': { - schema: { $ref: '#/components/schemas/EditRequest' } - } - } - }, - responses: { - '200': jsonContent('#/components/schemas/AgentImageResponse'), - '400': jsonContent('#/components/schemas/AgentError'), - '409': jsonContent('#/components/schemas/AgentError'), - ...commonAgentErrors, - '422': jsonContent('#/components/schemas/AgentError'), - '500': jsonContent('#/components/schemas/AgentError') - } - } - }, - '/api/agent/artifacts/{id}': { - get: { - summary: '获取产物元数据', - security: agentSecurity, - parameters: [{ $ref: '#/components/parameters/ArtifactId' }], - responses: { - '200': jsonContent('#/components/schemas/ArtifactMetadataResponse'), - '401': jsonContent('#/components/schemas/AgentError'), - '404': jsonContent('#/components/schemas/AgentError') - } - }, - delete: { - summary: '删除产物', - security: agentSecurity, - parameters: [{ $ref: '#/components/parameters/ArtifactId' }], - responses: { - '200': jsonContent('#/components/schemas/DeleteArtifactResponse'), - '401': jsonContent('#/components/schemas/AgentError'), - '404': jsonContent('#/components/schemas/AgentError') - } - } - }, - '/api/agent/artifacts/{id}/content': { - get: { - summary: '下载产物内容', - security: agentSecurity, - parameters: [{ $ref: '#/components/parameters/ArtifactId' }], - responses: { - '200': { - description: '图片二进制内容', - content: { - 'image/png': { schema: { type: 'string', format: 'binary' } }, - 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, - 'image/webp': { schema: { type: 'string', format: 'binary' } } - } - }, - '401': jsonContent('#/components/schemas/AgentError'), - '404': jsonContent('#/components/schemas/AgentError') - } - } - } - }, - components: { - securitySchemes: { - BearerAuth: { - type: 'http', - scheme: 'bearer' - }, - AppPasswordHash: { - type: 'apiKey', - in: 'header', - name: 'X-App-Password-Hash' - } - }, - parameters: { - IdempotencyKey: { - name: 'Idempotency-Key', - in: 'header', - required: true, - schema: { type: 'string', minLength: 1, maxLength: 200 } - }, - ArtifactId: { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string', minLength: 1 } - } - }, - schemas: { - AgentCapabilities: { - type: 'object', - required: ['api_version', 'schema_version', 'auth', 'endpoints', 'defaults', 'limits', 'supported', 'storage', 'idempotency'], - properties: { - api_version: { type: 'string' }, - schema_version: { type: 'string' }, - auth: { type: 'object' }, - endpoints: { type: 'object', additionalProperties: { type: 'string' } }, - defaults: { type: 'object' }, - limits: { type: 'object' }, - supported: { type: 'object' }, - storage: { - type: 'object', - required: ['image_storage_mode', 'postgres_configured'], - properties: { - image_storage_mode: { type: 'string' }, - postgres_configured: { type: 'boolean' } - } - }, - idempotency: { type: 'object' } - } - }, - GenerateRequest: { - type: 'object', - required: ['prompt'], - properties: { - prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH }, - model: { type: 'string', enum: AGENT_MODELS }, - n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT }, - size: { type: 'string' }, - quality: { type: 'string', enum: AGENT_QUALITIES, default: 'high' }, - output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS }, - output_compression: { type: 'integer', minimum: 0, maximum: 100 }, - background: { type: 'string', enum: AGENT_BACKGROUNDS }, - moderation: { type: 'string', enum: AGENT_MODERATIONS }, - response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' } - } - }, - EditRequest: { - type: 'object', - required: ['prompt', 'image_0'], - properties: { - prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH }, - model: { type: 'string', enum: AGENT_MODELS, default: 'gpt-image-2' }, - n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT }, - size: { type: 'string', default: 'auto' }, - quality: { type: 'string', enum: AGENT_QUALITIES, default: 'auto' }, - response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' }, - image_0: { type: 'string', format: 'binary' }, - mask: { type: 'string', format: 'binary' } - } - }, - AgentArtifact: { - type: 'object', - required: ['id', 'filename', 'content_url', 'metadata_url', 'output_format', 'mime_type', 'size_bytes', 'width', 'height'], - properties: { - id: { type: 'string' }, - filename: { type: 'string' }, - content_url: { type: 'string' }, - metadata_url: { type: 'string' }, - output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS }, - mime_type: { type: 'string' }, - size_bytes: { type: 'integer', minimum: 0 }, - width: { type: ['integer', 'null'] }, - height: { type: ['integer', 'null'] }, - b64_json: { type: 'string' } - } - }, - AgentImageResponse: { - type: 'object', - required: ['request_id', 'idempotency_key', 'cached', 'images', 'created_at'], - properties: { - request_id: { type: 'string' }, - idempotency_key: { type: 'string' }, - cached: { type: 'boolean' }, - images: { - type: 'array', - items: { $ref: '#/components/schemas/AgentArtifact' } - }, - usage: { type: 'object' }, - created_at: { type: 'string', format: 'date-time' } - } - }, - ArtifactMetadataResponse: { - type: 'object', - required: ['artifact'], - properties: { - artifact: { $ref: '#/components/schemas/AgentArtifact' } - } - }, - AgentArtifactRecord: { - type: 'object', - required: [ - 'id', - 'requestId', - 'filename', - 'filepath', - 'contentUrl', - 'metadataUrl', - 'outputFormat', - 'mimeType', - 'sizeBytes', - 'width', - 'height', - 'model', - 'promptHash', - 'createdAt' - ], - properties: { - id: { type: 'string' }, - requestId: { type: 'string' }, - filename: { type: 'string' }, - filepath: { type: 'string' }, - contentUrl: { type: 'string' }, - metadataUrl: { type: 'string' }, - outputFormat: { type: 'string', enum: AGENT_OUTPUT_FORMATS }, - mimeType: { type: 'string' }, - sizeBytes: { type: 'integer', minimum: 0 }, - width: { type: ['integer', 'null'] }, - height: { type: ['integer', 'null'] }, - model: { type: 'string' }, - promptHash: { type: 'string' }, - createdAt: { type: 'string', format: 'date-time' } - } - }, - DeleteArtifactResponse: { - type: 'object', - required: ['deleted', 'id'], - properties: { - deleted: { type: 'boolean' }, - id: { type: 'string' } - } - }, - AgentError: { - type: 'object', - required: ['error'], - properties: { - error: { - type: 'object', - required: ['code', 'message', 'retryable', 'request_id'], - properties: { - code: { type: 'string' }, - message: { type: 'string' }, - retryable: { type: 'boolean' }, - details: { type: 'object' }, - upstream_status: { type: 'integer' }, - request_id: { type: 'string' } - } - } - } - } - } - } - }; -} diff --git a/src/lib/agent-auth.test.ts b/src/lib/agent-auth.test.ts index 5ab9bf33d2cf47dc5d38a6b1f68fac653962d616..d7b76b8db90123088a0a9ede7a5bca1695d3ae35 100644 --- a/src/lib/agent-auth.test.ts +++ b/src/lib/agent-auth.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import { describe, it } from 'node:test'; -const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-'); +const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-'); describe('assertAgentAuthorized', () => { it('accepts the configured bearer token', () => { @@ -20,14 +20,14 @@ describe('assertAgentAuthorized', () => { ); }); - it('rejects missing bearer tokens without falling back to password auth', () => { + it('rejects missing bearer tokens without falling back to access-code auth', () => { assert.throws( - () => assertAgentAuthorized(new Headers(), { AGENT_API_TOKEN: 'secret-token', APP_PASSWORD: 'password' }), + () => assertAgentAuthorized(new Headers(), { AGENT_API_TOKEN: 'secret-token', APP_PASSWORD: 'access-code' }), (error) => error instanceof AgentApiError && error.code === 'unauthorized' ); }); - it('trims APP_PASSWORD before verifying password hashes', () => { + it('trims APP_PASSWORD before verifying access-code hashes', () => { const passwordHash = crypto.createHash('sha256').update(PAGE_PASSWORD_FIXTURE).digest('hex'); assert.doesNotThrow(() => assertAgentAuthorized(new Headers({ 'X-App-Password-Hash': passwordHash }), { diff --git a/src/lib/agent-auth.ts b/src/lib/agent-auth.ts index 9d6bfaf5c160043096587894246b56e0ea4a8dee..cf633a3d1801801be0d65b0d3f40f09b2728a7bc 100644 --- a/src/lib/agent-auth.ts +++ b/src/lib/agent-auth.ts @@ -25,7 +25,7 @@ export function assertAgentAuthorized(headers: Headers, env: Record { const credentialContext = createOpenAiClient(options.headers); + const startedAtMs = Date.now(); try { const result = await credentialContext.openai.images.generate({ model: options.request.model, @@ -157,7 +160,7 @@ export async function executeAgentGenerate(options: { }); } catch (error) { reportServerCredentialFailure(credentialContext.selectedCredential, error); - throw normalizeAgentError(error); + throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs)); } } @@ -169,6 +172,7 @@ export async function executeAgentEdit(options: { cached: boolean; }): Promise { const credentialContext = createOpenAiClient(options.headers); + const startedAtMs = Date.now(); try { const prompt = readRequiredText(options.formData, 'prompt'); const model = readModel(options.formData); @@ -202,7 +206,7 @@ export async function executeAgentEdit(options: { }); } catch (error) { reportServerCredentialFailure(credentialContext.selectedCredential, error); - throw normalizeAgentError(error); + throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs)); } } @@ -306,6 +310,7 @@ export function errorToAgentErrorBody(error: unknown, requestId: string): AgentE retryable: normalized.retryable, ...(normalized.details ? { details: normalized.details } : {}), ...(normalized.upstreamStatus ? { upstream_status: normalized.upstreamStatus } : {}), + ...(normalized.diagnostics ? { diagnostics: normalized.diagnostics } : {}), request_id: requestId } }; @@ -314,11 +319,12 @@ export function errorToAgentErrorBody(error: unknown, requestId: string): AgentE export async function hydrateAgentReplayResponse( store: AgentStateStore, record: { requestId: string; requestJson: unknown }, - response: AgentImageResponse + response: AgentImageResponse, + cached = true ): Promise { const responseMode = readAgentResponseModeFromRequestJson(record.requestJson); if (!shouldIncludeBase64(responseMode)) { - return { ...response, cached: true }; + return { ...response, cached }; } const artifacts = await store.listArtifactsForRequest(record.requestId); const encodedById = new Map(); @@ -328,7 +334,7 @@ export async function hydrateAgentReplayResponse( } return { ...response, - cached: true, + cached, images: response.images.map((image) => ({ ...image, ...(encodedById.has(image.id) ? { b64_json: encodedById.get(image.id) } : {}) @@ -359,10 +365,28 @@ function createOpenAiClient(headers: Headers): CredentialContext { apiKey, baseURL: baseUrl || undefined }), - selectedCredential: effectiveSelectedCredential + selectedCredential: effectiveSelectedCredential, + baseUrl }; } +function buildAgentExecutionDiagnostics(context: CredentialContext, startedAtMs: number): AgentErrorDiagnostics { + const upstreamHost = context.baseUrl ? readUrlHost(context.baseUrl) : undefined; + return { + elapsed_ms: Date.now() - startedAtMs, + ...(context.selectedCredential?.channelId ? { selected_channel_id: context.selectedCredential.channelId } : {}), + ...(upstreamHost ? { upstream_host: upstreamHost } : {}) + }; +} + +function readUrlHost(value: string): string | undefined { + try { + return new URL(value).host; + } catch { + return undefined; + } +} + function reportServerCredentialFailure(credential: ChannelCredential | undefined, error: unknown) { const serverChannelRouter = getServerChannelState().router; if (!credential || !serverChannelRouter) return; diff --git a/src/lib/agent-job-service.ts b/src/lib/agent-job-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..250f4a540efa5ea04f454e76092919f5b28d78b5 --- /dev/null +++ b/src/lib/agent-job-service.ts @@ -0,0 +1,207 @@ +import type { AgentGenerateRequest, AgentJobState, AgentJobStatusResponse } from './agent-api-contracts'; +import { + completeAgentExecutionState, + createArtifactPersistenceError, + deleteAgentExecutionFiles, + errorToAgentErrorBody, + executeAgentGenerate, + hydrateAgentReplayResponse, + saveAgentExecutionArtifacts +} from './agent-image-service'; +import { AgentApiError, toTerminalAgentErrorBody, type AgentErrorBody } from './api-error-response'; +import type { AgentRequestRecord, AgentStateStore } from './agent-state-store'; +import { appLogger } from './app-logger'; + +const DEFAULT_JOB_RETRY_AFTER_SECONDS = 5; +const MAX_JOB_LEASE_REFRESH_INTERVAL_MS = 30_000; + +export function buildAgentJobStatusResponse( + record: AgentRequestRecord, + options: { now?: Date; retryAfterSeconds?: number } = {} +): AgentJobStatusResponse { + const state = readAgentJobState(record, options.now); + return { + job: { + id: record.requestId, + request_id: record.requestId, + idempotency_key: record.idempotencyKey, + mode: record.mode, + state, + created_at: record.createdAt, + updated_at: record.updatedAt, + expires_at: record.expiresAt, + ...(state !== 'expired' ? { result_url: `/api/agent/jobs/${record.requestId}/result` } : {}), + ...(state === 'running' || state === 'queued' + ? { retry_after_seconds: options.retryAfterSeconds ?? DEFAULT_JOB_RETRY_AFTER_SECONDS } + : {}), + ...(state === 'failed' && record.errorJson ? { error: summarizeJobError(record.errorJson) } : {}) + } + }; +} + +export function readAgentJobState(record: AgentRequestRecord, now = new Date()): AgentJobState { + if (isExpiredJobRecord(record, now)) return 'expired'; + if (record.status === 'pending') return 'queued'; + if (record.status === 'running') return 'running'; + if (record.status === 'succeeded') return 'succeeded'; + if (record.status === 'failed') return 'failed'; + return 'expired'; +} + +export function assertReadableJobRecord(record: AgentRequestRecord | undefined, id: string): AgentRequestRecord { + if (!record) { + throw new AgentApiError({ + code: 'job_not_found', + message: 'Agent job 不存在。', + status: 404, + retryable: false, + details: { id } + }); + } + if (readAgentJobState(record) === 'expired') { + throw new AgentApiError({ + code: 'job_expired', + message: 'Agent job 已过期。', + status: 410, + retryable: false, + details: { id } + }); + } + return record; +} + +export async function readCompletedJobResult( + store: AgentStateStore, + record: AgentRequestRecord +): Promise { + if (record.status === 'succeeded' && record.responseJson) { + return { + type: 'response', + response: await hydrateAgentReplayResponse(store, record, record.responseJson, record.responseJson.cached) + }; + } + if (record.status === 'failed' && record.errorJson) { + return { type: 'stored_error', error: toTerminalJobErrorBody(record.errorJson) }; + } + return { type: 'running' }; +} + +export function startAgentGenerateJob(options: { + store: AgentStateStore; + request: AgentGenerateRequest; + headers: Headers; + requestId: string; + idempotencyKey: string; + leaseMs: number; +}): void { + void runAgentGenerateJob(options).catch((error) => { + appLogger.error('Agent generate job 后台执行失败。', error); + }); +} + +type ResponseReadyResult = + | { type: 'response'; response: unknown } + | { type: 'stored_error'; error: AgentErrorBody } + | { type: 'running' }; + +async function runAgentGenerateJob(options: { + store: AgentStateStore; + request: AgentGenerateRequest; + headers: Headers; + requestId: string; + idempotencyKey: string; + leaseMs: number; +}): Promise { + const heartbeat = startAgentJobLeaseHeartbeat(options.store, options.requestId, options.leaseMs); + try { + const execution = await executeAgentGenerate({ + request: options.request, + headers: options.headers, + requestId: options.requestId, + idempotencyKey: options.idempotencyKey, + cached: false + }); + await persistAgentJobSuccess(options.store, execution, options.requestId); + } catch (error) { + await failAgentJob(options.store, options.requestId, error); + } finally { + heartbeat.stop(); + } +} + +function startAgentJobLeaseHeartbeat(store: AgentStateStore, requestId: string, leaseMs: number): { stop: () => void } { + const intervalMs = Math.max(100, Math.min(MAX_JOB_LEASE_REFRESH_INTERVAL_MS, Math.floor(leaseMs / 2))); + const timer = setInterval(() => { + void store.refreshRequestLease({ requestId, leaseMs }).catch((error) => { + appLogger.error('刷新 Agent job lease 失败。', error); + }); + }, intervalMs); + timer.unref?.(); + return { + stop: () => clearInterval(timer) + }; +} + +async function persistAgentJobSuccess( + store: AgentStateStore, + execution: Awaited>, + requestId: string +): Promise { + try { + await saveAgentExecutionArtifacts(store, execution); + } catch (error) { + appLogger.error('保存 Agent job 产物元数据失败。', error); + await cleanupFailedAgentJob(store, execution, requestId); + return; + } + try { + await completeAgentExecutionState(store, execution); + } catch (error) { + appLogger.error('保存 Agent job 完成状态失败。', error); + } +} + +async function cleanupFailedAgentJob( + store: AgentStateStore, + execution: Awaited>, + requestId: string +): Promise { + try { + await deleteAgentExecutionFiles(execution); + } catch (error) { + appLogger.error('Agent job 产物元数据保存失败后清理文件失败。', error); + } + await failAgentJob(store, requestId, createArtifactPersistenceError()); +} + +async function failAgentJob(store: AgentStateStore, requestId: string, error: unknown): Promise { + try { + await store.failRequest({ requestId, error: errorToAgentErrorBody(error, requestId) }); + } catch (storeError) { + appLogger.error('保存 Agent job 失败状态失败。', storeError); + } +} + +function isExpiredJobRecord(record: AgentRequestRecord, now: Date): boolean { + return new Date(record.expiresAt).getTime() <= now.getTime(); +} + +function summarizeJobError(errorBody: AgentErrorBody) { + const error = toTerminalJobError(errorBody); + return { + code: error.code, + message: error.message, + retryable: error.retryable, + ...(error.details ? { details: error.details } : {}), + ...(error.upstream_status !== undefined ? { upstream_status: error.upstream_status } : {}), + ...(error.diagnostics ? { diagnostics: error.diagnostics } : {}) + }; +} + +function toTerminalJobErrorBody(errorBody: AgentErrorBody): AgentErrorBody { + return toTerminalAgentErrorBody(errorBody); +} + +function toTerminalJobError(errorBody: AgentErrorBody): AgentErrorBody['error'] { + return toTerminalAgentErrorBody(errorBody).error; +} diff --git a/src/lib/agent-openapi.ts b/src/lib/agent-openapi.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2335f1054b624f0b501ce29de5affe915873de2 --- /dev/null +++ b/src/lib/agent-openapi.ts @@ -0,0 +1,567 @@ +import { MAX_IMAGE_COUNT, MAX_PROMPT_LENGTH } from './image-request-utils'; +import { + AGENT_BACKGROUNDS, + AGENT_JOB_STATES, + AGENT_MODERATIONS, + AGENT_MODELS, + AGENT_OUTPUT_FORMATS, + AGENT_QUALITIES, + AGENT_RESPONSE_MODES, + type AgentAuthScheme, + buildAgentCapabilities, + readAgentPublicBaseUrl +} from './agent-api-contracts'; + +type AgentOpenApiSecurityRequirement = { BearerAuth: [] } | { AppPasswordHash: [] }; + +function buildAgentOpenApiSecurity(schemes: readonly AgentAuthScheme[]): AgentOpenApiSecurityRequirement[] { + return schemes.map((scheme) => (scheme === 'bearer' ? { BearerAuth: [] } : { AppPasswordHash: [] })); +} + +function buildAgentOpenApiSecuritySchemes(schemes: readonly AgentAuthScheme[]) { + const securitySchemes: Record = {}; + if (schemes.includes('bearer')) { + securitySchemes.BearerAuth = { + type: 'http', + scheme: 'bearer' + }; + } + if (schemes.includes('x-app-password-hash')) { + securitySchemes.AppPasswordHash = { + type: 'apiKey', + in: 'header', + name: 'X-App-Password-Hash' + }; + } + return securitySchemes; +} + +export function buildAgentOpenApiDocument(env: Record) { + const capabilities = buildAgentCapabilities(env); + const jsonContent = (schemaRef: string) => ({ + content: { + 'application/json': { + schema: { $ref: schemaRef } + } + } + }); + const agentSecurity = buildAgentOpenApiSecurity(capabilities.auth.schemes); + const securitySchemes = buildAgentOpenApiSecuritySchemes(capabilities.auth.schemes); + const commonAgentErrors = { + '401': jsonContent('#/components/schemas/AgentError'), + '403': jsonContent('#/components/schemas/AgentError'), + '415': jsonContent('#/components/schemas/AgentError'), + '429': jsonContent('#/components/schemas/AgentError'), + '502': jsonContent('#/components/schemas/AgentError') + }; + return { + openapi: '3.1.0', + info: { + title: 'GPT Image Playground Agent API', + version: capabilities.api_version + }, + servers: [{ url: readAgentPublicBaseUrl(env) }], + paths: { + '/api/agent/capabilities': { + get: { + summary: '获取机器可读的 Agent API 能力信息', + responses: { + '200': jsonContent('#/components/schemas/AgentCapabilities') + } + } + }, + '/api/agent/openapi.json': { + get: { + summary: '获取 Agent API 的 OpenAPI 文档', + responses: { + '200': { description: 'OpenAPI 文档' } + } + } + }, + '/api/agent/images/generate': { + post: { + summary: '为 Agent 生成图片', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }], + requestBody: { + required: true, + ...jsonContent('#/components/schemas/GenerateRequest') + }, + responses: { + '200': jsonContent('#/components/schemas/AgentImageResponse'), + '400': jsonContent('#/components/schemas/AgentError'), + '409': { + ...jsonContent('#/components/schemas/AgentError'), + headers: { + 'Retry-After': { schema: { type: 'integer', minimum: 1 } } + } + }, + ...commonAgentErrors, + '422': jsonContent('#/components/schemas/AgentError'), + '500': jsonContent('#/components/schemas/AgentError') + } + } + }, + '/api/agent/images/edit': { + post: { + summary: '为 Agent 编辑图片', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }], + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { $ref: '#/components/schemas/EditRequest' } + } + } + }, + responses: { + '200': jsonContent('#/components/schemas/AgentImageResponse'), + '400': jsonContent('#/components/schemas/AgentError'), + '409': { + ...jsonContent('#/components/schemas/AgentError'), + headers: { + 'Retry-After': { schema: { type: 'integer', minimum: 1 } } + } + }, + ...commonAgentErrors, + '422': jsonContent('#/components/schemas/AgentError'), + '500': jsonContent('#/components/schemas/AgentError') + } + } + }, + '/api/agent/jobs/images/generate': { + post: { + summary: '创建 Agent 图片生成 job', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }], + requestBody: { + required: true, + ...jsonContent('#/components/schemas/GenerateRequest') + }, + responses: { + '200': jsonContent('#/components/schemas/AgentJobStatusResponse'), + '202': { + ...jsonContent('#/components/schemas/AgentJobStatusResponse'), + headers: { + 'Retry-After': { schema: { type: 'integer', minimum: 1 } } + } + }, + '400': jsonContent('#/components/schemas/AgentError'), + '409': jsonContent('#/components/schemas/AgentError'), + ...commonAgentErrors, + '422': jsonContent('#/components/schemas/AgentError'), + '500': jsonContent('#/components/schemas/AgentError') + } + } + }, + '/api/agent/jobs/{id}': { + get: { + summary: '获取 Agent job 状态', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/JobId' }], + responses: { + '200': jsonContent('#/components/schemas/AgentJobStatusResponse'), + '401': jsonContent('#/components/schemas/AgentError'), + '404': jsonContent('#/components/schemas/AgentError'), + '410': jsonContent('#/components/schemas/AgentError'), + '500': jsonContent('#/components/schemas/AgentError') + } + } + }, + '/api/agent/jobs/{id}/result': { + get: { + summary: '获取 Agent job 结果', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/JobId' }], + responses: { + '200': jsonContent('#/components/schemas/AgentImageResponse'), + '401': jsonContent('#/components/schemas/AgentError'), + '403': jsonContent('#/components/schemas/AgentError'), + '404': jsonContent('#/components/schemas/AgentError'), + '409': { + ...jsonContent('#/components/schemas/AgentError'), + headers: { + 'Retry-After': { schema: { type: 'integer', minimum: 1 } } + } + }, + '410': jsonContent('#/components/schemas/AgentError'), + '422': jsonContent('#/components/schemas/AgentError'), + '429': jsonContent('#/components/schemas/AgentError'), + '502': jsonContent('#/components/schemas/AgentError'), + '500': jsonContent('#/components/schemas/AgentError') + } + } + }, + '/api/agent/artifacts/{id}': { + get: { + summary: '获取产物元数据', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/ArtifactId' }], + responses: { + '200': jsonContent('#/components/schemas/ArtifactMetadataResponse'), + '401': jsonContent('#/components/schemas/AgentError'), + '404': jsonContent('#/components/schemas/AgentError') + } + }, + delete: { + summary: '删除产物', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/ArtifactId' }], + responses: { + '200': jsonContent('#/components/schemas/DeleteArtifactResponse'), + '401': jsonContent('#/components/schemas/AgentError'), + '404': jsonContent('#/components/schemas/AgentError') + } + } + }, + '/api/agent/artifacts/{id}/content': { + get: { + summary: '下载产物内容', + security: agentSecurity, + parameters: [{ $ref: '#/components/parameters/ArtifactId' }], + responses: { + '200': { + description: '图片二进制内容', + content: { + 'image/png': { schema: { type: 'string', format: 'binary' } }, + 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, + 'image/webp': { schema: { type: 'string', format: 'binary' } } + } + }, + '401': jsonContent('#/components/schemas/AgentError'), + '404': jsonContent('#/components/schemas/AgentError') + } + } + } + }, + components: { + securitySchemes, + parameters: { + IdempotencyKey: { + name: 'Idempotency-Key', + in: 'header', + required: true, + schema: { type: 'string', minLength: 1, maxLength: 200 } + }, + ArtifactId: { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', minLength: 1 } + }, + JobId: { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', minLength: 1 } + } + }, + schemas: { + AgentCapabilities: { + type: 'object', + required: [ + 'api_version', + 'schema_version', + 'auth', + 'endpoints', + 'defaults', + 'limits', + 'model_limits', + 'agent_streaming', + 'agent_jobs', + 'supported', + 'storage', + 'idempotency' + ], + properties: { + api_version: { type: 'string' }, + schema_version: { type: 'string' }, + auth: { + type: 'object', + required: ['required', 'schemes'], + properties: { + required: { type: 'boolean', const: capabilities.auth.required }, + schemes: { + type: 'array', + items: { type: 'string' }, + const: capabilities.auth.schemes + } + } + }, + endpoints: { type: 'object', additionalProperties: { type: 'string' } }, + defaults: { type: 'object' }, + limits: { type: 'object' }, + model_limits: { $ref: '#/components/schemas/AgentModelLimits' }, + agent_streaming: { $ref: '#/components/schemas/AgentStreamingCapabilities' }, + agent_jobs: { $ref: '#/components/schemas/AgentJobCapabilities' }, + supported: { type: 'object' }, + storage: { + type: 'object', + required: ['image_storage_mode', 'postgres_configured'], + properties: { + image_storage_mode: { type: 'string' }, + postgres_configured: { type: 'boolean' } + } + }, + idempotency: { type: 'object' } + } + }, + AgentModelLimits: { + type: 'object', + required: ['gpt-image-2'], + properties: { + 'gpt-image-2': { + type: 'object', + required: [ + 'max_edge', + 'max_pixels', + 'edge_multiple', + 'max_aspect', + 'min_pixels', + 'recommended_presets', + 'high_4k_risk' + ], + properties: { + max_edge: { type: 'integer', minimum: 1 }, + max_pixels: { type: 'integer', minimum: 1 }, + edge_multiple: { type: 'integer', minimum: 1 }, + max_aspect: { type: 'number', minimum: 1 }, + min_pixels: { type: 'integer', minimum: 1 }, + recommended_presets: { + type: 'array', + items: { + type: 'object', + required: ['name', 'size', 'purpose'], + properties: { + name: { type: 'string' }, + size: { type: 'string' }, + purpose: { type: 'string' } + } + } + }, + high_4k_risk: { + type: 'object', + required: ['applies_to', 'guidance'], + properties: { + applies_to: { type: 'array', items: { type: 'string' } }, + guidance: { type: 'string' } + } + } + } + } + } + }, + AgentStreamingCapabilities: { + type: 'object', + required: ['generate', 'edit', 'page_sse'], + properties: { + generate: { $ref: '#/components/schemas/AgentEndpointStreamingCapability' }, + edit: { $ref: '#/components/schemas/AgentEndpointStreamingCapability' }, + page_sse: { + type: 'object', + required: ['supported', 'mode', 'endpoint', 'contract'], + properties: { + supported: { type: 'boolean', const: true }, + mode: { type: 'string', enum: ['form_data_sse'] }, + endpoint: { type: 'string' }, + contract: { type: 'string', enum: ['page_ui_only'] } + } + } + } + }, + AgentEndpointStreamingCapability: { + type: 'object', + required: ['supported', 'mode', 'endpoint'], + properties: { + supported: { type: 'boolean', const: false }, + mode: { type: 'string', enum: ['non_streaming_only'] }, + endpoint: { type: 'string' } + } + }, + AgentJobCapabilities: { + type: 'object', + required: [ + 'supported', + 'mode', + 'intended_for', + 'endpoints', + 'states', + 'current_guidance' + ], + properties: { + supported: { type: 'boolean', const: true }, + mode: { type: 'string', enum: ['job_polling'] }, + intended_for: { type: 'array', items: { type: 'string' } }, + endpoints: { + type: 'object', + required: ['create_generate_job', 'get_job', 'get_job_result'], + properties: { + create_generate_job: { type: 'string' }, + get_job: { type: 'string' }, + get_job_result: { type: 'string' } + } + }, + states: { type: 'array', items: { type: 'string', enum: AGENT_JOB_STATES } }, + current_guidance: { type: 'string' } + } + }, + GenerateRequest: { + type: 'object', + required: ['prompt'], + properties: { + prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH }, + model: { type: 'string', enum: AGENT_MODELS }, + n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT }, + size: { type: 'string' }, + quality: { type: 'string', enum: AGENT_QUALITIES, default: 'high' }, + output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS }, + output_compression: { type: 'integer', minimum: 0, maximum: 100 }, + background: { type: 'string', enum: AGENT_BACKGROUNDS }, + moderation: { type: 'string', enum: AGENT_MODERATIONS }, + response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' } + } + }, + EditRequest: { + type: 'object', + required: ['prompt', 'image_0'], + properties: { + prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH }, + model: { type: 'string', enum: AGENT_MODELS, default: 'gpt-image-2' }, + n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT }, + size: { type: 'string', default: 'auto' }, + quality: { type: 'string', enum: AGENT_QUALITIES, default: 'auto' }, + response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' }, + image_0: { type: 'string', format: 'binary' }, + mask: { type: 'string', format: 'binary' } + } + }, + AgentArtifact: { + type: 'object', + required: ['id', 'filename', 'content_url', 'metadata_url', 'output_format', 'mime_type', 'size_bytes', 'width', 'height'], + properties: { + id: { type: 'string' }, + filename: { type: 'string' }, + content_url: { type: 'string' }, + metadata_url: { type: 'string' }, + output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS }, + mime_type: { type: 'string' }, + size_bytes: { type: 'integer', minimum: 0 }, + width: { type: ['integer', 'null'] }, + height: { type: ['integer', 'null'] }, + b64_json: { type: 'string' } + } + }, + AgentImageResponse: { + type: 'object', + required: ['request_id', 'idempotency_key', 'cached', 'images', 'created_at'], + properties: { + request_id: { type: 'string' }, + idempotency_key: { type: 'string' }, + cached: { type: 'boolean' }, + images: { + type: 'array', + items: { $ref: '#/components/schemas/AgentArtifact' } + }, + usage: { type: 'object' }, + created_at: { type: 'string', format: 'date-time' } + } + }, + AgentJobStatusResponse: { + type: 'object', + required: ['job'], + properties: { + job: { + type: 'object', + required: [ + 'id', + 'request_id', + 'idempotency_key', + 'mode', + 'state', + 'created_at', + 'updated_at', + 'expires_at' + ], + properties: { + id: { type: 'string' }, + request_id: { type: 'string' }, + idempotency_key: { type: 'string' }, + mode: { type: 'string', enum: ['generate', 'edit'] }, + state: { type: 'string', enum: AGENT_JOB_STATES }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' }, + expires_at: { type: 'string', format: 'date-time' }, + result_url: { type: 'string' }, + retry_after_seconds: { type: 'integer', minimum: 1 }, + error: { + type: 'object', + required: ['code', 'message', 'retryable'], + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + retryable: { type: 'boolean' }, + details: { type: 'object' }, + upstream_status: { type: 'integer' }, + diagnostics: { $ref: '#/components/schemas/AgentErrorDiagnostics' } + } + } + } + } + } + }, + ArtifactMetadataResponse: { + type: 'object', + required: ['artifact'], + properties: { + artifact: { $ref: '#/components/schemas/AgentArtifact' } + } + }, + DeleteArtifactResponse: { + type: 'object', + required: ['deleted', 'id'], + properties: { + deleted: { type: 'boolean' }, + id: { type: 'string' } + } + }, + AgentError: { + type: 'object', + required: ['error'], + properties: { + error: { + type: 'object', + required: ['code', 'message', 'retryable', 'request_id'], + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + retryable: { type: 'boolean' }, + details: { type: 'object' }, + upstream_status: { type: 'integer' }, + diagnostics: { $ref: '#/components/schemas/AgentErrorDiagnostics' }, + request_id: { type: 'string' } + } + } + } + }, + AgentErrorDiagnostics: { + type: 'object', + properties: { + elapsed_ms: { type: 'integer', minimum: 0 }, + selected_channel_id: { type: 'string' }, + upstream_host: { type: 'string' }, + upstream_status: { type: 'integer' }, + transport_error: { type: 'boolean' }, + retry_after_seconds: { type: 'integer', minimum: 1 }, + channel_cooldown_scope: { type: 'string', enum: ['credential', 'channel'] }, + response_headers: { + type: 'object', + additionalProperties: { type: 'string' } + } + }, + additionalProperties: false + } + } + } + }; +} diff --git a/src/lib/agent-state-memory.test.ts b/src/lib/agent-state-memory.test.ts index 410cece5d181a7893df781589fa0bbbe07d623d0..e10a8fce4b4ab9e5f72c6477c28a7c4e02c3e6ee 100644 --- a/src/lib/agent-state-memory.test.ts +++ b/src/lib/agent-state-memory.test.ts @@ -87,6 +87,45 @@ describe('MemoryAgentStateStore', () => { assert.equal(replay.type, 'replay'); }); + it('keeps refreshed running requests out of recovery', async () => { + const store = new MemoryAgentStateStore(); + await store.init(); + const requestJson = { prompt: 'memory lease refresh' }; + const requestHash = hashAgentPayload(requestJson); + const begin = await store.beginRequest({ + idempotencyKey: 'idem-memory-refresh', + requestHash, + mode: 'generate', + requestJson, + leaseMs: 100, + ttlSeconds: 60, + now: new Date('2026-05-12T00:00:00.000Z') + }); + assert.equal(begin.type, 'acquired'); + if (begin.type !== 'acquired') throw new Error('expected acquired'); + + assert.equal( + await store.refreshRequestLease({ + requestId: begin.record.requestId, + leaseMs: 1000, + now: new Date('2026-05-12T00:00:00.050Z') + }), + true + ); + assert.equal(await store.recoverExpiredRequests(new Date('2026-05-12T00:00:00.200Z')), 0); + + const retry = await store.beginRequest({ + idempotencyKey: 'idem-memory-refresh', + requestHash, + mode: 'generate', + requestJson, + leaseMs: 100, + ttlSeconds: 60, + now: new Date('2026-05-12T00:00:00.200Z') + }); + assert.equal(retry.type, 'in_progress'); + }); + it('rejects artifact metadata for unknown requests', async () => { const store = new MemoryAgentStateStore(); await store.init(); @@ -238,6 +277,40 @@ describe('MemoryAgentStateStore', () => { assert.equal(record.expiresAt, '2026-05-14T09:00:00.000Z'); }); + it('rejects inconsistent image share access code metadata', async () => { + const store = new MemoryAgentStateStore(); + await store.init(); + + await assert.rejects( + () => + store.createImageShareRecord({ + token: 'f'.repeat(24), + sourceFilename: 'protected.png', + contentFilename: `${'f'.repeat(24)}.png`, + mimeType: 'image/png', + sizeBytes: 12, + createdAt: '2026-05-14T08:00:00.000Z', + accessCodeRequired: true + }), + /CHECK/ + ); + await assert.rejects( + () => + store.createImageShareRecord({ + token: '0'.repeat(24), + sourceFilename: 'public.png', + contentFilename: `${'0'.repeat(24)}.png`, + mimeType: 'image/png', + sizeBytes: 12, + createdAt: '2026-05-14T08:00:00.000Z', + accessCodeRequired: false, + accessCodeSalt: 'salt', + accessCodeHash: 'hash' + }), + /CHECK/ + ); + }); + it('deletes expired image share records and lists active share records', async () => { const store = new MemoryAgentStateStore(); await store.init(); diff --git a/src/lib/agent-state-memory.ts b/src/lib/agent-state-memory.ts index 4d6d81068866f6ebc1035f648d46cc1b7a926b59..1ce68ba54696a5297d8a9a07d4eaadc8b6ea39ff 100644 --- a/src/lib/agent-state-memory.ts +++ b/src/lib/agent-state-memory.ts @@ -158,6 +158,23 @@ export class MemoryAgentStateStore implements AgentStateStore, ImageShareStateSt return { type: 'acquired', record: reacquired }; } + async refreshRequestLease(input: { requestId: string; leaseMs: number; now?: Date }): Promise { + const now = input.now ?? new Date(); + const nowIso = isoDate(now); + const lockedUntil = isoDate(addMilliseconds(now, input.leaseMs)); + let refreshed = false; + this.updateRequestById(input.requestId, (record) => { + if (record.status !== 'running' && record.status !== 'pending') return record; + refreshed = true; + return { + ...record, + lockedUntil, + updatedAt: nowIso + }; + }); + return refreshed; + } + async saveArtifacts(artifacts: AgentArtifactRecord[]): Promise { this.insertArtifacts(artifacts); } @@ -187,6 +204,10 @@ export class MemoryAgentStateStore implements AgentStateStore, ImageShareStateSt })); } + async getRequest(requestId: string): Promise { + return [...this.requestsByIdempotencyKey.values()].find((record) => record.requestId === requestId); + } + async getArtifact(id: string): Promise { return this.artifactsById.get(id); } @@ -200,6 +221,7 @@ export class MemoryAgentStateStore implements AgentStateStore, ImageShareStateSt } async createImageShareRecord(record: ImageShareRecord): Promise { + validateImageShareAccessCodeMetadata(record); if (this.sharesByToken.has(record.token)) { throw new Error('UNIQUE constraint failed: image_shares.token'); } @@ -282,3 +304,14 @@ function withoutUndefined(record: T): T { function sameArtifactRecord(left: AgentArtifactRecord, right: AgentArtifactRecord): boolean { return JSON.stringify(left) === JSON.stringify(right); } + +function validateImageShareAccessCodeMetadata(record: ImageShareRecord): void { + const hasAccessCodeSalt = record.accessCodeSalt !== undefined; + const hasAccessCodeHash = record.accessCodeHash !== undefined; + const isValid = record.accessCodeRequired + ? hasAccessCodeSalt && hasAccessCodeHash + : !hasAccessCodeSalt && !hasAccessCodeHash; + if (!isValid) { + throw new Error('CHECK constraint failed: image_shares.access_code_metadata'); + } +} diff --git a/src/lib/agent-state-postgres.ts b/src/lib/agent-state-postgres.ts index 3e5019700a42e6bfde81de733d5fcde684d032d4..8a90f49cf44a130692bcd812b0b72e633e1f491a 100644 --- a/src/lib/agent-state-postgres.ts +++ b/src/lib/agent-state-postgres.ts @@ -200,6 +200,15 @@ export class PostgresAgentStateStore implements AgentStateStore, ImageShareState } } + async refreshRequestLease(input: { requestId: string; leaseMs: number; now?: Date }): Promise { + const now = input.now ?? new Date(); + const result = await this.pool.query( + "UPDATE agent_requests SET locked_until = $1, updated_at = $2 WHERE request_id = $3 AND status IN ('running', 'pending')", + [isoDate(addMilliseconds(now, input.leaseMs)), isoDate(now), input.requestId] + ); + return (result.rowCount ?? 0) > 0; + } + async completeRequest(input: CompleteAgentRequestInput): Promise { const client = await this.pool.connect(); const nowIso = isoDate(input.now ?? new Date()); @@ -240,6 +249,12 @@ export class PostgresAgentStateStore implements AgentStateStore, ImageShareState ); } + async getRequest(requestId: string): Promise { + const result = await this.pool.query('SELECT * FROM agent_requests WHERE request_id = $1', [requestId]); + const row = result.rows[0] as PostgresRequestRow | undefined; + return row ? this.mapRequestRow(row) : undefined; + } + async getArtifact(id: string): Promise { const result = await this.pool.query('SELECT * FROM agent_artifacts WHERE id = $1', [id]); const row = result.rows[0] as PostgresArtifactRow | undefined; diff --git a/src/lib/agent-state-runtime.test.ts b/src/lib/agent-state-runtime.test.ts index 6e2062e0efb321be22d2e8f140758430bac1d86b..7f685767259c6ee05e9ebbcabc3b9d8bd0dd85fc 100644 --- a/src/lib/agent-state-runtime.test.ts +++ b/src/lib/agent-state-runtime.test.ts @@ -228,6 +228,9 @@ function createFakeStore(options: { failFirstRecovery?: boolean; failInit?: () = async beginRequest() { throw new Error('not implemented'); }, + async refreshRequestLease() { + return false; + }, async saveArtifacts() {}, async completeRequest() {}, async failRequest() {}, diff --git a/src/lib/agent-state-sqlite.test.ts b/src/lib/agent-state-sqlite.test.ts index 16397feb36e90786c16290dd1d887c5f9746bc15..60d91cc5ff9834195a10a8de0266969549247a09 100644 --- a/src/lib/agent-state-sqlite.test.ts +++ b/src/lib/agent-state-sqlite.test.ts @@ -180,6 +180,43 @@ describe('SqliteAgentStateStore', () => { assert.equal(reacquired.type, 'acquired'); }); + it('keeps refreshed running requests out of recovery', async () => { + const requestJson = { prompt: 'sqlite lease refresh' }; + const requestHash = hashAgentPayload(requestJson); + const begin = await store.beginRequest({ + idempotencyKey: 'idem-sqlite-refresh', + requestHash, + mode: 'generate', + requestJson, + leaseMs: 100, + ttlSeconds: 60, + now: new Date('2026-05-12T00:00:00.000Z') + }); + assert.equal(begin.type, 'acquired'); + if (begin.type !== 'acquired') throw new Error('expected acquired'); + + assert.equal( + await store.refreshRequestLease({ + requestId: begin.record.requestId, + leaseMs: 1000, + now: new Date('2026-05-12T00:00:00.050Z') + }), + true + ); + await store.recoverExpiredRequests(new Date('2026-05-12T00:00:00.200Z')); + + const retry = await store.beginRequest({ + idempotencyKey: 'idem-sqlite-refresh', + requestHash, + mode: 'generate', + requestJson, + leaseMs: 100, + ttlSeconds: 60, + now: new Date('2026-05-12T00:00:00.200Z') + }); + assert.equal(retry.type, 'in_progress'); + }); + it('recovers expired running requests with artifact metadata as succeeded', async () => { const requestJson = { prompt: 'recover artifact' }; const requestHash = hashAgentPayload(requestJson); diff --git a/src/lib/agent-state-sqlite.ts b/src/lib/agent-state-sqlite.ts index e3d3882c6629f304e7a348d3a294df5c4c3100de..04e3312b0d83cbc622e6661e82eca4bc8f21cdf2 100644 --- a/src/lib/agent-state-sqlite.ts +++ b/src/lib/agent-state-sqlite.ts @@ -189,6 +189,16 @@ export class SqliteAgentStateStore implements AgentStateStore, ImageShareStateSt } } + async refreshRequestLease(input: { requestId: string; leaseMs: number; now?: Date }): Promise { + const now = input.now ?? new Date(); + const result = this.requireDb() + .prepare( + "UPDATE agent_requests SET locked_until = ?, updated_at = ? WHERE request_id = ? AND status IN ('running', 'pending')" + ) + .run(isoDate(addMilliseconds(now, input.leaseMs)), isoDate(now), input.requestId); + return result.changes > 0; + } + private beginRequestInTransaction( input: BeginAgentRequestInput, now: Date, @@ -286,6 +296,13 @@ export class SqliteAgentStateStore implements AgentStateStore, ImageShareStateSt ).run(serializeJson(input.error), nowIso, input.requestId); } + async getRequest(requestId: string): Promise { + const row = this.requireDb() + .prepare('SELECT * FROM agent_requests WHERE request_id = ?') + .get(requestId) as SqliteRequestRow | undefined; + return row ? this.mapRequestRow(row) : undefined; + } + async getArtifact(id: string): Promise { const row = this.requireDb().prepare('SELECT * FROM agent_artifacts WHERE id = ?').get(id) as SqliteArtifactRow | undefined; return row ? this.mapArtifactRow(row) : undefined; diff --git a/src/lib/agent-state-store.ts b/src/lib/agent-state-store.ts index 8441d647f389fab2b365e204b65a000d8a0910a7..b4482c9f1dc1dd70a343f4bbaa62adfa17061b6f 100644 --- a/src/lib/agent-state-store.ts +++ b/src/lib/agent-state-store.ts @@ -66,14 +66,22 @@ export type FailAgentRequestInput = { now?: Date; }; +export type RefreshAgentRequestLeaseInput = { + requestId: string; + leaseMs: number; + now?: Date; +}; + export type AgentStateStore = { init(): Promise; recoverExpiredRequests(now?: Date): Promise; purgeExpiredRequests(now?: Date): Promise; beginRequest(input: BeginAgentRequestInput): Promise; + refreshRequestLease(input: RefreshAgentRequestLeaseInput): Promise; saveArtifacts(artifacts: AgentArtifactRecord[]): Promise; completeRequest(input: CompleteAgentRequestInput): Promise; failRequest(input: FailAgentRequestInput): Promise; + getRequest(requestId: string): Promise; getArtifact(id: string): Promise; listArtifactsForRequest(requestId: string): Promise; deleteArtifact(id: string): Promise; diff --git a/src/lib/api-error-guidance.test.ts b/src/lib/api-error-guidance.test.ts index 6fa79375e19e53076cc46fd0c49ad8afaf12fd75..ff6817b042737cec7f4362095e2a5e68180ff0b5 100644 --- a/src/lib/api-error-guidance.test.ts +++ b/src/lib/api-error-guidance.test.ts @@ -10,7 +10,7 @@ import { describe, it } from 'node:test'; const translate = (key: string, values?: Record) => { const messages: Record = { 'error.apiFailedWithAdvice': '{message}。建议:{advice}', - 'error.adviceAuth': '检查 API Key、页面密码或渠道权限。', + 'error.adviceAuth': '检查 API Key、访问码或渠道权限。', 'error.adviceRateLimit': '请求被限流。请稍后重试,或降低并发和图片数量。', 'error.adviceUpstream': '上游或 API 中转站异常。请稍后重试,或切换可用渠道。', 'error.adviceCloudflare': @@ -28,7 +28,7 @@ describe('buildUserFacingApiErrorMessage', () => { t: translate }); - assert.match(message, /检查 API Key、页面密码或渠道权限/); + assert.match(message, /检查 API Key、访问码或渠道权限/); }); it('adds rate-limit advice for 429 failures', () => { diff --git a/src/lib/api-error-response.test.ts b/src/lib/api-error-response.test.ts index 468411c03c4e436e6dcc7ddb42187528e7521991..447f891f3fed3409c325f1901099d9d5bfcd39b5 100644 --- a/src/lib/api-error-response.test.ts +++ b/src/lib/api-error-response.test.ts @@ -49,6 +49,85 @@ describe('normalizeAgentError', () => { assert.equal(error.status, 502); assert.equal(error.retryable, true); assert.equal(error.retryAfterSeconds, 15); + assert.equal(error.upstreamStatus, undefined); + assert.equal(error.diagnostics?.transport_error, true); + assert.equal(error.diagnostics?.channel_cooldown_scope, 'channel'); + }); + + it('adds sanitized upstream diagnostics without inventing an HTTP status', () => { + const error = normalizeAgentError( + Object.assign(new Error('Connection error.'), { + name: 'APIConnectionError', + headers: { + 'cf-ray': 'abc-SJC', + authorization: 'Bearer secret' + } + }), + { + elapsed_ms: 1234, + selected_channel_id: 'channel-a', + upstream_host: 'api.example.test' + } + ); + const body = createAgentErrorBody(error, 'request-2'); + + assert.equal(body.error.upstream_status, undefined); + assert.equal(body.error.diagnostics?.elapsed_ms, 1234); + assert.equal(body.error.diagnostics?.selected_channel_id, 'channel-a'); + assert.equal(body.error.diagnostics?.upstream_host, 'api.example.test'); + assert.equal(body.error.diagnostics?.transport_error, true); + assert.deepEqual(body.error.diagnostics?.response_headers, { 'cf-ray': 'abc-SJC' }); + assert.equal(JSON.stringify(body).includes('secret'), false); + }); + + it('filters caller-provided diagnostic response headers through the allowlist', () => { + const error = normalizeAgentError(new Error('diagnostics'), { + response_headers: { + 'cf-ray': 'abc-SJC', + authorization: 'Bearer secret', + 'x-api-key': 'secret' + } + }); + const body = createAgentErrorBody(error, 'request-3'); + + assert.deepEqual(body.error.diagnostics?.response_headers, { 'cf-ray': 'abc-SJC' }); + assert.equal(JSON.stringify(body).includes('secret'), false); + }); + + it('copies upstream status and retry timing into diagnostics', () => { + const error = normalizeAgentError({ + status: 524, + message: 'timeout', + headers: { + 'retry-after': '7', + server: 'cloudflare' + } + }); + + assert.equal(error.code, 'upstream_unavailable'); + assert.equal(error.upstreamStatus, 524); + assert.equal(error.retryAfterSeconds, 7); + assert.equal(error.diagnostics?.upstream_status, 524); + assert.equal(error.diagnostics?.retry_after_seconds, 7); + assert.equal(error.diagnostics?.channel_cooldown_scope, 'channel'); + assert.deepEqual(error.diagnostics?.response_headers, { + 'retry-after': '7', + server: 'cloudflare' + }); + }); + + it('falls back when upstream retry-after headers are unsafe', () => { + const error = normalizeAgentError({ + status: 429, + message: 'rate limit', + headers: { + 'retry-after': '999999999999999999999' + } + }); + + assert.equal(error.code, 'upstream_rate_limited'); + assert.equal(error.retryAfterSeconds, 30); + assert.equal(error.diagnostics?.retry_after_seconds, 30); }); }); diff --git a/src/lib/api-error-response.ts b/src/lib/api-error-response.ts index 12f7ad9e730bfef407d37751eddb10b47393aba6..8f22f85a59843d71579b2a89bafc08de43401667 100644 --- a/src/lib/api-error-response.ts +++ b/src/lib/api-error-response.ts @@ -10,11 +10,24 @@ export type AgentErrorCode = | 'idempotency_conflict' | 'request_in_progress' | 'artifact_not_found' + | 'job_not_found' + | 'job_expired' | 'upstream_rate_limited' | 'upstream_auth_failed' | 'upstream_unavailable' | 'unexpected_error'; +export type AgentErrorDiagnostics = { + elapsed_ms?: number; + selected_channel_id?: string; + upstream_host?: string; + upstream_status?: number; + transport_error?: boolean; + retry_after_seconds?: number; + channel_cooldown_scope?: 'credential' | 'channel'; + response_headers?: Record; +}; + export type AgentErrorBody = { error: { code: AgentErrorCode; @@ -22,6 +35,7 @@ export type AgentErrorBody = { retryable: boolean; details?: Record; upstream_status?: number; + diagnostics?: AgentErrorDiagnostics; request_id: string; }; }; @@ -32,6 +46,8 @@ export function statusForAgentErrorCode(code: AgentErrorCode, upstreamStatus?: n if (code === 'idempotency_key_required') return 400; if (code === 'idempotency_conflict' || code === 'request_in_progress') return 409; if (code === 'artifact_not_found') return 404; + if (code === 'job_not_found') return 404; + if (code === 'job_expired') return 410; if (code === 'upstream_rate_limited') return 429; if (code === 'upstream_auth_failed') return upstreamStatus === 403 ? 403 : 401; if (code === 'upstream_unavailable') return 502; @@ -45,6 +61,7 @@ export class AgentApiError extends Error { readonly details?: Record; readonly upstreamStatus?: number; readonly retryAfterSeconds?: number; + readonly diagnostics?: AgentErrorDiagnostics; constructor(options: { code: AgentErrorCode; @@ -54,6 +71,7 @@ export class AgentApiError extends Error { details?: Record; upstreamStatus?: number; retryAfterSeconds?: number; + diagnostics?: AgentErrorDiagnostics; }) { super(options.message); this.name = 'AgentApiError'; @@ -63,9 +81,62 @@ export class AgentApiError extends Error { this.details = options.details; this.upstreamStatus = options.upstreamStatus; this.retryAfterSeconds = options.retryAfterSeconds; + this.diagnostics = cleanDiagnostics(options.diagnostics); } } +type ErrorDiagnosticsInput = AgentErrorDiagnostics & { + upstreamStatus?: number; + retryAfterSeconds?: number; +}; + +const DIAGNOSTIC_HEADER_ALLOWLIST = new Set([ + 'content-type', + 'date', + 'retry-after', + 'server', + 'cf-ray', + 'x-request-id', + 'x-ratelimit-reset-requests', + 'x-ratelimit-reset-tokens' +]); +const MAX_UPSTREAM_RETRY_AFTER_SECONDS = 3600; + +function buildDiagnostics(error: unknown, input: ErrorDiagnosticsInput = {}): AgentErrorDiagnostics | undefined { + const { upstreamStatus, retryAfterSeconds, ...base } = input; + const upstreamStatusValue = base.upstream_status ?? upstreamStatus; + const retryAfterSecondsValue = base.retry_after_seconds ?? retryAfterSeconds ?? readRetryAfterSeconds(error); + const transportError = base.transport_error ?? (isTransportError(error) || undefined); + const responseHeaders = base.response_headers ?? readWhitelistedHeaders(error); + return cleanDiagnostics({ + ...base, + ...(upstreamStatusValue !== undefined ? { upstream_status: upstreamStatusValue } : {}), + ...(retryAfterSecondsValue !== undefined ? { retry_after_seconds: retryAfterSecondsValue } : {}), + ...(transportError !== undefined ? { transport_error: transportError } : {}), + ...(responseHeaders ? { response_headers: responseHeaders } : {}) + }); +} + +function cleanDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): AgentErrorDiagnostics | undefined { + if (!diagnostics) return undefined; + const responseHeaders = readWhitelistedHeaderSource(diagnostics.response_headers); + const retryAfterSeconds = + diagnostics.retry_after_seconds !== undefined + ? normalizeRetryAfterSeconds(diagnostics.retry_after_seconds) + : undefined; + const cleaned: AgentErrorDiagnostics = { + ...(diagnostics.elapsed_ms !== undefined ? { elapsed_ms: Math.max(0, Math.round(diagnostics.elapsed_ms)) } : {}), + ...(diagnostics.selected_channel_id ? { selected_channel_id: diagnostics.selected_channel_id } : {}), + ...(diagnostics.upstream_host ? { upstream_host: diagnostics.upstream_host } : {}), + ...(diagnostics.upstream_status !== undefined ? { upstream_status: diagnostics.upstream_status } : {}), + ...(diagnostics.transport_error !== undefined ? { transport_error: diagnostics.transport_error } : {}), + ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}), + ...(diagnostics.channel_cooldown_scope ? { channel_cooldown_scope: diagnostics.channel_cooldown_scope } : {}), + ...(responseHeaders ? { response_headers: responseHeaders } : {}) + }; + return Object.keys(cleaned).length > 0 ? cleaned : undefined; +} + function readNumberField(error: unknown, field: string): number | undefined { if (typeof error !== 'object' || error === null || !(field in error)) return undefined; const value = (error as Record)[field]; @@ -78,6 +149,60 @@ function readStringField(error: unknown, field: string): string | undefined { return typeof value === 'string' ? value : undefined; } +function readRetryAfterSeconds(error: unknown): number | undefined { + const fromField = readNumberField(error, 'retryAfterSeconds'); + const normalizedField = fromField !== undefined ? normalizeRetryAfterSeconds(fromField) : undefined; + if (normalizedField !== undefined) return normalizedField; + const headers = readWhitelistedHeaders(error); + const retryAfter = headers?.['retry-after']; + if (!retryAfter || !/^\d+$/.test(retryAfter)) return undefined; + return normalizeRetryAfterSeconds(Number(retryAfter)); +} + +function normalizeRetryAfterSeconds(value: number): number | undefined { + if (!Number.isFinite(value)) return undefined; + const rounded = Math.round(value); + if (!Number.isSafeInteger(rounded) || rounded < 1 || rounded > MAX_UPSTREAM_RETRY_AFTER_SECONDS) return undefined; + return rounded; +} + +function readWhitelistedHeaders(error: unknown): Record | undefined { + const headers = readHeadersSource(error); + return readWhitelistedHeaderSource(headers); +} + +function readWhitelistedHeaderSource(headers: unknown): Record | undefined { + if (!headers) return undefined; + const result: Record = {}; + for (const name of DIAGNOSTIC_HEADER_ALLOWLIST) { + const value = readHeaderValue(headers, name); + if (value) { + result[name] = value; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function readHeadersSource(error: unknown): unknown { + if (typeof error !== 'object' || error === null) return undefined; + const object = error as Record; + if (object.headers) return object.headers; + const response = object.response; + if (typeof response !== 'object' || response === null) return undefined; + return (response as Record).headers; +} + +function readHeaderValue(headers: unknown, name: string): string | undefined { + if (headers instanceof Headers) { + return headers.get(name) || undefined; + } + if (typeof headers !== 'object' || headers === null) return undefined; + const entries = Object.entries(headers as Record); + const match = entries.find(([key]) => key.toLowerCase() === name); + const value = match?.[1]; + return typeof value === 'string' && value ? value : undefined; +} + function parseValidationDetails(message: string): Record | undefined { try { const parsed = JSON.parse(message); @@ -113,6 +238,32 @@ function inferValidationDetails(message: string): Record | unde return Object.keys(fields).length > 0 ? { fields } : undefined; } +function isTransportError(error: unknown): boolean { + const name = readStringField(error, 'name') || readConstructorName(error); + if (name === 'APIConnectionError' || name === 'APIConnectionTimeoutError') return true; + const code = readStringField(error, 'code') || readCauseChainString(error, 'code'); + if (code === 'ENOTFOUND' || code === 'ECONNRESET' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'EAI_AGAIN') { + return true; + } + const message = (readStringField(error, 'message') || '') + .trim() + .toLowerCase() + .replace(/[.!?]+$/, ''); + return message.includes('connection error') || message.includes('request timed out') || message.includes('fetch failed'); +} + +function readConstructorName(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) return undefined; + const constructorValue = (error as { constructor?: unknown }).constructor; + return typeof constructorValue === 'function' ? constructorValue.name : undefined; +} + +function readCauseChainString(error: unknown, fieldName: string, depth = 0): string | undefined { + if (depth > 4 || typeof error !== 'object' || error === null || !('cause' in error)) return undefined; + const cause = (error as { cause?: unknown }).cause; + return readStringField(cause, fieldName) || readCauseChainString(cause, fieldName, depth + 1); +} + export function createAgentErrorBody(error: AgentApiError, requestId: string): AgentErrorBody { return { error: { @@ -121,6 +272,7 @@ export function createAgentErrorBody(error: AgentApiError, requestId: string): A retryable: error.retryable, ...(error.details ? { details: error.details } : {}), ...(error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {}), + ...(error.diagnostics ? { diagnostics: error.diagnostics } : {}), request_id: requestId } }; @@ -136,14 +288,27 @@ export function agentErrorResponse(error: AgentApiError, requestId: string): Nex return NextResponse.json(createAgentErrorBody(error, requestId), { status: error.status, headers }); } -export function storedAgentErrorResponse(errorBody: AgentErrorBody): NextResponse { - return NextResponse.json(errorBody, { - status: statusForAgentErrorCode(errorBody.error.code, errorBody.error.upstream_status), - headers: { 'X-Request-Id': errorBody.error.request_id } +export function storedAgentErrorResponse( + errorBody: AgentErrorBody, + headers: Record = {} +): NextResponse { + const terminalError = toTerminalAgentErrorBody(errorBody); + return NextResponse.json(terminalError, { + status: statusForAgentErrorCode(terminalError.error.code, terminalError.error.upstream_status), + headers: { ...headers, 'X-Request-Id': terminalError.error.request_id } }); } -export function normalizeAgentError(error: unknown): AgentApiError { +export function toTerminalAgentErrorBody(errorBody: AgentErrorBody): AgentErrorBody { + return { + error: { + ...errorBody.error, + retryable: false + } + }; +} + +export function normalizeAgentError(error: unknown, diagnostics: AgentErrorDiagnostics = {}): AgentApiError { if (error instanceof AgentApiError) return error; if (error instanceof RequestValidationError) { const details = parseValidationDetails(error.message) ?? inferValidationDetails(error.message); @@ -152,7 +317,8 @@ export function normalizeAgentError(error: unknown): AgentApiError { message: details ? '请求校验失败。' : error.message, status: error.status === 400 ? 422 : error.status, retryable: false, - ...(details ? { details } : {}) + ...(details ? { details } : {}), + diagnostics: buildDiagnostics(error, diagnostics) }); } @@ -164,7 +330,12 @@ export function normalizeAgentError(error: unknown): AgentApiError { message, status, retryable: false, - upstreamStatus: status + upstreamStatus: status, + diagnostics: buildDiagnostics(error, { + ...diagnostics, + upstreamStatus: status, + channel_cooldown_scope: 'credential' + }) }); } if (status === 400) { @@ -174,36 +345,58 @@ export function normalizeAgentError(error: unknown): AgentApiError { status: 422, retryable: false, details: inferValidationDetails(message), - upstreamStatus: status + upstreamStatus: status, + diagnostics: buildDiagnostics(error, { ...diagnostics, upstreamStatus: status }) }); } if (status === 429) { + const retryAfterSeconds = readRetryAfterSeconds(error) ?? 30; return new AgentApiError({ code: 'upstream_rate_limited', message, status, retryable: true, upstreamStatus: status, - retryAfterSeconds: 30 + retryAfterSeconds, + diagnostics: buildDiagnostics(error, { + ...diagnostics, + upstreamStatus: status, + retryAfterSeconds, + channel_cooldown_scope: 'credential' + }) }); } if (status && [500, 502, 503, 504, 520, 522, 523, 524].includes(status)) { + const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15; return new AgentApiError({ code: 'upstream_unavailable', message, status: 502, retryable: true, upstreamStatus: status, - retryAfterSeconds: 15 + retryAfterSeconds, + diagnostics: buildDiagnostics(error, { + ...diagnostics, + upstreamStatus: status, + retryAfterSeconds, + channel_cooldown_scope: 'channel' + }) }); } if (isChannelFailure(error)) { + const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15; return new AgentApiError({ code: 'upstream_unavailable', message, status: 502, retryable: true, - retryAfterSeconds: 15 + retryAfterSeconds, + diagnostics: buildDiagnostics(error, { + ...diagnostics, + retryAfterSeconds, + channel_cooldown_scope: 'channel', + transport_error: true + }) }); } @@ -211,6 +404,7 @@ export function normalizeAgentError(error: unknown): AgentApiError { code: 'unexpected_error', message, status: 500, - retryable: false + retryable: false, + diagnostics: buildDiagnostics(error, diagnostics) }); } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 9fac2d9424960cfdb93e7f53160d61b20c9ec0cd..9d86b91b66e60558cd301852fec0b7a3dd1ed2b3 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -230,25 +230,25 @@ const messages: Record> = { 'api.urlHint': '填 OpenAI 兼容接口根地址,通常以 /v1 结尾;不要填管理后台网页地址。', 'api.saved': '已保存', 'api.saveFailed': '保存失败', - 'password.configure': '配置密码', - 'password.required': '需要密码', - 'password.initialDescription': '设置用于 API 请求的密码。', - 'password.retryDescription': '服务器要求密码,或上一次密码不正确。请输入密码后继续。', - 'password.entryDescription': '请输入访问密码以使用 GPT Image 生成服务。', + 'password.configure': '配置访问码', + 'password.required': '需要访问码', + 'password.initialDescription': '设置用于访问页面的访问码。', + 'password.retryDescription': '服务器要求访问码,或上一次访问码不正确。请输入访问码后继续。', + 'password.entryDescription': '请输入访问码以使用 GPT Image 生成服务。', 'password.unlock': '解锁', - 'password.placeholder': '输入密码', - 'password.empty': '密码不能为空。', - 'password.hashError': '保存密码失败:哈希计算出错。', - 'error.passwordRequired': '需要密码。请点击锁图标配置密码。', - 'error.unauthorized': '未授权:密码无效或缺失,请重试。', - 'error.passwordExpired': '访问密码已过期或已失效,请重新输入。', + 'password.placeholder': '输入访问码', + 'password.empty': '访问码不能为空。', + 'password.hashError': '保存访问码失败:哈希计算出错。', + 'error.passwordRequired': '需要访问码。请点击锁图标配置访问码。', + 'error.unauthorized': '未授权:访问码无效或缺失,请重试。', + 'error.passwordExpired': '访问码已过期或已失效,请重新输入。', 'error.authVerifyUnavailable': '无法刷新访问权限,请检查网络或稍后重试。', - 'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入密码后再试。', + 'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。', 'error.unexpected': '发生未知错误。', 'error.responseBodyNull': '响应体为空。', 'error.streaming': '流式生成出错。', 'error.apiFailedWithAdvice': '{message}。建议:{advice}', - 'error.adviceAuth': '检查 API Key、页面密码或渠道权限。', + 'error.adviceAuth': '检查 API Key、访问码或渠道权限。', 'error.adviceRateLimit': '请求被限流。请稍后重试,或降低并发和图片数量。', 'error.adviceUpstream': '上游或 API 中转站异常。请稍后重试,或切换可用渠道。', 'error.adviceCloudflare': @@ -508,26 +508,26 @@ const messages: Record> = { 'Use the OpenAI-compatible API root, usually ending with /v1. Do not enter an admin dashboard URL.', 'api.saved': 'Saved', 'api.saveFailed': 'Save failed', - 'password.configure': 'Configure Password', - 'password.required': 'Password Required', - 'password.initialDescription': 'Set a password to use for API requests.', + 'password.configure': 'Configure Access Code', + 'password.required': 'Access Code Required', + 'password.initialDescription': 'Set the access code used to open this page.', 'password.retryDescription': - 'The server requires a password, or the previous one was incorrect. Please enter it to continue.', - 'password.entryDescription': 'Enter the access password to use the GPT Image generation service.', + 'The server requires an access code, or the previous one was incorrect. Please enter it to continue.', + 'password.entryDescription': 'Enter the access code to use the GPT Image generation service.', 'password.unlock': 'Unlock', - 'password.placeholder': 'Enter your password', - 'password.empty': 'Password cannot be empty.', - 'password.hashError': 'Failed to save password due to a hashing error.', - 'error.passwordRequired': 'Password is required. Please configure the password by clicking the lock icon.', - 'error.unauthorized': 'Unauthorized: Invalid or missing password. Please try again.', - 'error.passwordExpired': 'The access password has expired or is no longer valid. Please enter it again.', + 'password.placeholder': 'Enter access code', + 'password.empty': 'Access code cannot be empty.', + 'password.hashError': 'Failed to save access code due to a hashing error.', + 'error.passwordRequired': 'Access code is required. Please configure it by clicking the lock icon.', + 'error.unauthorized': 'Unauthorized: Invalid or missing access code. Please try again.', + 'error.passwordExpired': 'The access code has expired or is no longer valid. Please enter it again.', 'error.authVerifyUnavailable': 'Could not refresh access. Check the network or try again later.', - 'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the password again and retry.', + 'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.', 'error.unexpected': 'An unexpected error occurred.', 'error.responseBodyNull': 'Response body is null', 'error.streaming': 'Streaming error occurred', 'error.apiFailedWithAdvice': '{message}. Recommendation: {advice}', - 'error.adviceAuth': 'Check the API Key, page password, or channel permissions.', + 'error.adviceAuth': 'Check the API Key, access code, or channel permissions.', 'error.adviceRateLimit': 'The request was rate limited. Retry later, or lower concurrency and image count.', 'error.adviceUpstream': 'The upstream service or API relay is failing. Retry later, or switch to another channel.', 'error.adviceCloudflare': diff --git a/src/lib/page-password-auth.test.ts b/src/lib/page-password-auth.test.ts index bb947bf8d5d063f1ee81b74dda8c592b6b5968cf..f536be3259de7a09d34f8624ef0958a8490dcd41 100644 --- a/src/lib/page-password-auth.test.ts +++ b/src/lib/page-password-auth.test.ts @@ -6,8 +6,8 @@ import { isPagePasswordAuthErrorCode } from './page-password-auth'; -describe('page password auth error codes', () => { - it('recognizes only page password auth errors', () => { +describe('page access code auth error codes', () => { + it('recognizes only page access code auth errors', () => { assert.equal(isPagePasswordAuthErrorCode(PAGE_PASSWORD_AUTH_ERROR_CODES.missing), true); assert.equal(isPagePasswordAuthErrorCode(PAGE_PASSWORD_AUTH_ERROR_CODES.invalid), true); assert.equal(isPagePasswordAuthErrorCode('upstream_unauthorized'), false); diff --git a/src/lib/server-runtime.test.ts b/src/lib/server-runtime.test.ts index 38b3fe7ab3fb87be86483f0844c1abf1b11b70c8..56f76757b7bd4936c32b495b82652a70995c1b15 100644 --- a/src/lib/server-runtime.test.ts +++ b/src/lib/server-runtime.test.ts @@ -18,13 +18,13 @@ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import { describe, it } from 'node:test'; -const PAGE_PASSWORD_FIXTURE = ['customer', 'password'].join('-'); -const OTHER_PAGE_PASSWORD_FIXTURE = ['other', 'password'].join('-'); +const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-'); +const OTHER_PAGE_PASSWORD_FIXTURE = ['other', 'access', 'code'].join('-'); const ACCESS_CODE_FIXTURE = ['test', 'fixture', 'access', 'code'].join('-'); const DIFFERENT_ACCESS_CODE_FIXTURE = ['different', 'test', 'fixture', 'access', 'code'].join('-'); describe('verifyPasswordHash', () => { - it('accepts only the sha256 hash for the configured password', () => { + it('accepts only the sha256 hash for the configured access code', () => { const passwordHash = crypto.createHash('sha256').update(PAGE_PASSWORD_FIXTURE).digest('hex'); assert.equal(verifyPasswordHash(passwordHash, PAGE_PASSWORD_FIXTURE), true); @@ -37,7 +37,7 @@ describe('verifyPasswordHash', () => { }); describe('verifyAccessToken', () => { - it('accepts only a fresh access token signed with the configured password', () => { + it('accepts only a fresh access token signed with the configured access code', () => { const issuedAtMs = 1_715_400_000_000; const accessToken = createAccessToken(ACCESS_CODE_FIXTURE, issuedAtMs); @@ -54,11 +54,11 @@ describe('verifyAccessToken', () => { assert.equal(verifyAccessToken(accessToken, ACCESS_CODE_FIXTURE, issuedAtMs - 60_001), false); }); - it('allows access when no server password is configured', () => { + it('allows access when no server access code is configured', () => { assert.equal(verifyAccessToken(undefined, undefined), true); }); - it('rejects missing or malformed tokens when a server password is configured', () => { + it('rejects missing or malformed tokens when a server access code is configured', () => { assert.equal(verifyAccessToken(undefined, ACCESS_CODE_FIXTURE), false); assert.equal(verifyAccessToken('not-a-hex-digest', ACCESS_CODE_FIXTURE), false); assert.equal(verifyAccessToken('0'.repeat(64), ACCESS_CODE_FIXTURE), false); @@ -76,7 +76,7 @@ describe('buildAccessCookieOptions', () => { }); describe('buildAccessCookie', () => { - it('builds the shared image access cookie from the configured password', () => { + it('builds the shared image access cookie from the configured access code', () => { const cookie = buildAccessCookie(PAGE_PASSWORD_FIXTURE, new Headers({ 'x-forwarded-proto': 'https' })); assert.equal(cookie.name, 'gptImageAccess'); @@ -171,10 +171,19 @@ describe('readOutputDirEnv', () => { }); describe('readPositiveIntegerEnv', () => { - it('reads a positive integer or falls back to a safe default', () => { + it('reads a positive integer or falls back only when the value is absent', () => { assert.equal(readPositiveIntegerEnv({}, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1), 1); assert.equal(readPositiveIntegerEnv({ OPENAI_MAX_STREAMS_PER_CREDENTIAL: '2' }, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1), 2); - assert.equal(readPositiveIntegerEnv({ OPENAI_MAX_STREAMS_PER_CREDENTIAL: '0' }, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1), 1); - assert.equal(readPositiveIntegerEnv({ OPENAI_MAX_STREAMS_PER_CREDENTIAL: 'bad' }, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1), 1); + }); + + it('fails explicitly when the configured value is invalid', () => { + assert.throws( + () => readPositiveIntegerEnv({ OPENAI_MAX_STREAMS_PER_CREDENTIAL: '0' }, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1), + /OPENAI_MAX_STREAMS_PER_CREDENTIAL/ + ); + assert.throws( + () => readPositiveIntegerEnv({ OPENAI_MAX_STREAMS_PER_CREDENTIAL: 'bad' }, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1), + /OPENAI_MAX_STREAMS_PER_CREDENTIAL/ + ); }); }); diff --git a/src/lib/server-runtime.ts b/src/lib/server-runtime.ts index 8b02cad61870c3663ec95c5685f49c755a63d5d8..96899982dcdebacdb8a895fb67d01072817e9177 100644 --- a/src/lib/server-runtime.ts +++ b/src/lib/server-runtime.ts @@ -149,13 +149,14 @@ export function readPositiveIntegerEnv( fieldName: string, fallback: number ): number { - const value = env[fieldName]; - if (!value || !/^\d+$/.test(value.trim())) { - return fallback; + const value = env[fieldName]?.trim(); + if (!value) return fallback; + if (!/^\d+$/.test(value)) { + throw new Error(`${fieldName} 必须是正整数。`); } const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < 1) { - return fallback; + throw new Error(`${fieldName} 必须是正整数。`); } return parsed; } diff --git a/src/lib/share-store.ts b/src/lib/share-store.ts index e79aed13aec04b74d6187fda246db6b9f0dbef74..77858ff5bc041cbb7405c5e491636b6607f7a106 100644 --- a/src/lib/share-store.ts +++ b/src/lib/share-store.ts @@ -52,11 +52,8 @@ function isShareToken(value: string): boolean { return /^[a-f0-9]{24}$/i.test(value); } -function shareContentFilename(token: string, sourceFilename: string, mimeType: string): string { - const ext = path.extname(sourceFilename); - if (ext) return `${token}${ext}`; - const mimeExtension = mimeTypeToExtension(mimeType); - return `${token}${mimeExtension}`; +function shareContentFilename(token: string, mimeType: string): string { + return `${token}${mimeTypeToExtension(mimeType)}`; } function mimeTypeToExtension(mimeType: string): string { @@ -93,7 +90,7 @@ function assertShareContentPathAllowed(contentFilename: string): string { export async function createImageShare(input: CreateImageShareInput): Promise { await fs.mkdir(shareDir(), { recursive: true }); const token = crypto.randomBytes(SHARE_TOKEN_BYTES).toString('hex'); - const contentFilename = shareContentFilename(token, input.sourceFilename, input.mimeType); + const contentFilename = shareContentFilename(token, input.mimeType); const createdAt = (input.now || new Date()).toISOString(); const expiresAt = typeof input.expiresInMinutes === 'number'