This commit is contained in:
2026-05-25 16:03:00 +08:00
parent da81c57db2
commit 23ec354eb5
8 changed files with 863 additions and 0 deletions
+83
View File
@@ -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_<PROVIDER>_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` 冲突,以子目录为准并及时更新根文件摘要。*
+137
View File
@@ -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()
+138
View File
@@ -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()
+33
View File
@@ -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)
+80
View File
@@ -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()
+133
View File
@@ -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")
+205
View File
@@ -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 "<html" in body.lower(),
f"HTTP {code} len={len(body)}")
code, _, _ = http("GET", "/v1/models")
check("/v1/models without auth → 401", code == 401, f"HTTP {code}")
# ---------------------------------------------------------- B. V2 diag codes
print("\n=== B. V2 diagnostic header contract ===\n")
# B1: malformed ephemeral pubkey (not base64 → eph_pubkey_malformed)
assert_v2_failure(
"V2 malformed eph_pubkey → eph_pubkey_malformed",
{**v2_headers(eph_pubkey_b64="not_base64_!!!"), },
b"\x00" * 12,
"eph_pubkey_malformed",
)
# B2: pubkey base64-decodes but wrong length (not 32 bytes) → eph_pubkey_malformed
assert_v2_failure(
"V2 wrong-length eph_pubkey → eph_pubkey_malformed",
{**v2_headers(eph_pubkey_b64="QUE=")}, # decodes to 2 bytes
b"\x00" * 12,
"eph_pubkey_malformed",
)
# B3: body too short (<28 bytes for nonce+tag) → body_too_short
assert_v2_failure(
"V2 body too short → body_too_short",
v2_headers(),
b"\x00" * 5, # too short
"body_too_short",
)
# B4: missing X-Heicode-Eph-Pubkey header entirely → eph_pubkey_missing
hdrs_no_eph = v2_headers()
hdrs_no_eph.pop("X-Heicode-Eph-Pubkey")
assert_v2_failure(
"V2 missing eph_pubkey header → eph_pubkey_missing",
hdrs_no_eph,
b"\x00" * 12,
"eph_pubkey_missing",
)
# B5: valid-shape but unknown device + decoy ciphertext → after decrypt
# gets `body_decrypt_failed` (AEAD tag mismatch). This is the most
# common real-world failure mode for a tampered request.
assert_v2_failure(
"V2 valid-shape but undecryptable → body_decrypt_failed",
v2_headers(), # 32-byte AAAAAA... pubkey is valid X25519
b"\xAA" * 28, # 12 nonce + 16 tag, but ciphertext won't authenticate
"body_decrypt_failed",
)
# ---------------------------------------------------------- C. Legacy compat
print("\n=== C. Legacy sk- bearer compat path ===\n")
# No Content-Encoding header → legacy dispatcher → ValidateUserToken
code, hdrs, _ = http("POST", "/v1/messages",
headers={"Content-Type": "application/json",
"Authorization": "Bearer sk-bogus-token-not-real"},
body=b'{"model":"claude-haiku","messages":[]}')
# V2 diagnostic headers MUST NOT be present on legacy path responses.
diag = hdrs.get("x-heicode-auth-error", "")
check("Legacy sk- request → 401 (no Content-Encoding)", code == 401, f"HTTP {code}")
check("Legacy path does NOT leak V2 diagnostic headers", diag == "",
f"x-heicode-auth-error={diag!r}")
# ---------------------------------------------------------- summary
if FAILS:
print(f"\n{len(FAILS)} smoke check(s) FAILED:")
for f in FAILS:
print(f" - {f}")
sys.exit(1)
print("\nALL EXTENDED SMOKE CHECKS PASSED")
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Unstick a manager deploy where the container name collides with a
half-removed sibling. Removes everything named heicode* (except the
openbao auxiliary), then `compose up -d` to recreate cleanly.
"""
import os, sys, paramiko, time
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)
_, stdout, _ = client.exec_command(cmd, timeout=timeout, get_pty=True)
for raw in iter(stdout.readline, ""):
if not raw: break
sys.stdout.write(raw.encode("ascii","replace").decode("ascii"))
sys.stdout.flush()
return stdout.channel.recv_exit_status()
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)
# Find repo
run(client, "ls -d ~/heicode-mananger ~/heicode 2>/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")