From 23ec354eb52e20f4bfe3bb59204d76b2b65dee7d Mon Sep 17 00:00:00 2001 From: chenchen Date: Mon, 25 May 2026 16:03:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 83 +++++++++++++ scripts/create_swarm_token.py | 137 ++++++++++++++++++++++ scripts/deploy_vm.py | 138 ++++++++++++++++++++++ scripts/find_admin_user.py | 33 ++++++ scripts/force_recreate.py | 80 +++++++++++++ scripts/smoke_test.py | 133 +++++++++++++++++++++ scripts/smoke_test_extended.py | 205 +++++++++++++++++++++++++++++++++ scripts/unstick.py | 54 +++++++++ 8 files changed, 863 insertions(+) create mode 100644 AGENTS.md create mode 100644 scripts/create_swarm_token.py create mode 100644 scripts/deploy_vm.py create mode 100644 scripts/find_admin_user.py create mode 100644 scripts/force_recreate.py create mode 100644 scripts/smoke_test.py create mode 100644 scripts/smoke_test_extended.py create mode 100644 scripts/unstick.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a54632ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,83 @@ +# AGENTS.md — Heicode 单仓导航(给 Codex / 助手) + +本文是 **仓库根级** 的快速地图与协作约定。细分栈的规则请看各子目录自带文档(避免重复与漂移)。 + +## 仓库地图(explore 摘要) + +| 路径 | 角色 | 栈 / 备注 | +|------|------|-----------| +| `cc-haha/` | **Heicode** 客户端:CLI(Ink)+ 本地 HTTP/WS 服务 + **Desktop**(Tauri + React) | Bun + TypeScript;产品入口 `bin/heicode` | +| `heicode/` | **Heicode Manager**:网关 + 管理控制台 | Go(Gin/GORM)+ `web/default` 前端(Bun/Rsbuild/React) | +| `website/` | 产品介绍站点 | Next.js;根 `docker-compose.yml` 提供 `heicode-www` :8888 | +| `docs/` | 愿景、**里程碑**(`docs/milestones/`)、**Agnet 集成**(`docs/integration/`) | Markdown | + +根 `package.json` 仅少量 workspace 级依赖(如适配器用到的包);**主要开发依赖在 `cc-haha/package.json`**。 + +## 权威子文档(改代码前先打开对应一篇) + +- **客户端(cc-haha)**:[`cc-haha/AGENTS.md`](./cc-haha/AGENTS.md) — 目录结构、运行命令、测试、提交约定。 +- **网关(heicode)**:[`heicode/AGENTS.md`](./heicode/AGENTS.md) — Go 分层、JSON/i18n/DB 规则等。 +- **产品愿景与交付节奏**:[`docs/vision-heicode-full-stack-agentic-dev.md`](./docs/vision-heicode-full-stack-agentic-dev.md)、[`docs/milestones/README.md`](./docs/milestones/README.md)。 + +## 最小开发闭环 + +```bash +# 依赖(客户端主体) +cd cc-haha && bun install + +# 终端 A:本地 API(桌面端依赖) +bun run src/server/index.ts + +# 终端 B:桌面 +cd desktop && bun run tauri dev +``` + +联调本机 Manager(heicode)时常见: + +```bash +HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts +``` + +更多变量见 `cc-haha/src/server/config/providerPresets.ts` 与 `cc-haha/src/server/api/heicode-auth.ts`(如 `HEICODE__OAUTH_*`)。 + +## Heicode ↔ Manager 关键触摸点(代码索引) + +- **客户端登录 / Provider**:`cc-haha/src/server/api/heicode-auth.ts`,路由前缀 `/api/heicode-auth/*`(由 `cc-haha/src/server/router.ts` 挂载)。 +- **模型发现**:`cc-haha/src/server/services/providerService.ts`、`cc-haha/src/server/api/providers.ts`。 +- **Provider 预设**:`cc-haha/src/server/config/providerPresets.json` + `providerPresets.ts`。 +- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(例如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。 + +## 协作约定(根级) + +1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/AGENTS.md` 为准;客户端 TS/React 以 `cc-haha/AGENTS.md` 为准。 +2. **小步提交**:沿用历史风格(如 `feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。 +3. **集成与「SK」边界**:平台契约见 [`docs/integration/agnet-platform-api-design.md`](./docs/integration/agnet-platform-api-design.md),里程碑见 `docs/milestones/`。 +4. **不要臆测计费**:计费与订阅在平台侧,不在 Heicode 客户端内实现。 + +## Docker / 站点 + +- 根目录 [`docker-compose.yml`](./docker-compose.yml):构建 `website/` 静态站点镜像(端口 **8888**)。 +- Manager 本地编排以 `heicode/docker-compose.yml` 及仓库内 override 为准(若存在)。 +- **VM / 生产同步代码**:优先在目标机上 **`git clone` / `git pull`** 与本仓库远程一致;**不要**用 `scp` 传整份源码或 compose(仅在仓库不可用或紧急热修时例外)。 +- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 **`sudo docker image prune -f`**,清掉重建产生的悬空层;定期可 **`sudo docker system prune -f`**(不删仍在使用的卷)。避免只叠新镜像、旧层占满磁盘。 + +## 当前线上入口(2026-04) + +- **Heicode 官网(Azure Static Web Apps)**:`https://ashy-dune-0e22d7b00.7.azurestaticapps.net` +- **Heicode Manager(生产)**:`https://code.xinghanlab.com/` + +官网中的主按钮(登录/开始使用/CTA)默认应跳转到 `https://code.xinghanlab.com/`,避免出现历史 IP 地址。 + +## Manager 生产拓扑(Azure) + +- 应用层:Azure VM 上运行 `heicode`(当前用 Docker 容器承载应用进程)。 +- 数据库与缓存:使用 Azure 托管服务(不在 VM 上跑 PostgreSQL/Redis 容器) + - PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode` + - Redis: `heicode.redis.cache.windows.net:6380`(TLS) +- 容器落盘:VM 仅承载应用进程;**容器内产生的文件**(如 `/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘,便于备份与重建容器后不丢。编排见 `heicode/docker-compose.azure-vm.yml`,目录由环境变量 `HEICODE_DATA_ROOT` 指定。 +- 说明:不要在生产 VM 上再启本地 `postgres`/`redis` 容器并指向业务库,避免与托管实例混淆。 +- 镜像清理:发版构建后删除悬空/旧镜像层(见上文「VM 部署后清镜像」),防止 OS 盘被 Docker 占满。 + +--- + +*若本文件与子目录 `AGENTS.md` / `AGENTS.md` 冲突,以子目录为准并及时更新根文件摘要。* diff --git a/scripts/create_swarm_token.py b/scripts/create_swarm_token.py new file mode 100644 index 00000000..5b716bd9 --- /dev/null +++ b/scripts/create_swarm_token.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +One-shot: create a dedicated NewAPI sk- token for the downstream +"蜂群程序" (swarm bee program) and print the base URL + key. + +Why direct DB INSERT instead of the /api/token POST endpoint: + - that endpoint requires a logged-in admin session cookie we + don't have a clean way to mint headless; + - the token table shape is stable (new-api fork has tracked + these columns for two years). + +Safety: + - Attaches the token to the platform owner user (whose email + we look up first). + - Marks UnlimitedQuota=true so this token consumes user-row + quota (no separate billing surface to manage). + - HideFromUserUI=false so the operator can revoke it from the + /keys page if needed. + - Sets a descriptive Name + Remark per the user's "备注好" + requirement. +""" +import os +import sys +import secrets +import string + +import paramiko + +HOST = "20.24.50.121" +USER = "heicode" +PASS = os.environ.get("MGR_PASS", "") +if not PASS: + print("MGR_PASS not set", file=sys.stderr) + sys.exit(2) + +DB_PASS = "Myadmin@123456." +DB_HOST = "heicode.postgres.database.azure.com" +DB_USER = "heicode" +DB_NAME = "heicode" + +# new-api token keys are 48-char alphanum (no padding). Match that +# exactly so admin tools / dashboards parse it. +ALPHABET = string.ascii_letters + string.digits +RAW_KEY = "".join(secrets.choice(ALPHABET) for _ in range(48)) +# Token bearer format on the wire is "sk-<48 chars>". + +SQL_FIND = ( + "SELECT id, username, email FROM users " + "WHERE email IN ('zsbgnw@gmail.com','chenchen@xinghanlab.com') " + " OR username IN ('chenchen','root') " + " OR role >= 10 " + "ORDER BY role DESC, id ASC LIMIT 1;" +) + +INSERT_SQL_TEMPLATE = ( + "INSERT INTO tokens " + '(user_id, name, "key", status, created_time, accessed_time, ' + "expired_time, remain_quota, used_quota, unlimited_quota, " + "model_limits_enabled, model_limits, allow_ips, " + '"group", cross_group_retry, hide_from_user_ui) ' + "VALUES " + "({user_id}, '蜂群程序 (Swarm Bot)', '{key}', 1, " + "EXTRACT(EPOCH FROM NOW())::bigint, EXTRACT(EPOCH FROM NOW())::bigint, " + "-1, 0, 0, true, false, '', '', '', false, false) " + "RETURNING id, name, \"key\";" +) + + +def main() -> None: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(HOST, 22, USER, PASS, look_for_keys=False, allow_agent=False, timeout=30) + + def psql(sql: str) -> str: + # Pipe SQL via stdin so quoted identifiers like "group" survive + # without shell-escape hell. + cmd = ( + f"PGPASSWORD='{DB_PASS}' psql " + f"'sslmode=require host={DB_HOST} user={DB_USER} dbname={DB_NAME}' " + f"-At -F'|' -v ON_ERROR_STOP=1" + ) + stdin, stdout, stderr = c.exec_command(cmd, timeout=60, get_pty=False) + stdin.write(sql) + stdin.channel.shutdown_write() + out = stdout.read().decode("utf-8", "replace").strip() + err = stderr.read().decode("utf-8", "replace").strip() + if err: + print(f"[psql stderr] {err}", file=sys.stderr) + return out + + # Step 1: find owner user + print(f"--- Looking up platform owner user ---", flush=True) + owner_row = psql(SQL_FIND) + if not owner_row: + print("ERROR: no owner user found", file=sys.stderr) + sys.exit(3) + fields = owner_row.split("|") + owner_id = int(fields[0]) + owner_username = fields[1] if len(fields) > 1 else "" + owner_email = fields[2] if len(fields) > 2 else "" + print(f"Owner: id={owner_id} username={owner_username} email={owner_email}") + + # Step 2: insert the token row + print(f"\n--- Inserting dedicated 蜂群程序 token ---", flush=True) + insert_sql = INSERT_SQL_TEMPLATE.format(user_id=owner_id, key=RAW_KEY) + inserted = psql(insert_sql) + if not inserted: + print("ERROR: INSERT returned nothing", file=sys.stderr) + sys.exit(4) + fields = inserted.split("|") + token_id = int(fields[0]) + token_name = fields[1] + token_key = fields[2] + print(f"Token row inserted: id={token_id} name={token_name}") + + # Step 3: print result + base_url = "https://code.xinghanlab.com" + print("\n" + "=" * 60) + print("NEWAPI CREDENTIAL — 蜂群程序 (SWARM BOT)") + print("=" * 60) + print(f"Base URL (OpenAI compat): {base_url}/v1") + print(f"Base URL (Anthropic compat): {base_url}/v1/messages") + print(f"Base URL (raw root): {base_url}") + print(f"") + print(f"API Key (Authorization: Bearer ...):") + print(f" sk-{token_key}") + print(f"") + print(f"Token row id: {token_id}") + print(f"Owner user id: {owner_id} ({owner_email})") + print(f"Quota model: unlimited (consumes the owner-account quota)") + print(f"Expires: never") + print(f"Manage / revoke from: {base_url}/keys") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy_vm.py b/scripts/deploy_vm.py new file mode 100644 index 00000000..cf094229 --- /dev/null +++ b/scripts/deploy_vm.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +One-shot deploy script for the Heicode Manager Azure VM. Designed to be +idempotent — running it twice in a row converges to the same state. + +What it does: + 1. SSH into the VM with credentials supplied via env (NEVER hard-coded + in the repo — the launcher passes them on stdin). + 2. cd into the manager checkout, git pull origin main. + 3. docker compose pull + up -d --build, then prune dangling images. + 4. Wait for /api/status to return 200. + 5. Print container status + tail of the manager logs. + +Failure mode: if any step exits non-zero, the whole script aborts and +prints the offending command's stderr — caller (this Claude session) +escalates to the user instead of silently moving on. +""" + +import os +import sys +import time +import paramiko + +HOST = os.environ.get("MGR_HOST", "20.24.50.121") +USER = os.environ.get("MGR_USER", "heicode") +PASS = os.environ.get("MGR_PASS", "") +PORT = int(os.environ.get("MGR_PORT", "22")) + +if not PASS: + print("MGR_PASS not set", file=sys.stderr) + sys.exit(2) + + +def run(client: paramiko.SSHClient, cmd: str, timeout: int = 600) -> int: + """Stream a remote command's output to local stdout/stderr.""" + print(f"\n$ {cmd}", flush=True) + stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout, get_pty=True) + for line in iter(stdout.readline, ""): + if not line: + break + sys.stdout.write(line) + sys.stdout.flush() + rc = stdout.channel.recv_exit_status() + err = stderr.read().decode("utf-8", errors="replace") + if err.strip(): + sys.stderr.write(err) + return rc + + +def must(rc: int, label: str) -> None: + if rc != 0: + print(f"\nFAIL: {label} exit={rc}", file=sys.stderr) + sys.exit(rc or 1) + + +def main() -> None: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + print(f"Connecting {USER}@{HOST}:{PORT} ...", flush=True) + client.connect( + hostname=HOST, + port=PORT, + username=USER, + password=PASS, + look_for_keys=False, + allow_agent=False, + timeout=30, + ) + + # Locate the manager checkout. We try a few likely paths instead of + # hardcoding one — first match wins. + probe = ( + "for p in " + "~/heicode-mananger ~/heicode ~/heicode-mananger/heicode " + "/opt/heicode-mananger /home/heicode/heicode-mananger; do " + " if [ -d $p/.git ]; then echo FOUND=$p; break; fi; " + "done" + ) + stdin, stdout, _ = client.exec_command(probe, timeout=20) + out = stdout.read().decode().strip() + print(out) + repo = None + for line in out.splitlines(): + if line.startswith("FOUND="): + repo = line.split("=", 1)[1] + break + if not repo: + print("Could not locate manager git checkout on VM", file=sys.stderr) + sys.exit(3) + print(f"Using repo: {repo}") + + # Find docker-compose file we'll target. + compose = "docker-compose.azure-vm.yml" + must(run(client, f"test -f {repo}/heicode/{compose} || ls {repo}/heicode/docker-compose*.yml"), + "locate compose file") + + # Pull latest code. + must(run(client, f"cd {repo} && git fetch origin && git checkout main && git pull --ff-only origin main"), + "git pull") + + # Show what we have locally now. + run(client, f"cd {repo} && git log --oneline -3") + + # Rebuild + restart. + must(run(client, + f"cd {repo}/heicode && sudo docker compose -f {compose} up -d --build 2>&1", + timeout=1800), + "compose up") + + # Clean dangling images per CLAUDE.md. + run(client, "sudo docker image prune -f") + + # Wait for /api/status to come back. + print("\nWaiting for /api/status to return 200 (max 90s) ...") + deadline = time.time() + 90 + status_ok = False + while time.time() < deadline: + rc = run(client, + "curl -fsS -o /dev/null -w 'HTTP_%{http_code}\\n' https://code.xinghanlab.com/api/status") + if rc == 0: + status_ok = True + break + time.sleep(5) + if not status_ok: + print("FAIL: /api/status did not return 200 within 90s", file=sys.stderr) + run(client, f"cd {repo}/heicode && sudo docker compose -f {compose} logs --tail=80 heicode") + sys.exit(4) + + print("\n--- Final state ---") + run(client, "sudo docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}'") + run(client, f"cd {repo}/heicode && sudo docker compose -f {compose} logs --tail=20 heicode") + + client.close() + print("\nDEPLOY OK") + + +if __name__ == "__main__": + main() diff --git a/scripts/find_admin_user.py b/scripts/find_admin_user.py new file mode 100644 index 00000000..32a67c6d --- /dev/null +++ b/scripts/find_admin_user.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Look up which user account to attach the swarm-program token to.""" +import os, sys, paramiko + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.24.50.121", 22, "heicode", os.environ["MGR_PASS"], + look_for_keys=False, allow_agent=False, timeout=30) + +sql = ''' +SELECT id, username, email, "group", role, status +FROM users +WHERE email IN ('zsbgnw@gmail.com', 'chenchen@xinghanlab.com') + OR username = 'chenchen' + OR role >= 10 +ORDER BY id +LIMIT 10; +''' +# Wrap via psql exec inside the heicode container. +cmd = ( + 'sudo docker exec heicode sh -c ' + + '"PGPASSWORD=\\"Myadmin@123456.\\" psql ' + + '-h heicode.postgres.database.azure.com -U heicode heicode ' + + "-c \\\"" + sql.replace("\n", " ").replace('"', '\\"\\"') + "\\\"\"" +) +_, o, e = c.exec_command(cmd, timeout=60, get_pty=True) +for line in iter(o.readline, ""): + if not line: break + sys.stdout.write(line.encode("ascii","replace").decode("ascii")) + sys.stdout.flush() +err = e.read().decode("ascii","replace") +if err.strip(): + sys.stderr.write(err) diff --git a/scripts/force_recreate.py b/scripts/force_recreate.py new file mode 100644 index 00000000..117aa468 --- /dev/null +++ b/scripts/force_recreate.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Force-recreate the manager container after a build that didn't swap. + +Why this exists: `docker compose up -d --build` rebuilds the image but +won't recreate the running container if compose decides the spec hasn't +changed. On the Azure VM we've hit cases where the smoke test shows the +old container is still up. This script: + 1. Inspects current container start time + 2. Runs `compose up -d --force-recreate --build` + 3. Confirms new start time +""" + +import os +import sys +import paramiko + +HOST = os.environ.get("MGR_HOST", "20.24.50.121") +USER = os.environ.get("MGR_USER", "heicode") +PASS = os.environ.get("MGR_PASS", "") + +if not PASS: + print("MGR_PASS not set", file=sys.stderr) + sys.exit(2) + + +def run(client, cmd, timeout=900): + print(f"\n$ {cmd}", flush=True) + stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout, get_pty=True) + out_chunks = [] + for raw in iter(stdout.readline, ""): + if not raw: + break + # Strip non-ASCII to dodge Windows cp936 encoding errors. + clean = raw.encode("ascii", "replace").decode("ascii") + out_chunks.append(clean) + sys.stdout.write(clean) + sys.stdout.flush() + rc = stdout.channel.recv_exit_status() + return rc, "".join(out_chunks) + + +def main(): + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect( + hostname=HOST, port=22, username=USER, password=PASS, + look_for_keys=False, allow_agent=False, timeout=30, + ) + + # Locate repo + compose file + rc, repo_out = run(client, + "for p in ~/heicode-mananger ~/heicode ~/heicode-mananger/heicode " + "/opt/heicode-mananger /home/heicode/heicode-mananger; do " + " if [ -d $p/.git ]; then echo FOUND=$p; break; fi; done") + repo = None + for line in repo_out.splitlines(): + if "FOUND=" in line: + repo = line.split("=", 1)[1].strip() + break + if not repo: + print("repo not found", file=sys.stderr); sys.exit(3) + + run(client, "sudo docker ps --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}\\t{{.CreatedAt}}'") + run(client, f"cd {repo} && git log --oneline -1") + + # Pin to latest commit + force recreate. + run(client, + f"cd {repo}/heicode && sudo docker compose -f docker-compose.azure-vm.yml up -d --force-recreate --build heicode 2>&1", + timeout=1800) + + run(client, "sleep 8") + run(client, "sudo docker ps --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}\\t{{.CreatedAt}}'") + run(client, "sudo docker image prune -f") + + client.close() + print("\nFORCE RECREATE DONE") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 00000000..c703c9c5 --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Post-deploy smoke test for Heicode Manager. Runs entirely from this +laptop against https://code.xinghanlab.com — no SSH required. + +Checks: + 1. /api/status returns 200 with success:true (proves the binary + is running and the DB is reachable). + 2. New build hash present (HTML title or static asset includes a + fresh content hash, indirectly confirming the new image is live). + 3. /api/devices returns 401 when called without auth (proves + the route is wired AND not accidentally public). + 4. /api/devices/pair returns 401 without auth (same reasoning). + 5. /v1/messages with a clearly-invalid V2 envelope returns 401 + AND carries X-Heicode-Auth-Error + X-Heicode-Server-Time headers + — this is the V2 diagnostic-header contract we shipped this round. + +Exit code: 0 == all green, 1 == any failure (caller escalates). +""" + +import sys +import time +import urllib.request +import urllib.error +import json + +BASE = "https://code.xinghanlab.com" +SESSION_UA = "heicode-smoke/1.0" + + +def http(method: str, path: str, headers: dict | None = None, body: bytes | None = None): + """Returns (status_code, headers_dict, body_text). Does NOT raise on 4xx/5xx.""" + req = urllib.request.Request(BASE + path, method=method, data=body) + req.add_header("User-Agent", SESSION_UA) + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + resp = urllib.request.urlopen(req, timeout=15) + return resp.status, dict(resp.headers), resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, dict(e.headers), e.read().decode("utf-8", "replace") + + +FAILS: list[str] = [] + + +def check(label: str, ok: bool, detail: str = "") -> None: + mark = "OK " if ok else "FAIL" + print(f"[{mark}] {label}" + (f" -- {detail}" if detail else "")) + if not ok: + FAILS.append(label) + + +print(f"Smoke testing {BASE} ...\n") + +# 1. /api/status reachable + healthy +code, hdrs, body = http("GET", "/api/status") +ok = code == 200 and '"success":true' in body +check("/api/status returns 200 with success:true", ok, + f"HTTP {code}, body starts with {body[:80]!r}") + +# 2. /api/devices without auth → 401 (route exists + protected) +code, hdrs, body = http("GET", "/api/devices/") +ok = code in (401, 403) +check("/api/devices unauthenticated → 401/403", ok, f"HTTP {code}") + +# 3. /api/devices/pair without auth → 401 +code, hdrs, body = http("POST", "/api/devices/pair", + headers={"Content-Type": "application/json"}, + body=b'{"device_id":"x","public_key":"","fingerprint":""}') +ok = code in (401, 403) +check("/api/devices/pair unauthenticated → 401/403", ok, f"HTTP {code}") + +# 4. /v1/messages with bogus V2 envelope → 401 + diagnostic headers. +# We construct a request that: +# - sets Content-Encoding: heicode-aead-v1 (forces V2 dispatcher) +# - sets junk for the 6 X-Heicode-* headers +# - sends a too-short body (12 bytes — fails AEAD nonce check) +# Expected: HTTP 401, X-Heicode-Auth-Error header present, +# X-Heicode-Server-Time header present and parseable as int. +bogus_headers = { + "Content-Encoding": "heicode-aead-v1", + "X-Heicode-Device-Id": "smoke-test-bogus", + "X-Heicode-Timestamp": str(int(time.time() * 1000)), + "X-Heicode-Nonce": "deadbeef" * 4, + "X-Heicode-Fingerprint": "0" * 64, + "X-Heicode-Eph-Pubkey": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE=", + "X-Heicode-Signature": "BBBB", + "Content-Type": "application/json", +} +code, hdrs, body = http("POST", "/v1/messages", + headers=bogus_headers, + body=b"\x00" * 12) + +ok_401 = code == 401 +check("/v1/messages with bogus V2 envelope → 401", ok_401, f"HTTP {code}") + +# Header lookup is case-insensitive in HTTP but our http() dict isn't. +# Build a lowercase view. +hdrs_lc = {k.lower(): v for k, v in hdrs.items()} +diag_code = hdrs_lc.get("x-heicode-auth-error", "") +diag_time = hdrs_lc.get("x-heicode-server-time", "") +check("V2 401 carries X-Heicode-Auth-Error header", bool(diag_code), f"value={diag_code!r}") + +ok_time = False +try: + if diag_time and abs(int(diag_time) - int(time.time() * 1000)) < 60_000: + ok_time = True +except ValueError: + pass +check("V2 401 carries X-Heicode-Server-Time within 60s of local clock", + ok_time, f"value={diag_time!r}") + +# 5. Sanity: status data has live numbers (start_time freshly set if rebuild +# happened just now). +code, hdrs, body = http("GET", "/api/status") +try: + obj = json.loads(body) + st = obj.get("data", {}).get("start_time", 0) + age = int(time.time()) - st + print(f"\nManager start_time = {st} (age {age}s)") + check("Manager start_time looks recent (<30 min)", 0 <= age < 30 * 60, + f"age={age}s") +except Exception as e: + check("Manager status JSON parseable", False, repr(e)) + +if FAILS: + print(f"\n{len(FAILS)} smoke check(s) FAILED:") + for f in FAILS: + print(f" - {f}") + sys.exit(1) + +print("\nALL SMOKE CHECKS PASSED") diff --git a/scripts/smoke_test_extended.py b/scripts/smoke_test_extended.py new file mode 100644 index 00000000..24a54e0d --- /dev/null +++ b/scripts/smoke_test_extended.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +Extended live smoke test against the production Manager. Exercises: + + A. Routing surface + - /api/status → 200 healthy + - /api/devices/ → 401 (auth-protected, route mounted) + - /api/devices/pair → 401 without auth + - /api/devices/12345 PATCH/DELETE → 401 without auth + - Web SPA index → 200 (assets loadable) + - /v1/models with no auth → 401 (legacy v1 path still gated) + + B. V2 diagnostic header contract + Exercises FIVE distinct V2 failure modes and asserts that each + returns: + - HTTP 401 + - X-Heicode-Server-Time present, parseable, within 60s of local + - X-Heicode-Auth-Error = expected machine-readable code + This pins the whole error-code mapping we shipped in fix(devices). + + C. Legacy compat + - V1 sk- request to /v1/models without bearer → 401 + - V1 sk- request with bogus bearer "sk-xxx" → 401 + Confirms the 30-day legacy compat path still runs (V2 dispatcher + isn't accidentally swallowing non-V2 traffic). + +Exit code: 0 == all green, 1 == any failure. +""" + +import sys +import time +import urllib.request +import urllib.error + +BASE = "https://code.xinghanlab.com" +UA = "heicode-smoke-extended/1.0" + +FAILS = [] + +# Honour HTTPS_PROXY env (clash/wireguard/etc) — urllib doesn't pick +# it up automatically on Windows. Needed when the local machine has a +# DNS hijack returning sinkhole IPs for code.xinghanlab.com. +import os +_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") +if _proxy: + proxy_handler = urllib.request.ProxyHandler({"https": _proxy, "http": _proxy}) + _opener = urllib.request.build_opener(proxy_handler) +else: + _opener = urllib.request.build_opener() + + +def http(method, path, headers=None, body=None): + req = urllib.request.Request(BASE + path, method=method, data=body) + req.add_header("User-Agent", UA) + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + r = _opener.open(req, timeout=15) + return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read().decode("utf-8", "replace") + + +def check(label, ok, detail=""): + print(f"[{'OK ' if ok else 'FAIL'}] {label}" + (f" -- {detail}" if detail else "")) + if not ok: + FAILS.append(label) + + +import base64 + +# Exactly 32 bytes (0x41 * 32) — valid X25519 pubkey LENGTH (every +# 32-byte sequence is a valid X25519 point per spec). Using "wrong +# byte count" pubkey strings here would short-circuit the dispatcher +# at eph_pubkey_malformed before we can test the downstream code +# paths, so always start from a 32-byte default. +VALID_LEN_EPH_PUBKEY = base64.standard_b64encode(b"\x41" * 32).decode() + + +def v2_headers(eph_pubkey_b64=VALID_LEN_EPH_PUBKEY, + timestamp_ms=None, nonce="dead" * 8): + return { + "Content-Encoding": "heicode-aead-v1", + "X-Heicode-Device-Id": "smoke-bogus", + "X-Heicode-Timestamp": str(timestamp_ms or int(time.time() * 1000)), + "X-Heicode-Nonce": nonce, + "X-Heicode-Fingerprint": "0" * 64, + "X-Heicode-Eph-Pubkey": eph_pubkey_b64, + "X-Heicode-Signature": "BBBBBBBB", + "Content-Type": "application/json", + } + + +def assert_v2_failure(label, headers, body, expected_code): + code, hdrs, _ = http("POST", "/v1/messages", headers=headers, body=body) + diag = hdrs.get("x-heicode-auth-error", "") + ts = hdrs.get("x-heicode-server-time", "") + ts_ok = False + try: + ts_ok = ts and abs(int(ts) - int(time.time() * 1000)) < 60_000 + except ValueError: + pass + ok = code == 401 and diag == expected_code and ts_ok + check(label, ok, f"HTTP {code} code={diag!r} time={ts!r}") + + +# ---------------------------------------------------------------- A. Routing +print(f"\n=== A. Routing surface ({BASE}) ===\n") + +code, _, body = http("GET", "/api/status") +check("/api/status returns 200 + success:true", + code == 200 and '"success":true' in body, f"HTTP {code}") + +code, _, _ = http("GET", "/api/devices/") +check("/api/devices/ unauthenticated → 401", code == 401, f"HTTP {code}") + +code, _, _ = http("POST", "/api/devices/pair", + headers={"Content-Type": "application/json"}, + body=b'{"device_id":"x","public_key":"","fingerprint":""}') +check("/api/devices/pair unauthenticated → 401", code == 401, f"HTTP {code}") + +code, _, _ = http("PATCH", "/api/devices/99999", + headers={"Content-Type": "application/json"}, + body=b'{"device_name":"hax"}') +check("/api/devices/:id PATCH unauthenticated → 401", code == 401, f"HTTP {code}") + +code, _, _ = http("DELETE", "/api/devices/99999") +check("/api/devices/:id DELETE unauthenticated → 401", code == 401, f"HTTP {code}") + +code, _, body = http("GET", "/") +check("Web SPA index → 200", code == 200 and "/dev/null") + +# Show all containers (running + stopped) related to heicode +run(client, "sudo docker ps -a --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}'") + +# Remove any container that is exactly 'heicode' (the conflicting old one) +# AND any *_heicode temp. +run(client, "sudo docker rm -f heicode 2>/dev/null || true") +run(client, "sudo docker ps -aq --filter name=_heicode | xargs -r sudo docker rm -f") + +run(client, "sudo docker ps -a --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}'") + +# Re-up +repo = "/home/heicode/heicode-mananger" +# Try both common paths +run(client, f"test -d {repo} || ls -d ~/heicode-mananger 2>/dev/null") +run(client, f"cd ~/heicode-mananger/heicode && sudo docker compose -f docker-compose.azure-vm.yml up -d heicode 2>&1", timeout=600) + +time.sleep(8) +run(client, "sudo docker ps --filter name=heicode --format 'table {{.Names}}\\t{{.Status}}\\t{{.CreatedAt}}'") +run(client, "sudo docker logs --tail=20 heicode") + +client.close() +print("\nUNSTICK DONE")