gpt-image-playground deploy commited on
Commit
ecb6084
·
1 Parent(s): 5166446

Deploy 6590d91 to Docker Space

Browse files
.env.example CHANGED
@@ -151,6 +151,11 @@ OPENAI_API_BASE_URL=
151
  # 可选:给网页加一个访问码。公网部署时建议一定要设置。
152
  APP_PASSWORD=
153
 
 
 
 
 
 
154
  # 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
155
  # AGENT_API_TOKEN=
156
 
@@ -158,6 +163,8 @@ APP_PASSWORD=
158
  # AGENT_STATE_BACKEND=sqlite
159
  # AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
160
  # AGENT_DATABASE_URL=postgres://gpt_image:<database-password>@localhost:5432/gpt_image_playground
 
 
161
  # AGENT_REQUEST_LEASE_MS=600000
162
  # AGENT_REQUEST_TTL_SECONDS=86400
163
 
 
151
  # 可选:给网页加一个访问码。公网部署时建议一定要设置。
152
  APP_PASSWORD=
153
 
154
+ # Compose 默认只发布到 127.0.0.1:4783。需要局域网或公网访问时,在 shell 或 Compose 的 .env
155
+ # 文件显式设置非回环地址,并同时在 .env.local 设置上面的 APP_PASSWORD;未设置访问码时容器会拒绝启动。
156
+ # GIP_BIND_HOST=0.0.0.0
157
+ # GIP_PORT=4783
158
+
159
  # 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
160
  # AGENT_API_TOKEN=
161
 
 
163
  # AGENT_STATE_BACKEND=sqlite
164
  # AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
165
  # AGENT_DATABASE_URL=postgres://gpt_image:<database-password>@localhost:5432/gpt_image_playground
166
+ # docker-compose.postgres.yml 从 shell 或 Compose 的 .env 读取此 Secret,不要把真实值写进 .env.local 或仓库文件。
167
+ # GPT_IMAGE_POSTGRES_PASSWORD=
168
  # AGENT_REQUEST_LEASE_MS=600000
169
  # AGENT_REQUEST_TTL_SECONDS=86400
170
 
.github/workflows/ci.yml CHANGED
@@ -96,22 +96,34 @@ jobs:
96
  persist-credentials: false
97
 
98
  - name: Build production image
99
- run: docker build --tag gpt-image-playground-customer:ci .
100
 
101
  - name: Start production container
102
- run: docker run --detach --name gpt-image-playground-customer-ci --publish 127.0.0.1:4783:4783 gpt-image-playground-customer:ci
 
 
 
103
 
104
  - name: Verify production endpoint
105
  shell: bash
106
  run: |
107
  set -euo pipefail
108
- for attempt in {1..30}; do
109
- if curl --fail --silent --max-time 5 http://127.0.0.1:4783/api/auth-status >/tmp/auth-status.json; then
 
110
  node --input-type=module -e "import { readFileSync } from 'node:fs'; const response = JSON.parse(readFileSync('/tmp/auth-status.json', 'utf8')); if (response.passwordRequired !== false) throw new Error('Expected the CI container auth-status endpoint to report passwordRequired=false.');"
 
 
 
 
 
 
111
  exit 0
112
  fi
113
- printf 'Waiting for auth-status endpoint (attempt %s/30)\n' "$attempt"
114
- sleep 1
 
 
115
  done
116
  docker logs gpt-image-playground-customer-ci
117
  exit 1
 
96
  persist-credentials: false
97
 
98
  - name: Build production image
99
+ run: docker build --build-arg VCS_REF="${GITHUB_SHA}" --tag gpt-image-playground-customer:ci .
100
 
101
  - name: Start production container
102
+ run: >-
103
+ docker run --detach --name gpt-image-playground-customer-ci
104
+ --env GIP_COMPOSE_DEPLOYMENT=true --env GIP_BIND_HOST=127.0.0.1
105
+ --publish 127.0.0.1:4783:4783 gpt-image-playground-customer:ci
106
 
107
  - name: Verify production endpoint
108
  shell: bash
109
  run: |
110
  set -euo pipefail
111
+ for attempt in {1..120}; do
112
+ health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' gpt-image-playground-customer-ci)"
113
+ if [[ "$health_status" == "healthy" ]] && curl --fail --silent --max-time 5 http://127.0.0.1:4783/api/auth-status >/tmp/auth-status.json; then
114
  node --input-type=module -e "import { readFileSync } from 'node:fs'; const response = JSON.parse(readFileSync('/tmp/auth-status.json', 'utf8')); if (response.passwordRequired !== false) throw new Error('Expected the CI container auth-status endpoint to report passwordRequired=false.');"
115
+ image_revision="$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' gpt-image-playground-customer:ci)"
116
+ if [[ "$image_revision" != "$GITHUB_SHA" ]]; then
117
+ printf 'Image revision mismatch: expected %s, received %s\n' "$GITHUB_SHA" "$image_revision"
118
+ docker logs gpt-image-playground-customer-ci
119
+ exit 1
120
+ fi
121
  exit 0
122
  fi
123
+ printf 'Waiting for healthy production container and auth-status endpoint (attempt %s/120, health=%s)\n' "$attempt" "$health_status"
124
+ if (( attempt < 120 )); then
125
+ sleep 1
126
+ fi
127
  done
128
  docker logs gpt-image-playground-customer-ci
129
  exit 1
Dockerfile CHANGED
@@ -18,19 +18,23 @@ RUN npm run build
18
  FROM node:26-alpine AS runner
19
  WORKDIR /app
20
  ARG NEXT_PUBLIC_IMAGE_STORAGE_MODE=fs
 
21
  ENV NODE_ENV=production
22
  ENV NEXT_TELEMETRY_DISABLED=1
23
  ENV NEXT_PUBLIC_IMAGE_STORAGE_MODE=${NEXT_PUBLIC_IMAGE_STORAGE_MODE}
24
  ENV PORT=4783
25
  ENV HOSTNAME=0.0.0.0
 
26
 
27
  COPY --from=builder --chown=node:node /app/.next/standalone ./
28
  COPY --from=builder --chown=node:node /app/.next/static ./.next/static
29
  COPY --from=builder --chown=node:node /app/public ./public
30
  COPY --from=builder --chown=node:node /app/node_modules/next/dist/compiled/next-server ./node_modules/next/dist/compiled/next-server
 
31
 
32
  RUN mkdir -p /app/generated-images && chown node:node /app/generated-images
33
  USER node
34
 
35
  EXPOSE 4783
36
- CMD ["node", "server.js"]
 
 
18
  FROM node:26-alpine AS runner
19
  WORKDIR /app
20
  ARG NEXT_PUBLIC_IMAGE_STORAGE_MODE=fs
21
+ ARG VCS_REF=unknown
22
  ENV NODE_ENV=production
23
  ENV NEXT_TELEMETRY_DISABLED=1
24
  ENV NEXT_PUBLIC_IMAGE_STORAGE_MODE=${NEXT_PUBLIC_IMAGE_STORAGE_MODE}
25
  ENV PORT=4783
26
  ENV HOSTNAME=0.0.0.0
27
+ LABEL org.opencontainers.image.revision=$VCS_REF
28
 
29
  COPY --from=builder --chown=node:node /app/.next/standalone ./
30
  COPY --from=builder --chown=node:node /app/.next/static ./.next/static
31
  COPY --from=builder --chown=node:node /app/public ./public
32
  COPY --from=builder --chown=node:node /app/node_modules/next/dist/compiled/next-server ./node_modules/next/dist/compiled/next-server
33
+ COPY --from=builder --chown=node:node /app/scripts/docker-entrypoint.mjs ./scripts/docker-entrypoint.mjs
34
 
35
  RUN mkdir -p /app/generated-images && chown node:node /app/generated-images
36
  USER node
37
 
38
  EXPOSE 4783
39
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD ["node", "-e", "const port = process.env.PORT || '4783'; fetch(`http://127.0.0.1:${port}/api/auth-status`, { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1));"]
40
+ CMD ["node", "scripts/docker-entrypoint.mjs"]
README.md CHANGED
@@ -12,7 +12,7 @@ app_port: 4783
12
  本地 AI 图片创作工作台,面向中文内容运营、设计草图和自动化生图流程。支持 `gpt-image-2`、OpenAI 兼容图片接口、文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪和 Agent API。
13
 
14
  <p align="center">
15
- <img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/128efae485d0eb35670a525cf19069b5da3f4ace/readme-images/interface.jpg?v=20260608-07b596b" alt="GPT Image Playground 界面" width="900"/>
16
  </p>
17
 
18
  ## 快速开始
@@ -30,10 +30,10 @@ npm run first-run -- --base-url https://your-space.hf.space
30
  npm run first-run -- --json --base-url https://your-space.hf.space
31
  ```
32
 
33
- 本地服务推荐用 Docker
34
 
35
  ```bash
36
- docker compose up -d --build --remove-orphans
37
  ```
38
 
39
  打开:
@@ -83,13 +83,13 @@ start-windows.bat
83
  遮罩编辑示例:
84
 
85
  <p align="center">
86
- <img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/128efae485d0eb35670a525cf19069b5da3f4ace/readme-images/mask-creation.jpg?v=20260608-07b596b" alt="遮罩创建" width="900"/>
87
  </p>
88
 
89
  历史与费用示例:
90
 
91
  <p align="center">
92
- <img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/128efae485d0eb35670a525cf19069b5da3f4ace/readme-images/history.jpg?v=20260608-07b596b" alt="历史面板" width="900"/>
93
  </p>
94
 
95
  ## 配置
@@ -98,6 +98,7 @@ start-windows.bat
98
 
99
  | 场景 | 变量 | 说明 |
100
  | ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
 
101
  | 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
102
  | 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 可选。只用于服务端到图片上游的出站请求;渠道级地址优先于全局地址。仅接受无认证、无路径、无查询参数和无片段的 `http://` / `https://` 根代理地址,不支持 SOCKS。运行态和 Agent 诊断只公开是否启用及协议,不公开代理主机或端口。 |
103
  | 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
@@ -316,16 +317,16 @@ node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
316
 
317
  ## Docker 与部署
318
 
319
- 默认 Compose 使用 SQLite 状态库和本地图片目录:
320
 
321
  ```bash
322
- docker compose up -d --build --remove-orphans
323
  ```
324
 
325
- 重建探测真实端点:
326
 
327
  ```bash
328
- npm run deploy:local
329
  ```
330
 
331
  本仓库的 Compose 服务只挂载 `generated-images/`。不要用 `docker run -v "$PWD:/workspace"` 启动本地图片上游 fixture;这会把 `.git/`、`node_modules/` 和 `.next/` 暴露给 Docker Desktop 文件共享层,可能触发文件事件风暴。本地 fixture gate 使用进程内服务:
@@ -342,11 +343,13 @@ npm run docker:cleanup-fixtures
342
 
343
  常见部署模式:
344
 
345
- | 模式 | 命令或配置 | 适用场景 |
346
- | ---------- | ---------------------------------------------------- | ------------------------------------- |
347
- | SQLite | `docker-compose.yml` | 本地单实例和长期本地服务。 |
348
- | Memory | `docker-compose.yml` + `docker-compose.memory.yml` | Hugging Face Space 或临时演示。 |
349
- | PostgreSQL | `docker-compose.yml` + `docker-compose.postgres.yml` | 高并发、多实例或集中状态库。 |
 
 
350
 
351
  图片默认保存在:
352
 
 
12
  本地 AI 图片创作工作台,面向中文内容运营、设计草图和自动化生图流程。支持 `gpt-image-2`、OpenAI 兼容图片接口、文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪和 Agent API。
13
 
14
  <p align="center">
15
+ <img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd/readme-images/interface.jpg?v=20260608-07b596b" alt="GPT Image Playground 界面" width="900"/>
16
  </p>
17
 
18
  ## 快速开始
 
30
  npm run first-run -- --json --base-url https://your-space.hf.space
31
  ```
32
 
33
+ 本地服务推荐使带健康检查和镜像 revision 核验的部署脚本
34
 
35
  ```bash
36
+ npm run deploy:local
37
  ```
38
 
39
  打开:
 
83
  遮罩编辑示例:
84
 
85
  <p align="center">
86
+ <img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd/readme-images/mask-creation.jpg?v=20260608-07b596b" alt="遮罩创建" width="900"/>
87
  </p>
88
 
89
  历史与费用示例:
90
 
91
  <p align="center">
92
+ <img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd/readme-images/history.jpg?v=20260608-07b596b" alt="历史面板" width="900"/>
93
  </p>
94
 
95
  ## 配置
 
98
 
99
  | 场景 | 变量 | 说明 |
100
  | ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
101
+ | Docker 监听地址 | `GIP_BIND_HOST`、`GIP_PORT` | Compose 默认仅发布到 `127.0.0.1:4783`。需要局域网或公网访问时显式设置非回环 `GIP_BIND_HOST`,并同时设置 `APP_PASSWORD`;容器会拒绝未设置访问码的非回环发布。 |
102
  | 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
103
  | 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 可选。只用于服务端到图片上游的出站请求;渠道级地址优先于全局地址。仅接受无认证、无路径、无查询参数和无片段的 `http://` / `https://` 根代理地址,不支持 SOCKS。运行态和 Agent 诊断只公开是否启用及协议,不公开代理主机或端口。 |
104
  | 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
 
317
 
318
  ## Docker 与部署
319
 
320
+ 默认 Compose 使用 SQLite 状态库和本地图片目录,并且只绑定本机回环地址
321
 
322
  ```bash
323
+ npm run deploy:local
324
  ```
325
 
326
+ 部署脚会拒绝脏工作区、重建镜像、等待 Docker healthcheck、探测真实端点,并确认容器镜像的 revision 与当前 Git 提交一致。默认模式会断言 `sqlite/fs` 生效,`--memory` 会断言 `memory/indexeddb`,`--postgres` 会断言 `postgres/fs`。若确实需要局域网访问,先在 `.env.local` 设置 `APP_PASSWORD`,再显式指定绑定地址
327
 
328
  ```bash
329
+ GIP_BIND_HOST=0.0.0.0 npm run deploy:local
330
  ```
331
 
332
  本仓库的 Compose 服务只挂载 `generated-images/`。不要用 `docker run -v "$PWD:/workspace"` 启动本地图片上游 fixture;这会把 `.git/`、`node_modules/` 和 `.next/` 暴露给 Docker Desktop 文件共享层,可能触发文件事件风暴。本地 fixture gate 使用进程内服务:
 
343
 
344
  常见部署模式:
345
 
346
+ | 模式 | 命令 | 适用场景 |
347
+ | ---------- | ----------------------------------------- | ------------------------------------- |
348
+ | SQLite | `npm run deploy:local` | 本地单实例和长期本地服务。 |
349
+ | Memory | `npm run deploy:local -- --memory` | Hugging Face Space 或临时演示。 |
350
+ | PostgreSQL | `npm run deploy:local -- --postgres` | 高并发、多实例或集中状态库。 |
351
+
352
+ PostgreSQL 模式要求在运行 Compose 的 shell 或 Compose `.env` 中提供 `GPT_IMAGE_POSTGRES_PASSWORD`。不要把该 Secret 写入 `.env.local`;overlay 会显式清空直连密码变量,确保应用只读取 Docker secret 文件。
353
 
354
  图片默认保存在:
355
 
docker-compose.postgres.yml CHANGED
@@ -20,36 +20,21 @@ services:
20
  - postgres-data:/var/lib/postgresql/data
21
 
22
  gpt-image-playground:
23
- build:
24
- context: .
25
- image: gpt-image-playground-customer:postgres
26
- container_name: gpt-image-playground-customer
27
- restart: unless-stopped
28
- env_file:
29
- - path: .env.local
30
- required: false
31
  environment:
32
- NODE_ENV: production
33
- NEXT_TELEMETRY_DISABLED: "1"
34
- NEXT_PUBLIC_IMAGE_STORAGE_MODE: fs
35
  AGENT_STATE_BACKEND: postgres
36
- # Force Docker secret fields below even if .env.local contains AGENT_DATABASE_URL.
37
  AGENT_DATABASE_URL: ""
 
38
  AGENT_DB_HOST: postgres
39
  AGENT_DB_PORT: "5432"
40
  AGENT_DB_NAME: gpt_image_playground
41
  AGENT_DB_USER: gpt_image
42
  AGENT_DB_PASSWORD_FILE: /run/secrets/postgres_password
43
- PORT: "4783"
44
  secrets:
45
  - postgres_password
46
  depends_on:
47
  postgres:
48
  condition: service_healthy
49
- ports:
50
- - "4783:4783"
51
- volumes:
52
- - ./generated-images:/app/generated-images
53
 
54
  volumes:
55
  postgres-data:
 
20
  - postgres-data:/var/lib/postgresql/data
21
 
22
  gpt-image-playground:
 
 
 
 
 
 
 
 
23
  environment:
 
 
 
24
  AGENT_STATE_BACKEND: postgres
25
+ # Force Docker secret fields below even if .env.local contains direct database credentials.
26
  AGENT_DATABASE_URL: ""
27
+ AGENT_DB_PASSWORD: ""
28
  AGENT_DB_HOST: postgres
29
  AGENT_DB_PORT: "5432"
30
  AGENT_DB_NAME: gpt_image_playground
31
  AGENT_DB_USER: gpt_image
32
  AGENT_DB_PASSWORD_FILE: /run/secrets/postgres_password
 
33
  secrets:
34
  - postgres_password
35
  depends_on:
36
  postgres:
37
  condition: service_healthy
 
 
 
 
38
 
39
  volumes:
40
  postgres-data:
docker-compose.yml CHANGED
@@ -4,7 +4,9 @@ services:
4
  gpt-image-playground:
5
  build:
6
  context: .
7
- image: gpt-image-playground-customer:local
 
 
8
  container_name: gpt-image-playground-customer
9
  restart: unless-stopped
10
  env_file:
@@ -17,7 +19,11 @@ services:
17
  AGENT_STATE_BACKEND: sqlite
18
  NEXT_PUBLIC_IMAGE_STORAGE_MODE: fs
19
  PORT: "4783"
 
 
 
 
20
  ports:
21
- - "4783:4783"
22
  volumes:
23
  - ./generated-images:/app/generated-images
 
4
  gpt-image-playground:
5
  build:
6
  context: .
7
+ args:
8
+ VCS_REF: ${GIP_IMAGE_REVISION:-unknown}
9
+ image: gpt-image-playground-customer:${GIP_IMAGE_TAG:-local}
10
  container_name: gpt-image-playground-customer
11
  restart: unless-stopped
12
  env_file:
 
19
  AGENT_STATE_BACKEND: sqlite
20
  NEXT_PUBLIC_IMAGE_STORAGE_MODE: fs
21
  PORT: "4783"
22
+ GIP_COMPOSE_DEPLOYMENT: "true"
23
+ GIP_BIND_HOST: "${GIP_BIND_HOST:-127.0.0.1}"
24
+ labels:
25
+ org.opencontainers.image.revision: "${GIP_IMAGE_REVISION:-unknown}"
26
  ports:
27
+ - "${GIP_BIND_HOST:-127.0.0.1}:${GIP_PORT:-4783}:4783"
28
  volumes:
29
  - ./generated-images:/app/generated-images
docs/deployment/huggingface-space-free.md CHANGED
@@ -85,7 +85,7 @@ npm run agent:doctor
85
  - `status`:只读输出 git、Node、固定 Space 目标、Agent capabilities 路径和 Skill 入口。
86
  - `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查,并校验当前 npm 是否支持严格安装脚本策略、本地 `node_modules` 隐藏锁文件和直接依赖版本是否与根锁文件一致。
87
  - `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、lint、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL gate 时加 `--postgres`。
88
- - `deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb overlay 生效。
89
  - `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证;已存在 Docker Space 根据远端元数据直接使用认证 Git 推送,其他类型才优先尝试 `hf upload`。
90
  - `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。
91
 
 
85
  - `status`:只读输出 git、Node、固定 Space 目标、Agent capabilities 路径和 Skill 入口。
86
  - `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查,并校验当前 npm 是否支持严格安装脚本策略、本地 `node_modules` 隐藏锁文件和直接依赖版本是否与根锁文件一致。
87
  - `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、lint、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL gate 时加 `--postgres`。
88
+ - `deploy:local`:拒绝脏工作区后重建本地 Docker 服务,等待 healthcheck、核验镜像 revision 并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb overlay 生效,加 `--postgres` 会断言 postgres/fs overlay 生效且要求通过 shell 或 Compose `.env` 提供 `GPT_IMAGE_POSTGRES_PASSWORD`默认 Compose 只绑定 `127.0.0.1:4783`;需要非回环发布时必须显式设置 `GIP_BIND_HOST` 和 `APP_PASSWORD`。
89
  - `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证;已存在 Docker Space 根据远端元数据直接使用认证 Git 推送,其他类型才优先尝试 `hf upload`。
90
  - `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。
91
 
docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 部署加固回归门禁
2
+
3
+ 日期: 2026-07-27
4
+
5
+ 范围: Docker 本地部署、Hugging Face Space memory smoke、PostgreSQL overlay、CI 运行时检查、部署脚本和部署文档。
6
+
7
+ ## 已审查变更
8
+
9
+ - 默认 Compose 发布限制为 `127.0.0.1:4783`。
10
+ - 非回环 Compose 发布必须设置非空 `APP_PASSWORD`。
11
+ - Docker 镜像提供 OCI revision label 和 healthcheck。
12
+ - 本地部署校验干净的 Git revision、镜像身份、发布端口和选定的状态/存储模式。
13
+ - PostgreSQL overlay 在使用 Docker secret 文件前清空直连数据库凭证变量。
14
+ - HF Space 和本地端点轮询不会在最后一次失败后继续等待。
15
+ - CI 校验实际 Docker 入口点的回环分支、健康状态、端点响应和镜像 revision。
16
+
17
+ ## 自动化证据
18
+
19
+ | 命令 | 退出码 | 结果 |
20
+ | --- | --- | --- |
21
+ | `npm run verify` | 0 | 版本、安装策略、依赖、测试、lint、脚本语法、生产构建和 diff 检查均通过。 |
22
+ | `npm run test:postgres` | 0 | 101 个测试通过,包含真实 PostgreSQL 并发和 schema 契约。 |
23
+ | `npm run smoke:hf-space-local` | 0 | 最新 Docker 镜像通过 memory/indexeddb 运行态和非计费 Agent 契约检查。 |
24
+ | `docker build --check .` | 0 | 无 Dockerfile 警告。 |
25
+ | `docker compose ... config --quiet` | 0 | SQLite、memory 和 PostgreSQL Compose 配置均成功渲染。 |
26
+ | CI 固定 digest 的 actionlint 容器 | 0 | GitHub Actions 工作流语法和语义通过 actionlint。 |
27
+ | 非回环 Docker 入口点检查 | 预期退出码 1 | 容器拒绝 `GIP_COMPOSE_DEPLOYMENT= TRUE `、`GIP_BIND_HOST=0.0.0.0` 且未设置 `APP_PASSWORD` 的启动。 |
28
+
29
+ ## 审查证据
30
+
31
+ - CodeRabbit 审查全部已修改和未跟踪文件后未发现问题。
32
+ - Claude Code 使用默认模型且未传 `--model`,报告未发现 P0-P3 问题。
33
+ - OMP 仅识别出本地部署和 CI 的最后一次轮询延迟。两条路径均已改为仅在仍有下一次尝试时等待,并为本地探针补充回归覆盖。
34
+
35
+ ## 范围边界
36
+
37
+ - 本门禁不执行计费的图片生成或编辑请求。
38
+ - 真实本地 Docker 和 Hugging Face Space 发布检查属于独立部署验证步骤,因为它们需要干净的已提交 revision 和实时服务状态。
public/hf-space-deploy-marker.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "schema_version": 1,
3
- "local_sha": "128efae485d0eb35670a525cf19069b5da3f4ace",
4
- "created_at": "2026-07-26T03:42:06.320Z",
5
- "deploy_id": "aaf32bf5-46f3-48cf-8f53-ca6c7a48ba6e"
6
  }
 
1
  {
2
  "schema_version": 1,
3
+ "local_sha": "6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd",
4
+ "created_at": "2026-07-27T03:45:51.712Z",
5
+ "deploy_id": "d1603a14-8385-441d-adac-e4c5b059dde4"
6
  }
scripts/command-center.test.mjs CHANGED
@@ -7,7 +7,17 @@ import {
7
  summarizeDockerMounts
8
  } from './cleanup-docker-fixtures.mjs';
9
  import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
10
- import { assertLocalProbeMatchesMode, buildDockerComposeArgs, buildDockerComposeEnv } from './deploy-local.mjs';
 
 
 
 
 
 
 
 
 
 
11
  import { buildFirstRunReport, formatFirstRunText } from './first-run.mjs';
12
  import {
13
  buildAdminCommands,
@@ -449,7 +459,19 @@ describe('Command center scripts', () => {
449
  });
450
 
451
  it('builds deterministic local deploy compose arguments', () => {
452
- assert.deepEqual(buildDockerComposeArgs(), ['compose', '-f', 'docker-compose.yml', 'up', '-d', '--build']);
 
 
 
 
 
 
 
 
 
 
 
 
453
  assert.deepEqual(buildDockerComposeArgs({ memory: true }), [
454
  'compose',
455
  '-f',
@@ -458,8 +480,29 @@ describe('Command center scripts', () => {
458
  'docker-compose.memory.yml',
459
  'up',
460
  '-d',
461
- '--build'
 
 
 
 
 
462
  ]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  });
464
 
465
  it('uses plain compose progress for diagnosable local deploy output', () => {
@@ -467,6 +510,109 @@ describe('Command center scripts', () => {
467
  PATH: '/bin',
468
  COMPOSE_PROGRESS: 'plain'
469
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  });
471
 
472
  it('detects only the legacy fixture whole-repository Docker mount', () => {
@@ -540,14 +686,33 @@ describe('Command center scripts', () => {
540
  assert.equal(buildSkippedReport('custom', container.Mounts).removed, false);
541
  });
542
 
543
- it('fails local memory deploy probes when the overlay did not take effect', () => {
544
  assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }));
 
 
 
 
545
  assert.doesNotThrow(() =>
546
  assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }, { memory: true })
547
  );
548
  assert.throws(
549
  () => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { memory: true }),
550
- /Memory overlay did not take effect/
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  );
552
  });
553
 
 
7
  summarizeDockerMounts
8
  } from './cleanup-docker-fixtures.mjs';
9
  import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
10
+ import {
11
+ assertDeploymentImageIdentity,
12
+ assertLocalProbeMatchesMode,
13
+ buildDeploymentImageReference,
14
+ buildDeploymentImageTag,
15
+ buildDockerComposeArgs,
16
+ buildDockerComposeEnv,
17
+ buildLocalBaseUrl,
18
+ parsePublishedContainerPortBindings,
19
+ waitForLocalEndpoints
20
+ } from './deploy-local.mjs';
21
  import { buildFirstRunReport, formatFirstRunText } from './first-run.mjs';
22
  import {
23
  buildAdminCommands,
 
459
  });
460
 
461
  it('builds deterministic local deploy compose arguments', () => {
462
+ assert.deepEqual(buildDockerComposeArgs(), [
463
+ 'compose',
464
+ '-f',
465
+ 'docker-compose.yml',
466
+ 'up',
467
+ '-d',
468
+ '--build',
469
+ '--force-recreate',
470
+ '--remove-orphans',
471
+ '--wait',
472
+ '--wait-timeout',
473
+ '120'
474
+ ]);
475
  assert.deepEqual(buildDockerComposeArgs({ memory: true }), [
476
  'compose',
477
  '-f',
 
480
  'docker-compose.memory.yml',
481
  'up',
482
  '-d',
483
+ '--build',
484
+ '--force-recreate',
485
+ '--remove-orphans',
486
+ '--wait',
487
+ '--wait-timeout',
488
+ '120'
489
  ]);
490
+ assert.deepEqual(buildDockerComposeArgs({ postgres: true }), [
491
+ 'compose',
492
+ '-f',
493
+ 'docker-compose.yml',
494
+ '-f',
495
+ 'docker-compose.postgres.yml',
496
+ 'up',
497
+ '-d',
498
+ '--build',
499
+ '--force-recreate',
500
+ '--remove-orphans',
501
+ '--wait',
502
+ '--wait-timeout',
503
+ '120'
504
+ ]);
505
+ assert.throws(() => buildDockerComposeArgs({ memory: true, postgres: true }), /不能同时使用/);
506
  });
507
 
508
  it('uses plain compose progress for diagnosable local deploy output', () => {
 
510
  PATH: '/bin',
511
  COMPOSE_PROGRESS: 'plain'
512
  });
513
+ assert.deepEqual(
514
+ buildDockerComposeEnv(
515
+ { PATH: '/bin' },
516
+ {
517
+ revision: '0123456789abcdef0123456789abcdef01234567',
518
+ imageTag: 'local-0123456789abcdef0123456789abcdef01234567'
519
+ }
520
+ ),
521
+ {
522
+ PATH: '/bin',
523
+ COMPOSE_PROGRESS: 'plain',
524
+ GIP_IMAGE_REVISION: '0123456789abcdef0123456789abcdef01234567',
525
+ GIP_IMAGE_TAG: 'local-0123456789abcdef0123456789abcdef01234567'
526
+ }
527
+ );
528
+ });
529
+
530
+ it('uses immutable full-revision Docker image tags for deploys', () => {
531
+ const revision = '0123456789abcdef0123456789abcdef01234567';
532
+ assert.equal(buildDeploymentImageTag(revision), `local-${revision}`);
533
+ assert.equal(buildDeploymentImageReference(revision), `gpt-image-playground-customer:local-${revision}`);
534
+ assert.throws(() => buildDeploymentImageTag('0123456'), /40/);
535
+ assert.throws(() => buildDeploymentImageTag('z'.repeat(40)), /40/);
536
+ });
537
+
538
+ it('builds local probe URLs from the published container port mapping', () => {
539
+ assert.equal(buildLocalBaseUrl('127.0.0.1', '4783'), 'http://127.0.0.1:4783');
540
+ assert.equal(buildLocalBaseUrl('0.0.0.0', '4784'), 'http://127.0.0.1:4784');
541
+ assert.equal(buildLocalBaseUrl('::1', '4783'), 'http://[::1]:4783');
542
+ assert.deepEqual(parsePublishedContainerPortBindings('[{"HostIp":"127.0.0.1","HostPort":"4783"}]'), {
543
+ bindHost: '127.0.0.1',
544
+ hostPort: '4783',
545
+ baseUrl: 'http://127.0.0.1:4783'
546
+ });
547
+ assert.deepEqual(
548
+ parsePublishedContainerPortBindings('[{"HostIp":"::","HostPort":"4784"},{"HostIp":"0.0.0.0","HostPort":"4784"}]'),
549
+ {
550
+ bindHost: '0.0.0.0',
551
+ hostPort: '4784',
552
+ baseUrl: 'http://127.0.0.1:4784'
553
+ }
554
+ );
555
+ assert.throws(() => buildLocalBaseUrl('127.0.0.1', '0'), /1 到 65535/);
556
+ assert.throws(() => parsePublishedContainerPortBindings('not-json'), /端口映射/);
557
+ assert.throws(() => parsePublishedContainerPortBindings('null'), /未发布/);
558
+ });
559
+
560
+ it('waits only between failed local endpoint probe attempts', async () => {
561
+ let requestCount = 0;
562
+ let sleepCount = 0;
563
+
564
+ await assert.rejects(
565
+ waitForLocalEndpoints('http://127.0.0.1:4783', {
566
+ attempts: 2,
567
+ intervalMs: 2000,
568
+ fetchJson: async () => {
569
+ requestCount += 1;
570
+ throw new Error('container not ready');
571
+ },
572
+ sleep: async () => {
573
+ sleepCount += 1;
574
+ }
575
+ }),
576
+ /container not ready/
577
+ );
578
+
579
+ assert.equal(requestCount, 2);
580
+ assert.equal(sleepCount, 1);
581
+ });
582
+
583
+ it('requires the running Docker image and revision label to match the deployed revision', () => {
584
+ const deployment = {
585
+ revision: '0123456789abcdef0123456789abcdef01234567',
586
+ imageTag: 'local-0123456789abcdef0123456789abcdef01234567'
587
+ };
588
+ assert.doesNotThrow(() =>
589
+ assertDeploymentImageIdentity(
590
+ {
591
+ image: 'gpt-image-playground-customer:local-0123456789abcdef0123456789abcdef01234567',
592
+ revision: deployment.revision
593
+ },
594
+ deployment
595
+ )
596
+ );
597
+ assert.throws(
598
+ () =>
599
+ assertDeploymentImageIdentity(
600
+ { image: 'gpt-image-playground-customer:local', revision: deployment.revision },
601
+ deployment
602
+ ),
603
+ /镜像不匹配/
604
+ );
605
+ assert.throws(
606
+ () =>
607
+ assertDeploymentImageIdentity(
608
+ {
609
+ image: 'gpt-image-playground-customer:local-0123456789abcdef0123456789abcdef01234567',
610
+ revision: 'different'
611
+ },
612
+ deployment
613
+ ),
614
+ /revision 不匹配/
615
+ );
616
  });
617
 
618
  it('detects only the legacy fixture whole-repository Docker mount', () => {
 
686
  assert.equal(buildSkippedReport('custom', container.Mounts).removed, false);
687
  });
688
 
689
+ it('requires local deploy probes to match the selected state and storage backend', () => {
690
  assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }));
691
+ assert.throws(
692
+ () => assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }),
693
+ /SQLite deployment mode did not take effect/
694
+ );
695
  assert.doesNotThrow(() =>
696
  assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }, { memory: true })
697
  );
698
  assert.throws(
699
  () => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { memory: true }),
700
+ /Memory deployment mode did not take effect/
701
+ );
702
+ assert.doesNotThrow(() =>
703
+ assertLocalProbeMatchesMode({ stateBackend: 'postgres', imageStorageMode: 'fs' }, { postgres: true })
704
+ );
705
+ assert.throws(
706
+ () => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { postgres: true }),
707
+ /PostgreSQL deployment mode did not take effect/
708
+ );
709
+ assert.throws(
710
+ () =>
711
+ assertLocalProbeMatchesMode(
712
+ { stateBackend: 'memory', imageStorageMode: 'indexeddb' },
713
+ { memory: true, postgres: true }
714
+ ),
715
+ /不能同时使用/
716
  );
717
  });
718
 
scripts/deploy-hf-space.mjs CHANGED
@@ -353,7 +353,7 @@ export async function waitForRunning(spaceCommitSha, deployMarker, options = {})
353
  lastManagementError = error instanceof Error ? error.message : String(error);
354
  if (!isRetryableSpaceInfoReadError(error)) throw error;
355
  log(`attempt=${attempt} management_status=unavailable error=${lastManagementError}`);
356
- await sleep(intervalMs);
357
  continue;
358
  }
359
  lastStage = info.runtime?.stage || 'unknown';
@@ -368,15 +368,16 @@ export async function waitForRunning(spaceCommitSha, deployMarker, options = {})
368
  log(`attempt=${attempt} marker_status=not_ready error=${lastMarkerError}`);
369
  }
370
  }
371
- await sleep(intervalMs);
372
  }
373
  const marker = await verifyMarker(deployMarker);
374
  return {
375
  stage: lastStage,
376
  sha: lastSha,
377
- management_status: 'runtime_stage_not_running',
 
378
  service_marker_verified: true,
379
- warning: `Space did not reach RUNNING with a matching service marker for ${spaceCommitSha}; last stage=${lastStage} sha=${lastSha} marker_error=${lastMarkerError} management_error=${lastManagementError}`,
380
  marker
381
  };
382
  }
 
353
  lastManagementError = error instanceof Error ? error.message : String(error);
354
  if (!isRetryableSpaceInfoReadError(error)) throw error;
355
  log(`attempt=${attempt} management_status=unavailable error=${lastManagementError}`);
356
+ if (attempt < attempts) await sleep(intervalMs);
357
  continue;
358
  }
359
  lastStage = info.runtime?.stage || 'unknown';
 
368
  log(`attempt=${attempt} marker_status=not_ready error=${lastMarkerError}`);
369
  }
370
  }
371
+ if (attempt < attempts) await sleep(intervalMs);
372
  }
373
  const marker = await verifyMarker(deployMarker);
374
  return {
375
  stage: lastStage,
376
  sha: lastSha,
377
+ management_status: 'target_commit_not_confirmed',
378
+ verification_source: 'service_marker_after_management_timeout',
379
  service_marker_verified: true,
380
+ warning: `Hugging Face management status did not confirm RUNNING for ${spaceCommitSha}, but the public service returned the exact one-time deploy marker; last stage=${lastStage} sha=${lastSha} marker_error=${lastMarkerError} management_error=${lastManagementError}`,
381
  marker
382
  };
383
  }
scripts/deploy-hf-space.test.mjs CHANGED
@@ -344,6 +344,93 @@ describe('HF Space deploy script', () => {
344
  });
345
  });
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  it('only classifies transient Hugging Face management transport errors as retryable', () => {
348
  assert.equal(
349
  isRetryableSpaceInfoReadError(
 
344
  });
345
  });
346
 
347
+ it('does not sleep after the final transient management status error before verifying the marker', async () => {
348
+ const marker = buildDeployMarker(
349
+ '7777777777777777777777777777777777777777',
350
+ new Date('2026-06-20T14:00:00.000Z'),
351
+ 'deploy-777'
352
+ );
353
+ let sleepCount = 0;
354
+
355
+ const runtime = await waitForRunning('cccccccccccccccccccccccccccccccccccccccc', marker, {
356
+ attempts: 1,
357
+ intervalMs: 10_000,
358
+ readInfo: () => {
359
+ throw new Error('httpx.ReadTimeout: timed out while reading Space status');
360
+ },
361
+ verifyMarker: async (expected) => assertDeployMarkerMatches(marker, expected),
362
+ sleep: async () => {
363
+ sleepCount += 1;
364
+ },
365
+ log: () => {}
366
+ });
367
+
368
+ assert.equal(sleepCount, 0);
369
+ assert.deepEqual(runtime, {
370
+ stage: 'unknown',
371
+ sha: 'unknown',
372
+ management_status: 'target_commit_not_confirmed',
373
+ verification_source: 'service_marker_after_management_timeout',
374
+ service_marker_verified: true,
375
+ warning:
376
+ 'Hugging Face management status did not confirm RUNNING for cccccccccccccccccccccccccccccccccccccccc, but the public service returned the exact one-time deploy marker; last stage=unknown sha=unknown marker_error=unknown management_error=httpx.ReadTimeout: timed out while reading Space status',
377
+ marker
378
+ });
379
+ });
380
+
381
+ it('accepts an exact one-time service marker when management status remains stale', async () => {
382
+ const marker = buildDeployMarker(
383
+ '8888888888888888888888888888888888888888',
384
+ new Date('2026-06-20T15:00:00.000Z'),
385
+ 'deploy-888'
386
+ );
387
+ let sleepCount = 0;
388
+
389
+ const runtime = await waitForRunning('dddddddddddddddddddddddddddddddddddddddd', marker, {
390
+ attempts: 1,
391
+ intervalMs: 0,
392
+ readInfo: () => ({ runtime: { stage: 'RUNNING' }, sha: 'stale-space-sha' }),
393
+ verifyMarker: async (expected) => assertDeployMarkerMatches(marker, expected),
394
+ sleep: async () => {
395
+ sleepCount += 1;
396
+ },
397
+ log: () => {}
398
+ });
399
+
400
+ assert.equal(sleepCount, 0);
401
+ assert.deepEqual(runtime, {
402
+ stage: 'RUNNING',
403
+ sha: 'stale-space-sha',
404
+ management_status: 'target_commit_not_confirmed',
405
+ verification_source: 'service_marker_after_management_timeout',
406
+ service_marker_verified: true,
407
+ warning:
408
+ 'Hugging Face management status did not confirm RUNNING for dddddddddddddddddddddddddddddddddddddddd, but the public service returned the exact one-time deploy marker; last stage=RUNNING sha=stale-space-sha marker_error=unknown management_error=none',
409
+ marker
410
+ });
411
+ });
412
+
413
+ it('rejects a stale service marker after management status times out', async () => {
414
+ const marker = buildDeployMarker(
415
+ '9999999999999999999999999999999999999999',
416
+ new Date('2026-06-20T16:00:00.000Z'),
417
+ 'deploy-999'
418
+ );
419
+ const staleMarker = { ...marker, deploy_id: 'deploy-stale' };
420
+
421
+ await assert.rejects(
422
+ waitForRunning('eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', marker, {
423
+ attempts: 1,
424
+ intervalMs: 0,
425
+ readInfo: () => ({ runtime: { stage: 'RUNNING' }, sha: 'stale-space-sha' }),
426
+ verifyMarker: async (expected) => assertDeployMarkerMatches(staleMarker, expected),
427
+ sleep: async () => {},
428
+ log: () => {}
429
+ }),
430
+ /deploy_id mismatch/
431
+ );
432
+ });
433
+
434
  it('only classifies transient Hugging Face management transport errors as retryable', () => {
435
  assert.equal(
436
  isRetryableSpaceInfoReadError(
scripts/deploy-local.mjs CHANGED
@@ -4,57 +4,165 @@ import { setTimeout as delay } from 'node:timers/promises';
4
 
5
  import { fetchJsonWithTimeout, isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
6
 
7
- const LOCAL_BASE_URL = 'http://localhost:4783';
 
 
 
8
  const PROBE_PATHS = ['/api/auth-status', '/api/runtime-capabilities', '/api/agent/capabilities'];
9
  const PROBE_ATTEMPTS = 30;
10
  const PROBE_INTERVAL_MS = 2000;
11
  const PROBE_TIMEOUT_MS = 5000;
12
  const DOCKER_COMPOSE_TIMEOUT_MS = 10 * 60 * 1000;
 
 
13
 
14
  export function buildDockerComposeArgs(options = {}) {
 
15
  const files = ['-f', 'docker-compose.yml'];
16
  if (options.memory) files.push('-f', 'docker-compose.memory.yml');
17
- return ['compose', ...files, 'up', '-d', '--build'];
 
 
 
 
 
 
 
 
 
 
 
 
18
  }
19
 
20
- export function buildDockerComposeEnv(env = process.env) {
21
- return { ...env, COMPOSE_PROGRESS: 'plain' };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
23
 
24
  function parseArgs(argv) {
25
- const unknown = argv.find((arg) => !['--help', '-h', '--memory', '--skip-probe'].includes(arg));
26
  if (unknown) throw new Error(`Unknown option: ${unknown}`);
27
- return {
28
  help: argv.includes('--help') || argv.includes('-h'),
29
  memory: argv.includes('--memory'),
 
30
  skipProbe: argv.includes('--skip-probe')
31
  };
 
 
32
  }
33
 
34
  function printHelp() {
35
  console.log(`Usage:
36
  npm run deploy:local
37
  npm run deploy:local -- --memory
 
38
 
39
  Options:
40
  --memory Use docker-compose.memory.yml overlay for HF Space-like memory mode.
 
41
  --skip-probe Rebuild and start the container without HTTP endpoint probes.
42
  --help Show this help.`);
43
  }
44
 
45
- async function fetchJson(path) {
46
- return fetchJsonWithTimeout(new URL(path, LOCAL_BASE_URL), { timeoutMs: PROBE_TIMEOUT_MS });
 
 
 
 
 
 
 
 
 
 
 
47
  }
48
 
49
- async function waitForLocalEndpoints() {
 
 
 
 
 
 
 
 
50
  let lastError = '';
51
- for (let attempt = 1; attempt <= PROBE_ATTEMPTS; attempt += 1) {
52
  try {
53
  const responses = {};
54
- for (const path of PROBE_PATHS) responses[path] = await fetchJson(path);
55
  return {
56
  attempts: attempt,
57
- baseUrl: LOCAL_BASE_URL,
58
  authRequired: responses['/api/auth-status'].passwordRequired,
59
  stateBackend: responses['/api/agent/capabilities'].defaults?.state_backend,
60
  imageStorageMode: responses['/api/agent/capabilities'].storage?.image_storage_mode,
@@ -62,20 +170,57 @@ async function waitForLocalEndpoints() {
62
  };
63
  } catch (error) {
64
  lastError = error instanceof Error ? error.message : String(error);
65
- await delay(PROBE_INTERVAL_MS);
66
  }
67
  }
68
  throw new Error(`Local container did not pass HTTP probes: ${lastError}`);
69
  }
70
 
71
  export function assertLocalProbeMatchesMode(probe, options = {}) {
72
- if (!options.memory) return;
 
 
 
 
 
73
  const mismatches = [];
74
- if (probe.stateBackend !== 'memory') mismatches.push(`stateBackend=${probe.stateBackend ?? '<missing>'} expected memory`);
75
- if (probe.imageStorageMode !== 'indexeddb') {
76
- mismatches.push(`imageStorageMode=${probe.imageStorageMode ?? '<missing>'} expected indexeddb`);
 
 
77
  }
78
- if (mismatches.length) throw new Error(`Memory overlay did not take effect: ${mismatches.join(', ')}.`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
 
81
  async function main() {
@@ -85,8 +230,9 @@ async function main() {
85
  return;
86
  }
87
 
 
88
  const docker = runCommand('docker', buildDockerComposeArgs(options), {
89
- env: buildDockerComposeEnv(),
90
  timeoutMs: DOCKER_COMPOSE_TIMEOUT_MS
91
  });
92
  if (!docker.ok) {
@@ -94,14 +240,16 @@ async function main() {
94
  process.exit(1);
95
  }
96
 
 
 
97
  if (options.skipProbe) {
98
- printJson({ ok: true, phase: 'docker-compose', probe: 'skipped' });
99
  return;
100
  }
101
 
102
- const probe = await waitForLocalEndpoints();
103
  assertLocalProbeMatchesMode(probe, options);
104
- printJson({ ok: true, phase: 'ready', probe });
105
  }
106
 
107
  if (isMainModule(import.meta.url, process.argv[1])) {
 
4
 
5
  import { fetchJsonWithTimeout, isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
6
 
7
+ const CONTAINER_NAME = 'gpt-image-playground-customer';
8
+ const IMAGE_REPOSITORY = 'gpt-image-playground-customer';
9
+ const DEFAULT_BIND_HOST = '127.0.0.1';
10
+ const DEFAULT_HOST_PORT = '4783';
11
  const PROBE_PATHS = ['/api/auth-status', '/api/runtime-capabilities', '/api/agent/capabilities'];
12
  const PROBE_ATTEMPTS = 30;
13
  const PROBE_INTERVAL_MS = 2000;
14
  const PROBE_TIMEOUT_MS = 5000;
15
  const DOCKER_COMPOSE_TIMEOUT_MS = 10 * 60 * 1000;
16
+ const DOCKER_COMPOSE_WAIT_TIMEOUT_SECONDS = 120;
17
+ const GIT_REVISION_PATTERN = /^[0-9a-f]{40}$/i;
18
 
19
  export function buildDockerComposeArgs(options = {}) {
20
+ assertSingleDeploymentMode(options);
21
  const files = ['-f', 'docker-compose.yml'];
22
  if (options.memory) files.push('-f', 'docker-compose.memory.yml');
23
+ if (options.postgres) files.push('-f', 'docker-compose.postgres.yml');
24
+ return [
25
+ 'compose',
26
+ ...files,
27
+ 'up',
28
+ '-d',
29
+ '--build',
30
+ '--force-recreate',
31
+ '--remove-orphans',
32
+ '--wait',
33
+ '--wait-timeout',
34
+ String(DOCKER_COMPOSE_WAIT_TIMEOUT_SECONDS)
35
+ ];
36
  }
37
 
38
+ function assertSingleDeploymentMode(options = {}) {
39
+ if (options.memory && options.postgres) {
40
+ throw new Error('--memory 和 --postgres 不能同时使用。');
41
+ }
42
+ }
43
+
44
+ export function buildDockerComposeEnv(env = process.env, deployment) {
45
+ return {
46
+ ...env,
47
+ COMPOSE_PROGRESS: 'plain',
48
+ ...(deployment
49
+ ? {
50
+ GIP_IMAGE_REVISION: deployment.revision,
51
+ GIP_IMAGE_TAG: deployment.imageTag
52
+ }
53
+ : {})
54
+ };
55
+ }
56
+
57
+ export function buildDeploymentImageTag(revision) {
58
+ const normalized = revision?.trim().toLowerCase();
59
+ if (!GIT_REVISION_PATTERN.test(normalized || '')) throw new Error('Git revision 必须是完整的 40 位 SHA。');
60
+ return `local-${normalized}`;
61
+ }
62
+
63
+ export function buildDeploymentImageReference(revision) {
64
+ return `${IMAGE_REPOSITORY}:${buildDeploymentImageTag(revision)}`;
65
+ }
66
+
67
+ export function buildLocalBaseUrl(bindHost = DEFAULT_BIND_HOST, hostPort = DEFAULT_HOST_PORT) {
68
+ const host = bindHost.trim();
69
+ const port = hostPort.trim();
70
+ if (!host) throw new Error('GIP_BIND_HOST 不能为空。');
71
+ if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) {
72
+ throw new Error(`GIP_PORT 必须是 1 到 65535 的整数,收到:${hostPort}`);
73
+ }
74
+
75
+ const probeHost = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '[::1]' : host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
76
+ return `http://${probeHost}:${port}`;
77
+ }
78
+
79
+ export function parsePublishedContainerPortBindings(output) {
80
+ let bindings;
81
+ try {
82
+ bindings = JSON.parse(output);
83
+ } catch {
84
+ throw new Error('无法解析 Docker 容器端口映射。');
85
+ }
86
+ if (!Array.isArray(bindings) || bindings.length === 0) {
87
+ throw new Error('Docker 容器未发布 4783/tcp 端口。');
88
+ }
89
+
90
+ const binding = bindings.find((entry) => typeof entry?.HostIp === 'string' && !entry.HostIp.includes(':')) || bindings[0];
91
+ const bindHost = typeof binding?.HostIp === 'string' && binding.HostIp.trim() ? binding.HostIp.trim() : '0.0.0.0';
92
+ const hostPort = typeof binding?.HostPort === 'string' ? binding.HostPort.trim() : '';
93
+ return { bindHost, hostPort, baseUrl: buildLocalBaseUrl(bindHost, hostPort) };
94
+ }
95
+
96
+ export function assertDeploymentImageIdentity(identity, deployment) {
97
+ const expectedImage = buildDeploymentImageReference(deployment.revision);
98
+ if (identity.image !== expectedImage) {
99
+ throw new Error(`运行容器镜像不匹配:expected ${expectedImage}, received ${identity.image || '<missing>'}。`);
100
+ }
101
+ if (identity.revision !== deployment.revision) {
102
+ throw new Error(
103
+ `运行镜像 revision 不匹配:expected ${deployment.revision}, received ${identity.revision || '<missing>'}。`
104
+ );
105
+ }
106
  }
107
 
108
  function parseArgs(argv) {
109
+ const unknown = argv.find((arg) => !['--help', '-h', '--memory', '--postgres', '--skip-probe'].includes(arg));
110
  if (unknown) throw new Error(`Unknown option: ${unknown}`);
111
+ const options = {
112
  help: argv.includes('--help') || argv.includes('-h'),
113
  memory: argv.includes('--memory'),
114
+ postgres: argv.includes('--postgres'),
115
  skipProbe: argv.includes('--skip-probe')
116
  };
117
+ assertSingleDeploymentMode(options);
118
+ return options;
119
  }
120
 
121
  function printHelp() {
122
  console.log(`Usage:
123
  npm run deploy:local
124
  npm run deploy:local -- --memory
125
+ npm run deploy:local -- --postgres
126
 
127
  Options:
128
  --memory Use docker-compose.memory.yml overlay for HF Space-like memory mode.
129
+ --postgres Use docker-compose.postgres.yml and require GPT_IMAGE_POSTGRES_PASSWORD.
130
  --skip-probe Rebuild and start the container without HTTP endpoint probes.
131
  --help Show this help.`);
132
  }
133
 
134
+ function readCleanGitRevision() {
135
+ const revisionResult = runCommand('git', ['rev-parse', '--verify', 'HEAD']);
136
+ if (!revisionResult.ok) throw new Error(`无法读取当前 Git revision:${pickFailureOutput(revisionResult)}`);
137
+
138
+ const revision = revisionResult.stdout.trim().toLowerCase();
139
+ const imageTag = buildDeploymentImageTag(revision);
140
+ const statusResult = runCommand('git', ['status', '--porcelain=v1', '--untracked-files=all']);
141
+ if (!statusResult.ok) throw new Error(`无法检查 Git 工作区状态:${pickFailureOutput(statusResult)}`);
142
+ if (statusResult.stdout) {
143
+ throw new Error('拒绝部署脏工作区。请先提交或清理当前改动,再运行 npm run deploy:local。');
144
+ }
145
+
146
+ return { revision, imageTag };
147
  }
148
 
149
+ async function fetchJson(path, baseUrl) {
150
+ return fetchJsonWithTimeout(new URL(path, baseUrl), { timeoutMs: PROBE_TIMEOUT_MS });
151
+ }
152
+
153
+ export async function waitForLocalEndpoints(baseUrl, options = {}) {
154
+ const attempts = options.attempts ?? PROBE_ATTEMPTS;
155
+ const intervalMs = options.intervalMs ?? PROBE_INTERVAL_MS;
156
+ const requestJson = options.fetchJson || fetchJson;
157
+ const sleep = options.sleep || delay;
158
  let lastError = '';
159
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
160
  try {
161
  const responses = {};
162
+ for (const path of PROBE_PATHS) responses[path] = await requestJson(path, baseUrl);
163
  return {
164
  attempts: attempt,
165
+ baseUrl,
166
  authRequired: responses['/api/auth-status'].passwordRequired,
167
  stateBackend: responses['/api/agent/capabilities'].defaults?.state_backend,
168
  imageStorageMode: responses['/api/agent/capabilities'].storage?.image_storage_mode,
 
170
  };
171
  } catch (error) {
172
  lastError = error instanceof Error ? error.message : String(error);
173
+ if (attempt < attempts) await sleep(intervalMs);
174
  }
175
  }
176
  throw new Error(`Local container did not pass HTTP probes: ${lastError}`);
177
  }
178
 
179
  export function assertLocalProbeMatchesMode(probe, options = {}) {
180
+ assertSingleDeploymentMode(options);
181
+ const expected = options.memory
182
+ ? { label: 'Memory', stateBackend: 'memory', imageStorageMode: 'indexeddb' }
183
+ : options.postgres
184
+ ? { label: 'PostgreSQL', stateBackend: 'postgres', imageStorageMode: 'fs' }
185
+ : { label: 'SQLite', stateBackend: 'sqlite', imageStorageMode: 'fs' };
186
  const mismatches = [];
187
+ if (probe.stateBackend !== expected.stateBackend) {
188
+ mismatches.push(`stateBackend=${probe.stateBackend ?? '<missing>'} expected ${expected.stateBackend}`);
189
+ }
190
+ if (probe.imageStorageMode !== expected.imageStorageMode) {
191
+ mismatches.push(`imageStorageMode=${probe.imageStorageMode ?? '<missing>'} expected ${expected.imageStorageMode}`);
192
  }
193
+ if (mismatches.length) throw new Error(`${expected.label} deployment mode did not take effect: ${mismatches.join(', ')}.`);
194
+ }
195
+
196
+ function inspectDeploymentImage(deployment) {
197
+ const expectedImage = buildDeploymentImageReference(deployment.revision);
198
+ const containerImage = runCommand('docker', ['inspect', '--format', '{{.Config.Image}}', CONTAINER_NAME]);
199
+ if (!containerImage.ok) throw new Error(`无法读取部署容器镜像:${pickFailureOutput(containerImage)}`);
200
+
201
+ const imageRevision = runCommand('docker', [
202
+ 'image',
203
+ 'inspect',
204
+ '--format',
205
+ '{{ index .Config.Labels "org.opencontainers.image.revision" }}',
206
+ expectedImage
207
+ ]);
208
+ if (!imageRevision.ok) throw new Error(`无法读取部署镜像 revision:${pickFailureOutput(imageRevision)}`);
209
+
210
+ const identity = { image: containerImage.stdout.trim(), revision: imageRevision.stdout.trim().toLowerCase() };
211
+ assertDeploymentImageIdentity(identity, deployment);
212
+ return identity;
213
+ }
214
+
215
+ function inspectPublishedContainerPort() {
216
+ const result = runCommand('docker', [
217
+ 'inspect',
218
+ '--format',
219
+ '{{json (index .NetworkSettings.Ports "4783/tcp")}}',
220
+ CONTAINER_NAME
221
+ ]);
222
+ if (!result.ok) throw new Error(`无法读取部署容器端口映射:${pickFailureOutput(result)}`);
223
+ return parsePublishedContainerPortBindings(result.stdout.trim());
224
  }
225
 
226
  async function main() {
 
230
  return;
231
  }
232
 
233
+ const deployment = readCleanGitRevision();
234
  const docker = runCommand('docker', buildDockerComposeArgs(options), {
235
+ env: buildDockerComposeEnv(process.env, deployment),
236
  timeoutMs: DOCKER_COMPOSE_TIMEOUT_MS
237
  });
238
  if (!docker.ok) {
 
240
  process.exit(1);
241
  }
242
 
243
+ const image = inspectDeploymentImage(deployment);
244
+ const localConfig = inspectPublishedContainerPort();
245
  if (options.skipProbe) {
246
+ printJson({ ok: true, phase: 'docker-compose', image, probe: 'skipped' });
247
  return;
248
  }
249
 
250
+ const probe = await waitForLocalEndpoints(localConfig.baseUrl);
251
  assertLocalProbeMatchesMode(probe, options);
252
+ printJson({ ok: true, phase: 'ready', deployment: { ...deployment, ...localConfig }, image, probe });
253
  }
254
 
255
  if (isMainModule(import.meta.url, process.argv[1])) {
scripts/docker-build-context.test.mjs CHANGED
@@ -4,12 +4,16 @@ import { describe, it } from 'node:test';
4
 
5
  describe('Docker build context', () => {
6
  it('keeps the tracked real-upstream smoke template available to containerized tests', async () => {
7
- const [dockerignore, dockerfile, gitignore, realSmokeTemplate] = await Promise.all([
8
- readFile(new URL('../.dockerignore', import.meta.url), 'utf8'),
9
- readFile(new URL('../Dockerfile', import.meta.url), 'utf8'),
10
- readFile(new URL('../.gitignore', import.meta.url), 'utf8'),
11
- readFile(new URL('../.env.real-smoke.example', import.meta.url), 'utf8')
12
- ]);
 
 
 
 
13
 
14
  assert.match(dockerignore, /^\.gitignore$/m);
15
  assert.match(dockerignore, /^!\.gitignore$/m);
@@ -17,7 +21,24 @@ describe('Docker build context', () => {
17
  assert.match(dockerignore, /^!\.env\.real-smoke\.example$/m);
18
  assert.match(dockerfile, /^COPY \. \.$/m);
19
  assert.match(dockerfile, /^COPY vendor\/brace-expansion-compat \.\/vendor\/brace-expansion-compat$/m);
 
 
 
 
 
 
20
  assert.match(gitignore, /^!\.env\.real-smoke\.example$/m);
21
  assert.match(realSmokeTemplate, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
 
 
 
 
 
 
 
 
 
 
 
22
  });
23
  });
 
4
 
5
  describe('Docker build context', () => {
6
  it('keeps the tracked real-upstream smoke template available to containerized tests', async () => {
7
+ const [dockerignore, dockerfile, gitignore, realSmokeTemplate, compose, postgresCompose, ciWorkflow] =
8
+ await Promise.all([
9
+ readFile(new URL('../.dockerignore', import.meta.url), 'utf8'),
10
+ readFile(new URL('../Dockerfile', import.meta.url), 'utf8'),
11
+ readFile(new URL('../.gitignore', import.meta.url), 'utf8'),
12
+ readFile(new URL('../.env.real-smoke.example', import.meta.url), 'utf8'),
13
+ readFile(new URL('../docker-compose.yml', import.meta.url), 'utf8'),
14
+ readFile(new URL('../docker-compose.postgres.yml', import.meta.url), 'utf8'),
15
+ readFile(new URL('../.github/workflows/ci.yml', import.meta.url), 'utf8')
16
+ ]);
17
 
18
  assert.match(dockerignore, /^\.gitignore$/m);
19
  assert.match(dockerignore, /^!\.gitignore$/m);
 
21
  assert.match(dockerignore, /^!\.env\.real-smoke\.example$/m);
22
  assert.match(dockerfile, /^COPY \. \.$/m);
23
  assert.match(dockerfile, /^COPY vendor\/brace-expansion-compat \.\/vendor\/brace-expansion-compat$/m);
24
+ assert.match(
25
+ dockerfile,
26
+ /^COPY --from=builder --chown=node:node \/app\/scripts\/docker-entrypoint\.mjs \.\/scripts\/docker-entrypoint\.mjs$/m
27
+ );
28
+ assert.match(dockerfile, /^HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD /m);
29
+ assert.match(dockerfile, /^LABEL org\.opencontainers\.image\.revision=\$VCS_REF$/m);
30
  assert.match(gitignore, /^!\.env\.real-smoke\.example$/m);
31
  assert.match(realSmokeTemplate, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
32
+ assert.match(compose, /^ - "\$\{GIP_BIND_HOST:-127\.0\.0\.1\}:\$\{GIP_PORT:-4783\}:4783"$/m);
33
+ assert.doesNotMatch(compose, /^ - "4783:4783"$/m);
34
+ assert.match(postgresCompose, /^ AGENT_DATABASE_URL: ""$/m);
35
+ assert.match(postgresCompose, /^ AGENT_DB_PASSWORD: ""$/m);
36
+ assert.match(postgresCompose, /^ AGENT_DB_PASSWORD_FILE: \/run\/secrets\/postgres_password$/m);
37
+ assert.doesNotMatch(postgresCompose, /gpt-image-playground-customer:postgres/);
38
+ assert.doesNotMatch(postgresCompose, /^ ports:/m);
39
+ assert.match(ciWorkflow, /--env GIP_COMPOSE_DEPLOYMENT=true --env GIP_BIND_HOST=127\.0\.0\.1/);
40
+ assert.match(ciWorkflow, /for attempt in \{1\.\.120\}; do/);
41
+ assert.match(ciWorkflow, /attempt %s\/120/);
42
+ assert.match(ciWorkflow, /if \(\( attempt < 120 \)\); then\n\s+sleep 1/);
43
  });
44
  });
scripts/docker-entrypoint.mjs ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const IPV4_LOOPBACK_PATTERN = /^127(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
7
+ const LOOPBACK_HOSTS = new Set(['localhost', '::1', '[::1]']);
8
+
9
+ export function isLoopbackBindHost(value) {
10
+ const host = value?.trim().toLowerCase();
11
+ return Boolean(host) && (LOOPBACK_HOSTS.has(host) || IPV4_LOOPBACK_PATTERN.test(host));
12
+ }
13
+
14
+ export function assertDockerComposeAccessPolicy(env = process.env) {
15
+ if (env.GIP_COMPOSE_DEPLOYMENT?.trim().toLowerCase() !== 'true') return;
16
+ if (isLoopbackBindHost(env.GIP_BIND_HOST)) return;
17
+ if (env.APP_PASSWORD?.trim()) return;
18
+
19
+ throw new Error(
20
+ '拒绝以非回环地址发布 Docker 服务:请先在 .env.local 设置 APP_PASSWORD,或将 GIP_BIND_HOST 保持为 127.0.0.1。'
21
+ );
22
+ }
23
+
24
+ async function main() {
25
+ assertDockerComposeAccessPolicy();
26
+ await import('../server.js');
27
+ }
28
+
29
+ if (path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
30
+ main().catch((error) => {
31
+ console.error(`[ERROR] ${error instanceof Error ? error.message : String(error)}`);
32
+ process.exit(1);
33
+ });
34
+ }
scripts/docker-entrypoint.test.mjs ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { assertDockerComposeAccessPolicy, isLoopbackBindHost } from './docker-entrypoint.mjs';
2
+ import assert from 'node:assert/strict';
3
+ import { describe, it } from 'node:test';
4
+
5
+ describe('Docker Compose access policy', () => {
6
+ it('recognizes only loopback bind hosts as local-only', () => {
7
+ for (const host of ['127.0.0.1', '127.0.12.34', 'localhost', '::1', '[::1]']) {
8
+ assert.equal(isLoopbackBindHost(host), true, host);
9
+ }
10
+ for (const host of ['0.0.0.0', '10.0.90.200', '::', 'localhost.example.test']) {
11
+ assert.equal(isLoopbackBindHost(host), false, host);
12
+ }
13
+ });
14
+
15
+ it('allows standalone images and loopback Compose deployments without a page password', () => {
16
+ assert.doesNotThrow(() => assertDockerComposeAccessPolicy({}));
17
+ assert.doesNotThrow(() =>
18
+ assertDockerComposeAccessPolicy({ GIP_COMPOSE_DEPLOYMENT: 'true', GIP_BIND_HOST: '127.0.0.1' })
19
+ );
20
+ });
21
+
22
+ it('rejects unauthenticated non-loopback Compose deployments', () => {
23
+ assert.throws(
24
+ () => assertDockerComposeAccessPolicy({ GIP_COMPOSE_DEPLOYMENT: 'true', GIP_BIND_HOST: '0.0.0.0' }),
25
+ /APP_PASSWORD/
26
+ );
27
+ assert.throws(
28
+ () => assertDockerComposeAccessPolicy({ GIP_COMPOSE_DEPLOYMENT: ' TRUE ', GIP_BIND_HOST: '0.0.0.0' }),
29
+ /APP_PASSWORD/
30
+ );
31
+ assert.doesNotThrow(() =>
32
+ assertDockerComposeAccessPolicy({
33
+ GIP_COMPOSE_DEPLOYMENT: 'true',
34
+ GIP_BIND_HOST: '10.0.90.200',
35
+ APP_PASSWORD: 'access-code'
36
+ })
37
+ );
38
+ });
39
+ });
scripts/smoke-hf-space-memory.mjs CHANGED
@@ -5,7 +5,11 @@ import { readPositiveIntegerEnv } from './env-utils.mjs';
5
 
6
  const imageName = process.env.HF_SPACE_SMOKE_IMAGE || 'gpt-image-playground:hf-space-memory-smoke';
7
  const containerName = process.env.HF_SPACE_SMOKE_CONTAINER || 'gpt-image-playground-hf-space-smoke';
8
- const hostPort = process.env.HF_SPACE_SMOKE_PORT || '4785';
 
 
 
 
9
  const token = process.env.HF_SPACE_SMOKE_AGENT_TOKEN || 'hf-space-smoke-token';
10
  const baseUrl = `http://127.0.0.1:${hostPort}`;
11
  const readyTimeoutMs = readPositiveIntegerEnv('HF_SPACE_SMOKE_READY_TIMEOUT_MS', 45_000);
@@ -16,6 +20,9 @@ function run(command, args, options = {}) {
16
  encoding: 'utf8',
17
  env: { ...process.env, ...(options.env || {}) }
18
  });
 
 
 
19
  if (result.status !== 0) {
20
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
21
  throw new Error(`${command} ${args.join(' ')} failed${output ? `\n${output}` : ''}`);
@@ -93,7 +100,7 @@ try {
93
  '--name',
94
  containerName,
95
  '-p',
96
- `${hostPort}:4783`,
97
  '-e',
98
  'AGENT_STATE_BACKEND=memory',
99
  '-e',
 
5
 
6
  const imageName = process.env.HF_SPACE_SMOKE_IMAGE || 'gpt-image-playground:hf-space-memory-smoke';
7
  const containerName = process.env.HF_SPACE_SMOKE_CONTAINER || 'gpt-image-playground-hf-space-smoke';
8
+ const parsedHostPort = readPositiveIntegerEnv('HF_SPACE_SMOKE_PORT', 4785);
9
+ if (parsedHostPort > 65_535) {
10
+ throw new Error('HF_SPACE_SMOKE_PORT must be less than or equal to 65535');
11
+ }
12
+ const hostPort = String(parsedHostPort);
13
  const token = process.env.HF_SPACE_SMOKE_AGENT_TOKEN || 'hf-space-smoke-token';
14
  const baseUrl = `http://127.0.0.1:${hostPort}`;
15
  const readyTimeoutMs = readPositiveIntegerEnv('HF_SPACE_SMOKE_READY_TIMEOUT_MS', 45_000);
 
20
  encoding: 'utf8',
21
  env: { ...process.env, ...(options.env || {}) }
22
  });
23
+ if (result.error) {
24
+ throw new Error(`${command} ${args.join(' ')} failed to start: ${result.error.message}`);
25
+ }
26
  if (result.status !== 0) {
27
  const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
28
  throw new Error(`${command} ${args.join(' ')} failed${output ? `\n${output}` : ''}`);
 
100
  '--name',
101
  containerName,
102
  '-p',
103
+ `127.0.0.1:${hostPort}:4783`,
104
  '-e',
105
  'AGENT_STATE_BACKEND=memory',
106
  '-e',
scripts/smoke-hf-space-memory.test.mjs CHANGED
@@ -1,5 +1,6 @@
1
  import assert from 'node:assert/strict';
2
  import { spawnSync } from 'node:child_process';
 
3
  import { join } from 'node:path';
4
  import { fileURLToPath } from 'node:url';
5
  import { describe, it } from 'node:test';
@@ -8,6 +9,13 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
8
  const scriptPath = join(repoRoot, 'scripts/smoke-hf-space-memory.mjs');
9
 
10
  describe('HF Space memory smoke script validation', () => {
 
 
 
 
 
 
 
11
  it('rejects invalid ready timeout values before Docker access', () => {
12
  const result = spawnSync(process.execPath, [scriptPath], {
13
  cwd: repoRoot,
@@ -20,4 +28,30 @@ describe('HF Space memory smoke script validation', () => {
20
  assert.match(result.stderr, /positive integer/);
21
  assert.equal(result.stdout.trim(), '');
22
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  });
 
1
  import assert from 'node:assert/strict';
2
  import { spawnSync } from 'node:child_process';
3
+ import { readFileSync } from 'node:fs';
4
  import { join } from 'node:path';
5
  import { fileURLToPath } from 'node:url';
6
  import { describe, it } from 'node:test';
 
9
  const scriptPath = join(repoRoot, 'scripts/smoke-hf-space-memory.mjs');
10
 
11
  describe('HF Space memory smoke script validation', () => {
12
+ it('publishes the temporary container only on the IPv4 loopback interface', () => {
13
+ const source = readFileSync(scriptPath, 'utf8');
14
+
15
+ assert.match(source, /`127\.0\.0\.1:\$\{hostPort\}:4783`/);
16
+ assert.doesNotMatch(source, /^\s*`\$\{hostPort\}:4783`,?$/m);
17
+ });
18
+
19
  it('rejects invalid ready timeout values before Docker access', () => {
20
  const result = spawnSync(process.execPath, [scriptPath], {
21
  cwd: repoRoot,
 
28
  assert.match(result.stderr, /positive integer/);
29
  assert.equal(result.stdout.trim(), '');
30
  });
31
+
32
+ it('rejects out-of-range host ports before Docker access', () => {
33
+ const result = spawnSync(process.execPath, [scriptPath], {
34
+ cwd: repoRoot,
35
+ encoding: 'utf8',
36
+ env: { ...process.env, HF_SPACE_SMOKE_PORT: '65536' }
37
+ });
38
+
39
+ assert.equal(result.status, 1);
40
+ assert.match(result.stderr, /HF_SPACE_SMOKE_PORT/);
41
+ assert.match(result.stderr, /65535/);
42
+ assert.equal(result.stdout.trim(), '');
43
+ });
44
+
45
+ it('reports the underlying spawn error when Docker is unavailable', () => {
46
+ const result = spawnSync(process.execPath, [scriptPath], {
47
+ cwd: repoRoot,
48
+ encoding: 'utf8',
49
+ env: { ...process.env, PATH: '' }
50
+ });
51
+
52
+ assert.equal(result.status, 1);
53
+ assert.match(result.stderr, /docker build/);
54
+ assert.match(result.stderr, /failed to start/);
55
+ assert.match(result.stderr, /ENOENT/);
56
+ });
57
  });
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -141,7 +141,7 @@ Authorization: Bearer <token>
141
  - `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
142
  - `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
143
  - `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
144
- - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。
145
  - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
146
  - `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;支持 `-- --base-url <url>`;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。
147
 
 
141
  - `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
142
  - `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
143
  - `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
144
+ - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效,加 `-- --postgres` 会断言 postgres/fs overlay 生效PostgreSQL 模式要求在运行环境或 Compose `.env` 中提供 `GPT_IMAGE_POSTGRES_PASSWORD`。
145
  - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
146
  - `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;支持 `-- --base-url <url>`;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。
147
 
src/app/api/deploy-marker/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextResponse } from 'next/server';
2
 
3
- const deployMarker = {"schema_version":1,"local_sha":"128efae485d0eb35670a525cf19069b5da3f4ace","created_at":"2026-07-26T03:42:06.320Z","deploy_id":"aaf32bf5-46f3-48cf-8f53-ca6c7a48ba6e"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
 
1
  import { NextResponse } from 'next/server';
2
 
3
+ const deployMarker = {"schema_version":1,"local_sha":"6590d91c9e6e0a3314fa16bd8e5667b2d9e855cd","created_at":"2026-07-27T03:45:51.712Z","deploy_id":"d1603a14-8385-441d-adac-e4c5b059dde4"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6