diff --git a/.dockerignore b/.dockerignore index 33c31f5..dc8974b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,21 @@ .git -__pycache__/ -*.pyc -.pytest_cache/ +.gitignore +.venv +venv +__pycache__ +*.py[cod] +*$py.class +.pytest_cache +.mypy_cache +reports/* +!reports/.gitkeep +docs +tests +scripts +config +*.md +!README.md +.env +.env.* +*.log +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f01d46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Secrets / local env +.env +.env.* +!.env.example +config/local.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +*.egg-info/ +dist/ +build/ + +# Dry-run / local artifacts (keep directory) +reports/* +!reports/.gitkeep + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp +*~ + +# Docker +*.tar diff --git a/Dockerfile b/Dockerfile index 9d210a0..733fe08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,18 @@ -FROM modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim - -ENV PYTHONUNBUFFERED=1 +# ModelHub XC adaptation agent — Phase1 skeleton +# Default base MUST stay the platform image (do not swap to public mirrors). +ARG BASE_IMAGE=modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim +FROM ${BASE_IMAGE} WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Copy only what the runtime needs (see .dockerignore) +COPY src/ ./src/ -COPY . . +ENV PORT=8080 +ENV PYTHONUNBUFFERED=1 +# STRATEGY_ID and tokens are injected by the platform at run time — never bake them in. EXPOSE 8080 -CMD ["python", "main.py"] +# Start HTTP service only (no register / submit side effects) +CMD ["python", "-m", "src.server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c854043 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +# ModelHub XC agent contrib — Phase1 +# Allowed: test / build / smoke-health / dry-run +# FORBIDDEN: register / submit / deploy / push targets + +.PHONY: test build smoke-health dry-run help + +PYTHON ?= python3 +IMAGE ?= modelhub-agent-contrib:local +PORT ?= 8080 + +help: + @echo "test stdlib unit tests + local SIGTERM integration (needs sockets)" + @echo "build Docker build using platform base (needs Docker + TCR access)" + @echo "smoke-health local HTTP smoke (needs sockets; default test STRATEGY_ID)" + @echo "dry-run local example report; pending-submit integration outstanding" + @echo "No register/submit/deploy/push — Phase1 hard boundary." + +test: + $(PYTHON) -m unittest discover -s tests -v + +build: + @command -v docker >/dev/null 2>&1 || { \ + echo "ERROR: docker not found. See docs/build-notes.md"; \ + exit 1; \ + } + docker build -t $(IMAGE) . + +smoke-health: + $(PYTHON) scripts/smoke_health.py + +dry-run: + $(PYTHON) scripts/dry_run.py diff --git a/README.md b/README.md index 3b42c73..0e9b345 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,74 @@ -# Strategy Platform Demo Repo +# modelhub-agent-contrib -Minimal strategy repository for testing the platform Kaniko build and deployment flow. +ModelHub XC(信创模盒)**适配智能体** Phase1 本地骨架。 -It satisfies the runtime contract from the parent README: +> **不代注册、不代提交。** 发布智能体本身 **不得分**。积分来自成功适配,数字一律 **待平台核实**。 +> 冲突裁决:**适配 W1 优先**;本仓默认 dry-run,不抢验证队列。 -- Root-level `Dockerfile` -- Listens on port `8080` -- Exposes `GET /health` -- Reads `EXTERNAL_SERVICE_TOKEN` -- Handles `SIGTERM` for graceful shutdown +## 契约要点 -## Local Run +| 项 | 值 | +|----|-----| +| 端口 | 8080 | +| 健康检查 | `GET /health` → `{"status":"ok"}` | +| 启动 | **必须**有非空 `STRATEGY_ID`,否则 exit ≠0 | +| Live | `ALLOW_LIVE_SUBMIT=1` **且** `LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT`;Phase1 仍无真实 `task/add` 客户端 | +| Base 镜像 | 平台 `xc_agent_platform/python:3.11-slim`(勿擅自改默认 FROM) | + +闸门以 `src/gate.py` 实现为准:仅 `ALLOW_LIVE_SUBMIT=1` 与 +`LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT` 同时满足才开逻辑闸门。PRD 示例 +`ALLOW_LIVE_SUBMIT=true` / `CONFIRM_LIVE=YES` 不受支持;本仓不读取确认文件。 +即使开闸,无真实客户端仍返回 blocked;请保持默认关闭。 + +## 快速开始 ```bash -python main.py -curl http://localhost:8080/health -curl http://localhost:8080/ +cd /workspace/modelhub-agent-contrib +make test +make smoke-health # 自动起本地 server(需 STRATEGY_ID,脚本可注入测试值) +make dry-run # 写出 reports/dry-run-*.md +make build # 需 Docker + 可达平台 base;失败见 docs/build-notes.md ``` -## Build Locally +本地起服务: ```bash -docker build -t strategy-demo:v1.0.0 . - -docker run --rm -p 8080:8080 strategy-demo:v1.0.0 +export STRATEGY_ID=local-dev +python3 -m src.server +# → http://127.0.0.1:8080/health ``` -## Platform Test +## 目录 -Push this directory to Gitea and keep the `v1.0.0` tag. Then create a strategy with: - -```json -{ - "name": "strategy-demo", - "repo_url": "https:////strategy-demo", - "tag": "v1.0.0" -} +```text +Dockerfile Makefile README.md +src/ # server / health / strategy / gate +config/ # example.env, candidates.example.json +scripts/ # smoke_health.py, dry_run.py, tag_release.py +reports/ # dry-run 输出(gitignore) +docs/ # 契约 / runbook / kickoff / issues +tests/ # stdlib unittest ``` + +## Makefile 目标 + +- `test` / `build` / `smoke-health` / `dry-run` +- **没有** `register` / `submit` / `deploy` / `push` + +## 文档 + +- [`docs/kickoff-for-victor.md`](docs/kickoff-for-victor.md) — 一页开工 + 验收勾选 +- [`docs/platform-contract-agent.md`](docs/platform-contract-agent.md) — 契约表 +- [`docs/register-runbook.md`](docs/register-runbook.md) — 仅人工注册 +- [`docs/api-probe-design.md`](docs/api-probe-design.md) — 写接口禁止代调 +- [`docs/diff-vs-demo.md`](docs/diff-vs-demo.md) — 对照官方 demo +- [`docs/issues.md`](docs/issues.md) — M0–M4 / Phase 清单 +- [`docs/build-notes.md`](docs/build-notes.md) — 镜像构建说明 + +## 资源声明 + +按平台量级设计:约 **100m–1 CPU / 256–512Mi**。 + +## 许可与边界 + +本仓库用于 Victor 本地/Gitea 交付骨架;任何 ModelHub 写操作须 Victor 本人在控制台或显式批准后执行。 diff --git a/config/candidates.example.json b/config/candidates.example.json new file mode 100644 index 0000000..36fb509 --- /dev/null +++ b/config/candidates.example.json @@ -0,0 +1,21 @@ +{ + "candidates": [ + { + "model_id": "example-org/example-model", + "modelAddress": "example-org/example-model", + "taskType": "text-generation", + "targetGpu": "Iluvatar_bi-100", + "framework": "vllm", + "notes": "Phase1 example only — not a real submit payload" + }, + { + "model_id": "example-org/another-model", + "taskType": "text-generation", + "notes": "Minimal fields: model_id required; others optional" + } + ], + "_meta": { + "description": "Minimal dry-run input schema for Phase1", + "pending_align": "Future Phase2 may read /workspace/modelhub-adapt/pending-submit.md A→B order" + } +} diff --git a/config/example.env b/config/example.env new file mode 100644 index 0000000..e7f12ac --- /dev/null +++ b/config/example.env @@ -0,0 +1,20 @@ +# Copy to config/local.env (gitignored) for local runs. Never commit real tokens. + +# Required for process start (Phase1): non-empty STRATEGY_ID +STRATEGY_ID=local-dev-strategy-placeholder + +# Optional listen port (default 8080) +PORT=8080 + +# Implementation is authoritative: ALLOW_LIVE_SUBMIT=1 AND +# LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT. No confirmation file is read. +# PRD examples ALLOW_LIVE_SUBMIT=true / CONFIRM_LIVE=YES are unsupported. +# Even with both set, no real client exists: still blocked. Keep defaults below. +# Dual live gate — BOTH required for live; default is dry-run / blocked +ALLOW_LIVE_SUBMIT=0 +LIVE_CONFIRM= + +# Platform injects EXTERNAL_SERVICE_TOKEN at runtime. +# Community aliases (presence only; never log values): +# XC_TOKEN_FILE / XC_TOKEN / MODELHUB_XC_TOKEN / EXTERNAL_SERVICE_TOKEN +# EXTERNAL_SERVICE_TOKEN= diff --git a/docs/api-probe-design.md b/docs/api-probe-design.md new file mode 100644 index 0000000..123542d --- /dev/null +++ b/docs/api-probe-design.md @@ -0,0 +1,38 @@ +# API 探针设计(只读意图 · 禁止代调写接口) + +- 日期:2026-10-16 Asia/Shanghai +- Phase1:**仅设计文档**;本仓不实现真实 HTTP 写客户端 + +## 管理 API `/agent_platform/strategies*` + +| 方法 | 路径 | 本仓态度 | +|------|------|----------| +| POST | `/agent_platform/strategies` | **禁止代调**(注册) | +| POST | `.../{id}/deploy` | **禁止代调** | +| POST | `.../{id}/stop` | **禁止代调** | +| DELETE | `.../{id}` | **禁止代调** | +| GET | `.../{id}` | 未来只读探针可选(Phase3+,需 Victor 授权) | +| GET | `.../{id}/logs` | 未来只读探针可选 | + +鉴权(浏览器):`Authorization: Bearer `(等级 A)。 + +## 适配任务 API + +| 方法 | 路径 | 本仓态度 | +|------|------|----------| +| GET | `/api/adapt/task/page` | 未来只读鉴权探针(对齐 huni-probe,`ALLOW_SUBMIT=0`) | +| POST | `/api/adapt/task/build-config` | 设计可引用;Phase1 不实现 | +| POST | `/api/adapt/task/add` | **禁止代调**;live 仅闸门 + 单测假客户端 | +| GET | `/api/adapt/task/calculate-credit` | 只读索引;勿写死 KPI | + +容器内社区鉴权(C):`Xc-Token` + body `strategyId` — **待 Victor 账号核实**后再实现探针。 + +## Phase1 落地边界 + +- `src/gate.py`:闸门逻辑 + `attempt_live_submit(..., client=Fake)` 仅供单测 +- **无**默认 `urllib`/`requests` 客户端指向 `task/add` 或 `strategies` POST +- Makefile **无** register / submit / deploy / push + +## 冲突裁决提醒 + +适配 W1:队列 ≥95 → 禁 live;智能体不抢验证空位。 diff --git a/docs/build-notes.md b/docs/build-notes.md new file mode 100644 index 0000000..a3f5dc4 --- /dev/null +++ b/docs/build-notes.md @@ -0,0 +1,59 @@ +# Build notes + +- 日期:2026-10-16 Asia/Shanghai +- 默认 `FROM`:`modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` +- **禁止**因本地拉不下而改默认 FROM 为公网镜像 + +## 本机(box)首次尝试 + +| 项 | 结果 | +|----|------| +| `docker` CLI | **未安装 / 不可用**(`which docker` 空) | +| `make build` | 预期失败:`docker not found`(Makefile 显式报错) | +| 默认 FROM | **未修改** | + +### 后续建议(Victor) + +1. 在有 Docker 且能访问腾讯云 TCR 平台镜像的环境执行 `make build` +2. 若 `docker pull` 平台 base 失败:把完整错误贴本文件「拉取失败记录」,仍保持默认 FROM +3. 无镜像时用本地验收:`STRATEGY_ID=test make smoke-health` / `make test` + +## 拉取失败记录 + +(尚无 Docker,无 pull 日志。有 Docker 后在此追加。) + +## 2026-10-16 13:46 Asia/Shanghai — `make build` 实测 + +```text +ERROR: docker not found. See docs/build-notes.md +make: *** [Makefile:19: build] Error 1 +``` + +- 原因:box 无 Docker 引擎/CLI,**未发生**平台 base `docker pull`(因此无 TCR 拉取错误日志) +- 默认 `FROM modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` **未改** +- 替代验收:`make test` 绿;`STRATEGY_ID=test make smoke-health` 绿(本地 `python -m src.server`) + +## 2026-10-16 Asia/Shanghai — Victor Mac `make build` + +- Docker Desktop:已连接 +- 结果:失败 — `modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` pull access denied / insufficient_scope +- 默认 FROM:未修改 + +## 2026-10-16 Asia/Shanghai — Codex 沙箱复测 + +- `make build` 实际退出 2:`ERROR: docker not found. See docs/build-notes.md`。 +- 无 Docker,未尝试拉取 TCR;本机结果不能证明 TCR 可达或不可达。上文 Mac 的拒绝记录为已有人工证据,未在本轮复现。 +- `STRATEGY_ID=test make smoke-health` 实际退出 2:沙箱创建 socket 被拒,`PermissionError: [Errno 1] Operation not permitted`。 +- `make test`:优化后 19 项中 18 项通过,SIGTERM 集成项在监听前同样被 socket 权限阻塞;未跳过或标绿。 +- 默认平台 base / FROM 保持原样;须在允许本地 socket、具备 Docker 与 TCR 权限的环境复验完整 DoD。 + +## 2026-10-16 ~15:20 Asia/Shanghai — tcr-then-build 探测(Mac) + +- Docker Desktop:已连接;`docker` CLI 可用 +- `~/.docker/config.json`:`auths` 空,`credsStore=desktop` → **未登录** TCR +- `docker pull modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim`: + `Error response from daemon: pull access denied ... may require 'docker login'` +- 默认 FROM:**未修改** +- 既有 `agent-contrib:local`(sha256:95a4d01e2b55…):**不算**本轮 DoD(CMD=`src.main`,基底公网 python:3.11-slim) +- 验收标准:`/Users/vc/modelhub-adapt-logs/agent-image-dod-2026-10-16.md`(box 同源 `/workspace/modelhub-adapt/logs/agent-image-dod-2026-10-16.md`) +- 阻塞:需 Victor 提供 TCR 账号并对 `xc_agent_platform/python` 有 pull,执行 `docker login modelhubxc-4pd.tencentcloudcr.com` 后重试 pull + `make build` diff --git a/docs/diff-vs-demo.md b/docs/diff-vs-demo.md new file mode 100644 index 0000000..b5a17d7 --- /dev/null +++ b/docs/diff-vs-demo.md @@ -0,0 +1,40 @@ +# 对照官方演示仓 `luopingyi/xc_agent_platform_demo` + +- 日期:2026-10-16 Asia/Shanghai +- 官方演示:https://dev.modelhub.org.cn/luopingyi/xc_agent_platform_demo +- 安全探针参照:`huni/huni-probe-agent`(只读) +- **不要照搬**:`huni/huni-adapt-agent` 默认真提交循环 + +## 对齐项(保留) + +| 项 | demo / 规划 | 本仓 | +|----|-------------|------| +| 根目录 Dockerfile | 是 | 是 | +| 默认 base 镜像 | 平台 `python:3.11-slim` | 同(`BASE_IMAGE` 仅 build-arg,默认不改公网) | +| `EXPOSE 8080` | 是 | 是 | +| `GET /health` → 200 `{"status":"ok"}` | 是 | 是 | +| 读 `EXTERNAL_SERVICE_TOKEN` 是否存在 | demo | `/status` 布尔;永不打值 | +| stdlib / 轻依赖 | demo 极简 | Phase1 仅 stdlib | + +## 本仓相对 demo 的补强 + +| 项 | 说明 | +|----|------| +| `STRATEGY_ID` 启动校验 | Phase1:**缺失则 exit ≠0**(产品规划;相对「仅读 token 保活」的社区变体) | +| 双重 live 闸门 | `ALLOW_LIVE_SUBMIT` + `LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT` | +| `GET /status` | 仅布尔字段 | +| dry-run 报告 | `scripts/dry_run.py` → `reports/dry-run-*.md` | +| 单测 | health / 缺 STRATEGY_ID / 闸门默认拒绝 / SIGTERM | +| 无默认真提交 | live 仅闸门 + **注入假客户端**单测;仓库无默认真打 `/api/adapt/task/add` 的 HTTP 客户端 | + +## 明确不照搬 + +| 来源 | 行为 | 本仓 | +|------|------|------| +| huni-adapt-agent | 默认真提交循环 | **禁止** | +| CoolBoy / 社区刷分脚本 | 绕队列 / 无闸门 | **禁止** | +| 旧对话口径「缺 STRATEGY_ID 保活 + /health 仍 200」 | 与产品规划 Phase1 冲突 | **以产品规划为准:启动失败** | + +## 资源声明 + +文档按约 **100m–1 CPU / 256–512Mi** 设计;Phase1 不做 k8s manifests。 diff --git a/docs/issues.md b/docs/issues.md new file mode 100644 index 0000000..fc4ca2a --- /dev/null +++ b/docs/issues.md @@ -0,0 +1,47 @@ +# Issue / Phase 清单 + +对齐 PRD M0–M4 与产品规划 Phase 0–5。 + +## Phase / Milestone 映射 + +| Phase(产品规划) | PRD | 状态(本仓) | +|-------------------|-----|--------------| +| Phase 0 调研冻结 | M0 | 文档已落 `docs/` | +| Phase 1 本地可部署样例 | M1 | **本轮交付** | +| Phase 2 Dry-run 闭环 | M2 | 骨架:`scripts/dry_run.py`;**待对接 pending-submit**:A→B / PASS / 硬门尚未自动核验 | +| Phase 3 人工注册 + 只读探针 | M3 | 仅 runbook / api-probe-design;**不代注册** | +| Phase 4 一次 live 验证 | M4 | 闸门已备;**无真实客户端** | +| Phase 5 规模化 | — | 未开 | + +## P0 Issues + +- [x] docs: 冻结智能体平台契约(health/STRATEGY_ID/SIGTERM/资源) +- [x] docs: 对照 xc_agent_platform_demo 写 diff-vs-demo +- [x] chore: 初始化 Dockerfile / Makefile / example.env +- [x] feat: GET /health 200 + STRATEGY_ID 校验(缺则启动失败) +- [x] feat: SIGTERM 优雅退出(≤30s;测试 <5s) +- [x] feat: dry-run 报告生成器(默认模式) +- [x] feat: live 人工确认闸门 + 单测(默认拒绝) +- [x] docs: 注册智能体 runbook — 仅人工 +- [x] chore: tag_release(只打本地 tag,不 push / 不注册) +- [x] docs: api-probe-design(禁止代调写接口) + +Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。 + +## P1(未做) + +- [ ] 对接 `/workspace/modelhub-adapt/pending-submit.md` A→B 只读 +- [ ] 结构化 JSON 日志(禁密钥) +- [ ] evidence-agent 模板填写 + +## P2(未做) + +- [ ] 贡献台账 CSV +- [ ] 多 Tag 矩阵 + +## 硬边界(持续) + +- 无 register / submit / deploy / push Makefile 目标 +- 无 `/agent_platform/strategies*` 写调用 +- 无 `/api/adapt/task/add` 真客户端 +- 适配 W1 优先;不抢队列 diff --git a/docs/kickoff-for-victor.md b/docs/kickoff-for-victor.md new file mode 100644 index 0000000..22e250b --- /dev/null +++ b/docs/kickoff-for-victor.md @@ -0,0 +1,43 @@ +# 开工包(一页)— Victor + +**仓路径(本地)**:`/workspace/modelhub-agent-contrib/` +**远程**:Gitea 命名空间/仓名 **未定** → Phase1 以本地验收为主 +**模式**:默认 dry-run;**不代注册、不代提交** + +## 闸门 + +| 条件 | 值 | +|------|-----| +| `ALLOW_LIVE_SUBMIT` | 默认 `0` | +| `LIVE_CONFIRM` | 空;live 须 `I_CONFIRM_LIVE_SUBMIT` | +| `STRATEGY_ID` | **启动必需**;缺失 → exit ≠0 | +| 真实 `task/add` | Phase1 **未实现**(仅假客户端单测) | + +## 本地命令 + +```bash +cd /workspace/modelhub-agent-contrib +make test +make build # 需 Docker + 可达平台 base;失败见 docs/build-notes.md +make smoke-health # 无镜像时脚本会拉起本地 server +make dry-run # 生成 reports/dry-run-*.md +``` + +入口:`python -m src.server`(Dockerfile `CMD` 同) + +## 验收勾选(Phase1) + +- [ ] `make test` 绿(health / 缺 STRATEGY_ID / 闸门拒绝 / SIGTERM) +- [ ] `make build` 绿 **或** `docs/build-notes.md` 记录平台镜像拉取失败且 **未改默认 FROM** +- [ ] `make smoke-health` 绿(镜像或本地 server) +- [ ] 根目录 Dockerfile + `EXPOSE 8080` +- [ ] README 写清:发布 = 0 分;积分待核实;不代注册/提交 +- [ ] Makefile **无** register/submit/deploy/push +- [ ] dry-run 报告含「需 Victor 人工提交」「积分:待平台核实」 +- [ ] 未调用任何 ModelHub 写接口 + +## 人工下一步(非本 Phase 自动化) + +1. 定 Gitea `/` +2. push + tag(`scripts/tag_release.py` 仅本地 tag) +3. 控制台新增智能体(见 `docs/register-runbook.md`) diff --git a/docs/platform-contract-agent.md b/docs/platform-contract-agent.md new file mode 100644 index 0000000..b352e37 --- /dev/null +++ b/docs/platform-contract-agent.md @@ -0,0 +1,62 @@ +# 平台契约 — 适配智能体(Phase1) + +- 日期:2026-10-16 Asia/Shanghai +- 依据:产品规划 `agent-product-plan-2026-10-16.md` §1 + PRD `02-agent-contribution-prd.md` +- **冲突裁决:适配 W1 优先**(队列 ≥95 禁 live;本骨架默认 dry-run) + +## 核实等级 + +| 等级 | 含义 | +|------|------| +| **A** | 官方演示仓 / 主站前端明文 | +| **B** | PRD 已核实(docs/6 + 控制台,基线 2026-10-12) | +| **C** | 社区样例,非平台承诺 | +| **D** | 待核实 | + +## 运行契约 + +| 项 | 内容 | 等级 | +|----|------|------| +| 代码托管 | `dev.modelhub.org.cn` 用户仓 | B | +| 注册 UI | 名称、仓库地址、**Git Tag**(不可用分支名) | B | +| 构建 | 平台 Kaniko,`refs/tags/` | B | +| Dockerfile | **仓库根目录**;base `modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` | A | +| 端口 | **8080**,`EXPOSE 8080` | A | +| 健康检查 | `GET /health` → 200 `{"status":"ok"}` | A | +| 可选状态 | `GET /status` → 布尔 `strategy_id_present` / `external_service_token_present`(**不回传值**) | 本仓 Phase1 | +| 优雅退出 | `SIGTERM`,约 **≤30s** 平台窗口;本实现目标 ~1s 退出主循环 | B | +| 平台注入 | `STRATEGY_ID`;`EXTERNAL_SERVICE_TOKEN`(及社区别名) | A/B | +| **STRATEGY_ID 缺失** | **Phase1:进程启动失败(exit ≠0)** | 产品规划 Phase1 + PRD | +| 资源 | 约 100m–1 CPU / 256–512Mi | B | +| **发布智能体得分** | **0 分**(发布 ≠ 得分) | B | +| Live 默认 | **关**;双重闸门 `ALLOW_LIVE_SUBMIT=1` + `LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT` | 本仓 | +| 积分 | 来自**成功适配**;智能体代提成功计同一成功分;公式/周报 **待平台核实** | B / D | + +## 鉴权双轨(文档索引,Phase1 不代调) + +| 场景 | 头 | 等级 | +|------|----|------| +| 浏览器 / `agent_platform` | `Authorization: Bearer ` | A | +| 容器内社区实践 | `Xc-Token: `;body 带 `strategyId` | C | + +## 禁止 + +- 代注册、代 live 提交、Makefile 含 register/submit/deploy/push +- 日志/响应/镜像层写入 token 或 STRATEGY_ID **值** +- 把「智能体已发布」写入千分进度 + +## 冲突裁决(强制) + +| 资源 | 裁决 | +|------|------| +| 异步验证队列 | 适配 W1 优先;≥95 全体停交(含智能体 live) | +| Codex | hold 期扩池/归因 > 智能体脚手架 | +| 本骨架 | 默认 dry-run;本轮无真实 `task/add` HTTP 客户端 | + +## 闸门配置以实现为准 + +`src/gate.py` 只接受 `ALLOW_LIVE_SUBMIT=1` 与 +`LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT`(去除首尾空白后精确匹配)。 +PRD 的 `ALLOW_LIVE_SUBMIT=true` / `CONFIRM_LIVE=YES` 仅为需求示例, +本仓不支持这些值/别名,也不读取确认文件。开闸无客户端仍 blocked。 +这只是本地逻辑闸门,不代表已获 live 授权或已校验队列与候选硬门。 diff --git a/docs/register-runbook.md b/docs/register-runbook.md new file mode 100644 index 0000000..f42283c --- /dev/null +++ b/docs/register-runbook.md @@ -0,0 +1,39 @@ +# 注册智能体 Runbook(仅人工 · Victor) + +> Agent / Bot **不代填、不代点、不代调写接口**。 + +## 流程 + +1. **本地绿**:`make test`(及有 Docker 时 `make build` + `make smoke-health`) +2. **Gitea 建仓**(Victor):`dev.modelhub.org.cn//` + - 命名空间/仓名 **未定**时:仅本地验收,不阻塞 Phase1 +3. **推送代码**(Victor 本人):本 Makefile **无 push 目标** +4. **打 Git Tag**(可用本仓 `scripts/tag_release.py` **仅本地 tag**;push tag 由 Victor 执行) +5. **控制台新增**:个人主页 → 我的适配智能体 → 新增 + - 名称 + - 仓库地址(远程 Git URL) + - **Git Tag**(须先出现在远程;`GET .../tags` 下拉可选后再填) +6. **Kaniko 构建**:等待状态;证据同时记中文文案与枚举(`building` / `ready` / `failed` / `running` / `stopped`) +7. **部署后**:确认容器 `GET /health` → 200;积分应 **无变化**(发布 = 0 分) + +## POST `/agent_platform/strategies`(文档说明 · 禁止代调) + +| 字段 | 说明 | +|------|------| +| `name` | 策略显示名 | +| `repo_url` | Gitea 仓库 URL | +| `tag` | Git Tag(非分支) | + +成功响应含 `.id`(等级 A:前端 bundle)。 +**本仓与 Agent 禁止代调该写接口。** + +相关写接口(同样禁止代调):`/{id}/deploy`、`/{id}/stop`、`DELETE /{id}`。 + +## 环境变量(平台注入 / 控制台) + +| 变量 | 说明 | +|------|------| +| `STRATEGY_ID` | 策略 id;本仓启动必需 | +| `EXTERNAL_SERVICE_TOKEN` | 服务凭证;只检存在,不记值 | +| `ALLOW_LIVE_SUBMIT` | 默认 `0` | +| `LIVE_CONFIRM` | live 时须为 `I_CONFIRM_LIVE_SUBMIT` | diff --git a/main.py b/main.py deleted file mode 100644 index ae1b62f..0000000 --- a/main.py +++ /dev/null @@ -1,70 +0,0 @@ -import json -import os -import signal -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - - -HOST = "0.0.0.0" -PORT = 8080 -shutdown_requested = False - - -def _config() -> dict: - token = os.getenv("EXTERNAL_SERVICE_TOKEN", "") - return { - "external_service_token_present": bool(token), - } - - -class Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - if self.path == "/health": - self._send_json({"status": "ok"}) - return - - if self.path == "/": - self._send_json( - { - "name": "strategy-demo", - "status": "running", - "config": _config(), - } - ) - return - - self._send_json({"error": "not found"}, status=404) - - def log_message(self, fmt: str, *args: object) -> None: - print(f"{self.address_string()} - {fmt % args}", flush=True) - - def _send_json(self, body: dict, status: int = 200) -> None: - payload = json.dumps(body).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - -def _handle_signal(signum: int, _frame: object) -> None: - global shutdown_requested - shutdown_requested = True - print(f"received signal {signum}, shutting down", flush=True) - - -def main() -> None: - signal.signal(signal.SIGTERM, _handle_signal) - signal.signal(signal.SIGINT, _handle_signal) - - server = ThreadingHTTPServer((HOST, PORT), Handler) - server.timeout = 1 - print(f"strategy-demo listening on {HOST}:{PORT}", flush=True) - - while not shutdown_requested: - server.handle_request() - - server.server_close() - - -if __name__ == "__main__": - main() diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8b13789..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/dry_run.py b/scripts/dry_run.py new file mode 100755 index 0000000..53df4aa --- /dev/null +++ b/scripts/dry_run.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Generate a dry-run adaptation report (no platform side effects). + +Reads config/candidates.example.json (or DRY_RUN_CANDIDATES path). +Writes reports/dry-run-.md +""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +ROOT = Path(__file__).resolve().parents[1] +TZ = ZoneInfo("Asia/Shanghai") + + +def _load_candidates(path: Path) -> list[dict]: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + return data + if isinstance(data, dict) and "candidates" in data: + return list(data["candidates"]) + raise SystemExit(f"unexpected candidates schema in {path}") + + +def _render(candidates: list[dict], ts: str, src: Path) -> str: + lines = [ + f"# Dry-run 适配建议报告 — {ts} (Asia/Shanghai)", + "", + "> **模式:dry-run** — 本报告不产生平台侧真实提交。", + "> **需 Victor 人工提交**:请登录模盒控制台按草稿确认后手工提交。", + "> **积分:待平台核实**(发布智能体本身不得分;成功适配按账本计)。", + "", + "## 闸门状态", + "", + f"- ALLOW_LIVE_SUBMIT is 1: {os.environ.get('ALLOW_LIVE_SUBMIT', '0').strip() == '1'}", + f"- LIVE_CONFIRM set: {'yes' if os.environ.get('LIVE_CONFIRM', '').strip() else 'no'}", + f"- STRATEGY_ID present: {'yes' if os.environ.get('STRATEGY_ID', '').strip() else 'no'}", + "", + "## 输入", + "", + f"- 候选文件:`{src}`", + f"- 候选条数:{len(candidates)}", + "", + "## 建议草稿(字段示意,非真实 POST)", + "", + ] + for i, c in enumerate(candidates, 1): + model = c.get("modelAddress") or c.get("model_id") or "(missing model_id)" + lines.append(f"### {i}. `{model}`") + lines.append("") + lines.append("```json") + draft = { + "modelAddress": model, + "taskType": c.get("taskType", "text-generation"), + "targetGpu": c.get("targetGpu", "(unset)"), + "framework": c.get("framework", "(unset)"), + "strategyId": "", + "configParams": "", + "notes": c.get("notes", ""), + } + lines.append(json.dumps(draft, ensure_ascii=False, indent=2)) + lines.append("```") + lines.append("") + lines.append("- 风险:Phase1 未做唯一性/硬门预检;提交前请对照 do-not-resubmit 与队列空位。") + lines.append("- **需 Victor 人工提交**(或经双重闸门的受控 live;本骨架默认拒绝 live)。") + lines.append("- **积分:待平台核实**") + lines.append("") + lines.extend( + [ + "## 后续对接(Phase2)", + "", + "- **待对接 pending-submit**:尚未读取 `/workspace/modelhub-adapt/pending-submit.md`,未验证 A→B、PASS、硬门与 do-not-resubmit;本报告仅保留输入顺序。", + "- 适配 W1 优先;同一 100 队列,≥95 全体停交;空位先 A 再 B,人工核验后决定,不凭示例报告提交。", + "- 禁止 Makefile / 脚本调用 `/api/adapt/task/add` 或 `/agent_platform/strategies*` 写接口。", + "", + ] + ) + return "\n".join(lines) + + +def main() -> None: + src = Path( + os.environ.get( + "DRY_RUN_CANDIDATES", + str(ROOT / "config" / "candidates.example.json"), + ) + ) + if not src.is_file(): + raise SystemExit(f"candidates file not found: {src}") + + candidates = _load_candidates(src) + now = datetime.now(TZ) + ts = now.strftime("%Y%m%d-%H%M%S") + out_dir = ROOT / "reports" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"dry-run-{ts}.md" + out_path.write_text(_render(candidates, now.strftime("%Y-%m-%d %H:%M:%S"), src), encoding="utf-8") + print(f"Wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_health.py b/scripts/smoke_health.py new file mode 100755 index 0000000..39fdea0 --- /dev/null +++ b/scripts/smoke_health.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Smoke: GET /health must return 200 {"status":"ok"}. + +Usage: + # Against already-running server: + SMOKE_BASE_URL=http://127.0.0.1:8080 python3 scripts/smoke_health.py + + # Or let this script start a local server (needs STRATEGY_ID): + STRATEGY_ID=test python3 scripts/smoke_health.py +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _get(url: str, timeout: float = 2.0) -> tuple[int, dict]: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read().decode("utf-8") + return resp.status, json.loads(body) + + +def _wait_health(base: str, attempts: int = 30) -> None: + url = base.rstrip("/") + "/health" + last_err: Exception | None = None + for _ in range(attempts): + try: + code, data = _get(url) + if code == 200 and data.get("status") == "ok": + print(f"OK: GET {url} -> {code} {data}") + return + raise AssertionError(f"unexpected response: {code} {data}") + except (urllib.error.URLError, ConnectionError, AssertionError, TimeoutError) as exc: + last_err = exc + time.sleep(0.2) + raise SystemExit(f"smoke-health failed: {last_err}") + + +def main() -> None: + external = os.environ.get("SMOKE_BASE_URL", "").strip() + if external: + _wait_health(external) + # optional status check + try: + code, data = _get(external.rstrip("/") + "/status") + print(f"OK: GET /status -> {code} {data}") + except Exception as exc: # noqa: BLE001 + print(f"WARN: /status check skipped: {exc}", file=sys.stderr) + return + + if not os.environ.get("STRATEGY_ID", "").strip(): + os.environ["STRATEGY_ID"] = "smoke-test-strategy" + + port = os.environ.get("SMOKE_PORT", "18080") + env = os.environ.copy() + env["PORT"] = port + env["HOST"] = "127.0.0.1" + env["STRATEGY_ID"] = env.get("STRATEGY_ID") or "smoke-test-strategy" + + proc = subprocess.Popen( + [sys.executable, "-m", "src.server"], + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + try: + _wait_health(f"http://127.0.0.1:{port}") + code, data = _get(f"http://127.0.0.1:{port}/status") + print(f"OK: GET /status -> {code} {data}") + assert "strategy_id_present" in data + assert data["strategy_id_present"] is True + # never assert on secret values — only booleans + finally: + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + if proc.returncode not in (0, -signal.SIGTERM, signal.SIGTERM): + # 0 or terminated by SIGTERM is fine + err = (proc.stderr.read() or b"").decode("utf-8", errors="replace") + if proc.returncode is not None and proc.returncode > 0: + print(f"WARN: server exit code {proc.returncode}: {err}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/tag_release.py b/scripts/tag_release.py new file mode 100755 index 0000000..6d0364c --- /dev/null +++ b/scripts/tag_release.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Create a local git tag only. Does NOT push. Does NOT register on ModelHub. + +Usage: + python3 scripts/tag_release.py v0.1.0 + python3 scripts/tag_release.py v0.1.0 --message "Phase1 skeleton" +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Local git tag only (no push, no register)") + parser.add_argument("tag", help="Tag name, e.g. v0.1.0") + parser.add_argument("--message", "-m", default="", help="Annotated tag message") + args = parser.parse_args() + + if not (ROOT / ".git").exists(): + print( + "WARN: not a git repository yet. Init locally if needed; " + "this script will not push or register.", + file=sys.stderr, + ) + # Still attempt so the failure is clear + msg = args.message or f"Release {args.tag} (local tag only — no push / no ModelHub register)" + cmd = ["git", "tag", "-a", args.tag, "-m", msg] + print(f"Running (cwd={ROOT}): {' '.join(cmd)}") + print("NOTE: This does NOT git push and does NOT call ModelHub APIs.") + result = subprocess.run(cmd, cwd=str(ROOT)) + if result.returncode != 0: + raise SystemExit(result.returncode) + print(f"OK: local tag {args.tag!r} created. Push tag yourself when ready for Kaniko.") + + +if __name__ == "__main__": + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..d931643 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +"""ModelHub XC adaptation agent — Phase1 local skeleton.""" + +__version__ = "0.1.0" diff --git a/src/gate.py b/src/gate.py new file mode 100644 index 0000000..3fb9dd3 --- /dev/null +++ b/src/gate.py @@ -0,0 +1,107 @@ +"""Dual live-submit gate. Default: dry-run / blocked. + +Live is allowed only when ALL of: + - ALLOW_LIVE_SUBMIT=1 + - LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT + - STRATEGY_ID present + +Phase1: no real HTTP client for /api/adapt/task/add. Live path accepts an +injected fake client for unit tests only. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional, Protocol + +from .strategy import strategy_id_present + +LIVE_CONFIRM_VALUE = "I_CONFIRM_LIVE_SUBMIT" + + +class SubmitClient(Protocol): + """Minimal interface for a submit client (injected fake in tests).""" + + def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]: + ... + + +@dataclass(frozen=True) +class GateDecision: + mode: str # "dry-run" | "live" | "blocked" + allowed: bool + reason: str + + +def _allow_live_flag() -> bool: + return os.environ.get("ALLOW_LIVE_SUBMIT", "0").strip() == "1" + + +def _live_confirm_ok() -> bool: + return os.environ.get("LIVE_CONFIRM", "").strip() == LIVE_CONFIRM_VALUE + + +def evaluate_gate() -> GateDecision: + """Evaluate whether live submit is permitted.""" + if not strategy_id_present(): + return GateDecision( + mode="blocked", + allowed=False, + reason="STRATEGY_ID missing or empty", + ) + if not _allow_live_flag(): + return GateDecision( + mode="dry-run", + allowed=False, + reason="ALLOW_LIVE_SUBMIT is not 1 (default dry-run)", + ) + if not _live_confirm_ok(): + return GateDecision( + mode="blocked", + allowed=False, + reason=f"LIVE_CONFIRM must be exactly {LIVE_CONFIRM_VALUE!r}", + ) + return GateDecision( + mode="live", + allowed=True, + reason="dual gate open (ALLOW_LIVE_SUBMIT=1 and LIVE_CONFIRM set)", + ) + + +def attempt_live_submit( + payload: Dict[str, Any], + client: Optional[SubmitClient] = None, +) -> Dict[str, Any]: + """Attempt a live submit only if the gate opens and a client is injected. + + Phase1: without an injected client, even an open gate returns blocked with + a message that real HTTP submit is not implemented in this skeleton. + Never calls platform write APIs from this module. + """ + decision = evaluate_gate() + if not decision.allowed: + return { + "ok": False, + "mode": decision.mode, + "reason": decision.reason, + "submitted": False, + } + if client is None: + return { + "ok": False, + "mode": "blocked", + "reason": ( + "Phase1 skeleton: no real task/add client; " + "inject a fake client in tests only" + ), + "submitted": False, + } + result = client.submit(payload) + return { + "ok": True, + "mode": "live", + "reason": decision.reason, + "submitted": True, + "result": result, + } diff --git a/src/health.py b/src/health.py new file mode 100644 index 0000000..8f0fee7 --- /dev/null +++ b/src/health.py @@ -0,0 +1,33 @@ +"""Health and status HTTP handlers. Never echo secrets.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Tuple + +from .strategy import external_service_token_present, strategy_id_present + + +def health_payload() -> Dict[str, str]: + return {"status": "ok"} + + +def status_payload() -> Dict[str, Any]: + """Booleans only — never include STRATEGY_ID or token values.""" + return { + "strategy_id_present": strategy_id_present(), + "external_service_token_present": external_service_token_present(), + } + + +def handle_get(path: str) -> Tuple[int, Dict[str, str], bytes]: + """Return (status_code, headers, body) for GET paths.""" + headers = {"Content-Type": "application/json; charset=utf-8"} + if path == "/health" or path.startswith("/health?"): + body = json.dumps(health_payload(), ensure_ascii=False).encode("utf-8") + return 200, headers, body + if path == "/status" or path.startswith("/status?"): + body = json.dumps(status_payload(), ensure_ascii=False).encode("utf-8") + return 200, headers, body + body = json.dumps({"error": "not_found"}, ensure_ascii=False).encode("utf-8") + return 404, headers, body diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..e076722 --- /dev/null +++ b/src/server.py @@ -0,0 +1,83 @@ +"""Minimal HTTP server for the adaptation agent (stdlib only). + +Listens on PORT (default 8080). Handles SIGTERM and exits the main loop +within ~1s (platform window documented as ≤30s). + +Startup: require STRATEGY_ID (Phase1 product plan — fail if missing). +""" + +from __future__ import annotations + +import os +import signal +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Optional + +from .health import handle_get +from .strategy import require_strategy_id + +_shutdown = threading.Event() +_httpd: Optional[ThreadingHTTPServer] = None + + +class AgentHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt: str, *args) -> None: # noqa: A003 + # Avoid noisy default logging; never log env secrets. + sys.stderr.write("[http] request handled (details omitted)\n") + + def do_GET(self) -> None: # noqa: N802 + code, headers, body = handle_get(self.path) + self.send_response(code) + for k, v in headers.items(): + self.send_header(k, v) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self) -> None: # noqa: N802 + body = b'{"error":"method_not_allowed"}' + self.send_response(405) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def _on_sigterm(signum: int, frame) -> None: # noqa: ARG001 + sys.stderr.write(f"Received signal {signum}; shutting down...\n") + _shutdown.set() + httpd = _httpd + if httpd is not None: + # wake serve_forever + threading.Thread(target=httpd.shutdown, daemon=True).start() + + +def main() -> None: + global _httpd + + require_strategy_id() # exit 1 if missing + + port = int(os.environ.get("PORT", "8080")) + host = os.environ.get("HOST", "0.0.0.0") + + signal.signal(signal.SIGTERM, _on_sigterm) + signal.signal(signal.SIGINT, _on_sigterm) + + httpd = ThreadingHTTPServer((host, port), AgentHandler) + httpd.daemon_threads = True + _httpd = httpd + + sys.stderr.write(f"Listening on {host}:{port} (GET /health, GET /status)\n") + try: + httpd.serve_forever(poll_interval=0.5) + finally: + httpd.server_close() + sys.stderr.write("Server stopped.\n") + + +if __name__ == "__main__": + main() diff --git a/src/strategy.py b/src/strategy.py new file mode 100644 index 0000000..2836415 --- /dev/null +++ b/src/strategy.py @@ -0,0 +1,81 @@ +"""STRATEGY_ID and external-token presence helpers. + +Phase1 (product plan): missing STRATEGY_ID → process must fail to start +(exit non-zero). Values are never logged or returned in responses. +""" + +from __future__ import annotations + +import os +import sys +from typing import Optional + +# Community alias order (level C); we only check presence, never echo values. +_TOKEN_ENV_KEYS = ( + "XC_TOKEN_FILE", + "XC_TOKEN", + "MODELHUB_XC_TOKEN", + "EXTERNAL_SERVICE_TOKEN", +) + + +def get_strategy_id() -> Optional[str]: + """Return STRATEGY_ID if set and non-empty after strip; else None.""" + raw = os.environ.get("STRATEGY_ID") + if raw is None: + return None + value = raw.strip() + return value if value else None + + +def require_strategy_id() -> str: + """Return STRATEGY_ID or exit the process with a non-zero status. + + Does not print the value. Safe for startup path. + """ + sid = get_strategy_id() + if sid is None: + print( + "ERROR: STRATEGY_ID is required and must be non-empty. " + "Refusing to start (Phase1 contract).", + file=sys.stderr, + ) + sys.exit(1) + return sid + + +def strategy_id_present() -> bool: + return get_strategy_id() is not None + + +def _token_from_file(path: str) -> bool: + """True if path exists and has non-empty content (value never read into logs).""" + try: + if not path or not os.path.isfile(path): + return False + with open(path, "r", encoding="utf-8") as fh: + content = fh.read().strip() + return bool(content) + except OSError: + return False + + +def external_service_token_present() -> bool: + """Whether any known token env / file alias is present (boolean only).""" + for key in _TOKEN_ENV_KEYS: + raw = os.environ.get(key) + if raw is None: + continue + value = raw.strip() + if not value: + continue + if key == "XC_TOKEN_FILE": + if _token_from_file(value): + return True + continue + # Reject obvious placeholders (community practice, level C) + lowered = value.lower() + if lowered in ("tmp", "placeholder", "changeme", "xxx"): + continue + return True + return False diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_gate.py b/tests/test_gate.py new file mode 100644 index 0000000..3f7a50f --- /dev/null +++ b/tests/test_gate.py @@ -0,0 +1,138 @@ +"""Gate defaults to dry-run / blocked; live only with dual confirm + STRATEGY_ID.""" + +from __future__ import annotations + +import os +import unittest +from unittest import mock + +from src.gate import LIVE_CONFIRM_VALUE, attempt_live_submit, evaluate_gate + + +class FakeClient: + def __init__(self) -> None: + self.calls = [] + + def submit(self, payload): + self.calls.append(payload) + return {"task_id": "fake-1"} + + +class TestGate(unittest.TestCase): + def _clear_gate_env(self): + return mock.patch.dict( + os.environ, + { + "STRATEGY_ID": "sid-1", + "ALLOW_LIVE_SUBMIT": "0", + "LIVE_CONFIRM": "", + }, + clear=False, + ) + + def test_default_dry_run(self) -> None: + with self._clear_gate_env(): + d = evaluate_gate() + self.assertFalse(d.allowed) + self.assertEqual(d.mode, "dry-run") + + def test_missing_strategy_blocked(self) -> None: + env = { + "ALLOW_LIVE_SUBMIT": "1", + "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, + } + # Remove STRATEGY_ID + base = {k: v for k, v in os.environ.items() if k != "STRATEGY_ID"} + base.update(env) + with mock.patch.dict(os.environ, base, clear=True): + d = evaluate_gate() + self.assertFalse(d.allowed) + self.assertEqual(d.mode, "blocked") + + def test_allow_without_confirm_blocked(self) -> None: + with mock.patch.dict( + os.environ, + { + "STRATEGY_ID": "sid-1", + "ALLOW_LIVE_SUBMIT": "1", + "LIVE_CONFIRM": "YES", + }, + clear=False, + ): + d = evaluate_gate() + self.assertFalse(d.allowed) + self.assertEqual(d.mode, "blocked") + + def test_dual_gate_opens(self) -> None: + with mock.patch.dict( + os.environ, + { + "STRATEGY_ID": "sid-1", + "ALLOW_LIVE_SUBMIT": "1", + "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, + }, + clear=False, + ): + d = evaluate_gate() + self.assertTrue(d.allowed) + self.assertEqual(d.mode, "live") + + def test_open_gate_without_client_still_blocked(self) -> None: + """Phase1: no real HTTP client even when gate is open.""" + with mock.patch.dict( + os.environ, + { + "STRATEGY_ID": "sid-1", + "ALLOW_LIVE_SUBMIT": "1", + "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, + }, + clear=False, + ): + result = attempt_live_submit({"modelAddress": "x/y"}) + self.assertFalse(result["submitted"]) + self.assertIn("no real", result["reason"].lower()) + self.assertEqual(result["mode"], "blocked") + self.assertFalse(result["ok"]) + + def test_prd_example_and_whitespace_id_never_submit(self) -> None: + cases = [ + {"ALLOW_LIVE_SUBMIT": "true", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE}, + {"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": "", "CONFIRM_LIVE": "YES"}, + {"ALLOW_LIVE_SUBMIT": "1", "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, "STRATEGY_ID": " "}, + ] + for overrides in cases: + with self.subTest(overrides=overrides): + client = FakeClient() + env = {"STRATEGY_ID": "sid-test", **overrides} + with mock.patch.dict(os.environ, env, clear=True): + result = attempt_live_submit({}, client=client) + self.assertFalse(result["submitted"]) + self.assertFalse(result["ok"]) + self.assertEqual(client.calls, []) + self.assertTrue(result["reason"]) + + def test_injected_fake_client(self) -> None: + client = FakeClient() + with mock.patch.dict( + os.environ, + { + "STRATEGY_ID": "sid-1", + "ALLOW_LIVE_SUBMIT": "1", + "LIVE_CONFIRM": LIVE_CONFIRM_VALUE, + }, + clear=False, + ): + result = attempt_live_submit({"modelAddress": "x/y"}, client=client) + self.assertTrue(result["submitted"]) + self.assertEqual(len(client.calls), 1) + + def test_default_attempt_rejects(self) -> None: + client = FakeClient() + with self._clear_gate_env(): + result = attempt_live_submit({"modelAddress": "x/y"}, client=client) + self.assertFalse(result["submitted"]) + self.assertEqual(len(client.calls), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..35c88f6 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,60 @@ +"""Tests for /health and /status handlers.""" + +from __future__ import annotations + +import json +import os +import unittest +from unittest import mock + +from src.health import handle_get, health_payload, status_payload + + +class TestHealth(unittest.TestCase): + def test_health_payload(self) -> None: + self.assertEqual(health_payload(), {"status": "ok"}) + + def test_handle_get_health(self) -> None: + code, headers, body = handle_get("/health") + self.assertEqual(code, 200) + self.assertIn("application/json", headers["Content-Type"]) + data = json.loads(body.decode("utf-8")) + self.assertEqual(data, {"status": "ok"}) + + def test_handle_get_unknown(self) -> None: + code, _, body = handle_get("/nope") + self.assertEqual(code, 404) + self.assertIn("not_found", body.decode("utf-8")) + + def test_status_booleans_only_no_secret_values(self) -> None: + with mock.patch.dict( + os.environ, + { + "STRATEGY_ID": "secret-strategy-id-xyz", + "EXTERNAL_SERVICE_TOKEN": "secret-token-abc", + }, + clear=False, + ): + payload = status_payload() + self.assertTrue(payload["strategy_id_present"]) + self.assertTrue(payload["external_service_token_present"]) + blob = json.dumps(payload) + self.assertNotIn("secret-strategy-id-xyz", blob) + self.assertNotIn("secret-token-abc", blob) + + def test_status_absent(self) -> None: + env = {k: v for k, v in os.environ.items() if k not in ( + "STRATEGY_ID", + "EXTERNAL_SERVICE_TOKEN", + "XC_TOKEN", + "XC_TOKEN_FILE", + "MODELHUB_XC_TOKEN", + )} + with mock.patch.dict(os.environ, env, clear=True): + payload = status_payload() + self.assertFalse(payload["strategy_id_present"]) + self.assertFalse(payload["external_service_token_present"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report_safety.py b/tests/test_report_safety.py new file mode 100644 index 0000000..b8caa3c --- /dev/null +++ b/tests/test_report_safety.py @@ -0,0 +1,32 @@ +"""Local report and HTTP log regression checks; no network requests.""" +import contextlib +import io +import os +import unittest +from pathlib import Path +from unittest import mock + +from scripts.dry_run import _render +from src.server import AgentHandler + + +class TestReportSafety(unittest.TestCase): + def test_report_marks_pending_and_does_not_echo_gate_values(self): + sentinel = "synthetic-sensitive-value" + with mock.patch.dict(os.environ, { + "ALLOW_LIVE_SUBMIT": sentinel, + "LIVE_CONFIRM": sentinel, + "STRATEGY_ID": sentinel, + "EXTERNAL_SERVICE_TOKEN": sentinel, + }, clear=True): + report = _render([], "test-time", Path("example.json")) + self.assertNotIn(sentinel, report) + for required in ("待对接 pending-submit", "A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"): + self.assertIn(required, report) + + def test_http_log_omits_request_details(self): + output = io.StringIO() + with contextlib.redirect_stderr(output): + AgentHandler.log_message(None, '"%s" %s', '/health?token=synthetic-sensitive-value', 200) + self.assertNotIn("synthetic-sensitive-value", output.getvalue()) + self.assertIn("request handled", output.getvalue()) diff --git a/tests/test_sigterm.py b/tests/test_sigterm.py new file mode 100644 index 0000000..dc9f22c --- /dev/null +++ b/tests/test_sigterm.py @@ -0,0 +1,69 @@ +"""SIGTERM should stop the server subprocess promptly (<< 30s platform window).""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +import unittest +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class TestSigterm(unittest.TestCase): + def test_sigterm_exits_quickly(self) -> None: + port = "19090" + env = os.environ.copy() + env["STRATEGY_ID"] = "sigterm-test" + env["PORT"] = port + env["HOST"] = "127.0.0.1" + + proc = subprocess.Popen( + [sys.executable, "-m", "src.server"], + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + try: + # wait until health is up + url = f"http://127.0.0.1:{port}/health" + deadline = time.time() + 5 + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=0.5) as resp: + data = json.loads(resp.read().decode("utf-8")) + if resp.status == 200 and data.get("status") == "ok": + break + except Exception: + time.sleep(0.1) + else: + if proc.poll() is not None: + self.fail(f"server exited before health check: {proc.stderr.read().decode(errors='replace')}") + self.fail("server did not become healthy in time") + + t0 = time.time() + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + self.fail("server did not exit within 5s after SIGTERM (platform allows ≤30s)") + elapsed = time.time() - t0 + self.assertLess(elapsed, 5.0, f"exit took {elapsed:.2f}s") + self.assertEqual(proc.returncode, 0, "SIGTERM must exit cleanly, not kill the process") + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=2) + if proc.stderr: + proc.stderr.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strategy_startup.py b/tests/test_strategy_startup.py new file mode 100644 index 0000000..a4c73ff --- /dev/null +++ b/tests/test_strategy_startup.py @@ -0,0 +1,57 @@ +"""Missing STRATEGY_ID must cause startup failure (exit non-zero).""" + +from __future__ import annotations + +import os +import subprocess +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] + + +class TestStrategyStartup(unittest.TestCase): + def test_missing_strategy_id_exits_nonzero(self) -> None: + env = os.environ.copy() + env["STRATEGY_ID"] = "" + env["PORT"] = "19081" + env["HOST"] = "127.0.0.1" + proc = subprocess.run( + [sys.executable, "-m", "src.server"], + cwd=str(ROOT), + env=env, + capture_output=True, + text=True, + timeout=10, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertIn("STRATEGY_ID", proc.stderr) + + def test_whitespace_strategy_id_exits_nonzero(self) -> None: + env = os.environ.copy() + env["STRATEGY_ID"] = " " + env["PORT"] = "19082" + env["HOST"] = "127.0.0.1" + proc = subprocess.run( + [sys.executable, "-m", "src.server"], + cwd=str(ROOT), + env=env, + capture_output=True, + text=True, + timeout=10, + ) + self.assertNotEqual(proc.returncode, 0) + + def test_require_strategy_id_helper(self) -> None: + from src.strategy import require_strategy_id + + with self.assertRaises(SystemExit) as ctx: + with mock.patch.dict(os.environ, {"STRATEGY_ID": ""}, clear=False): + require_strategy_id() + self.assertNotEqual(ctx.exception.code, 0) + + +if __name__ == "__main__": + unittest.main()