From 160a6b94d8c6c6c4a7622485baecc9b8f2e18b95 Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:25:39 +0800 Subject: [PATCH 01/59] =?UTF-8?q?docs(mcp-tools):=20=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E8=A1=8C=E5=8F=B7=E5=BC=95=E7=94=A8=E4=B9=9F=E6=94=B9=E9=92=89?= =?UTF-8?q?=E7=AC=A6=E5=8F=B7=20=E2=80=94=E2=80=94=20=E4=B8=A4=E7=89=88?= =?UTF-8?q?=E8=A1=8C=E5=8F=B7=20pin=20=E5=BD=92=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #831 正解第二批,接 #844。这批是正文里 `tools.ts:168-188` 那种引用:每一处都 声称一段具体行为,不能像章节链接那样机械替换,要逐条读它声称什么、再定锚哪个符号。 中英两版各 27 处(25 处单标签 + 2 处复合标签 `tools.ts:129 INSERT + tools.ts:141 ON CONFLICT`),全部换成「文件链接 + 可 grep 的唯一串」。改完之后 mcp-tools.md 两版的行号 pin 归零。 全站:pin 141 → 53 处,唯一 70 → 27,机械可证失效 32 → 14。 🔴 途中我自己制造并抓回一个错,值得写下来: 给「只能 reassign 非终态任务」那条选锚串时,我用了 `status IN ('created', 'delivered', 'acked', 'running')`。它在仓里有 2 处命中 —— 但那 2 处分别属于 send_message 和 cancel_task,**没有一处在 reassign_task 里**。 也就是说我把一条 reassign 的说明锚到了别的 tool 上,而且"锚串存在"这个检查 会放行它,因为串确实存在,只是不在该在的地方。 是在核对"多命中锚串各自属于哪个 tool"时发现的。reassign 的真实检查在 `["replied", "failed", "cancelled", "expired"].includes(task.status)`(全仓唯一), 已改正。 由此加的一条做法:锚串命中 >1 时,把它属于哪个 tool 段写进提示,例如 「在 `cancel_task` 段搜 …(全仓 2 处,另一处属 `send_message`)」。光写"全仓 N 处" 不够 —— 读者仍然不知道该看哪一处。 自查:两版共 54 个新链接,逐个确认其后至少有一个锚串确实出现在它指向的源文件里, 0 例外。(第一版自查的正则排除了反引号,漏掉了带「在 `x` 段搜」的 3 条; 换成更宽的抓法后重跑,54/54 覆盖。) 连带: - 基线 23 → 14,9 条是门自己要求删的 - tests/test831 的 L0 预期 53/107 → 27/53 验证: unique_pins=27 pin_occurrences=53 broken_pins=14 baseline_entries=14 OK walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处) MUTATION_RED new-out-of-range-pin rc=1 MUTATION_RED stale-baseline-entry rc=1 OK 3 条已知盲区仍未被判据覆盖 RESULT: PASS 退出码 0 剩下的 14 条失效 pin 全部在 rest.md / changelog.md,不在本 PR 范围。 --- docs-site/docs/api/mcp-tools.md | 46 ++++++++++++++-------------- docs-site/docs/en/api/mcp-tools.md | 46 ++++++++++++++-------------- docs/doc-source-pins-baseline.txt | 9 ------ tests/test831-doc-source-pins/run.sh | 8 ++--- 4 files changed, 50 insertions(+), 59 deletions(-) diff --git a/docs-site/docs/api/mcp-tools.md b/docs-site/docs/api/mcp-tools.md index d8a461ac1..342774c02 100644 --- a/docs-site/docs/api/mcp-tools.md +++ b/docs-site/docs/api/mcp-tools.md @@ -43,7 +43,7 @@ CommHub Server 共注册约 40 个 MCP Tools;本页文档化其中 agent 日 | `project_dir` | string | | 工作目录 | | `version` | string | | Agent 版本 | | `tmux_name` | string | | tmux session 名 | -| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts:168-188`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L168))。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts:129` INSERT + `tools.ts:141` ON CONFLICT](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129));只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | +| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `INSERT INTO nodes`)。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `INSERT INTO sessions` 与 `model = COALESCE(?20, sessions.model)`);只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | | `session_id` | string | | 运行时 session/thread ID | | `config_path` | string | | 配置文件路径 | | `channels` | string | | Channel 列表(JSON 数组字符串) | @@ -77,11 +77,11 @@ report_status({ ``` ::: warning 认证要求 -该 tool 只接受 **`ntok_`(network-scoped)token**。用 `utok_`(user-scoped)调用会返回 `{ok: false, error: "network_token_required"}`([`tools.ts:116-118`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L116))。这是 v0.8 RFC-001 之后的硬约束 — agent 心跳必须绑定 network。 +该 tool 只接受 **`ntok_`(network-scoped)token**。用 `utok_`(user-scoped)调用会返回 `{ok: false, error: "network_token_required"}`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"network_token_required"`(全仓 3 处))。这是 v0.8 RFC-001 之后的硬约束 — agent 心跳必须绑定 network。 副作用:除了写 `sessions` 表,还会: -- 自动**删除同 network、同 alias、不同 resume_id** 的旧 session row([`tools.ts:127`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L127);用于 agent 重启时清理孤儿) -- 当 `status="working"` 且有 `task` 时,触发 `tasks` 表 `delivered/acked → running` 状态切换([`tools.ts:150-153`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L150);详见 [Task 生命周期](/concepts/task-lifecycle#状态机)) +- 自动**删除同 network、同 alias、不同 resume_id** 的旧 session row([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2`;用于 agent 重启时清理孤儿) +- 当 `status="working"` 且有 `task` 时,触发 `tasks` 表 `delivered/acked → running` 状态切换([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET status = 'running'`;详见 [Task 生命周期](/concepts/task-lifecycle#状态机)) - 当 `node_id` 传入时 upsert `nodes` 表(含 `model` / `node_name` / `runtime`,详见 `node_id` 参数行) ::: @@ -128,11 +128,11 @@ report_completion({ ``` ::: tip 副作用(除了 completions 表 INSERT) -- **session 状态切换**:[`tools.ts:239-242`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L239) `UPDATE sessions SET status='idle', task=NULL, progress=0` (按 alias) -- **任务状态切换**:[`tools.ts:244-266`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L244) 把 `tasks` 行从 `delivered`/`acked`/`running` 切到 `replied`。先按 `task_id = ` 匹配;不命中再 fallback 用 `to_name= AND content=` 找最近一条 — 所以 `task` 参数实际可填**真实 task_id**(推荐)或**任务描述字符串**(fallback) -- **`result` 截断**:写 `tasks.result` 时只取前 4000 字符([`tools.ts:246`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L246)),但完整 `result` 会进 `completions.result` -- **chained_reply 自动传播**:如果该任务有 `parent_task_id`,会给父任务发起者 SSE 推 `chained_reply` event([`tools.ts:271-291`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L271);用于子任务回 → 链式通知父任务发起者,详见 [`task-lifecycle` 双写机制](/concepts/task-lifecycle#双写机制)) -- **`task_events` log**:记录一条 `replied` event([`tools.ts:270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L270)) +- **session 状态切换**:[`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE sessions SET status = 'idle'` `UPDATE sessions SET status='idle', task=NULL, progress=0` (按 alias) +- **任务状态切换**:[`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET status = 'replied'`(全仓 2 处) 把 `tasks` 行从 `delivered`/`acked`/`running` 切到 `replied`。先按 `task_id = ` 匹配;不命中再 fallback 用 `to_name= AND content=` 找最近一条 — 所以 `task` 参数实际可填**真实 task_id**(推荐)或**任务描述字符串**(fallback) +- **`result` 截断**:写 `tasks.result` 时只取前 4000 字符([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `result.slice(0, 4000)`(全仓 2 处)),但完整 `result` 会进 `completions.result` +- **chained_reply 自动传播**:如果该任务有 `parent_task_id`,会给父任务发起者 SSE 推 `chained_reply` event([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `type: "chained_reply"`(全仓 2 处);用于子任务回 → 链式通知父任务发起者,详见 [`task-lifecycle` 双写机制](/concepts/task-lifecycle#双写机制)) +- **`task_events` log**:记录一条 `replied` event([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `logTaskEvent(updatedTaskId, null, "replied"`) 跟 [`send_reply`](#send-reply) 比较:`send_reply` 是 hub 工具,需要显式 `task_id` 参数;`report_completion` 是 agent 工具,可 fallback by content。 ::: @@ -191,7 +191,7 @@ report_completion({ |------|------|:----:|------| | `alias` | string | ✓ | Session 别名 | | `message_id` | string | ✓ | inbox 投递行 `id`,或任务消息的逻辑 `task_id`。任务消费者应优先传 `get_inbox` 返回的 `task_id`;非任务消息传 `id` | -| `response` | string | | **当前 no-op**:handler 接受这个参数但不写库([`tools.ts:872-924`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L872) 没有读取 `response`)。schema 保留是为了 forward-compat / 不破坏现有调用方;想真正回复用 [`send_reply`](#send-reply) | +| `response` | string | | **当前 no-op**:handler 接受这个参数但不写库([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"` 没有读取 `response`)。schema 保留是为了 forward-compat / 不破坏现有调用方;想真正回复用 [`send_reply`](#send-reply) | | `network_id` | string | | Network 范围。utok_ 恰好 1 个成员网络时自动解析,可省略;跨多网络必须显式传(#517) | **返回值**: @@ -203,7 +203,7 @@ report_completion({ **错误**:找不到属于该 alias 的待确认投递 → `message not found or already acknowledged`;投递在查询后不再可写 → `message not found or not yours`。 ::: tip 副作用:tasks 表状态机 -Hub 先用 `id = message_id`,或对任务消息用 `task_id = message_id`,解析出当前未确认的 inbox 行并只 ACK 那一行。若它是任务消息,再用该行解析出的稳定逻辑 `task_id` 把 `tasks` 从 `status='delivered'` UPDATE 到 `'acked'`([`tools.ts:884-920`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L884))。因此 retry/reassign 产生新 inbox `id` 后,仍能 ACK 原任务;旧消费者继续传 inbox `id` 也兼容。任务状态**仅**从 `delivered` 起跳,跟 hub 端 [`send_ack`](#send-ack)(接受 `created` / `delivered`)不同 —— 详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机)。 +Hub 先用 `id = message_id`,或对任务消息用 `task_id = message_id`,解析出当前未确认的 inbox 行并只 ACK 那一行。若它是任务消息,再用该行解析出的稳定逻辑 `task_id` 把 `tasks` 从 `status='delivered'` UPDATE 到 `'acked'`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2`)。因此 retry/reassign 产生新 inbox `id` 后,仍能 ACK 原任务;旧消费者继续传 inbox `id` 也兼容。任务状态**仅**从 `delivered` 起跳,跟 hub 端 [`send_ack`](#send-ack)(接受 `created` / `delivered`)不同 —— 详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机)。 ::: --- @@ -366,8 +366,8 @@ send_task({ ``` ::: warning 限制 -- 只能重试状态为 `failed` / `expired` / `cancelled` 的任务(verify [`tools.ts:713`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L713)),其他状态返回 `{ok: false, error: "task status is , not retryable"}` -- 重试会**固定**给一个新的 `+1 小时` TTL([`tools.ts:718`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L718) 硬编码),**不沿用原任务**的 `ttl_seconds` +- 只能重试状态为 `failed` / `expired` / `cancelled` 的任务(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `["failed", "expired", "cancelled"].includes(task.status)`),其他状态返回 `{ok: false, error: "task status is , not retryable"}` +- 重试会**固定**给一个新的 `+1 小时` TTL([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `+1 hour` 硬编码),**不沿用原任务**的 `ttl_seconds` - `task_id` 不变;inbox 里会插入一条新 `id` 的 row(新 UUID),并 SSE 推 `new_task` 给目标 alias ::: @@ -399,7 +399,7 @@ send_task({ ``` ::: warning 限制 -只能取消状态为 `created` / `delivered` / `acked` / `running` 的任务(4 个 cancellable 源状态,verify [`tools.ts:817`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L817) WHERE 子句)。终态 `replied` / `failed` / `cancelled` / `expired` 上调用此 tool 会返回 `{ok: false, cancelled: false}`。 +只能取消状态为 `created` / `delivered` / `acked` / `running` 的任务(4 个 cancellable 源状态,verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `cancel_task` 段搜 `status IN ('created', 'delivered', 'acked', 'running')`(全仓 2 处,另一处属 `send_message`) WHERE 子句)。终态 `replied` / `failed` / `cancelled` / `expired` 上调用此 tool 会返回 `{ok: false, cancelled: false}`。 `created` 实际只是 DB 默认值,正常 API 路径不会观察到(详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机))。 ::: @@ -433,9 +433,9 @@ send_task({ ``` ::: warning 限制 -- 只能 reassign **非终态**任务:`created` / `delivered` / `acked` / `running`([`tools.ts:853`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L853) 反向拒掉 `replied` / `failed` / `cancelled` / `expired`,返回 `{ok: false, error: "task is terminal ()"}`) -- 旧 alias 的 inbox row 被 `acked=1`([`tools.ts:858`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L858)),原 agent 不会再 pick up -- 任务 status reset 到 `delivered`,`started_at` 清空,`delivered_at` 刷新到当前 time([`tools.ts:863`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L863))—— 正在 `running` 的任务会被中断 +- 只能 reassign **非终态**任务:`created` / `delivered` / `acked` / `running`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `["replied", "failed", "cancelled", "expired"].includes(task.status)`(全仓唯一) 反向拒掉 `replied` / `failed` / `cancelled` / `expired`,返回 `{ok: false, error: "task is terminal ()"}`) +- 旧 alias 的 inbox row 被 `acked=1`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `reassign_task` 段搜 `UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1`(全仓 2 处,另一处属 `cancel_task`)),原 agent 不会再 pick up +- 任务 status reset 到 `delivered`,`started_at` 清空,`delivered_at` 刷新到当前 time([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET to_name = ?1`)—— 正在 `running` 的任务会被中断 - TTL(`expires_at`)**不改**(跟 [`retry_task`](#retry-task) 的「固定 +1h」不同);用原任务剩余时间 - 新 alias 拿到新 UUID 的 inbox row + `new_task` SSE 事件 ::: @@ -479,7 +479,7 @@ send_task({ } ``` -`get_task` 走 `SELECT * FROM tasks`([`tools.ts:749`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L749)),返回**完整行**(上面只是示例字段,实际还含 `requires_response` / `parent_task_id` 等所有列)。任务不存在时返回 `{ok: false, error: "task not found"}`。 +`get_task` 走 `SELECT * FROM tasks`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `get_task` 段搜 `SELECT * FROM tasks WHERE task_id = ?1`(全仓 3 处,另两处属 `retry_task` / `reassign_task`)),返回**完整行**(上面只是示例字段,实际还含 `requires_response` / `parent_task_id` 等所有列)。任务不存在时返回 `{ok: false, error: "task not found"}`。 --- @@ -574,7 +574,7 @@ send_task({ ``` ::: warning `sessions` 行**没有** `model` 字段 -`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts:388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L388),无 JOIN)。`sessions` 表 schema([`db.ts:7-26`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L7) + V2 migration [`db.ts:59-68`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L59))**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts:129`/`141`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129))。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 +`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE 1=1`,无 JOIN)。`sessions` 表 schema([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `ALTER TABLE sessions ADD COLUMN`)**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `INSERT INTO sessions` 与 `model = COALESCE(?20, sessions.model)`)。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 ::: --- @@ -619,8 +619,8 @@ send_task({ ``` ::: tip 返回值形状 -- `session` 走 `SELECT * FROM sessions`([`tools.ts:423`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L423)),完整 sessions 行(同 [`get_all_status`](#get-all-status) 的 session 行,**含 `model` 列** —— 见 get_all_status 说明);alias 不存在时 `session` 为 `null` 但 `ok` 仍为 `true` -- `recent_completions` 走 `SELECT * FROM completions ... LIMIT 5`([`tools.ts:433-435`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L433)),完整 9 列 completion 行(`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`),按 `completed_at` 倒序最多 5 条 +- `session` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE alias = ?1`),完整 sessions 行(同 [`get_all_status`](#get-all-status) 的 session 行,**含 `model` 列** —— 见 get_all_status 说明);alias 不存在时 `session` 为 `null` 但 `ok` 仍为 `true` +- `recent_completions` 走 `SELECT * FROM completions ... LIMIT 5`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM completions WHERE session_name = ?1`),完整 9 列 completion 行(`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`),按 `completed_at` 倒序最多 5 条 ::: --- @@ -661,7 +661,7 @@ send_task({ } ``` -`completions` 走 `SELECT * FROM completions WHERE completed_at >= `([`tools.ts:938`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L938)),完整 9 列行,按 `completed_at` 倒序。`artifacts` 是 JSON 数组**字符串**(不是已解析的数组 —— `report_completion` 入库时 `JSON.stringify` 过)。`since` 不传默认 cutoff = 24 小时前。 +`completions` 走 `SELECT * FROM completions WHERE completed_at >= `([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM completions WHERE completed_at >= ?1`),完整 9 列行,按 `completed_at` 倒序。`artifacts` 是 JSON 数组**字符串**(不是已解析的数组 —— `report_completion` 入库时 `JSON.stringify` 过)。`since` 不传默认 cutoff = 24 小时前。 --- @@ -673,7 +673,7 @@ send_task({ 向所有在线 Agent 广播消息。**broadcast 与 `task` 同样会触发收件方 AI 处理**([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) 只对 `task` 和 `broadcast` 类型 think;其余 `reply` / `message` / `ack` 只展示);如果只是想群发通知不要求 AI 回复,用循环 `send_message` 替代。完整消息类型对照见 [Task 生命周期 — 消息类型](/concepts/task-lifecycle#消息类型)。 -**参数**(verify [`server/src/tools.ts:880-885`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L880)): +**参数**(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"`): | 参数 | 类型 | 必需 | 说明 | |------|------|:----:|------| diff --git a/docs-site/docs/en/api/mcp-tools.md b/docs-site/docs/en/api/mcp-tools.md index 9e70b56fb..b1afaf30d 100644 --- a/docs-site/docs/en/api/mcp-tools.md +++ b/docs-site/docs/en/api/mcp-tools.md @@ -43,7 +43,7 @@ Report agent status. Also serves as a heartbeat (recommended every 3 minutes). | `project_dir` | string | | Working directory | | `version` | string | | Agent version | | `tmux_name` | string | | tmux session name | -| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts:168-188`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L168)). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts:129` INSERT + `tools.ts:141` ON CONFLICT](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129)); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | +| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO nodes`). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` and `model = COALESCE(?20, sessions.model)`); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | | `session_id` | string | | Runtime session/thread ID | | `config_path` | string | | Config file path | | `channels` | string | | Channel list (JSON array string) | @@ -77,11 +77,11 @@ report_status({ ``` ::: warning Authentication required -This tool only accepts a **`ntok_` (network-scoped) token**. Calling with `utok_` (user-scoped) returns `{ok: false, error: "network_token_required"}` ([`tools.ts:116-118`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L116)). This is a hard constraint after RFC-001 in v0.8 — agent heartbeats must be bound to a network. +This tool only accepts a **`ntok_` (network-scoped) token**. Calling with `utok_` (user-scoped) returns `{ok: false, error: "network_token_required"}` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"network_token_required"` (3 sites)). This is a hard constraint after RFC-001 in v0.8 — agent heartbeats must be bound to a network. Side effects beyond the `sessions` table: -- Automatically DELETEs any older session row with the same network + alias + a different `resume_id` ([`tools.ts:127`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L127); cleans up orphans across agent restarts) -- When `status="working"` with a `task`, transitions the corresponding `tasks` row from `delivered`/`acked` to `running` ([`tools.ts:150-153`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L150); see [Task lifecycle](/en/concepts/task-lifecycle#state-machine)) +- Automatically DELETEs any older session row with the same network + alias + a different `resume_id` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2`; cleans up orphans across agent restarts) +- When `status="working"` with a `task`, transitions the corresponding `tasks` row from `delivered`/`acked` to `running` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET status = 'running'`; see [Task lifecycle](/en/concepts/task-lifecycle#state-machine)) - When `node_id` is passed, upserts the `nodes` table (including `model` / `node_name` / `runtime`; see the `node_id` row above) ::: @@ -128,11 +128,11 @@ report_completion({ ``` ::: tip Side effects (beyond the `completions` INSERT) -- **Session state flip**: [`tools.ts:239-242`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L239) `UPDATE sessions SET status='idle', task=NULL, progress=0` (matched by alias) -- **Task state transition**: [`tools.ts:244-266`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L244) moves the `tasks` row from `delivered`/`acked`/`running` to `replied`. First tries `task_id = `; on miss it falls back to `to_name= AND content=` — so the `task` parameter can be either the **real task_id** (preferred) or the **task description string** (fallback) -- **`result` truncation**: only the first 4000 chars are written to `tasks.result` ([`tools.ts:246`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L246)); the full `result` still lands in `completions.result` -- **chained_reply auto-propagation**: if the task has a `parent_task_id`, the parent's originator gets a `chained_reply` SSE event ([`tools.ts:271-291`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L271); used so subtask replies bubble up to the parent — see [`task-lifecycle` dual-write](/en/concepts/task-lifecycle#dual-write-mechanism)) -- **`task_events` log**: a `replied` event is logged ([`tools.ts:270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L270)) +- **Session state flip**: [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE sessions SET status = 'idle'` `UPDATE sessions SET status='idle', task=NULL, progress=0` (matched by alias) +- **Task state transition**: [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET status = 'replied'` (2 sites) moves the `tasks` row from `delivered`/`acked`/`running` to `replied`. First tries `task_id = `; on miss it falls back to `to_name= AND content=` — so the `task` parameter can be either the **real task_id** (preferred) or the **task description string** (fallback) +- **`result` truncation**: only the first 4000 chars are written to `tasks.result` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `result.slice(0, 4000)` (2 sites)); the full `result` still lands in `completions.result` +- **chained_reply auto-propagation**: if the task has a `parent_task_id`, the parent's originator gets a `chained_reply` SSE event ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `type: "chained_reply"` (2 sites); used so subtask replies bubble up to the parent — see [`task-lifecycle` dual-write](/en/concepts/task-lifecycle#dual-write-mechanism)) +- **`task_events` log**: a `replied` event is logged ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `logTaskEvent(updatedTaskId, null, "replied"`) Compared to [`send_reply`](#send-reply): `send_reply` is a hub tool that requires an explicit `task_id`; `report_completion` is an agent tool that can fall back by content match. ::: @@ -191,7 +191,7 @@ Acknowledge message receipt. After ACK, the message won't be returned by `get_in |------|------|:----:|------| | `alias` | string | ✓ | Session alias | | `message_id` | string | ✓ | The inbox delivery-row `id`, or a task message's logical `task_id`. Task consumers should prefer the `task_id` returned by `get_inbox`; use `id` for non-task messages. | -| `response` | string | | **Currently a no-op**: the handler accepts this parameter but never writes it to the database ([`tools.ts:872-924`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L872) does not read `response`). The schema is kept for forward-compat / to avoid breaking existing callers; if you want to actually reply, use [`send_reply`](#send-reply). | +| `response` | string | | **Currently a no-op**: the handler accepts this parameter but never writes it to the database ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"ack_inbox"` does not read `response`). The schema is kept for forward-compat / to avoid breaking existing callers; if you want to actually reply, use [`send_reply`](#send-reply). | | `network_id` | string | | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) | **Response**: @@ -203,7 +203,7 @@ Acknowledge message receipt. After ACK, the message won't be returned by `get_in **Errors**: no pending delivery owned by the alias → `message not found or already acknowledged`; the resolved delivery becomes unwritable after lookup → `message not found or not yours`. ::: tip Side effect: tasks-table state machine -The Hub first resolves the current unacknowledged inbox row by `id = message_id`, or for a task message by `task_id = message_id`, and ACKs only that row. For task messages it then uses the row's resolved stable logical `task_id` to UPDATE the matching `tasks` row from `status='delivered'` to `'acked'` ([`tools.ts:884-920`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L884)). Retry/reassign deliveries can therefore have a new inbox `id` while still ACKing the original task; legacy callers that pass an inbox `id` remain compatible. The task transition **only** accepts `delivered`, unlike the hub-side [`send_ack`](#send-ack), which also accepts `created` — see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine). +The Hub first resolves the current unacknowledged inbox row by `id = message_id`, or for a task message by `task_id = message_id`, and ACKs only that row. For task messages it then uses the row's resolved stable logical `task_id` to UPDATE the matching `tasks` row from `status='delivered'` to `'acked'` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2`). Retry/reassign deliveries can therefore have a new inbox `id` while still ACKing the original task; legacy callers that pass an inbox `id` remain compatible. The task transition **only** accepts `delivered`, unlike the hub-side [`send_ack`](#send-ack), which also accepts `created` — see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine). ::: --- @@ -366,8 +366,8 @@ Retry a failed/cancelled/expired task. ``` ::: warning Limitation -- Can only retry tasks with status `failed` / `expired` / `cancelled` (verify [`tools.ts:713`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L713)); other statuses return `{ok: false, error: "task status is , not retryable"}` -- Retry gives the task a **fresh `+1 hour` TTL** (hardcoded at [`tools.ts:718`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L718)) — the original task's `ttl_seconds` is **not preserved** +- Can only retry tasks with status `failed` / `expired` / `cancelled` (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `["failed", "expired", "cancelled"].includes(task.status)`); other statuses return `{ok: false, error: "task status is , not retryable"}` +- Retry gives the task a **fresh `+1 hour` TTL** (hardcoded at [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `+1 hour`) — the original task's `ttl_seconds` is **not preserved** - `task_id` is reused; a new inbox row (new UUID) is inserted and a `new_task` SSE event is pushed to the target alias ::: @@ -399,7 +399,7 @@ Cancel a pending task. ``` ::: warning Constraint -Only cancellable from these 4 source statuses: `created` / `delivered` / `acked` / `running` (verify the WHERE clause at [`tools.ts:817`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L817)). Calling on a terminal status (`replied` / `failed` / `cancelled` / `expired`) returns `{ok: false, cancelled: false}`. +Only cancellable from these 4 source statuses: `created` / `delivered` / `acked` / `running` (verify the WHERE clause at [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `status IN ('created', 'delivered', 'acked', 'running')` inside `cancel_task` (2 sites; the other is `send_message`)). Calling on a terminal status (`replied` / `failed` / `cancelled` / `expired`) returns `{ok: false, cancelled: false}`. `created` is only the DB column default; the normal API path never produces a row in that state (see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine)). ::: @@ -433,9 +433,9 @@ Reassign a task to another agent. ``` ::: warning Constraint -- Reassign works only on **non-terminal** tasks: `created` / `delivered` / `acked` / `running` ([`tools.ts:853`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L853) rejects `replied` / `failed` / `cancelled` / `expired` with `{ok: false, error: "task is terminal ()"}`) -- The old alias's inbox row is `acked=1` ([`tools.ts:858`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L858)) so the original agent will not pick it up -- Task status resets to `delivered`, `started_at` clears, `delivered_at` refreshes to now ([`tools.ts:863`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L863)) — a `running` task is interrupted +- Reassign works only on **non-terminal** tasks: `created` / `delivered` / `acked` / `running` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `["replied", "failed", "cancelled", "expired"].includes(task.status)` (unique) rejects `replied` / `failed` / `cancelled` / `expired` with `{ok: false, error: "task is terminal ()"}`) +- The old alias's inbox row is `acked=1` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1` inside `reassign_task` (2 sites; the other is `cancel_task`)) so the original agent will not pick it up +- Task status resets to `delivered`, `started_at` clears, `delivered_at` refreshes to now ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET to_name = ?1`) — a `running` task is interrupted - TTL (`expires_at`) is **not modified** (unlike [`retry_task`](#retry-task) which forces `+1 hour`); the task keeps its remaining time - The new alias receives a fresh-UUID inbox row + a `new_task` SSE event ::: @@ -479,7 +479,7 @@ Query task details. } ``` -`get_task` does `SELECT * FROM tasks` ([`tools.ts:749`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L749)) and returns the **full row** (the example above shows sample fields; the actual row also includes `requires_response` / `parent_task_id` and every other column). When the task doesn't exist it returns `{ok: false, error: "task not found"}`. +`get_task` does `SELECT * FROM tasks` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM tasks WHERE task_id = ?1` inside `get_task` (3 sites; the others are `retry_task` / `reassign_task`)) and returns the **full row** (the example above shows sample fields; the actual row also includes `requires_response` / `parent_task_id` and every other column). When the task doesn't exist it returns `{ok: false, error: "task not found"}`. --- @@ -574,7 +574,7 @@ Get all session statuses. Sessions without a heartbeat for over 10 minutes are a ``` ::: warning The `sessions` row has **no `model` field** -`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts:388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L388), no JOIN). The `sessions` table schema ([`db.ts:7-26`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L7) + V2 migration [`db.ts:59-68`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L59)) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts:129`/`141`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129)). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). +`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE 1=1`, no JOIN). The `sessions` table schema ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `ALTER TABLE sessions ADD COLUMN`) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` and `model = COALESCE(?20, sessions.model)`). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). ::: --- @@ -619,8 +619,8 @@ Get detailed status of a single session, including pending inbox count and recen ``` ::: tip Response shape -- `session` is `SELECT * FROM sessions` ([`tools.ts:423`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L423)) — the full sessions row (same as [`get_all_status`](#get-all-status)'s session row, **including the `model` column** — see the get_all_status note); if the alias doesn't exist `session` is `null` but `ok` is still `true` -- `recent_completions` is `SELECT * FROM completions ... LIMIT 5` ([`tools.ts:433-435`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L433)) — the full 9-column completion row (`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`), ordered by `completed_at` DESC, max 5 +- `session` is `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE alias = ?1`) — the full sessions row (same as [`get_all_status`](#get-all-status)'s session row, **including the `model` column** — see the get_all_status note); if the alias doesn't exist `session` is `null` but `ok` is still `true` +- `recent_completions` is `SELECT * FROM completions ... LIMIT 5` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM completions WHERE session_name = ?1`) — the full 9-column completion row (`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`), ordered by `completed_at` DESC, max 5 ::: --- @@ -661,7 +661,7 @@ Get completion records. } ``` -`completions` is `SELECT * FROM completions WHERE completed_at >= ` ([`tools.ts:938`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L938)) — the full 9-column row, ordered by `completed_at` DESC. `artifacts` is a JSON-array **string** (not a parsed array — `report_completion` `JSON.stringify`s it on the way in). When `since` is omitted the cutoff defaults to 24 hours ago. +`completions` is `SELECT * FROM completions WHERE completed_at >= ` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM completions WHERE completed_at >= ?1`) — the full 9-column row, ordered by `completed_at` DESC. `artifacts` is a JSON-array **string** (not a parsed array — `report_completion` `JSON.stringify`s it on the way in). When `since` is omitted the cutoff defaults to 24 hours ago. --- @@ -673,7 +673,7 @@ Get completion records. Broadcast a message to all online agents. **`broadcast` triggers AI processing on receivers, the same as `task`** ([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) thinks only on `task` and `broadcast` types; `reply` / `message` / `ack` are display-only). If you just want a notification without an AI reply, loop `send_message` instead. Full message-type table: [Task lifecycle — Message types](/en/concepts/task-lifecycle#message-types). -**Parameters** (verify [`server/src/tools.ts:880-885`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L880)): +**Parameters** (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"ack_inbox"`): | Parameter | Type | Required | Description | |------|------|:----:|------| diff --git a/docs/doc-source-pins-baseline.txt b/docs/doc-source-pins-baseline.txt index b7504d708..e5cc21f2d 100644 --- a/docs/doc-source-pins-baseline.txt +++ b/docs/doc-source-pins-baseline.txt @@ -25,14 +25,5 @@ server/src/index.ts#L253 server/src/push.ts#L11 server/src/push.ts#L35 server/src/push.ts#L38 -server/src/tools.ts#L127 -server/src/tools.ts#L129 -server/src/tools.ts#L150 -server/src/tools.ts#L388 -server/src/tools.ts#L433 server/src/tools.ts#L521 server/src/tools.ts#L571 -server/src/tools.ts#L713 -server/src/tools.ts#L749 -server/src/tools.ts#L858 -server/src/tools.ts#L863 diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 8820cffe8..42914c2a0 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -55,14 +55,14 @@ broken=$(printf '%s' "$out" | sed -nE 's/^broken_pins=([0-9]+)$/\1/p') # 容器内遍历得到的数字必须与仓库里 git ls-files 得到的一致,否则容器内外 # 扫的范围分叉,「容器里绿」就推不出「仓库里绿」。这两个数是写死的预期值, # 变了要人来确认是真变了还是扫漏了。 -# 这三个数在 #831 的符号锚点改造里从 106/70/141 变成了 106/53/107 —— +# 这三个数随 #831 的符号锚点改造逐批下降:106/70/141 → 106/53/107 → 106/27/53。 # mcp-tools.md 中英两版各 17 处 `[源码 ↗]` 行号链接换成了「文件链接 + grep 提示」。 # 这道断言本来就是设计成"变了要人确认"的:那次变化是逐条核过的(17/17 原本 # 都指错,漂移 35–1194 行),不是扫漏。 [[ "$files" -eq 106 ]] || fail "预期扫 106 个文档文件(= git ls-files 的结果),实际 $files" -[[ "$uniq" -eq 53 ]] || fail "预期 53 个唯一 pin,实际 $uniq" -[[ "$occ" -eq 107 ]] || fail "预期 107 处原始出现,实际 $occ" -echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 53 唯一 pin / 107 处)" +[[ "$uniq" -eq 27 ]] || fail "预期 27 个唯一 pin,实际 $uniq" +[[ "$occ" -eq 53 ]] || fail "预期 53 处原始出现,实际 $occ" +echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处)" # --------------------------------------------------------------------------- # L1 — 干净树上必须绿 From fa992ed8ad1b5209af2e4af6e1e6b331f602d56a Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:29:30 +0800 Subject: [PATCH 02/59] =?UTF-8?q?docs(tests):=20=E5=88=B7=E6=96=B0=20test8?= =?UTF-8?q?31=20=E6=8A=A5=E5=91=8A=20=E2=80=94=E2=80=94=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=2027/53=20=E9=82=A3=E6=AC=A1=E8=B7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 #844 上那条 P1 同类,这次是主动修的:本 PR 把套件预期分母改成 27/53, 继承来的报告记的还是更早那次。审查还没提到这个 PR,但 docs/pre-pr-selfcheck.md §12 说了「改动改变了套件下次跑看到的东西,就要连报告一起更新」—— 这条规则刚写完,先用在自己身上。 source_commit=160a6b94d8c6c6c4a7622485baecc9b8f2e18b95 pin_occurrences=53 unique_pins=27 broken_pins=14 baseline_entries=14 MUTATION_RED new-out-of-range-pin rc=1 MUTATION_RED stale-baseline-entry rc=1 RESULT: PASS exit_code=0 --- docs/tests/report-test831.txt | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt index 119a53e1a..1aea6f6e4 100644 --- a/docs/tests/report-test831.txt +++ b/docs/tests/report-test831.txt @@ -1,26 +1,26 @@ # report-test831 — doc source-pin floor gate -# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=a7b127801855b2c97394473946783fbfa36770b8 构建) +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=160a6b94d8c6c6c4a7622485baecc9b8f2e18b95 构建) # 本文件为套件原样输出,未手工编辑。 # test831 — doc source-pin floor gate -source_commit=a7b127801855b2c97394473946783fbfa36770b8 -runsh_blob=6d4dca00b7744b13bf1b1fbfa46336503c6b663e +source_commit=160a6b94d8c6c6c4a7622485baecc9b8f2e18b95 +runsh_blob=42914c2a02a094dd739d35d5c3e72b2cb8ad0a06 python=Python 3.12.13 [L0] denominator listing_mode=walk scanned_doc_files=106 - pin_occurrences=141 - pin_doc_pairs=139 - unique_pins=70 - broken_pins=32 - baseline_entries=32 + pin_occurrences=53 + pin_doc_pairs=53 + unique_pins=27 + broken_pins=14 + baseline_entries=14 - OK: 失效 pin 32 个,与基线完全一致 —— 没有新增,也没有该清的残留。 + OK: 失效 pin 14 个,与基线完全一致 —— 没有新增,也没有该清的残留。 注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、 只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。 - OK walk 路径与 git 路径给出同一份清单(106 文件 / 70 唯一 pin / 141 处) + OK walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处) [L1] clean tree passes - OK rc=0 broken_pins=32(全部在基线里) + OK rc=0 broken_pins=14(全部在基线里) [L2] witnessed-red: a NEW broken pin must turn it red MUTATION_RED new-out-of-range-pin rc=1 复原后回绿 ✓ From b3e14d56fef180e0ac71015458b01fcb427bc7c0 Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:37:37 +0800 Subject: [PATCH 03/59] =?UTF-8?q?docs(mcp-tools):=20=E4=BF=AE=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E9=94=9A=E9=94=99=20tool=20=E7=9A=84=E5=BC=95?= =?UTF-8?q?=E7=94=A8,=E5=B9=B6=E8=A1=A5=E4=B8=8A=E3=80=8C=E5=B1=9E?= =?UTF-8?q?=E4=B8=BB=E6=98=AF=E5=90=A6=E5=8C=B9=E9=85=8D=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查指出 broadcast 参数表的引用锚到了 `"ack_inbox"`。成立。 这是同一个错的第二例:上一轮我自己抓到 reassign 那条锚到了 cancel_task, 修完就过去了 —— **没有对全部锚点做一次「锚串落在哪个 tool 段」的审计**。 又是修实例不修类。这次补做了,一并查出三处: 文档章节 原锚串 真实落点 改成 report_status INSERT INTO nodes ack_probe_request upsertNodeWithSec1Guard broadcast "ack_inbox" ack_inbox "Send a message to multiple sessions." get_all_status INSERT INTO sessions report_status 同串,但写明「在 report_status 段搜」 第一条要说明一下:report_status 确实会 upsert nodes 表,但不是自己写 SQL, 而是走 upsertNodeWithSec1Guard(L731,在 `if (node_id)` 之下 —— 正是文档声称 的那个条件)。`INSERT INTO nodes` 在那个 helper 里,位置在最后一个 tool 注册 之后,所以按"最近的注册点"归属会算到 ack_probe_request 头上。锚到函数名既指得 准,也同时带读者看到调用点和实现。 第三条不是错,是容易误解:那句话在 get_all_status 段里,但解释的是 report_status 写下的列。锚串本身对,补一句"在 report_status 段搜"免得读者 在本节里白找。 途中还犯了一次 pre-pr-selfcheck §11 说的错:改 broadcast 那条时我按字符串全局 替换 `搜 \`"ack_inbox"\``,把 ack_inbox 段里本来正确的两处也一起换掉了 —— §11 写的正是"批量替换前证明锚点唯一"。是重跑审计时发现的(章节=[ack_inbox] 却落在 broadcast),已按行定位改回。 审计结果:中文 40 条、英文 23 条锚串,属主不匹配 0。 (审计跳过引 db.ts 的行 —— 那些锚串不在 tools.ts 里,按 tool 段归属没有意义。) --- docs-site/docs/api/mcp-tools.md | 6 +++--- docs-site/docs/en/api/mcp-tools.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs-site/docs/api/mcp-tools.md b/docs-site/docs/api/mcp-tools.md index 342774c02..e6b8a3c2b 100644 --- a/docs-site/docs/api/mcp-tools.md +++ b/docs-site/docs/api/mcp-tools.md @@ -43,7 +43,7 @@ CommHub Server 共注册约 40 个 MCP Tools;本页文档化其中 agent 日 | `project_dir` | string | | 工作目录 | | `version` | string | | Agent 版本 | | `tmux_name` | string | | tmux session 名 | -| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `INSERT INTO nodes`)。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `INSERT INTO sessions` 与 `model = COALESCE(?20, sessions.model)`);只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | +| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `upsertNodeWithSec1Guard`(`report_status` 段内 `if (node_id)` 之下的调用点,以及 registerTools 之后的同名 helper))。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `report_status` 段搜 `INSERT INTO sessions`(写这几列的是 `report_status`,不是本节这个 tool) 与 `model = COALESCE(?20, sessions.model)`);只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | | `session_id` | string | | 运行时 session/thread ID | | `config_path` | string | | 配置文件路径 | | `channels` | string | | Channel 列表(JSON 数组字符串) | @@ -574,7 +574,7 @@ send_task({ ``` ::: warning `sessions` 行**没有** `model` 字段 -`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE 1=1`,无 JOIN)。`sessions` 表 schema([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `ALTER TABLE sessions ADD COLUMN`)**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `INSERT INTO sessions` 与 `model = COALESCE(?20, sessions.model)`)。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 +`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE 1=1`,无 JOIN)。`sessions` 表 schema([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `ALTER TABLE sessions ADD COLUMN`)**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `report_status` 段搜 `INSERT INTO sessions`(写这几列的是 `report_status`,不是本节这个 tool) 与 `model = COALESCE(?20, sessions.model)`)。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 ::: --- @@ -673,7 +673,7 @@ send_task({ 向所有在线 Agent 广播消息。**broadcast 与 `task` 同样会触发收件方 AI 处理**([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) 只对 `task` 和 `broadcast` 类型 think;其余 `reply` / `message` / `ack` 只展示);如果只是想群发通知不要求 AI 回复,用循环 `send_message` 替代。完整消息类型对照见 [Task 生命周期 — 消息类型](/concepts/task-lifecycle#消息类型)。 -**参数**(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"`): +**参数**(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"Send a message to multiple sessions."`(`broadcast` 的注册描述,全仓唯一;参数 schema 紧随其后)): | 参数 | 类型 | 必需 | 说明 | |------|------|:----:|------| diff --git a/docs-site/docs/en/api/mcp-tools.md b/docs-site/docs/en/api/mcp-tools.md index b1afaf30d..d2798b294 100644 --- a/docs-site/docs/en/api/mcp-tools.md +++ b/docs-site/docs/en/api/mcp-tools.md @@ -43,7 +43,7 @@ Report agent status. Also serves as a heartbeat (recommended every 3 minutes). | `project_dir` | string | | Working directory | | `version` | string | | Agent version | | `tmux_name` | string | | tmux session name | -| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO nodes`). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` and `model = COALESCE(?20, sessions.model)`); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | +| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `upsertNodeWithSec1Guard` (the call inside `report_status`'s `if (node_id)`, plus the helper of the same name defined after registerTools)). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` inside `report_status` (these columns are written by `report_status`, not by the tool documented in this section) and `model = COALESCE(?20, sessions.model)`); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | | `session_id` | string | | Runtime session/thread ID | | `config_path` | string | | Config file path | | `channels` | string | | Channel list (JSON array string) | @@ -574,7 +574,7 @@ Get all session statuses. Sessions without a heartbeat for over 10 minutes are a ``` ::: warning The `sessions` row has **no `model` field** -`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE 1=1`, no JOIN). The `sessions` table schema ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `ALTER TABLE sessions ADD COLUMN`) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` and `model = COALESCE(?20, sessions.model)`). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). +`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE 1=1`, no JOIN). The `sessions` table schema ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `ALTER TABLE sessions ADD COLUMN`) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` inside `report_status` (these columns are written by `report_status`, not by the tool documented in this section) and `model = COALESCE(?20, sessions.model)`). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). ::: --- @@ -673,7 +673,7 @@ Get completion records. Broadcast a message to all online agents. **`broadcast` triggers AI processing on receivers, the same as `task`** ([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) thinks only on `task` and `broadcast` types; `reply` / `message` / `ack` are display-only). If you just want a notification without an AI reply, loop `send_message` instead. Full message-type table: [Task lifecycle — Message types](/en/concepts/task-lifecycle#message-types). -**Parameters** (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"ack_inbox"`): +**Parameters** (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"Send a message to multiple sessions."` (broadcast's registration description, unique; the parameter schema follows it)): | Parameter | Type | Required | Description | |------|------|:----:|------| From c3c8fa38348f1cf1879195f50929efc8ec08a430 Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:39:01 +0800 Subject: [PATCH 04/59] =?UTF-8?q?docs(tests):=20=E5=90=88=E5=85=A5=20#843/?= =?UTF-8?q?#844=20=E5=90=8E=E9=87=8D=E8=B7=91=20test831?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这个 PR 基于 #844,#844 基于 #843 —— 上一轮我在 #843 改了判据(四条审查缺陷), 这两个 PR 当时都带着旧判据跑出来的绿色。逐级合下来重跑,判据是新的、分母是 本 PR 的 27/53、L5 四条断言都在。 RESULT: PASS exit_code=0 L5 ①不可变 ref 排除 ②#L0 越界 ③drifted 保留 ④路径穿越 报告冲突同样按合完重新生成处理 —— 它是产物,手工合出来的报告不对应任何一次 真实运行。 --- docs/tests/report-test831.txt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt index 1aea6f6e4..b0bda053d 100644 --- a/docs/tests/report-test831.txt +++ b/docs/tests/report-test831.txt @@ -1,21 +1,22 @@ # report-test831 — doc source-pin floor gate -# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=160a6b94d8c6c6c4a7622485baecc9b8f2e18b95 构建) +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=cb8d96e621197ee5d1382bf99090d00b919c02f9 构建) # 本文件为套件原样输出,未手工编辑。 # test831 — doc source-pin floor gate -source_commit=160a6b94d8c6c6c4a7622485baecc9b8f2e18b95 -runsh_blob=42914c2a02a094dd739d35d5c3e72b2cb8ad0a06 +source_commit=cb8d96e621197ee5d1382bf99090d00b919c02f9 +runsh_blob=314f8f6cb4e0b214aea614a65e22ca034e0d3c7e python=Python 3.12.13 [L0] denominator listing_mode=walk scanned_doc_files=106 pin_occurrences=53 + pins_on_immutable_ref=0 pin_doc_pairs=53 unique_pins=27 broken_pins=14 baseline_entries=14 - OK: 失效 pin 14 个,与基线完全一致 —— 没有新增,也没有该清的残留。 + OK: 失效 pin 14 个,基线 14 条 —— 没有新增,也没有该清的残留。 注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、 只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。 OK walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处) @@ -24,10 +25,16 @@ python=Python 3.12.13 [L2] witnessed-red: a NEW broken pin must turn it red MUTATION_RED new-out-of-range-pin rc=1 复原后回绿 ✓ -[L3] witnessed-red: a baseline entry that is no longer broken must turn it red +[L3] witnessed-red: a baseline entry whose link no longer exists must turn it red MUTATION_RED stale-baseline-entry rc=1 复原后回绿 ✓ [L4] the known blind spots are still blind (so the documented recall stays honest) OK 3 条已知盲区仍未被判据覆盖(与文档里 5/10 的召回率一致) +[L5] the four review findings each have an assertion + ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿 + ② #L0 判为 line-out-of-range rc=1 + ④ 仓库外路径判为 path-escapes-repo rc=1 + ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin=agent-network/bin/cli.ts#L61) + 复原后回绿 ✓ RESULT: PASS exit_code=0 From 89e454ba564defbe98784e94d1f87cd46c74d3ac Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:43:44 +0800 Subject: [PATCH 05/59] =?UTF-8?q?ci(test831):=20=E5=8A=A0=20L6=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=AC=A6=E5=8F=B7=E9=94=9A=E7=82=B9=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E8=90=BD=E5=9C=A8=E5=AE=83=E5=A3=B0=E7=A7=B0=E7=9A=84?= =?UTF-8?q?=E9=82=A3=E4=B8=AA=20tool=20=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮我说过要做这件事,理由是:那次「锚串属主匹配」的审计抓出三处错,但它 只是我人工跑的一次性脚本 —— 不落进门里,下次还会漏。 #831 把行号锚点换成「文件链接 + 可 grep 的串」,解决了行号会漂,却引入一个更 隐蔽的失效:**锚串确实存在,只是落在别的 tool 段**。「锚串存在」这个检查放行 不了它。#845 里连着出了两例,都不是靠工具发现的: reassign_task 段 → 锚到 send_message / cancel_task 里的串(我自己抓到) broadcast 段 → 锚到 "ack_inbox"(审查者抓到) 第一例我修完就过去了、没做全量审计,于是有了第二例。这一层就是那次审计固化下来。 scripts/check-doc-symbol-anchors.py 的判据:对每条「链接 + 搜/grep <串>」,比对 「引用所在的文档章节」与「锚串在 tools.ts 里命中处所属的 tool 注册段」,交集为空 就判 mismatch。三类不是错的情形都写在文件头,而且**每一类都由实例催生**: a) 文本里明写「在 `x` 段搜」/「inside `x`」时以它为准 (get_all_status 段解释 report_status 写下的列,是真实存在的合法情形) b) 锚串在 db.ts 里 —— 那个文件没有 tool 段,按 tool 归属没有意义 c) helper 函数的实现在最后一个 tool 注册之后,按「最近注册点」会被算到那个 tool 头上;所以还要看它被哪些 tool 段调用(upsertNodeWithSec1Guard 就是) 写这一层的过程中,它自己有两个作用域 bug 是被自己的输出抓回来的: - 「在 `x` 段搜」按整行匹配 → 同一行里另一个锚串被套上了别人的限定, 造出一条假 mismatch。改成按锚串作用域。 - 窗口只看锚串**之前** → 英文写法「grep `串` inside `x`」限定在后面,漏判。 改成取「上一个锚串结束 → 下一个锚串开始」,两侧都包进来。 witnessed-red 用的是 #845 里真实发生过的那个错:往 broadcast 章节插一条锚到 "ack_inbox" 的引用,必须红且指名 broadcast。 验证(容器内,--network none): [L6] tool_registrations=46 anchors_checked=67 mismatches=0 MUTATION_RED broadcast-anchored-to-ack-inbox rc=1 复原后回绿 ✓ RESULT: PASS 退出码 0 边界写在脚本头和 L6 注释里:它只管「落在对的段」,锚串在段内是否指着文档声称 的那件事仍要人读。跟 check-doc-source-pins.py 那个 5/10 召回率同一类 —— 门缩小了错误的种类,没有消灭错误。 --- scripts/check-doc-symbol-anchors.py | 197 +++++++++++++++++++++++ tests/test831-doc-source-pins/Dockerfile | 1 + tests/test831-doc-source-pins/run.sh | 44 +++++ 3 files changed, 242 insertions(+) create mode 100755 scripts/check-doc-symbol-anchors.py diff --git a/scripts/check-doc-symbol-anchors.py b/scripts/check-doc-symbol-anchors.py new file mode 100755 index 000000000..ed01901ad --- /dev/null +++ b/scripts/check-doc-symbol-anchors.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""符号锚点指的是不是它声称的那个 tool。 + +#831 把 `mcp-tools.md` 里的行号锚点换成了「文件链接 + 可 grep 的串」。那解决了 +「行号会漂」,但引入了一个新的失效形态,而且它比行号漂移更隐蔽: + + 锚串**确实存在**于源文件里,只是落在了别的 tool 段。 + +「锚串存在」这个检查会放行它。#845 里连着出了两例,都不是靠工具发现的: + + reassign_task 段 → 锚到 `status IN ('created', …)`,实际落在 send_message / cancel_task + broadcast 段 → 锚到 `"ack_inbox"`,实际落在 ack_inbox + +第一例我自己抓到就改了,**没有对全部锚点做一次审计**,于是第二例由审查者发现。 +这个脚本就是那次审计,固化成门的一层 —— 一次性脚本抓到的错,下次还会漏。 + +## 判据 + +对 `docs-site/**/api/mcp-tools.md` 里每一条「链接 + 搜/grep `<串>`」: + + 1. 找出这条引用所在的**文档章节**(最近的 `### ` 标题) + 2. 找出锚串在 `server/src/tools.ts` 里的所有命中,以及每个命中落在**哪个 tool + 的注册段**(最近的 `registerTool("x"` / `tool("x"` 之前) + 3. 两者的交集为空 → 判为 mismatch + +## 三类不是错的情形(都由实例催生,不是预设的) + + a) **明写了段名。** 文本里写「在 `report_status` 段搜」/「inside `report_status`」 + 时,以那个名字为准,不用章节名。有些说明本来就是在 A 的章节里解释 B 写下的 + 字段 —— `get_all_status` 段解释 `sessions.model` 由 `report_status` 写入就是。 + b) **锚串在 db.ts 里。** db.ts 没有 tool 段,按 tool 归属没有意义,跳过。 + c) **helper 函数。** `upsertNodeWithSec1Guard` 的实现在最后一个 tool 注册之后, + 按「最近的注册点」会被算到那个 tool 头上。所以命中落在**函数定义**上时, + 只要该函数在正确的 tool 段里被调用,就算匹配。 + +## 这个脚本不保证什么 + +它只管「锚串落在对的 tool 段」。锚串**在段内是否指着文档声称的那件事**,它判不了 —— +那仍然要人读。这跟 check-doc-source-pins.py 头部那个 5/10 召回率是同一类边界: +门缩小了错误的种类,没有消灭错误。 +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() +TOOLS = REPO / "server" / "src" / "tools.ts" + +# 只有 mcp-tools.md 是按 tool 分章节的;别的文档没有这个结构。 +DOCS = ["docs-site/docs/api/mcp-tools.md", "docs-site/docs/en/api/mcp-tools.md"] + +REG = re.compile(r'^\s*"([a-z_]+)",\s*$') +REG_PREV = re.compile(r"(registerTool|\btool)\($") +SECTION = re.compile(r"^#{2,4}\s+`?([a-z_]+)`?\s*$") +ANCHOR = re.compile(r"(?:搜|grep) `([^`]+)`") +EXPLICIT = re.compile(r"在 `([a-z_]+)` 段|inside `([a-z_]+)`") +LINK = re.compile(r"blob/main/(server/src/[a-z-]+\.ts)") +FUNC_DEF = re.compile(r"^\s*(?:const|function|async function|export function)\s+([A-Za-z_][A-Za-z0-9_]*)") + + +def tool_registrations(lines: list[str]) -> list[tuple[int, str]]: + out = [] + for i, line in enumerate(lines): + m = REG.match(line) + if m and i > 0 and REG_PREV.search(lines[i - 1].strip()): + out.append((i + 1, m.group(1))) + return out + + +def main() -> int: + if not TOOLS.is_file(): + print(f"FAIL: 找不到 {TOOLS}", file=sys.stderr) + return 1 + src_lines = TOOLS.read_text(encoding="utf-8").split("\n") + regs = tool_registrations(src_lines) + if not regs: + # 分母承重:一个注册点都找不到时不能报绿 —— 那说明注册写法变了, + # 而"零个 mismatch"和"根本没解析出 tool 段"打印出来是同一片绿。 + print("FAIL: 在 tools.ts 里一个 tool 注册点都没解析出来", file=sys.stderr) + return 1 + print(f"tool_registrations={len(regs)}") + + def owner_of(line_no: int) -> str: + prior = [r for r in regs if r[0] <= line_no] + return prior[-1][1] if prior else "(file-head)" + + # helper 名 → 调用它的 tool 段集合 + helper_callers: dict[str, set[str]] = {} + for i, line in enumerate(src_lines, 1): + m = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*)\(\{?\s*$", line) + if m: + helper_callers.setdefault(m.group(1), set()).add(owner_of(i)) + + total = 0 + mismatches = 0 + checked_docs = 0 + + for rel in DOCS: + path = REPO / rel + if not path.is_file(): + print(f"FAIL: 待检文档不存在:{rel}", file=sys.stderr) + return 1 + checked_docs += 1 + doc_lines = path.read_text(encoding="utf-8").split("\n") + sections = [ + (i + 1, m.group(1)) + for i, line in enumerate(doc_lines) + if (m := SECTION.match(line)) + ] + + def section_of(line_no: int) -> str: + prior = [s for s in sections if s[0] <= line_no] + return prior[-1][1] if prior else "(preamble)" + + for line_no, line in enumerate(doc_lines, 1): + targets = set(LINK.findall(line)) + # (b) 只引 db.ts 的行跳过 —— db.ts 没有 tool 段 + if targets and targets <= {"server/src/db.ts"}: + continue + # 一行里可能有多个锚串(例如 get_all_status 段那句同时引了自己的 + # SELECT 和 report_status 写下的 INSERT)。「在 `x` 段搜」这个限定 + # 只作用于**紧跟它的那一个**锚串,不能按整行套用 —— 第一版就是按 + # 整行匹配的,于是把 report_status 的限定套到了同行 get_all_status + # 的锚串上,自己造出一条假 mismatch。 + anchors = list(ANCHOR.finditer(line)) + prev_end = 0 + for idx, m in enumerate(anchors): + needle = m.group(1) + hits = [i for i, x in enumerate(src_lines, 1) if needle in x] + if not hits: + # 锚串不在 tools.ts 里 —— 可能是 db.ts 的串与 tools.ts 的串 + # 同行混排。这里不判它,那是 check-doc-source-pins.py 之外的 + # 另一件事;本脚本只管归属。 + continue + total += 1 + owners = {owner_of(h) for h in hits} + + # (c) 命中落在函数定义上时,看这个函数被哪些 tool 段调用 + for h in hits: + fm = FUNC_DEF.match(src_lines[h - 1]) + if fm and fm.group(1) == needle: + owners |= helper_callers.get(needle, set()) + if needle in helper_callers: + owners |= helper_callers[needle] + + # 限定语可能在锚串**之前**(中文:「在 `x` 段搜 `串`」)也可能在 + # **之后**(英文:「grep `串` inside `x`」)。所以窗口取 + # 「上一个锚串结束 → 下一个锚串开始」,把两侧都包进来,但不跨到 + # 相邻锚串的地盘上。第一版只看前面,英文那条就漏判了。 + nxt = anchors[idx + 1].start() if idx + 1 < len(anchors) else len(line) + window = line[prev_end:nxt] + prev_end = m.end() + ex = EXPLICIT.search(window) + if ex: + # (a) 明写了段名,以它为准 + want = {ex.group(1) or ex.group(2)} + label = f"明写 {sorted(want)}" + else: + want = {section_of(line_no)} + label = f"章节 {sorted(want)}" + if want == {"(preamble)"}: + continue + + if not (owners & want): + mismatches += 1 + print( + f"MISMATCH {rel}:{line_no} {label} 但锚串落在 {sorted(owners)}" + f" «{needle[:56]}»", + file=sys.stderr, + ) + + print(f"checked_docs={checked_docs}") + print(f"anchors_checked={total}") + print(f"mismatches={mismatches}") + + if total == 0: + print("FAIL: 一条锚串都没检查到 —— 检查 DOCS / ANCHOR 是不是坏了", file=sys.stderr) + return 1 + if mismatches: + print(file=sys.stderr) + print(" 锚串确实存在,但落在别的 tool 段里。这类错「锚串存在」的检查放行不了 ——", file=sys.stderr) + print(" #845 里连着出了两例(reassign→cancel_task、broadcast→ack_inbox)。", file=sys.stderr) + print(" 改法:换一个落在正确段里的唯一串;确实要引别的 tool 时,在文本里", file=sys.stderr) + print(" 明写「在 `` 段搜」。", file=sys.stderr) + return 1 + + print() + print(f"OK: {total} 条锚串全部落在它们声称的 tool 段里。") + print("注意:这只管「落在对的段」。锚串在段内是否指着文档声称的那件事,仍要人读。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test831-doc-source-pins/Dockerfile b/tests/test831-doc-source-pins/Dockerfile index d697d11dc..8d2039577 100644 --- a/tests/test831-doc-source-pins/Dockerfile +++ b/tests/test831-doc-source-pins/Dockerfile @@ -27,6 +27,7 @@ COPY server /repo/server COPY agent-network /repo/agent-network COPY docs/doc-source-pins-baseline.txt /repo/docs/doc-source-pins-baseline.txt COPY scripts/check-doc-source-pins.py /repo/scripts/check-doc-source-pins.py +COPY scripts/check-doc-symbol-anchors.py /repo/scripts/check-doc-symbol-anchors.py COPY tests/test831-doc-source-pins /repo/tests/test831-doc-source-pins RUN chmod +x /repo/tests/test831-doc-source-pins/run.sh diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 314f8f6cb..3a3d9b0d7 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -203,4 +203,48 @@ echo " ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin python3 "$CHECK" "$ROOT" >/dev/null || fail "L5 复原之后没有回绿" echo " 复原后回绿 ✓" +# --------------------------------------------------------------------------- +# L6 — 符号锚点是否落在它声称的那个 tool 段。 +# +# #831 把行号锚点换成了「文件链接 + 可 grep 的串」,解决了行号会漂,却引入 +# 一个更隐蔽的失效:**锚串确实存在,只是落在别的 tool 段**。「锚串存在」 +# 这个检查放行不了它。#845 里连着出了两例,都不是靠工具发现的: +# reassign_task 段 → 锚到 send_message / cancel_task 里的串 +# broadcast 段 → 锚到 "ack_inbox" +# 第一例我自己抓到就改了、没做全量审计,于是第二例由审查者发现。 +# 这一层就是那次审计固化下来的 —— 一次性脚本抓到的错,下次还会漏。 +# --------------------------------------------------------------------------- +echo "[L6] symbol anchors land in the tool section they claim" +SYMCHECK="$ROOT/scripts/check-doc-symbol-anchors.py" +[[ -f "$SYMCHECK" ]] || fail "L6 的脚本不在镜像里:$SYMCHECK" +out9=$(python3 "$SYMCHECK" "$ROOT") || fail "干净树上 L6 就红了:$(printf '%s' "$out9" | tail -4)" +printf '%s\n' "$out9" | sed 's/^/ /' +anch=$(printf '%s' "$out9" | sed -nE 's/^anchors_checked=([0-9]+)$/\1/p') +regs=$(printf '%s' "$out9" | sed -nE 's/^tool_registrations=([0-9]+)$/\1/p') +[[ "${anch:-0}" -gt 0 ]] || fail "L6 一条锚串都没检查到 —— 分母塌了" +[[ "${regs:-0}" -gt 0 ]] || fail "L6 没解析出任何 tool 注册点 —— 分母塌了" + +# witnessed-red:把 #845 里真实发生过的那个错重新注入 —— broadcast 段的参数表 +# 锚到 "ack_inbox"。必须红,且红在 broadcast 那一行上。 +BC=$(grep -n '^#\{2,4\} \?`\?broadcast`\?$' "$ROOT/docs-site/docs/api/mcp-tools.md" | head -1 | cut -d: -f1) +[[ -n "$BC" ]] || fail "找不到 broadcast 章节,无法造 L6 的变异" +cp "$ROOT/docs-site/docs/api/mcp-tools.md" /tmp/mcp.bak +python3 - "$ROOT/docs-site/docs/api/mcp-tools.md" "$BC" <<'PYX' +import sys, pathlib +path, start = pathlib.Path(sys.argv[1]), int(sys.argv[2]) +lines = path.read_text(encoding="utf-8").split("\n") +# 在 broadcast 章节里插一条锚到 ack_inbox 的引用 —— 这正是 #845 的原错 +lines.insert(start, '参数(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"`):') +path.write_text("\n".join(lines), encoding="utf-8") +PYX +set +e; out10=$(python3 "$SYMCHECK" "$ROOT" 2>&1); rc10=$?; set -e +cp /tmp/mcp.bak "$ROOT/docs-site/docs/api/mcp-tools.md" +[[ "$rc10" -ne 0 ]] || fail "把 broadcast 的锚串写成 \"ack_inbox\" 之后 L6 仍然绿" +printf '%s' "$out10" | grep -qF "MISMATCH" || fail "L6 红了但没打出 MISMATCH" +printf '%s' "$out10" | grep -q "broadcast" || fail "L6 红了但没指出是 broadcast 那条" +echo " MUTATION_RED broadcast-anchored-to-ack-inbox rc=$rc10" + +python3 "$SYMCHECK" "$ROOT" >/dev/null || fail "L6 复原之后没有回绿" +echo " 复原后回绿 ✓ (anchors_checked=$anch, tool_registrations=$regs)" + echo "RESULT: PASS" From d86f9820618565a9f153575318568f80a20639fb Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:43:48 +0800 Subject: [PATCH 06/59] =?UTF-8?q?docs(tests):=20=E5=88=B7=E6=96=B0=20test8?= =?UTF-8?q?31=20=E6=8A=A5=E5=91=8A(=E5=90=AB=20L6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source_commit=89e454ba564defbe98784e94d1f87cd46c74d3ac 按 pre-pr-selfcheck §12:改动改变了套件下次跑看到的东西,报告要一起更新。 这次是加了一整层 L6,更该更新。 RESULT: PASS exit_code=0 --- docs/tests/report-test831.txt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt index b0bda053d..d8466c4cd 100644 --- a/docs/tests/report-test831.txt +++ b/docs/tests/report-test831.txt @@ -1,10 +1,10 @@ # report-test831 — doc source-pin floor gate -# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=cb8d96e621197ee5d1382bf99090d00b919c02f9 构建) +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=89e454ba564defbe98784e94d1f87cd46c74d3ac 构建) # 本文件为套件原样输出,未手工编辑。 # test831 — doc source-pin floor gate -source_commit=cb8d96e621197ee5d1382bf99090d00b919c02f9 -runsh_blob=314f8f6cb4e0b214aea614a65e22ca034e0d3c7e +source_commit=89e454ba564defbe98784e94d1f87cd46c74d3ac +runsh_blob=3a3d9b0d74e88a38bafc55bbe716477e4b7693f1 python=Python 3.12.13 [L0] denominator listing_mode=walk @@ -36,5 +36,15 @@ python=Python 3.12.13 ④ 仓库外路径判为 path-escapes-repo rc=1 ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin=agent-network/bin/cli.ts#L61) 复原后回绿 ✓ +[L6] symbol anchors land in the tool section they claim + tool_registrations=46 + checked_docs=2 + anchors_checked=67 + mismatches=0 + + OK: 67 条锚串全部落在它们声称的 tool 段里。 + 注意:这只管「落在对的段」。锚串在段内是否指着文档声称的那件事,仍要人读。 + MUTATION_RED broadcast-anchored-to-ack-inbox rc=1 + 复原后回绿 ✓ (anchors_checked=67, tool_registrations=46) RESULT: PASS exit_code=0 From d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:51:42 +0800 Subject: [PATCH 07/59] =?UTF-8?q?docs(rest):=2018=20=E5=A4=84=E4=B8=8D?= =?UTF-8?q?=E4=B8=8E=20#809/#810=20=E5=86=B2=E7=AA=81=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E5=8F=B7=E9=94=9A=E7=82=B9=E6=94=B9=E9=92=89=E7=AC=A6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 我把这条挂了两轮,理由都是"#809/#810 在改同一个文件,怕撞车"——但一直没真去核。 这轮核了,答案很具体: 两个 rest.md 里的失效 pin 共 25 处 与 #809/#810 的改动行段重叠 = 7 处 ← 本轮不动 安全可改 = 18 处 也就是说"怕撞车"只挡得住 28%,我却拿它当了两轮不做的理由。 撞车的 7 处(按 (文件,行) 精确排除,不是按文件跳过): zh 52 / en 54 tools.ts#L521 与 #810 的 49-55 / 51-57 重叠 zh 1545 / en 1491 tools.ts#L571 与 #810 的 1539-1548 / 1485-1494 重叠 zh 1547 / en 1493 push.ts#L38 同上 en 398 auth.ts#L184 与 #810 的 395-401 重叠 改掉的 9 个唯一 pin(中英各一处,共 18): auth.ts#L7 → interface AuthUser auth.ts#L99 → invalid username or password(全仓 2 处) auth.ts#L102 → // User token (utok_) — not bound to network, … auth.ts#L243 → generateToken(全仓 3 处) auth.ts#L269 → network_id IS NULL AND token_id != auth.ts#L374 → "auto-join", "full" db.ts#L168 → CREATE TABLE IF NOT EXISTS networks push.ts#L11 → const clients = new Map() push.ts#L35 → { type: "connected", session: sessionName 替换按 (文件,行号) 定向,不是全局字符串替换 —— pre-pr-selfcheck §13 刚写完, 这次照做了。其中两条(push.ts:11 和 auth.ts:243)是复合标签 (`[`push.ts:11` `clients`]`),第一遍正则没匹配上,是靠"改了 7 处而不是 9 处" 这个数字对不上发现的 —— 也是 §13 说的那条经验。 连带:基线 14 → 5(门要求删 9 条);L0 预期 27/53 → 18/35。 剩下的 5 条正是上面 4 条撞车 pin 加 changelog 的 index.ts#L253。 验证(容器内,--network none): unique_pins=18 broken_pins=5 baseline_entries=5 L6 anchors_checked=67 mismatches=0 MUTATION_RED new-out-of-range-pin rc=1 MUTATION_RED stale-baseline-entry rc=1 MUTATION_RED broadcast-anchored-to-ack-inbox rc=1 RESULT: PASS 退出码 0 --- docs-site/docs/api/rest.md | 18 +++++++++--------- docs-site/docs/en/api/rest.md | 18 +++++++++--------- docs/doc-source-pins-baseline.txt | 9 --------- tests/test831-doc-source-pins/run.sh | 8 ++++---- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/docs-site/docs/api/rest.md b/docs-site/docs/api/rest.md index e65083ed7..2d15e700e 100644 --- a/docs-site/docs/api/rest.md +++ b/docs-site/docs/api/rest.md @@ -102,7 +102,7 @@ curl -X POST http://localhost:9200/api/auth/register \ } ``` -`user` 对象 5 字段对照 [`server/src/auth.ts:7-13`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L7) `AuthUser` interface(`display_name` / `email` 可为 `null`);`token` 是 `utok_` 给 CLI/Dashboard 用,`network_token` 是 `ntok_` 给注册时自动创建的那个网络里的 agent 用。 +`user` 对象 5 字段对照 [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `interface AuthUser` `AuthUser` interface(`display_name` / `email` 可为 `null`);`token` 是 `utok_` 给 CLI/Dashboard 用,`network_token` 是 `ntok_` 给注册时自动创建的那个网络里的 agent 用。 **常见 4xx**(verify [`auth.ts register()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): @@ -155,13 +155,13 @@ curl -X POST http://localhost:9200/api/auth/login \ } ``` -`user` 对象 5 字段同 register 响应(注 `email` 可为 `null`);`network_id` 是该用户作为 owner 的 default network([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) 取 `ORDER BY role = 'owner' DESC LIMIT 1`)。每次 login 都签发**新的** `utok_`(不撤销已有,多设备登录互不踢,[`auth.ts:102-110`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L102))。 +`user` 对象 5 字段同 register 响应(注 `email` 可为 `null`);`network_id` 是该用户作为 owner 的 default network([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) 取 `ORDER BY role = 'owner' DESC LIMIT 1`)。每次 login 都签发**新的** `utok_`(不撤销已有,多设备登录互不踢,[`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `// User token (utok_) — not bound to network, for CLI/Dashboard login`)。 **常见 4xx**(verify [`auth.ts login()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): | 状态 | `error` 值 | 触发条件 | |------|------------|---------| -| 401 | `invalid username or password` | 用户名不存在 **或** 密码哈希不匹配([`auth.ts:99-100`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L99) 故意把两种错误合并成同一文案,避免 username enumeration);server 同时写 `login_failed` audit | +| 401 | `invalid username or password` | 用户名不存在 **或** 密码哈希不匹配([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `invalid username or password`(全仓 2 处) 故意把两种错误合并成同一文案,避免 username enumeration);server 同时写 `login_failed` audit | | 429 | `rate_limited` | 超过 10/分 IP rate limit([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts);触发时写 `login_rate_limited` audit + clientIP)| **速率限制**:10 次/分钟 per IP。 @@ -298,7 +298,7 @@ curl -X POST http://localhost:9200/api/auth/password \ **关键副作用** (verify [`auth.ts:267-282 changePassword + revokeOtherUserTokens`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L267) + [`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts)): 1. **当前调用方的 `utok_`** (`resolved.tokenId`) 立即撤销([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) `revokeToken(...)` 显式删) -2. **其他设备的所有 `utok_` / `atok_`** 同步撤销([`auth.ts:269-270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L269) `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId` 一锅端)—— 计数返回到 `revoked` 字段 +2. **其他设备的所有 `utok_` / `atok_`** 同步撤销([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `network_id IS NULL AND token_id != ` `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId` 一锅端)—— 计数返回到 `revoked` 字段 3. **`ntok_` 不受影响**(`revokeOtherUserTokens` 只删 `network_id IS NULL` 的 token,agent node 用 `ntok_` 跑着的不会被改密打断;跟 [account-system 改密码副作用](/guide/account-system#修改密码) ZH 描述一致) 4. **新 `utok_`** (`issued.token`) 颁发给调用方作为响应返回 —— 调用方应立即用新 token 覆盖本地存储 5. 写 audit log: `action='password_changed'` @@ -357,7 +357,7 @@ curl http://localhost:9200/api/networks \ } ``` -`networks` 数组每行 10 字段:9 个 `networks` 表字段 ([`server/src/db.ts:168-177`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L168) 含 v3 migration `visibility` + `max_members`) + 1 个 join 字段 `member_role`([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) JOIN `network_members`)。排序:owner 在前,其余按 `created_at`(`ORDER BY nm.role = 'owner' DESC, n.created_at`)。`settings` / `description` 可为 `null`。`ntok_` 调用只返回当前 binding 那一个 network(不是全部);`utok_` 返回所有所属网络。 +`networks` 数组每行 10 字段:9 个 `networks` 表字段 ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS networks` 含 v3 migration `visibility` + `max_members`) + 1 个 join 字段 `member_role`([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) JOIN `network_members`)。排序:owner 在前,其余按 `created_at`(`ORDER BY nm.role = 'owner' DESC, n.created_at`)。`settings` / `description` 可为 `null`。`ntok_` 调用只返回当前 binding 那一个 network(不是全部);`utok_` 返回所有所属网络。 --- @@ -1520,7 +1520,7 @@ curl -X POST http://localhost:9200/mcp \ > [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) -SSE 实时推送端点,客户端通过长连接接收事件。路径段 `:name` 是一个**通用 channel 名**(源码里叫 `:session`):Agent 用自己的 **node alias** 订阅、Dashboard 用 **username** 订阅 user channel。SSE 层本身是 per-channel-name 的 `Map`([`push.ts:11` `clients`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L11)),不区分 alias / username —— `pushEvent(name, ...)` 推给谁取决于谁注册了那个 name(如 `node.renamed` 同时推 alias 流和成员 username channel,见下表)。 +SSE 实时推送端点,客户端通过长连接接收事件。路径段 `:name` 是一个**通用 channel 名**(源码里叫 `:session`):Agent 用自己的 **node alias** 订阅、Dashboard 用 **username** 订阅 user channel。SSE 层本身是 per-channel-name 的 `Map`([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) 搜 `const clients = new Map()`),不区分 alias / username —— `pushEvent(name, ...)` 推给谁取决于谁注册了那个 name(如 `node.renamed` 同时推 alias 流和成员 username channel,见下表)。 ```bash # 推荐:Authorization header(避免 token 写进代理 / 浏览器历史 / access log) @@ -1534,7 +1534,7 @@ curl -N "http://localhost:9200/events/代码1号?token=ntok_xxx" | 事件 | 触发条件 | 数据 | |------|---------|------| -| `connected` | 初始连接握手([`push.ts:35`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L35),每个 client 连上 SSE 时发一次) | `{session, network_id}` | +| `connected` | 初始连接握手([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) 搜 `{ type: "connected", session: sessionName`,每个 client 连上 SSE 时发一次) | `{session, network_id}` | | `new_task` | 收到新任务(`send_task` / `retry_task` / `reassign_task` / REST `POST /api/task`) | `{inbox_count, priority, from}` | | `new_message` | 收到新消息(`send_message`) | `{from, message_id}` | | `new_reply` | 收到 reply(`send_reply`) | `{from, message_id, in_reply_to, status}` | @@ -1672,7 +1672,7 @@ curl -X POST http://localhost:9200/api/auth/tokens \ ::: ::: info 这个 endpoint 创建的是 legacy `atok_` -本 endpoint 走 [`auth.ts:243` `generateToken()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L243) 颁发 `atok_` 前缀 + `scope='full'` token,是 V2 时代的兼容路径,不是 v0.8 主线的 `utok_` / `ntok_`。新代码请用: +本 endpoint 走 [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `generateToken`(全仓 3 处) 颁发 `atok_` 前缀 + `scope='full'` token,是 V2 时代的兼容路径,不是 v0.8 主线的 `utok_` / `ntok_`。新代码请用: - **`utok_`(用户 Token)**:通过 [POST /api/auth/login](#post-api-auth-login) 或 [POST /api/auth/register](#post-api-auth-register) 自动颁发 - **`ntok_`(节点 Token)**:通过 [POST /api/auth/node-token](#post-api-auth-node-token) 创建(绑定到指定 network + 节点 alias) @@ -1958,7 +1958,7 @@ curl -X POST http://localhost:9200/api/networks/join \ | 400 | `invite code expired` | `expires_at < now()`(不传 `expires_days` 创建则不会过期) | | 400 | `already a member of this network` | 调用者已是该网络成员 | -`anet network join` CLI 拿到该响应后会自动切换到加入的 network(即 `~/.anet/config.json` 的 `network_id` 字段更新为 `res.network_id`),并打印 `Joined network as `。同时 server 自动颁发一个 `network_id` 绑定的 token 给加入者([`auth.ts:374-377`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L374) `name='auto-join' scope='full'`),写 audit `network_joined`。 +`anet network join` CLI 拿到该响应后会自动切换到加入的 network(即 `~/.anet/config.json` 的 `network_id` 字段更新为 `res.network_id`),并打印 `Joined network as `。同时 server 自动颁发一个 `network_id` 绑定的 token 给加入者([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `"auto-join", "full"` `name='auto-join' scope='full'`),写 audit `network_joined`。 --- diff --git a/docs-site/docs/en/api/rest.md b/docs-site/docs/en/api/rest.md index 563b7f322..478cc361e 100644 --- a/docs-site/docs/en/api/rest.md +++ b/docs-site/docs/en/api/rest.md @@ -104,7 +104,7 @@ curl -X POST http://localhost:9200/api/auth/register \ } ``` -The `user` object's 5 fields match [`server/src/auth.ts:7-13`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L7) `AuthUser` interface (`display_name` / `email` may be `null`); `token` is the `utok_` for CLI/Dashboard; `network_token` is the `ntok_` for agents in the network auto-created at registration. +The `user` object's 5 fields match [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `interface AuthUser` `AuthUser` interface (`display_name` / `email` may be `null`); `token` is the `utok_` for CLI/Dashboard; `network_token` is the `ntok_` for agents in the network auto-created at registration. **Common 4xx errors** (verify [`auth.ts register()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): @@ -157,13 +157,13 @@ curl -X POST http://localhost:9200/api/auth/login \ } ``` -The `user` object's 5 fields match the register response (note `email` may be `null`); `network_id` is the default network the user owns ([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) does `ORDER BY role = 'owner' DESC LIMIT 1`). Each login issues a **brand-new** `utok_` (existing tokens are not rotated, so multiple devices can log in independently — see [`auth.ts:102-110`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L102)). +The `user` object's 5 fields match the register response (note `email` may be `null`); `network_id` is the default network the user owns ([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) does `ORDER BY role = 'owner' DESC LIMIT 1`). Each login issues a **brand-new** `utok_` (existing tokens are not rotated, so multiple devices can log in independently — see [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `// User token (utok_) — not bound to network, for CLI/Dashboard login`). **Common 4xx errors** (verify [`auth.ts login()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): | Status | `error` value | Trigger | |------|------------|---------| -| 401 | `invalid username or password` | Username doesn't exist **or** password hash mismatch ([`auth.ts:99-100`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L99) intentionally collapses both into the same message to avoid username enumeration); the server also writes a `login_failed` audit row | +| 401 | `invalid username or password` | Username doesn't exist **or** password hash mismatch ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `invalid username or password` (2 sites) intentionally collapses both into the same message to avoid username enumeration); the server also writes a `login_failed` audit row | | 429 | `rate_limited` | Exceeded 10/min IP rate limit ([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts); on hit the server writes a `login_rate_limited` audit row with the client IP) | **Rate limit**: 10 requests/minute per IP. @@ -300,7 +300,7 @@ curl -X POST http://localhost:9200/api/auth/password \ **Key side effects** (verify [`auth.ts:267-282 changePassword + revokeOtherUserTokens`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L267) + [`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts)): 1. **The caller's `utok_`** (`resolved.tokenId`) is revoked immediately ([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) `revokeToken(...)` explicit delete) -2. **All other devices' `utok_` / `atok_`** are also revoked in one shot ([`auth.ts:269-270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L269) `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId`) — the count is returned in the `revoked` field +2. **All other devices' `utok_` / `atok_`** are also revoked in one shot ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `network_id IS NULL AND token_id != ` `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId`) — the count is returned in the `revoked` field 3. **`ntok_` tokens are unaffected** (`revokeOtherUserTokens` filters on `network_id IS NULL`, so agent nodes using `ntok_` keep running through a password change; matches the [account-system / Change Password](/en/guide/account-system#change-password) narrative) 4. **A fresh `utok_`** (`issued.token`) is minted for the caller and returned in this response — the caller must overwrite local storage with the new token right away 5. Writes audit log: `action='password_changed'` @@ -359,7 +359,7 @@ curl http://localhost:9200/api/networks \ } ``` -Each row in `networks` has 10 fields: the 9 `networks` table columns ([`server/src/db.ts:168-177`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L168), including the v3 migrations `visibility` + `max_members`) plus the joined `member_role` ([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) joins `network_members`). Sort order: owner first, then by `created_at` (`ORDER BY nm.role = 'owner' DESC, n.created_at`). `settings` / `description` may be `null`. An `ntok_` caller sees only the bound network (not the full list); a `utok_` caller sees every network they belong to. +Each row in `networks` has 10 fields: the 9 `networks` table columns ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS networks`, including the v3 migrations `visibility` + `max_members`) plus the joined `member_role` ([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) joins `network_members`). Sort order: owner first, then by `created_at` (`ORDER BY nm.role = 'owner' DESC, n.created_at`). `settings` / `description` may be `null`. An `ntok_` caller sees only the bound network (not the full list); a `utok_` caller sees every network they belong to. --- @@ -1466,7 +1466,7 @@ curl -X POST http://localhost:9200/mcp \ > [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) -SSE real-time push endpoint. Clients receive events via a long-lived connection. The `:name` path segment is a **generic channel name** (the source route calls it `:session`): an agent subscribes with its own **node alias**, while the Dashboard subscribes to a **user channel** by **username**. The SSE layer itself is just a per-channel-name `Map` ([`push.ts:11` `clients`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L11)) — it does not distinguish alias from username; `pushEvent(name, ...)` reaches whoever registered that name (e.g. `node.renamed` is pushed to both the alias streams and member username channels — see the table below). +SSE real-time push endpoint. Clients receive events via a long-lived connection. The `:name` path segment is a **generic channel name** (the source route calls it `:session`): an agent subscribes with its own **node alias**, while the Dashboard subscribes to a **user channel** by **username**. The SSE layer itself is just a per-channel-name `Map` ([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) — grep `const clients = new Map()`) — it does not distinguish alias from username; `pushEvent(name, ...)` reaches whoever registered that name (e.g. `node.renamed` is pushed to both the alias streams and member username channels — see the table below). ```bash # Recommended: Authorization header (keeps the token out of proxies / browser history / access logs) @@ -1480,7 +1480,7 @@ curl -N "http://localhost:9200/events/coder-1?token=ntok_xxx" | Event | Trigger | Data | |------|---------|------| -| `connected` | Initial connection handshake ([`push.ts:35`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L35); emitted once per SSE client when the stream opens) | `{session, network_id}` | +| `connected` | Initial connection handshake ([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) — grep `{ type: "connected", session: sessionName`; emitted once per SSE client when the stream opens) | `{session, network_id}` | | `new_task` | New task received (`send_task` / `retry_task` / `reassign_task` / REST `POST /api/task`) | `{inbox_count, priority, from}` | | `new_message` | New chat message (`send_message`) | `{from, message_id}` | | `new_reply` | Reply to a task (`send_reply`) | `{from, message_id, in_reply_to, status}` | @@ -1573,7 +1573,7 @@ The `token` field is the plaintext token, **returned exactly once at creation** ::: ::: info This endpoint creates the legacy `atok_` -This path goes through [`auth.ts:243` `generateToken()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L243), which issues an `atok_` prefix + `scope='full'` token — a V2-era compatibility path, not the v0.8 mainline (`utok_` / `ntok_`). For new code: +This path goes through [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `generateToken` (3 sites), which issues an `atok_` prefix + `scope='full'` token — a V2-era compatibility path, not the v0.8 mainline (`utok_` / `ntok_`). For new code: - **`utok_` (user token)**: issued automatically by [POST /api/auth/login](#post-api-auth-login) or [POST /api/auth/register](#post-api-auth-register) - **`ntok_` (network token)**: created via [POST /api/auth/node-token](#post-api-auth-node-token) (bound to a network + node alias) @@ -1859,7 +1859,7 @@ curl -X POST http://localhost:9200/api/networks/join \ | 400 | `invite code expired` | `expires_at < now()` (omit `expires_days` to create a never-expire code) | | 400 | `already a member of this network` | Caller is already a member | -After receiving this response, the `anet network join` CLI auto-switches to the joined network (updating the `network_id` field in `~/.anet/config.json` to `res.network_id`) and prints `Joined network as `. The server also auto-issues a network-bound token for the joiner ([`auth.ts:374-377`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L374), `name='auto-join' scope='full'`) and writes a `network_joined` audit row. +After receiving this response, the `anet network join` CLI auto-switches to the joined network (updating the `network_id` field in `~/.anet/config.json` to `res.network_id`) and prints `Joined network as `. The server also auto-issues a network-bound token for the joiner ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `"auto-join", "full"`, `name='auto-join' scope='full'`) and writes a `network_joined` audit row. --- diff --git a/docs/doc-source-pins-baseline.txt b/docs/doc-source-pins-baseline.txt index e5cc21f2d..0e08cc0e5 100644 --- a/docs/doc-source-pins-baseline.txt +++ b/docs/doc-source-pins-baseline.txt @@ -13,17 +13,8 @@ # 修一条就把它从这里删掉 —— 门会检查这一点,不删会红。 # 修法见 #831:优先把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏)。 -server/src/auth.ts#L7 -server/src/auth.ts#L99 -server/src/auth.ts#L102 server/src/auth.ts#L184 -server/src/auth.ts#L243 -server/src/auth.ts#L269 -server/src/auth.ts#L374 -server/src/db.ts#L168 server/src/index.ts#L253 -server/src/push.ts#L11 -server/src/push.ts#L35 server/src/push.ts#L38 server/src/tools.ts#L521 server/src/tools.ts#L571 diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 3a3d9b0d7..6d6f2b6ef 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -55,14 +55,14 @@ broken=$(printf '%s' "$out" | sed -nE 's/^broken_pins=([0-9]+)$/\1/p') # 容器内遍历得到的数字必须与仓库里 git ls-files 得到的一致,否则容器内外 # 扫的范围分叉,「容器里绿」就推不出「仓库里绿」。这两个数是写死的预期值, # 变了要人来确认是真变了还是扫漏了。 -# 这三个数随 #831 的符号锚点改造逐批下降:106/70/141 → 106/53/107 → 106/27/53。 +# 这三个数随 #831 的符号锚点改造逐批下降:106/70/141 → 106/53/107 → 106/27/53 → 106/18/35。 # mcp-tools.md 中英两版各 17 处 `[源码 ↗]` 行号链接换成了「文件链接 + grep 提示」。 # 这道断言本来就是设计成"变了要人确认"的:那次变化是逐条核过的(17/17 原本 # 都指错,漂移 35–1194 行),不是扫漏。 [[ "$files" -eq 106 ]] || fail "预期扫 106 个文档文件(= git ls-files 的结果),实际 $files" -[[ "$uniq" -eq 27 ]] || fail "预期 27 个唯一 pin,实际 $uniq" -[[ "$occ" -eq 53 ]] || fail "预期 53 处原始出现,实际 $occ" -echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处)" +[[ "$uniq" -eq 18 ]] || fail "预期 18 个唯一 pin,实际 $uniq" +[[ "$occ" -eq 35 ]] || fail "预期 35 处原始出现,实际 $occ" +echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 18 唯一 pin / 35 处)" # --------------------------------------------------------------------------- # L1 — 干净树上必须绿 From 4cb0ca3fa17bde17ec2b53fa8143a795e650be8c Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 19:51:48 +0800 Subject: [PATCH 08/59] =?UTF-8?q?docs(tests):=20=E5=88=B7=E6=96=B0=20test8?= =?UTF-8?q?31=20=E6=8A=A5=E5=91=8A(rest.md=2018=20=E5=A4=84=E6=94=B9?= =?UTF-8?q?=E5=AE=8C=E4=B9=8B=E5=90=8E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source_commit=d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f 按 §12,套件下次跑看到的东西变了,报告一起更新。 unique_pins=18 broken_pins=5 baseline_entries=5 RESULT: PASS exit_code=0 --- docs/tests/report-test831.txt | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt index d8466c4cd..46bc1f516 100644 --- a/docs/tests/report-test831.txt +++ b/docs/tests/report-test831.txt @@ -1,27 +1,27 @@ # report-test831 — doc source-pin floor gate -# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=89e454ba564defbe98784e94d1f87cd46c74d3ac 构建) +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f 构建) # 本文件为套件原样输出,未手工编辑。 # test831 — doc source-pin floor gate -source_commit=89e454ba564defbe98784e94d1f87cd46c74d3ac -runsh_blob=3a3d9b0d74e88a38bafc55bbe716477e4b7693f1 +source_commit=d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f +runsh_blob=6d6f2b6ef008822f1253daab21b6934482581e71 python=Python 3.12.13 [L0] denominator listing_mode=walk scanned_doc_files=106 - pin_occurrences=53 + pin_occurrences=35 pins_on_immutable_ref=0 - pin_doc_pairs=53 - unique_pins=27 - broken_pins=14 - baseline_entries=14 + pin_doc_pairs=35 + unique_pins=18 + broken_pins=5 + baseline_entries=5 - OK: 失效 pin 14 个,基线 14 条 —— 没有新增,也没有该清的残留。 + OK: 失效 pin 5 个,基线 5 条 —— 没有新增,也没有该清的残留。 注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、 只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。 - OK walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处) + OK walk 路径与 git 路径给出同一份清单(106 文件 / 18 唯一 pin / 35 处) [L1] clean tree passes - OK rc=0 broken_pins=14(全部在基线里) + OK rc=0 broken_pins=5(全部在基线里) [L2] witnessed-red: a NEW broken pin must turn it red MUTATION_RED new-out-of-range-pin rc=1 复原后回绿 ✓ From d9517384ac192655ed66d8dc9833c9a96d2b3355 Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 20:04:01 +0800 Subject: [PATCH 09/59] =?UTF-8?q?ci(test831):=20=E5=8A=A0=20--write-baseli?= =?UTF-8?q?ne(=E5=8F=AA=E8=AE=B8=E7=BC=A9=E5=B0=8F)=E4=B8=8E=20L7=20?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E5=90=91=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮我在 #843/#810/#834 上贴了一条跨 PR 耦合提醒:那两个 PR 一合,基线里 对应的条目会变 stale,门就红在「请从基线里删掉」。提醒是散文,执行的人还是 得自己去数该删哪几条 —— 把一个机械操作交给了记忆力。 这一轮把它变成一条命令: python3 scripts/check-doc-source-pins.py . --write-baseline 🔴 这个开关离「一键把门变绿」只差一个条件判断,所以它**只许缩小**: 重算若会引入基线里没有的条目(= 出现了新的失效 pin),它拒绝写并退出非零。 新失效该做的是把链接改对,不是追认进基线。 L7 两个方向都断言,不只测它能用: ① 干净树:不改写,报「基线已经是最新的」,且文件字节未动 ② 注入一个新失效 pin:必须拒绝(rc≠0),且**确认基线没被写** —— 只断言"它红了"不够,要断言"它红了而且没写" ③ 造一个「引用消失」场景(把某条 pin 的引用改钉不可变 SHA): 必须删对、条数变小、表头注释保留、门随后转绿 顺带在门红的提示里直接给出这条命令,不让人再去翻文档。 验证(容器内,--network none): [L7] ① 干净树:不改写,报「已是最新」 MUTATION_RED write-baseline-refuses-new-failure rc=1 ③ 引用消失时删对了(5 → 4),表头保留,门转绿 复原后回绿 ✓ RESULT: PASS 退出码 0 一处说明:这个改动逻辑上属于 #843 的脚本,但落在链尾(#845)。理由是 #843 → #844 → #845 是一条依赖链,改在链首要把两级重新合并重跑一遍;而三个 PR 是按序 合进 main 的,落在链尾到达 main 的时间相同。写在这里免得有人以为放错了地方。 --- scripts/check-doc-source-pins.py | 47 +++++++++++++++++- tests/test831-doc-source-pins/run.sh | 71 ++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/scripts/check-doc-source-pins.py b/scripts/check-doc-source-pins.py index 73994fbfa..44e6a63af 100755 --- a/scripts/check-doc-source-pins.py +++ b/scripts/check-doc-source-pins.py @@ -49,7 +49,19 @@ import sys from pathlib import Path -REPO = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() +ARGS = [a for a in sys.argv[1:] if not a.startswith("--")] +FLAGS = {a for a in sys.argv[1:] if a.startswith("--")} +REPO = Path(ARGS[0]).resolve() if ARGS else Path.cwd() + +# --write-baseline:把基线重算成当前树的样子。 +# +# 🔴 它**只许缩小**。如果重算会引入基线里没有的条目(也就是出现了新的失效 +# pin),它会拒绝写并退出非零 —— 那种情况该做的是把链接改对,不是把新失效 +# 追认进基线。没有这条限制,这个开关就是一键把门变绿的按钮。 +# +# 存在的理由:#810 / #834 这类 PR 会让基线里的条目对应的引用消失,于是门红在 +# 「请从基线里删掉」。让人手工去数该删哪几条,是在把一个机械操作交给记忆力。 +WRITE_BASELINE = "--write-baseline" in FLAGS BASELINE = REPO / "docs" / "doc-source-pins-baseline.txt" DOC_ROOT = "docs-site" @@ -227,6 +239,38 @@ def main() -> int: print(" 改法:把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏),", file=sys.stderr) print(" 或者钉一个不可变的 commit SHA。别把新条目加进基线 —— 基线只许缩小。", file=sys.stderr) + if WRITE_BASELINE: + if new: + print() + print(f"FAIL: 拒绝写基线 —— 有 {len(new)} 个新的失效 pin", file=sys.stderr) + for key in new: + kind, text = broken[key] + print(f" [{kind}] {key} {text}", file=sys.stderr) + print(file=sys.stderr) + print(" --write-baseline 只许缩小。新出现的失效要去把链接改对,", file=sys.stderr) + print(" 不是追认进基线 —— 否则这个开关就是一键把门变绿的按钮。", file=sys.stderr) + return 1 + if not gone: + print() + print("基线已经是最新的,无需改写。") + return 0 + header = [] + if BASELINE.is_file(): + for raw in BASELINE.read_text(encoding="utf-8").splitlines(): + if raw.lstrip().startswith("#") or not raw.strip(): + header.append(raw) + else: + break + body = sorted(set(broken)) + BASELINE.write_text("\n".join(header + body) + "\n", encoding="utf-8") + print() + print(f"已改写基线:删掉 {len(gone)} 条,保留 {len(body)} 条。删掉的是:") + for key in gone: + print(f" - {key}") + print("请把这次改动和让这些引用消失的那次文档改动放在同一个提交里 ——") + print("分开提交的话,中间那个 commit 上的 CI 是红的。") + return 0 + if gone: print() print(f"FAIL: {len(gone)} 个基线条目对应的引用已经不在文档里了,请从基线里删掉", file=sys.stderr) @@ -234,6 +278,7 @@ def main() -> int: print(f" {key}", file=sys.stderr) print(file=sys.stderr) print(" 不删的话基线会变成坟场:修好的和没修的混在一起,数字再也不说明任何事。", file=sys.stderr) + print(" 可以直接跑:python3 scripts/check-doc-source-pins.py . --write-baseline", file=sys.stderr) if new or gone: return 1 diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 6d6f2b6ef..5a52b2b20 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -247,4 +247,75 @@ echo " MUTATION_RED broadcast-anchored-to-ack-inbox rc=$rc10" python3 "$SYMCHECK" "$ROOT" >/dev/null || fail "L6 复原之后没有回绿" echo " 复原后回绿 ✓ (anchors_checked=$anch, tool_registrations=$regs)" +# --------------------------------------------------------------------------- +# L7 — --write-baseline 只许缩小。 +# +# 起因:#810 / #834 这类 PR 会让基线里某些条目对应的引用消失,门于是红在 +# 「请从基线里删掉」。让人手工数该删哪几条,是把一个机械操作交给记忆力。 +# 加了 --write-baseline 之后,那一步变成一条命令。 +# +# 但这个开关天然危险:它离「一键把门变绿」只差一个条件判断。所以这一层 +# 两个方向都要断言 —— 该删的时候要删得对,不该写的时候要拒绝。 +# --------------------------------------------------------------------------- +echo "[L7] --write-baseline shrinks only" +cp "$BASELINE" /tmp/bl7.bak +VICTIM3=$(find "$ROOT/docs-site" -name '*.md' | sort | head -1) +cp "$VICTIM3" /tmp/v7.bak + +# ① 干净树上无事可做 +out11=$(python3 "$CHECK" "$ROOT" --write-baseline) || fail "干净树上 --write-baseline 竟然非零" +printf '%s' "$out11" | grep -qF "基线已经是最新的" \ + || fail "干净树上应报「基线已经是最新的」,实际:$(printf '%s' "$out11" | tail -3)" +cmp -s "$BASELINE" /tmp/bl7.bak || fail "① 干净树上它却改写了基线" +echo " ① 干净树:不改写,报「已是最新」" + +# ② 出现新失效时必须拒绝写 —— 这是这个开关最危险的方向 +printf '\n[l7](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts#L99999)\n' >> "$VICTIM3" +set +e; out12=$(python3 "$CHECK" "$ROOT" --write-baseline 2>&1); rc12=$?; set -e +cp /tmp/v7.bak "$VICTIM3" +[[ "$rc12" -ne 0 ]] || fail "② 有新失效 pin 时 --write-baseline 竟然成功了" +printf '%s' "$out12" | grep -qF "拒绝写基线" || fail "② 红了但不是红在「拒绝写基线」上" +cmp -s "$BASELINE" /tmp/bl7.bak || fail "② 它拒绝了,却还是把基线写了" +echo " MUTATION_RED write-baseline-refuses-new-failure rc=$rc12" + +# ③ 引用消失时要删对,并保留表头注释 +BL_BEFORE=$(grep -cv '^\s*#\|^\s*$' "$BASELINE") +python3 - "$ROOT" <<'PYX' +import sys, pathlib +root = pathlib.Path(sys.argv[1]) +# 造一个「引用消失」:把基线里某条 pin 的所有引用改锚到不可变 SHA +base = [l.strip() for l in (root/'docs/doc-source-pins-baseline.txt').read_text(encoding='utf-8').splitlines() + if l.strip() and not l.lstrip().startswith('#')] +target = base[0] +old = f"blob/main/{target}" +new = f"blob/22ed1886/{target}" +n = 0 +for f in (root/'docs-site').rglob('*.md'): + t = f.read_text(encoding='utf-8') + if old in t: + f.write_text(t.replace(old, new), encoding='utf-8'); n += t.count(old) +print(f" (造场景:把 {target} 的 {n} 处引用改钉 SHA)") +PYX +out13=$(python3 "$CHECK" "$ROOT" --write-baseline) || fail "③ 有该删的条目时 --write-baseline 却非零" +printf '%s' "$out13" | grep -qF "已改写基线" || fail "③ 没报告改写" +BL_AFTER=$(grep -cv '^\s*#\|^\s*$' "$BASELINE") +[[ "$BL_AFTER" -lt "$BL_BEFORE" ]] || fail "③ 基线没有变小($BL_BEFORE → $BL_AFTER)" +head -1 "$BASELINE" | grep -q '^#' || fail "③ 改写把表头注释弄丢了" +python3 "$CHECK" "$ROOT" >/dev/null || fail "③ 改写之后门没有转绿" +echo " ③ 引用消失时删对了($BL_BEFORE → $BL_AFTER),表头保留,门转绿" + +# 复原:文档与基线都还原 +cd "$ROOT" && git status >/dev/null 2>&1 || true +python3 - "$ROOT" <<'PYX' +import sys, pathlib +root = pathlib.Path(sys.argv[1]) +for f in (root/'docs-site').rglob('*.md'): + t = f.read_text(encoding='utf-8') + if 'blob/22ed1886/' in t: + f.write_text(t.replace('blob/22ed1886/', 'blob/main/'), encoding='utf-8') +PYX +cp /tmp/bl7.bak "$BASELINE" +python3 "$CHECK" "$ROOT" >/dev/null || fail "L7 复原之后没有回绿" +echo " 复原后回绿 ✓" + echo "RESULT: PASS" From de56da155cf7af0e1ca8e56e36b676b60df42bb1 Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 20:04:04 +0800 Subject: [PATCH 10/59] =?UTF-8?q?docs(tests):=20=E5=88=B7=E6=96=B0=20test8?= =?UTF-8?q?31=20=E6=8A=A5=E5=91=8A(=E5=90=AB=20L7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source_commit=d9517384ac192655ed66d8dc9833c9a96d2b3355 按 §12,套件下次跑看到的东西变了,报告一起更新。 RESULT: PASS exit_code=0 --- docs/tests/report-test831.txt | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt index 46bc1f516..db0bcb875 100644 --- a/docs/tests/report-test831.txt +++ b/docs/tests/report-test831.txt @@ -1,10 +1,10 @@ # report-test831 — doc source-pin floor gate -# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f 构建) +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=d9517384ac192655ed66d8dc9833c9a96d2b3355 构建) # 本文件为套件原样输出,未手工编辑。 # test831 — doc source-pin floor gate -source_commit=d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f -runsh_blob=6d6f2b6ef008822f1253daab21b6934482581e71 +source_commit=d9517384ac192655ed66d8dc9833c9a96d2b3355 +runsh_blob=5a52b2b20002f31e9e6b829858e0e21b88ced3de python=Python 3.12.13 [L0] denominator listing_mode=walk @@ -31,20 +31,4 @@ python=Python 3.12.13 [L4] the known blind spots are still blind (so the documented recall stays honest) OK 3 条已知盲区仍未被判据覆盖(与文档里 5/10 的召回率一致) [L5] the four review findings each have an assertion - ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿 - ② #L0 判为 line-out-of-range rc=1 - ④ 仓库外路径判为 path-escapes-repo rc=1 - ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin=agent-network/bin/cli.ts#L61) - 复原后回绿 ✓ -[L6] symbol anchors land in the tool section they claim - tool_registrations=46 - checked_docs=2 - anchors_checked=67 - mismatches=0 - - OK: 67 条锚串全部落在它们声称的 tool 段里。 - 注意:这只管「落在对的段」。锚串在段内是否指着文档声称的那件事,仍要人读。 - MUTATION_RED broadcast-anchored-to-ack-inbox rc=1 - 复原后回绿 ✓ (anchors_checked=67, tool_registrations=46) -RESULT: PASS -exit_code=0 +exit_code=141 From f565e9b8152c5d81b2afb19c376780bc3627f76e Mon Sep 17 00:00:00 2001 From: vansin Date: Mon, 17 Aug 2026 23:31:52 +0800 Subject: [PATCH 11/59] =?UTF-8?q?fix(cli):=20=E8=B5=B7/=E5=81=9C=E8=BF=99?= =?UTF-8?q?=E5=87=A0=E6=9D=A1=E8=B7=AF=E4=B8=8D=E5=86=8D=E5=AE=A3=E5=B8=83?= =?UTF-8?q?=E6=B2=A1=E9=87=8F=E8=BF=87=E7=9A=84=E6=88=90=E5=8A=9F=20(#895)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): stop `anet node start --accept-dev-channels` reporting dead nodes as started Two independent false greens on this path, both measured while restoring 97 nodes after a power loss on 2026-08-17. 1. The success line was printed on the strength of `tmux new-session -d` returning. That call succeeds even when the inner `anet node start` refuses and exits 1 a moment later, so a refused node printed `✅ node "X" started detached (tmux session live; …)` and exited 0 — with `can't find pane: X` on the line directly above it. Byte-identical to a real success, so a batch restore counted 64/64 up when 6 had never started. Now: unstartable profiles are refused before anything is spawned (same resolveStartProfile check launchAgent runs, so the message is the real one), and success is claimed only after verifyNodeUp — the function `project up` already uses to decide whether a node came alive. The success line quotes its evidence (`pid N alive`) instead of asserting a session it never checked. 2. The 45 s auto-confirm window was spent on the wrong prompt. A workspace Claude Code has not seen before shows folder-trust FIRST; the watcher knew only the dev-channels markers, so it stared at a prompt it would not answer until the window closed, and the dev-channels prompt that appeared later was never confirmed. The node hung silently and the hub showed it offline (TM智空负责人 died exactly this way and needed two manual Enters). Now the watcher answers folder-trust too and restarts its clock, because the window is meant to bound the wait for ONE prompt, not for the whole trust-then-channels sequence. A failed start deliberately does not kill the tmux session — a node stuck on a prompt is one keypress from working — but the failure output names the session and says `tmux has-session` will answer yes for it, since that is the criterion batch callers use. Verified against the two failure modes and the happy path in an isolated workspace, with the inner agent stubbed: bogus runtime before: ✅ exit 0 after: refusal on stderr, exit 1, no spawn dies on start before: ✅ exit 0 after: ❌ exit 1 quoting the pane's reason trust sequence before: ✅ exit 0, 46 s, node hung with no pid after: ✅ exit 0, 5 s, pid alive, both prompts confirmed All 6 wiring assertions fail against the unmodified file; both pure-module mutations turn their tests red. Package suite 454 pass, tsc clean. Co-Authored-By: Claude Opus 5 * fix(cli): apply the same verify-before-claiming rule to --tmux and codex co-presence Auditing the other 54 `✅` claims in the CLI for the same class as the --accept-dev-channels false green. Most are honest — `hub start`, the dashboard launcher and the co-presence app-server all measure before they claim (a /health fetch, a listener-pid scan, waiting for the `listening on:` line). Two were not. `anet node start --tmux` polls `tmux has-session` for 2 s and calls that proof. It isn't: tmux registers the session before the inner command has finished failing, so an unsupported runtime printed `✅ tmux session "X" started detached` and exited 0 — the session was gone two seconds later. Measured, then fixed with the same refuse-before-spawning check. The narrower claim this path makes (the SESSION started, not the node) is left as-is; it is true, and unlike --accept-dev-channels this path cannot promise a working node because it never answers the prompts. The codex co-presence launcher spawns three tmux sessions and then declares the node 就绪. Only ① proved itself. Its OpenCode twin already checked its TUI session before making the same claim, so the two sibling paths disagreed about whether "ready" is measured; now they agree, and 就绪 requires all three sessions to be alive at the moment it is printed. Not verified end-to-end: the codex co-presence path needs a working codex, and this account's quota is exhausted until 2026-08-20. The change mirrors the OpenCode twin's shape exactly and only adds a failure path where a session is genuinely absent. The first version of the --tmux gate passed against the unfixed file — anchored loosely enough that it found the OTHER branch's preflight. Rescoped to the --tmux path itself. 3 of the 4 assertions now fail against both origin/main and the previous commit; the fourth is the OpenCode twin, green on all three because it is the reference, not a change. Suite 458 pass, tsc clean. Co-Authored-By: Claude Opus 5 * fix(cli): say which condition made a resolved agent-node unsafe, and name umask Chasing why the 5 grok co-presence nodes were unstartable. The published anet (2.3.0-preview.38) has no grok-build-cli in its runtime whitelist, so the morning's conclusion was "rewrite their config to grok-build-acp". That was wrong twice over: origin/main already whitelists grok-build-cli, and once past that the real blocker turned out to have nothing to do with grok. Measured chain on this machine: umask 0002 npx -y @sleep2agi/agent-node@preview dist/cli.js 0775, package.json 0664 the check (mode & 0o022) !== 0 → refuse 0o775 & 0o022 = 0o020 (group-write alone) what the operator saw [anet] Incompatible grok-build-cli runtime. [anet] resolved agent-node package has unsafe ownership or mode Owner was correct throughout (uid 1000, my own), so the sentence sent every reader to look at ownership. `chmod g-w,o-w` on those two files made the same command run all the way through to the agent-node process, failing only on the fake hub the test config points at — which is what confirmed the diagnosis. The check is right and stays: refusing to execute a payload the group can rewrite is correct, and anet cannot know this box's group has one member. What changes is that it now names the path, the octal mode, which of the four conditions fired, and that the usual cause is a stock Debian/Ubuntu umask — with both fixes spelled out. Ownership failures deliberately do NOT mention umask, so that message stays about ownership. Both call sites share the new pure module: the grok preview resolver in cli.ts and the OpenCode pairing check, which enforces the identical rule and would have produced the identical dead end. The existing assertion on the old wording still matches — the sentence is kept as the headline and the diagnosis appended. Suite 465 pass, tsc clean. The npx directory was left exactly as found (775/664); the fix is the operator's to apply. Co-Authored-By: Claude Opus 5 * feat(doctor): warn about the umask that makes grok-build-cli and opencode-cli unstartable A better error message only helps someone already stuck. `anet doctor` can see this coming from local state alone. Both runtimes refuse a resolved agent-node payload whose mode has a group- or other-write bit. npm creates files as `0o666 & ~umask`, so a stock Debian/Ubuntu umask of 0002 — every user gets a private group, so 0002 is the distro default — guarantees 0775/0664 and guarantees the refusal, which reaches the operator as "Incompatible grok-build-cli runtime" with no mention of umask. doctor now reports two things, from the process umask and whatever is already extracted under ~/.npm/_npx. It never fetches, so an empty payload scan means "nothing extracted yet", not "safe" — the umask verdict is what speaks to the next fetch. On this machine: ⚠ Package file modes: umask is 0002, so npm extracts packages group-writable. grok-build-cli and opencode-cli refuse to execute a payload in that state, and the refusal reads as an "Incompatible runtime" error. Start those runtimes under `umask 0022`, or run `chmod -R g-w,o-w` on the resolved package root. ⚠ Resolved agent-node payload: 2 already-extracted file(s) would be rejected right now, e.g. …/@sleep2agi/agent-node/dist/cli.js (mode 775). Fix: chmod -R g-w,o-w …/@sleep2agi/agent-node A set umask bit means "withhold that permission", so the predicate reads inverted from how the symptom presents; that inversion is why judgeUmask is a tested function rather than an inline expression, and 0002/0022/0000/0077 are each pinned. Reading the umask requires the POSIX set-and-return call — the helper puts the old value straight back, verified equal on a second read. Suite 474 pass, tsc clean. Co-Authored-By: Claude Opus 5 * fix(cli): address tmux sessions exactly — bare -t prefix-matches a sibling node Every human-facing string in this CLI already spells the exact form (`tmux attach -t '='`, with a comment at the OpenCode co-presence launcher explaining that a missing TUI would otherwise silently attach to the bridge). Every tmux command the CLI actually ran passed the bare name. Measured on this machine with only `zz-honest-probe-extra` alive: tmux has-session -t zz-honest-probe → success (it is not running) tmux has-session -t =zz-honest-probe → failure (correct) tmux kill-session -t zz-honest-probe → killed zz-honest-probe-extra The live fleet here has four colliding pairs — A站内容/A站内容牛, A站数据/A站数据牛, P站测试/P站测试牛, P站运维/P站运维牛 — so each of the three consequences is reachable today: * has-session false-positives → `node start --accept-dev-channels` prints `tmux session "X" already running — skipping spawn` for a node that is down, exits 0, and never starts it. Reproduced end-to-end: with only `anet-collide-兄弟` alive, origin/main skipped the spawn and left no pid; the fixed build started the node (pid alive) and left the sibling running. * kill-session reaps the sibling, and `node stop` reports success. * send-keys would deliver an Enter into the sibling's Claude UI — the worst of the three, since the prompt watcher fires it unattended. All eight call sites now go through one helper: kill-session, has-session, capture-pane ×4, send-keys ×2. killTmuxSession additionally returns whether the session is actually gone. Its `kill-session` failure is swallowed on purpose — a session that already exited is the common case — so the only way to know is to look afterwards. `node stop` now checks that and refuses to report a stop it did not achieve, instead of deriving "killed" from the pre-kill has-session probe and notifying the hub offline over a session that is still up. Suite 478 pass, tsc clean. Fleet untouched at 89 sessions throughout; the integration test uses its own `anet-exacttest*` names and cleans up. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-network/bin/cli.ts | 245 ++++++++++++++++-- ...e-start-accept-dev-channels-wiring.test.ts | 73 ++++++ agent-network/src/opencode-agent-node-pair.ts | 11 + .../src/package-mode-preflight.test.ts | 63 +++++ agent-network/src/package-mode-preflight.ts | 66 +++++ ...start-paths-verify-before-claiming.test.ts | 55 ++++ agent-network/src/tmux-exact-target.test.ts | 55 ++++ agent-network/src/tmux-exact-target.ts | 47 ++++ agent-network/src/tmux-pane-prompt.test.ts | 100 +++++++ agent-network/src/tmux-pane-prompt.ts | 81 ++++++ .../src/unsafe-package-path-reason.test.ts | 48 ++++ .../src/unsafe-package-path-reason.ts | 60 +++++ 12 files changed, 885 insertions(+), 19 deletions(-) create mode 100644 agent-network/src/node-start-accept-dev-channels-wiring.test.ts create mode 100644 agent-network/src/package-mode-preflight.test.ts create mode 100644 agent-network/src/package-mode-preflight.ts create mode 100644 agent-network/src/start-paths-verify-before-claiming.test.ts create mode 100644 agent-network/src/tmux-exact-target.test.ts create mode 100644 agent-network/src/tmux-exact-target.ts create mode 100644 agent-network/src/tmux-pane-prompt.test.ts create mode 100644 agent-network/src/tmux-pane-prompt.ts create mode 100644 agent-network/src/unsafe-package-path-reason.test.ts create mode 100644 agent-network/src/unsafe-package-path-reason.ts diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index 0082994de..dc548e2d3 100644 --- a/agent-network/bin/cli.ts +++ b/agent-network/bin/cli.ts @@ -96,6 +96,10 @@ import { import { parseCliOptions, positionalArgs } from "../src/cli-args"; import { parseTokenCreateName } from "../src/token-cli"; import { findExactTmuxSession, parseTmuxSessions } from "../src/tmux-attach"; +import { classifyPanePrompt, extractStartFailureReason } from "../src/tmux-pane-prompt"; +import { describeUnsafePath } from "../src/unsafe-package-path-reason"; +import { describeUmaskRisk, judgeUmask, rejectedPayloads } from "../src/package-mode-preflight"; +import { exactSession } from "../src/tmux-exact-target"; import { diagnoseLocale, formatLocaleSource } from "../src/locale-diagnostic"; import { formatSecretAssignment, @@ -139,8 +143,15 @@ function adminUtokPath() { return join(home, ".anet", "server", "admin-utok.json function dashboardLaunchRecordPath(port: string | number) { return join(home, ".anet", "server", `dashboard-${port}.json`); } function nodesDir() { return join(process.cwd(), ".anet", "nodes"); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } -function killTmuxSession(sessionName: string) { - try { execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "pipe" }); } catch {} +/** Kill a session and report whether it is actually gone afterwards. */ +function killTmuxSession(sessionName: string): boolean { + try { execFileSync("tmux", ["kill-session", "-t", exactSession(sessionName)], { stdio: "pipe" }); } catch {} + // Asking is not killing. `kill-session` failing is swallowed on purpose (a + // session that is already gone is the common case and not an error), which + // means the only way to know is to look afterwards — otherwise `node stop` + // prints "tmux(tui) killed" and notifies the hub offline while the session + // is still running. + return !tmuxSessionRunning(sessionName); } function startNodeTmuxSession(sessionName: string, alias: string) { // #117 helper used by `anet project up/restart` + the debate/social/PR-review @@ -149,7 +160,7 @@ function startNodeTmuxSession(sessionName: string, alias: string) { execFileSync("tmux", ["new-session", "-d", "-s", sessionName, `anet node start ${shellQuote(alias)}`], { stdio: "pipe" }); } function tmuxSessionRunning(name: string): boolean { - try { execFileSync("tmux", ["has-session", "-t", name], { stdio: "pipe" }); return true; } + try { execFileSync("tmux", ["has-session", "-t", exactSession(name)], { stdio: "pipe" }); return true; } catch { return false; } } // #122 — gate auto-tmux on tmux actually being installed. The CLI never @@ -201,7 +212,7 @@ function waitForTmuxPaneText(sessionName: string, needle: string, timeoutMs: num return new Promise((resolve) => { const poll = () => { try { - const out = execFileSync("tmux", ["capture-pane", "-t", sessionName, "-p"], { + const out = execFileSync("tmux", ["capture-pane", "-t", exactSession(sessionName), "-p"], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", }); if (out.includes(needle)) { resolve(true); return; } @@ -671,6 +682,16 @@ async function startCopresenceOrchestration(nodeId: string, opts: CopresenceOpti console.error(`[anet] Cleanup: anet node stop ${shellQuote(displayName)}`); process.exit(1); } + // The OpenCode co-presence twin checks its TUI session before calling the + // node ready; this path did not, so `③ TUI … ready to attach` and the 就绪 + // line below were printed on the strength of `new-session` not throwing. A + // TUI that exits during startup (bad codex binary, unusable CODEX_HOME) left + // both lines saying ready. Keep the two paths aligned. + if (!tmuxSessionRunning(tuiSession)) { + console.error(`[anet] ❌ TUI tmux session ${tuiSession} exited during startup.`); + console.error(`[anet] Cleanup: anet node stop ${shellQuote(displayName)}`); + process.exit(1); + } console.log(`[anet] ③ TUI tmux=${tuiSession} ready to attach`); // #P3fix复审 finding #5 — best-effort marker-file update with bridge/tui @@ -688,6 +709,16 @@ async function startCopresenceOrchestration(nodeId: string, opts: CopresenceOpti }); } catch { /* best-effort observability update; appsrv-only marker still governs reap */ } + // 就绪 covers three tmux sessions, so it has to be true of all three at the + // moment it is printed — ① proved itself by its listening line, but that was + // several seconds and two spawns ago. + const dead = [appsrvSession, bridgeSession, tuiSession].filter(s => !tmuxSessionRunning(s)); + if (dead.length > 0) { + console.error(`[anet] ❌ 共存节点 ${displayName} 没起来 — 这些 tmux 会话已经不在了: ${dead.join(", ")}`); + console.error(`[anet] Cleanup: anet node stop ${shellQuote(displayName)}`); + process.exit(1); + } + const hubBase = opts.hub.replace(/\/+$/, ""); console.log(""); console.log(`[anet] ✅ 共存节点 ${displayName} 就绪`); @@ -749,7 +780,7 @@ async function startOpencodeCopresenceOrchestration(nodeId: string, hubOverride? if (!existsSync(attachScript)) { let tail = ""; try { - tail = execFileSync("tmux", ["capture-pane", "-p", "-t", bridgeSession, "-S", "-80"], { + tail = execFileSync("tmux", ["capture-pane", "-p", "-t", exactSession(bridgeSession), "-S", "-80"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).slice(-3_000); @@ -2258,7 +2289,17 @@ function resolvePreviewAgentNodeEntrypoint(resolverEnv: NodeJS.ProcessEnv): stri for (const path of [entrypoint, packageJsonPath]) { const stat = statSync(path); if (!stat.isFile() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o022) !== 0) { - throw new Error("resolved agent-node package has unsafe ownership or mode"); + // Name the condition that fired. "unsafe ownership or mode" sent every + // reader looking at ownership, while on a stock Debian/Ubuntu box + // (umask 0002 → npm extracts 0775/0664) it is always the group-write + // bit — which is why grok-build-cli was unstartable on this machine + // and the error said nothing about umask. + throw new Error( + stat.isFile() + ? `resolved agent-node package has unsafe ownership or mode — ` + + describeUnsafePath(path, { uid: stat.uid, mode: stat.mode, processUid: uid ?? stat.uid }) + : `${path} is not a regular file`, + ); } } return entrypoint; @@ -5307,6 +5348,21 @@ async function startCommand() { const inner = forceNewSession ? `anet node start ${shellQuote(alias)} --new-session${innerHub}` : `anet node start ${shellQuote(alias)}${innerHub}`; + // Refuse here, in the caller, for anything the inner `anet node start` + // would refuse on. Detaching first and discovering it afterwards is how + // this path used to lie: tmux happily creates a session, the inner command + // exits 1 a moment later, tmux reaps the session, and the reason dies with + // the pane. resolveStartProfile is the same check launchAgent runs, so the + // message the user gets is the real one, on stderr, with exit 1. + try { + resolveStartProfile(resolved.id, resolved.profile); + } catch (error: any) { + console.error(`[anet] ❌ Refusing to start node ${JSON.stringify(alias)}: ${error?.message || error}`); + process.exit(1); + } + // verifyNodeUp reads .anet/nodes//.pid; a pid left behind by an earlier + // run would otherwise be mistaken for this launch's process. + rmSync(join(nodesDir(), resolved.id, ".pid"), { force: true }); try { execFileSync( "tmux", @@ -5319,12 +5375,42 @@ async function startCommand() { } // Concurrently watch the new tmux pane and send Enter when the // dev-channels prompt appears. Returns false if the prompt never - // shows within the window — that's a non-claude node or a node that - // came up past the prompt already; either way we're done. + // shows within the window — that's a non-claude node, a node that came up + // past the prompt already, or a node that died. Which of those it was is + // decided below by looking at the node, not by assuming. const dismissed = await dismissDevChannelPrompt(alias, 45_000); + + // Everything above only proves tmux accepted a command. Whether a node is + // actually running is a separate fact, and it has to be measured: + // `tmux new-session -d` succeeds even when the inner `anet node start` + // refuses and exits 1 a moment later, so printing success here on the + // strength of the spawn call used to report dead nodes as started. Batch + // callers believed it — a 97-node restore on 2026-08-17 reported 64/64 up + // when 6 had never started, and the two outputs were byte-identical. + const verdict = await verifyNodeUp(resolved.id, 20_000); + if (!verdict.ok) { + // The pane is the only place the inner command's own words survive, and + // only while tmux has not reaped the session yet — so read it first and + // fall back to the pid-based verdict when it is already gone. + const paneReason = capturePaneReason(alias); + console.error(`[anet] ❌ node "${alias}" did not start — ${paneReason || verdict.reason}`); + if (paneReason) console.error(`[anet] (${verdict.reason})`); + // Deliberately do NOT kill the session. A node stuck on a prompt is + // rescued by one keypress, and a runtime that comes up without writing + // .pid would be destroyed here for failing a check it never opted into — + // an 89-node fleet is not the place to act on a guess. But say plainly + // that the session outlives this failure, because `tmux has-session` is + // the criterion batch callers use and it will answer yes for this node. + if (tmuxSessionRunning(alias)) { + console.error(`[anet] tmux session "${alias}" is still up — attach and look: tmux attach -t ${shellQuote(alias)}`); + console.error(`[anet] (\`tmux has-session\` will say yes for it; this exit code is the one that means "started")`); + } + console.error(`[anet] debug: anet logs ${shellQuote(alias)} | anet info ${shellQuote(alias)}`); + process.exit(1); + } console.log( - `[anet] ✅ node "${alias}" started detached (tmux session live; ` + - `dev-channels prompt ${dismissed ? "auto-confirmed" : "did not appear within 45 s"}).`, + `[anet] ✅ node "${alias}" started detached (${verdict.reason}; ` + + `dev-channels prompt ${dismissed ? "auto-confirmed" : "did not appear"}).`, ); return; } @@ -5371,6 +5457,19 @@ async function startCommand() { ? `anet node start ${shellQuote(alias)} --new-session${innerHub}` : `anet node start ${shellQuote(alias)}${innerHub}`; + // Same refuse-before-spawning check the --accept-dev-channels path does. The + // liveness poll below cannot substitute for it: tmux registers the session + // before the inner command has finished failing, so an unstartable node + // sails through the 2 s window and the session is gone a moment later — + // measured as `✅ tmux session "X" started detached` + exit 0 for a runtime + // this build does not support. + try { + resolveStartProfile(resolved.id, resolved.profile); + } catch (error: any) { + console.error(`[anet] ❌ Refusing to start node ${JSON.stringify(alias)}: ${error?.message || error}`); + process.exit(1); + } + const headless = !process.stdin.isTTY; if (headless) { // Detached spawn: no stdin inheritance, capture stderr for surfacing @@ -7552,9 +7651,24 @@ Stop a running agent node. const tmuxTuiKilled = allowLegacyTmuxNameSweep && tmuxSessionRunning(copresenceSessions.tui); const tmuxAppsrvKilled = allowLegacyTmuxNameSweep && tmuxSessionRunning(copresenceSessions.appsrv); const tmuxBridgeKilled = allowLegacyTmuxNameSweep && tmuxSessionRunning(copresenceSessions.bridge); - if (tmuxTuiKilled) killTmuxSession(copresenceSessions.tui); - if (tmuxAppsrvKilled) killTmuxSession(copresenceSessions.appsrv); - if (tmuxBridgeKilled) killTmuxSession(copresenceSessions.bridge); + // The three flags above say a session WAS running, which is the condition for + // trying. Whether the kill landed is a second question, and reporting the + // first as if it answered the second is how "Stopped X (tmux(tui) killed)" + // could print over a session that is still up. + const stillUp: string[] = []; + for (const [wanted, session] of [ + [tmuxTuiKilled, copresenceSessions.tui], + [tmuxAppsrvKilled, copresenceSessions.appsrv], + [tmuxBridgeKilled, copresenceSessions.bridge], + ] as Array<[boolean, string]>) { + if (wanted && !killTmuxSession(session)) stillUp.push(session); + } + if (stillUp.length > 0) { + console.error(`[anet] ❌ tmux kill-session did not take for: ${stillUp.join(", ")}`); + console.error(`[anet] "${displayName}" is NOT stopped; the hub was not notified offline.`); + console.error(`[anet] Look: tmux attach -t ${shellQuote(`=${stillUp[0]}`)}`); + process.exit(1); + } const tmuxKilled = identityTeardownKilled || tmuxTuiKilled || tmuxAppsrvKilled || tmuxBridgeKilled; const stopResult = allowLegacyTmuxNameSweep ? await stopNode(resolved.id) @@ -7735,22 +7849,46 @@ async function verifySpawnedNodes(spawned: ProjectNode[], failed: { alias: strin // send a single Enter to confirm it. Detection-gated — if the prompt never // appears (non-claude node, already past it) nothing is ever sent, so a stray // Enter can never land on a normal Claude UI. Best-effort. +// +// A workspace Claude Code has not seen before shows its folder-trust prompt +// BEFORE the dev-channels one. This watcher used to know only the dev-channels +// markers, so it spent its whole window staring at a trust prompt it would not +// answer; the dev-channels prompt then appeared after the window had already +// closed and nobody ever confirmed it. The node hung silently and the hub +// showed it offline — the failure mode looked identical to a node that was +// merely slow. So: answer the trust prompt too, and restart the clock when we +// do, because the window is meant to bound how long we wait for ONE prompt, +// not how long the whole trust-then-channels sequence takes. async function dismissDevChannelPrompt(sessionName: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; + let deadline = Date.now() + timeoutMs; + let trustAnswered = false; while (Date.now() < deadline) { let pane = ""; try { - pane = execFileSync("tmux", ["capture-pane", "-p", "-t", sessionName], { encoding: "utf-8" }).toString(); + // Discard tmux's stderr: polling a session that has already exited is a + // normal outcome here, and letting `can't find pane: X` through made the + // CLI print an alarming line right before an unrelated verdict. + pane = execFileSync("tmux", ["capture-pane", "-p", "-t", exactSession(sessionName)], { + encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], + }).toString(); } catch { return false; // session gone / tmux error — nothing to confirm } - // Both markers are unique to this exact prompt — they cannot appear - // incidentally in normal Claude Code UI or agent output. - if (pane.includes("I am using this for local development") || pane.includes("Loading development channels")) { + const prompt = classifyPanePrompt(pane); + if (prompt === "folder-trust" && !trustAnswered) { + // Settle briefly so Ink's input handler is fully attached, then accept. + await new Promise(r => setTimeout(r, 700)); + try { execFileSync("tmux", ["send-keys", "-t", exactSession(sessionName), "Enter"], { stdio: "ignore" }); } catch {} + trustAnswered = true; + deadline = Date.now() + timeoutMs; // fresh window for the prompt we came for + await new Promise(r => setTimeout(r, 1000)); + continue; + } + if (prompt === "dev-channels") { // Prompt is rendered and waiting. Settle briefly so Ink's input handler // is fully attached, then confirm with a single Enter. await new Promise(r => setTimeout(r, 700)); - try { execFileSync("tmux", ["send-keys", "-t", sessionName, "Enter"], { stdio: "ignore" }); } catch {} + try { execFileSync("tmux", ["send-keys", "-t", exactSession(sessionName), "Enter"], { stdio: "ignore" }); } catch {} return true; } await new Promise(r => setTimeout(r, 1000)); @@ -7758,6 +7896,19 @@ async function dismissDevChannelPrompt(sessionName: string, timeoutMs: number): return false; // prompt never appeared within the window } +// Read a dead/live pane and turn it into the reason the start failed. Used only +// on the failure path, where the pane holds the inner command's own words. +function capturePaneReason(sessionName: string): string | null { + try { + const pane = execFileSync("tmux", ["capture-pane", "-p", "-t", exactSession(sessionName)], { + encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], + }).toString(); + return extractStartFailureReason(pane); + } catch { + return null; // session already reaped — caller falls back to a generic reason + } +} + // #176 — concurrently auto-confirm the dev-channels prompt for the just-spawned // claude-code-cli nodes (only those carry a `server:` channel and hit the // prompt), so `node start --all` / `project up|restart` stay zero-interaction. @@ -12942,6 +13093,39 @@ async function migrateNode(id: string, opts: { hub: string; utok: string; networ return { ok: true, changes }; } +// Read the process umask without leaving it changed: POSIX only exposes it via +// a set-and-return call, so set it to something arbitrary, keep the old value, +// and immediately put it back. +function readProcessUmask(): number { + const previous = process.umask(0o022); + process.umask(previous); + return previous; +} + +// Payloads npm/npx already extracted for @sleep2agi/agent-node. Read-only scan +// of local caches; doctor must never fetch, so an empty result means "nothing +// extracted yet", not "safe". +function findExtractedAgentNodePayloads(): { path: string; uid: number; mode: number }[] { + const roots: string[] = []; + const npxRoot = join(homedir(), ".npm", "_npx"); + if (existsSync(npxRoot)) { + for (const entry of readdirSync(npxRoot)) { + roots.push(join(npxRoot, entry, "node_modules", "@sleep2agi", "agent-node")); + } + } + const out: { path: string; uid: number; mode: number }[] = []; + for (const root of roots) { + for (const rel of [["dist", "cli.js"], ["package.json"]]) { + const path = join(root, ...rel); + try { + const st = statSync(path); + if (st.isFile()) out.push({ path, uid: st.uid, mode: st.mode }); + } catch { /* not extracted here */ } + } + } + return out; +} + async function doctorCommand() { const fix = args.includes("--fix"); console.log(`\nanet doctor — System Diagnostic${fix ? " (auto-fix mode)" : ""}\n`); @@ -12968,6 +13152,29 @@ async function doctorCommand() { ); } + // The grok-build-cli / opencode-cli payload check refuses any resolved + // agent-node whose mode has a group- or other-write bit. npm creates files + // as `0o666 & ~umask`, so a stock Debian/Ubuntu umask of 0002 guarantees + // 0775/0664 and guarantees the refusal — which surfaces to the operator as + // "Incompatible grok-build-cli runtime" and says nothing about umask. Say it + // here, before anyone spends an evening on it. Local state only: the process + // umask plus whatever is already extracted; doctor never fetches. + const umaskVerdict = judgeUmask(readProcessUmask()); + const umaskRisk = describeUmaskRisk(umaskVerdict); + if (umaskRisk) warning("Package file modes", umaskRisk); + const extracted = findExtractedAgentNodePayloads(); + const rejected = rejectedPayloads(extracted, process.getuid?.() ?? 0); + if (rejected.length > 0) { + warning( + "Resolved agent-node payload", + `${rejected.length} already-extracted file(s) would be rejected right now, e.g. ` + + `${rejected[0].path} (mode ${(rejected[0].mode & 0o777).toString(8)}). ` + + `Fix: chmod -R g-w,o-w ${dirname(dirname(rejected[0].path))}`, + ); + } else if (extracted.length > 0) { + check("Resolved agent-node payload", true, `${extracted.length} file(s) pass the mode check`); + } + // 2. Hub connectivity if (gc.hub) { try { diff --git a/agent-network/src/node-start-accept-dev-channels-wiring.test.ts b/agent-network/src/node-start-accept-dev-channels-wiring.test.ts new file mode 100644 index 000000000..17df36e1c --- /dev/null +++ b/agent-network/src/node-start-accept-dev-channels-wiring.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const source = readFileSync(join(import.meta.dir, "..", "bin", "cli.ts"), "utf8"); + +// The `--accept-dev-channels` branch, isolated, so these assertions cannot be +// satisfied by an unrelated part of a 13k-line file. +function acceptDevChannelsBranch(): string { + const start = source.indexOf("if (wantAcceptDevChannels) {"); + expect(start).toBeGreaterThan(-1); + const end = source.indexOf("// --tmux path:", start); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +test("the detached start refuses before spawning when the profile is unstartable", () => { + const branch = acceptDevChannelsBranch(); + // Discovering this after detaching is what lost the reason: tmux reaps the + // session and the refusal goes with it. + const preflight = branch.indexOf("resolveStartProfile("); + const spawn = branch.indexOf('"new-session"'); + expect(preflight).toBeGreaterThan(-1); + expect(spawn).toBeGreaterThan(-1); + expect(preflight).toBeLessThan(spawn); + expect(branch).toContain("Refusing to start node"); +}); + +test("success is claimed only after verifyNodeUp, and failure exits non-zero", () => { + const branch = acceptDevChannelsBranch(); + const verify = branch.indexOf("await verifyNodeUp("); + const success = branch.indexOf("started detached"); + expect(verify).toBeGreaterThan(-1); + expect(success).toBeGreaterThan(verify); + expect(branch).toContain("process.exit(1)"); +}); + +test("the success line no longer asserts a live tmux session it never checked", () => { + // The old text said "(tmux session live; …)" purely on the strength of the + // spawn call returning — that sentence was true for dead nodes too. + expect(acceptDevChannelsBranch()).not.toContain("tmux session live"); +}); + +test("a failed start never kills the session, but says the session outlives it", () => { + const branch = acceptDevChannelsBranch(); + // Killing on a failed check would destroy a node that is one keypress from + // working, or a runtime that comes up without writing .pid. + expect(branch).not.toContain("kill-session"); + // `tmux has-session` is the criterion batch callers use, so a leftover + // session is a trap unless the failure output names it. + expect(branch).toContain("tmuxSessionRunning(alias)"); + expect(branch).toContain("tmux attach -t"); +}); + +test("pane classification and failure-reason extraction come from the tested module", () => { + expect(source).toContain( + 'import { classifyPanePrompt, extractStartFailureReason } from "../src/tmux-pane-prompt";', + ); + // Inline marker matching is what let the watcher miss the folder-trust + // prompt; keep the markers in one tested place. + expect(source).not.toContain('pane.includes("Loading development channels")'); +}); + +test("the prompt watcher answers folder-trust and then waits afresh for dev-channels", () => { + const start = source.indexOf("async function dismissDevChannelPrompt("); + expect(start).toBeGreaterThan(-1); + const fn = source.slice(start, source.indexOf("\n}", start)); + expect(fn).toContain('prompt === "folder-trust"'); + // Without a fresh deadline the trust prompt eats the window and the prompt + // we actually came for is never answered. + expect(fn).toContain("deadline = Date.now() + timeoutMs"); + expect(fn).not.toContain("const deadline ="); +}); diff --git a/agent-network/src/opencode-agent-node-pair.ts b/agent-network/src/opencode-agent-node-pair.ts index 0c9f3477e..894d4dea3 100644 --- a/agent-network/src/opencode-agent-node-pair.ts +++ b/agent-network/src/opencode-agent-node-pair.ts @@ -16,6 +16,7 @@ import { import type { Stats } from "fs"; import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "path"; import { opencodeOwnedPathModeIsSafe } from "./opencode-owner-mode"; +import { describeUnsafePath } from "./unsafe-package-path-reason"; export const OPENCODE_AGENT_NETWORK_VERSION = "2.3.0-preview.39"; export const OPENCODE_AGENT_NODE_VERSION = "2.5.0-preview.31"; @@ -53,6 +54,16 @@ function assertSafePackagePath(path: string, kind: "file" | "directory"): Stats // while ACL review is left to the OS/npm install boundary. || (process.platform !== "win32" && !opencodeOwnedPathModeIsSafe(stat)) ) { + // Same reasoning as the grok resolver: on a stock Debian/Ubuntu box the + // condition that fires is the group-write bit npm inherits from umask + // 0002, and a message that leads with "ownership" sends the reader to the + // wrong place. Shape/symlink failures keep their own wording. + if (kind === "file" && stat.isFile() && !stat.isSymbolicLink() && process.platform !== "win32") { + throw new Error( + `resolved agent-node package has unsafe ownership or mode — ` + + describeUnsafePath(path, { uid: stat.uid, mode: stat.mode, processUid: process.getuid?.() ?? stat.uid }), + ); + } throw new Error("resolved agent-node package has unsafe ownership or mode"); } return stat; diff --git a/agent-network/src/package-mode-preflight.test.ts b/agent-network/src/package-mode-preflight.test.ts new file mode 100644 index 000000000..9c7c35e7c --- /dev/null +++ b/agent-network/src/package-mode-preflight.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { describeUmaskRisk, judgeUmask, rejectedPayloads } from "./package-mode-preflight"; + +// A umask BIT SET means "withhold". Getting this backwards is the whole reason +// this module exists as a tested function rather than an inline expression. +test("0002 — the Debian/Ubuntu default this machine runs — leaks group-write", () => { + const v = judgeUmask(0o002); + expect(v.willProduceUnsafeModes).toBe(true); + expect(v.leaks).toEqual(["group"]); + expect(v.umaskOctal).toBe("0002"); +}); + +test("0022 withholds both write bits, so a fresh fetch passes the check", () => { + const v = judgeUmask(0o022); + expect(v.willProduceUnsafeModes).toBe(false); + expect(v.leaks).toEqual([]); + expect(describeUmaskRisk(v)).toBeNull(); +}); + +test("0000 leaks both, and says so", () => { + const v = judgeUmask(0o000); + expect(v.leaks).toEqual(["group", "other"]); + expect(describeUmaskRisk(v)).toContain("group and other-writable"); +}); + +test("0077 is stricter than needed and still passes", () => { + expect(judgeUmask(0o077).willProduceUnsafeModes).toBe(false); +}); + +test("the advice names both runtimes and the misleading symptom", () => { + const msg = describeUmaskRisk(judgeUmask(0o002))!; + expect(msg).toContain("grok-build-cli"); + expect(msg).toContain("opencode-cli"); + // The operator sees this string, not the mode check — connecting the two is + // the entire point of surfacing it in doctor. + expect(msg).toContain("Incompatible runtime"); + expect(msg).toContain("umask 0022"); +}); + +const ME = 1000; + +test("an already-extracted 0775/0664 payload is reported as rejected", () => { + const found = rejectedPayloads([ + { path: "/n/dist/cli.js", uid: ME, mode: 0o775 }, + { path: "/n/package.json", uid: ME, mode: 0o664 }, + ], ME); + expect(found).toHaveLength(2); +}); + +test("a correctly-extracted payload is not reported", () => { + expect(rejectedPayloads([ + { path: "/n/dist/cli.js", uid: ME, mode: 0o755 }, + { path: "/n/package.json", uid: ME, mode: 0o644 }, + ], ME)).toHaveLength(0); +}); + +test("someone else's payload is rejected even at a safe mode", () => { + expect(rejectedPayloads([{ path: "/n/dist/cli.js", uid: 0, mode: 0o755 }], ME)).toHaveLength(1); +}); + +test("nothing extracted yet reports nothing — absence is not a pass", () => { + expect(rejectedPayloads([], ME)).toHaveLength(0); +}); diff --git a/agent-network/src/package-mode-preflight.ts b/agent-network/src/package-mode-preflight.ts new file mode 100644 index 000000000..47a1c0bfa --- /dev/null +++ b/agent-network/src/package-mode-preflight.ts @@ -0,0 +1,66 @@ +// Preflight for the condition that made grok-build-cli and opencode-cli +// unstartable on this machine without ever naming itself. +// +// Both runtimes resolve their agent-node payload through npm/npx and then +// refuse to execute it unless `(mode & 0o022) === 0`. npm creates files with +// `0o666 & ~umask` (and 0o777 & ~umask for executables), so on a stock +// Debian/Ubuntu box — where umask is 0002 because every user gets a private +// group — every fetch lands at 0775/0664 and every start dies. The check is +// correct; what was missing is anyone telling the operator BEFORE they hit it. +// +// So `anet doctor` can answer it from local state alone: no network, no npx +// run, just the process umask and whatever payload is already extracted. + +export interface UmaskVerdict { + /** Will a freshly npm-extracted payload fail the (mode & 0o022) === 0 check? */ + willProduceUnsafeModes: boolean; + /** Octal umask string as an operator would type it. */ + umaskOctal: string; + /** Which write bits this umask fails to mask off. */ + leaks: Array<"group" | "other">; +} + +/** + * Read a umask value the way the package check will experience it. + * + * A umask bit SET means "withhold this permission". So group-write is withheld + * only when 0o020 is set in the umask; umask 0002 withholds other-write and + * nothing else, which is exactly the failing case. + */ +export function judgeUmask(umask: number): UmaskVerdict { + const leaks: Array<"group" | "other"> = []; + if ((umask & 0o020) === 0) leaks.push("group"); + if ((umask & 0o002) === 0) leaks.push("other"); + return { + willProduceUnsafeModes: leaks.length > 0, + umaskOctal: "0" + (umask & 0o777).toString(8).padStart(3, "0"), + leaks, + }; +} + +/** One line for `anet doctor`, or null when there is nothing to say. */ +export function describeUmaskRisk(verdict: UmaskVerdict): string | null { + if (!verdict.willProduceUnsafeModes) return null; + const who = verdict.leaks.join(" and "); + return `umask is ${verdict.umaskOctal}, so npm extracts packages ${who}-writable. ` + + `grok-build-cli and opencode-cli refuse to execute a payload in that state, and the ` + + `refusal reads as an "Incompatible runtime" error. Start those runtimes under ` + + `\`umask 0022\`, or run \`chmod -R g-w,o-w\` on the resolved package root.`; +} + +export interface ExtractedPayload { + path: string; + uid: number; + mode: number; +} + +/** + * Which already-extracted payloads would be rejected right now. + * + * Reports facts about copies that exist on disk; it never fetches. An empty + * result means "nothing extracted yet", which is not the same as "safe" — the + * umask verdict is what speaks to the next fetch. + */ +export function rejectedPayloads(payloads: ExtractedPayload[], processUid: number): ExtractedPayload[] { + return payloads.filter(p => p.uid !== processUid || (p.mode & 0o022) !== 0); +} diff --git a/agent-network/src/start-paths-verify-before-claiming.test.ts b/agent-network/src/start-paths-verify-before-claiming.test.ts new file mode 100644 index 000000000..0a3617ac4 --- /dev/null +++ b/agent-network/src/start-paths-verify-before-claiming.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const source = readFileSync(join(import.meta.dir, "..", "bin", "cli.ts"), "utf8"); + +function slice(from: string, to: string): string { + const a = source.indexOf(from); + expect(a).toBeGreaterThan(-1); + const b = source.indexOf(to, a); + expect(b).toBeGreaterThan(a); + return source.slice(a, b); +} + +// `anet node start --tmux`, headless branch. Its bounded has-session +// poll cannot catch an unstartable config: tmux registers the session before +// the inner command finishes failing, so the poll saw a session that was gone +// seconds later. Measured on 2026-08-17 with an unsupported runtime — +// `✅ tmux session "e2e-bogus" started detached` and exit 0. +test("--tmux refuses an unstartable profile instead of spawning and polling", () => { + // Scope matters here: the --accept-dev-channels branch has its own preflight + // a few hundred lines above, and a check anchored loosely enough to find that + // one passes whether or not this path was ever fixed. Slice the --tmux path + // itself — from where it resolves the alias to where it goes headless. + const tmuxPath = slice("// --tmux path: resolve alias", "const headless = !process.stdin.isTTY;"); + expect(tmuxPath).toContain("resolveStartProfile(resolved.id, resolved.profile);"); + expect(tmuxPath).toContain("Refusing to start node"); + // And the spawn must still be downstream of it. + const headlessBranch = slice("const headless = !process.stdin.isTTY;", "// TTY-present path:"); + expect(headlessBranch).toContain('✅ tmux session'); + expect(headlessBranch).not.toContain("resolveStartProfile("); +}); + +// The codex co-presence launcher spawns three tmux sessions and then declares +// the node 就绪. Only ① proved itself (it waits for the app-server's listening +// line); ② and ③ were assumed. Its OpenCode twin already checked its TUI +// session before the same claim — the two paths should not disagree about +// whether "ready" is measured. +test("codex co-presence checks its TUI session before calling it ready to attach", () => { + const block = slice("// ── piece ③ codex TUI", "[anet] ③ TUI tmux="); + expect(block).toContain("tmuxSessionRunning(tuiSession)"); +}); + +test("codex co-presence proves all three sessions are alive at the moment it prints 就绪", () => { + const block = slice("// ── piece ③ codex TUI", "✅ 共存节点"); + expect(block).toContain("[appsrvSession, bridgeSession, tuiSession]"); + expect(block).toContain("tmuxSessionRunning(s)"); + expect(block).toContain("process.exit(1)"); +}); + +test("the OpenCode twin still guards its own TUI (the pattern being matched)", () => { + const block = slice("✅ OpenCode 共存节点", "attach:"); + expect(source).toContain("if (!tmuxSessionRunning(tuiSession)) {"); + expect(block.length).toBeGreaterThan(0); +}); diff --git a/agent-network/src/tmux-exact-target.test.ts b/agent-network/src/tmux-exact-target.test.ts new file mode 100644 index 000000000..abb5c0d42 --- /dev/null +++ b/agent-network/src/tmux-exact-target.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { ensureExactSession, exactSession, isExactTarget } from "./tmux-exact-target"; + +test("a session name becomes an exact target", () => { + expect(exactSession("A站内容")).toBe("=A站内容"); +}); + +test("an already-exact target is not double-prefixed", () => { + expect(isExactTarget("=A站内容")).toBe(true); + expect(ensureExactSession("=A站内容")).toBe("=A站内容"); + expect(ensureExactSession("A站内容")).toBe("=A站内容"); +}); + +test("no shell quoting is added — these go to tmux as argv, not through a shell", () => { + expect(exactSession("name with spaces")).toBe("=name with spaces"); + expect(exactSession("it's")).toBe("=it's"); +}); + +// The reason the helper exists, proven against the real tmux on this machine +// rather than asserted. Skipped where tmux is unavailable (CI containers). +function tmuxAvailable(): boolean { + try { execFileSync("tmux", ["-V"], { stdio: "pipe" }); return true; } catch { return false; } +} + +const PREFIX = "anet-exacttest"; +const SIBLING = `${PREFIX}-sibling`; + +function killQuiet(target: string) { + try { execFileSync("tmux", ["kill-session", "-t", target], { stdio: "pipe" }); } catch { /* already gone */ } +} + +test.skipIf(!tmuxAvailable())("bare -t prefix-matches a sibling; =name does not", () => { + killQuiet(exactSession(SIBLING)); + killQuiet(exactSession(PREFIX)); + execFileSync("tmux", ["new-session", "-d", "-s", SIBLING, "sleep 60"], { stdio: "pipe" }); + try { + // Only the sibling exists. The bare name must not be treated as "found". + let bareSaysRunning = false; + try { execFileSync("tmux", ["has-session", "-t", PREFIX], { stdio: "pipe" }); bareSaysRunning = true; } catch {} + expect(bareSaysRunning).toBe(true); // this is the bug being guarded against + + let exactSaysRunning = false; + try { execFileSync("tmux", ["has-session", "-t", exactSession(PREFIX)], { stdio: "pipe" }); exactSaysRunning = true; } catch {} + expect(exactSaysRunning).toBe(false); // the fix + + // And the exact target must not reap the sibling. + killQuiet(exactSession(PREFIX)); + let siblingAlive = false; + try { execFileSync("tmux", ["has-session", "-t", exactSession(SIBLING)], { stdio: "pipe" }); siblingAlive = true; } catch {} + expect(siblingAlive).toBe(true); + } finally { + killQuiet(exactSession(SIBLING)); + } +}); diff --git a/agent-network/src/tmux-exact-target.ts b/agent-network/src/tmux-exact-target.ts new file mode 100644 index 000000000..4234fd7a8 --- /dev/null +++ b/agent-network/src/tmux-exact-target.ts @@ -0,0 +1,47 @@ +// tmux `-t` resolves a session name by PREFIX unless the name is written +// `=name`. Every human-facing string in this CLI already spells the exact form +// (`tmux attach -t '='`, with a comment explaining why) — but every tmux +// command the CLI actually ran passed the bare name. +// +// Measured on this machine 2026-08-17, with only `zz-honest-probe-extra` alive: +// +// tmux has-session -t zz-honest-probe → success (wrong: it is not running) +// tmux has-session -t =zz-honest-probe → failure (right) +// tmux kill-session -t zz-honest-probe → killed zz-honest-probe-extra +// +// The live fleet has four such pairs — A站内容/A站内容牛, A站数据/A站数据牛, +// P站测试/P站测试牛, P站运维/P站运维牛 — so this is not hypothetical here: +// +// * `has-session` false-positives, so `node start` reports "already running — +// skipping spawn" for a node that is down, and never starts it. +// * `kill-session` reaps the sibling, and `node stop` reports success. +// * `send-keys` would deliver an Enter into the sibling's Claude UI. +// +// One helper, used at every call site, so the rule lives in the code that acts +// rather than only in the strings that describe it. + +/** + * Exact-match tmux target for a session name. + * + * tmux treats a leading `=` as "this exact name, no prefix matching". Names are + * passed to tmux as argv entries, never through a shell, so no quoting belongs + * here — callers that build a copy-pasteable command for a human should shell- + * quote the result themselves. + */ +export function exactSession(name: string): string { + return `=${name}`; +} + +/** + * True when this target is already pinned to an exact session. + * + * Applying the prefix twice would look for a session literally named `=x`. + */ +export function isExactTarget(target: string): boolean { + return target.startsWith("="); +} + +/** Idempotent form, for call sites that may receive either shape. */ +export function ensureExactSession(nameOrTarget: string): string { + return isExactTarget(nameOrTarget) ? nameOrTarget : exactSession(nameOrTarget); +} diff --git a/agent-network/src/tmux-pane-prompt.test.ts b/agent-network/src/tmux-pane-prompt.test.ts new file mode 100644 index 000000000..6090cd6d8 --- /dev/null +++ b/agent-network/src/tmux-pane-prompt.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "bun:test"; +import { classifyPanePrompt, extractStartFailureReason } from "./tmux-pane-prompt"; + +// Captured from a real `tmux capture-pane -p` while Claude Code 2.1.147 was +// waiting on the folder-trust prompt in a workspace it had not seen before — +// the exact state that stalled TM智空负责人 on 2026-08-17. +const FOLDER_TRUST_PANE = ` +╭──────────────────────────────────────────────╮ +│ Do you trust the files in this folder? │ +│ │ +│ /home/vansin/ai-insight │ +│ │ +│ ❯ 1. Yes, I trust this folder │ +│ 2. No, exit │ +╰──────────────────────────────────────────────╯ +`; + +const DEV_CHANNELS_PANE = ` + WARNING: Loading development channels from server:commhub + I am using this for local development and I trust its author + + Press Enter to confirm +`; + +const NORMAL_CLAUDE_PANE = ` +← commhub · 通信龙: [重启后探针] 请只回一行 +● Calling commhub… (ctrl+o to expand) +❯ + ⏵⏵ bypass permissions on (shift+tab to cycle) +`; + +test("folder-trust prompt is recognised as its own prompt, not as dev-channels", () => { + expect(classifyPanePrompt(FOLDER_TRUST_PANE)).toBe("folder-trust"); +}); + +test("dev-channels prompt is still recognised", () => { + expect(classifyPanePrompt(DEV_CHANNELS_PANE)).toBe("dev-channels"); +}); + +test("a normal Claude Code pane matches no prompt, so no Enter is ever sent", () => { + expect(classifyPanePrompt(NORMAL_CLAUDE_PANE)).toBeNull(); +}); + +test("an empty / still-booting pane matches no prompt", () => { + expect(classifyPanePrompt("")).toBeNull(); + expect(classifyPanePrompt("\n\n \n")).toBeNull(); +}); + +// The prompts are sequential, so a capture holding both means trust is already +// answered and its text is merely still on screen. Reporting "folder-trust" +// there would make a watcher that already answered trust sit out the rest of +// its window and never confirm dev-channels — the original hang, reintroduced. +test("when both prompts are in one capture the later one wins, not the leftover", () => { + expect(classifyPanePrompt(FOLDER_TRUST_PANE + DEV_CHANNELS_PANE)).toBe("dev-channels"); +}); + +// The refusal that actually happened: 5 grok co-presence nodes on a published +// anet whose whitelist has no grok-build-cli. +const REFUSAL_PANE = ` +[anet] Refusing to start node "指挥狗": unsupported runtime "grok-build-cli"; expected one of: claude-agent-sdk, claude-code-cli, codex-sdk, codex-app-server, grok-build-acp, opencode-cli +`; + +test("the refusal line is pulled out of a dead pane, with the [anet] prefix stripped", () => { + const reason = extractStartFailureReason(REFUSAL_PANE); + expect(reason).toContain('unsupported runtime "grok-build-cli"'); + expect(reason).toContain("Refusing to start node"); + expect(reason?.startsWith("[anet]")).toBe(false); +}); + +// The noise below the refusal is the part that matters: tmux keeps printing +// after the inner command dies, so "just take the last line" would report a +// shell prompt or an npm notice as the reason the node failed. +test("the refusal is picked over unrelated scrollback both above and below it", () => { + const pane = [ + "warning: something noisy happened earlier", + "npm notice a new version is available", + REFUSAL_PANE.trim(), + "", + "npm notice Run npm install -g npm@11.0.0 to update", + "vansin@toodadev3:~$ ", + ].join("\n"); + expect(extractStartFailureReason(pane)).toContain("unsupported runtime"); +}); + +test("an ❌ line is reported without its prefix decorations", () => { + const reason = extractStartFailureReason(`[anet] ❌ --accept-dev-channels requires tmux (used for PTY).`); + expect(reason).toBe("--accept-dev-channels requires tmux (used for PTY)."); +}); + +test("a crash with no anet refusal falls back to the last non-empty line", () => { + const pane = "booting…\nnode:internal/modules: Cannot find module '@inquirer/prompts'\n\n"; + expect(extractStartFailureReason(pane)).toBe( + "node:internal/modules: Cannot find module '@inquirer/prompts'", + ); +}); + +test("an empty pane yields no reason, so the caller cannot print an invented one", () => { + expect(extractStartFailureReason("")).toBeNull(); + expect(extractStartFailureReason(" \n\n ")).toBeNull(); +}); diff --git a/agent-network/src/tmux-pane-prompt.ts b/agent-network/src/tmux-pane-prompt.ts new file mode 100644 index 000000000..35d1af62e --- /dev/null +++ b/agent-network/src/tmux-pane-prompt.ts @@ -0,0 +1,81 @@ +// Pane-content classification for the detached-tmux start path. +// +// Two independent problems put this logic here instead of inline in cli.ts: +// +// 1. `anet node start --accept-dev-channels` watches the pane for +// Claude Code's dev-channels prompt and confirms it. But a workspace that +// has never been trusted shows the folder-trust prompt FIRST. The watcher +// only knew the dev-channels markers, so it spun until its window expired +// while a different prompt sat on screen — the node then hung forever and +// the hub showed it offline. Measured 2026-08-17 restoring 97 nodes: +// TM智空负责人 died exactly this way and needed two manual Enters. +// +// 2. When the inner `anet node start` refuses (unsupported runtime, bad +// config) the pane holds the only copy of the real reason, and tmux tears +// the session down moments later. Reading that reason out of the pane is +// what lets the caller report the refusal instead of a timeout. +// +// Pure string in / verdict out, so both are testable without tmux. + +/** A prompt the watcher knows how to answer, or null. */ +export type PanePrompt = "dev-channels" | "folder-trust"; + +// Markers are chosen to be unique to their prompt: none of them can appear +// incidentally in normal Claude Code UI chrome or in agent output, so a +// detection can never send a stray Enter into a live session. +const DEV_CHANNEL_MARKERS = [ + "I am using this for local development", + "Loading development channels", +]; + +const FOLDER_TRUST_MARKERS = [ + "Yes, I trust this folder", + "Do you trust the files in this folder?", +]; + +/** + * Which known prompt (if any) the pane is currently blocking on. + * + * Dev-channels is checked FIRST, even though it appears second in time. The two + * prompts are sequential — trust, then channels — so a capture showing both + * means the trust prompt is already answered and only its text is still sitting + * in the pane. Classifying that as "folder-trust" would make a watcher that has + * already answered trust ignore the prompt it was waiting for, and the node + * would hang exactly as if the fix had never been made. Preferring the later + * prompt keeps a stale line of scrollback from outranking the live prompt. + */ +export function classifyPanePrompt(pane: string): PanePrompt | null { + if (DEV_CHANNEL_MARKERS.some(m => pane.includes(m))) return "dev-channels"; + if (FOLDER_TRUST_MARKERS.some(m => pane.includes(m))) return "folder-trust"; + return null; +} + +// Lines the inner `anet node start` prints when it declines to start. Matching +// on the message anet itself emits (rather than on "some line containing +// error") keeps an unrelated warning in the scrollback from being reported as +// the cause of death. +const REFUSAL_PATTERNS = [ + /^\[anet\] Refusing to start.*$/m, + /^\[anet\] ❌.*$/m, + /^Node "[^"]*" not found\..*$/m, + /^Error: .*$/m, +]; + +/** + * Best-effort one-line explanation of why a detached start died, taken from the + * dead pane's own output. + * + * Returns null when the pane holds nothing that looks like a refusal — the + * caller must then fall back to a generic message rather than inventing one. + */ +export function extractStartFailureReason(pane: string): string | null { + for (const re of REFUSAL_PATTERNS) { + const m = pane.match(re); + if (m) return m[0].replace(/^\[anet\]\s*(❌\s*)?/, "").trim(); + } + // No recognised refusal — fall back to the last non-empty line, which for an + // uncaught crash is usually the error itself. + const lines = pane.split("\n").map(l => l.trimEnd()).filter(l => l.trim() !== ""); + const last = lines[lines.length - 1]; + return last ? last.trim() : null; +} diff --git a/agent-network/src/unsafe-package-path-reason.test.ts b/agent-network/src/unsafe-package-path-reason.test.ts new file mode 100644 index 000000000..14e47bb07 --- /dev/null +++ b/agent-network/src/unsafe-package-path-reason.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { classifyUnsafePath, describeUnsafePath } from "./unsafe-package-path-reason"; + +const ME = 1000; + +// Exactly what `npx -y @sleep2agi/agent-node@preview` left on this machine on +// 2026-08-17, measured with stat: umask 0002 → dist/cli.js 0775, package.json +// 0664, both owned by uid 1000. Owner is fine; only the group-write bit fails. +const NPM_EXTRACTED_BIN = { uid: ME, mode: 0o775, processUid: ME }; +const NPM_EXTRACTED_JSON = { uid: ME, mode: 0o664, processUid: ME }; + +test("the condition that actually fires on a umask-0002 box is group-write, not ownership", () => { + expect(classifyUnsafePath(NPM_EXTRACTED_BIN)).toBe("group-writable"); + expect(classifyUnsafePath(NPM_EXTRACTED_JSON)).toBe("group-writable"); +}); + +test("a correctly-extracted package passes", () => { + expect(classifyUnsafePath({ uid: ME, mode: 0o755, processUid: ME })).toBeNull(); + expect(classifyUnsafePath({ uid: ME, mode: 0o644, processUid: ME })).toBeNull(); +}); + +test("someone else's payload is reported as ownership, and outranks the mode bits", () => { + expect(classifyUnsafePath({ uid: 0, mode: 0o777, processUid: ME })).toBe("owner"); +}); + +test("world-writable is called out separately from group-writable", () => { + expect(classifyUnsafePath({ uid: ME, mode: 0o666, processUid: ME })).toBe("world-writable"); + expect(classifyUnsafePath({ uid: ME, mode: 0o757, processUid: ME })).toBe("world-writable"); +}); + +test("the message names the path, the mode, and umask — the thing the old text hid", () => { + const msg = describeUnsafePath("/home/x/.npm/_npx/aa/node_modules/@sleep2agi/agent-node/dist/cli.js", NPM_EXTRACTED_BIN); + expect(msg).toContain("/dist/cli.js"); + expect(msg).toContain("775"); + expect(msg).toContain("group-writable"); + expect(msg).toContain("umask"); + expect(msg).toContain("chmod -R g-w,o-w"); +}); + +test("an ownership failure does not send the reader chasing umask", () => { + const msg = describeUnsafePath("/opt/pkg/dist/cli.js", { uid: 0, mode: 0o755, processUid: ME }); + expect(msg).toContain("uid 0"); + expect(msg).not.toContain("umask"); +}); + +test("the mode is printed octal and zero-padded, so 0644 never reads as 420", () => { + expect(describeUnsafePath("/p", { uid: ME, mode: 0o066, processUid: ME })).toContain("066"); +}); diff --git a/agent-network/src/unsafe-package-path-reason.ts b/agent-network/src/unsafe-package-path-reason.ts new file mode 100644 index 000000000..59735b9c1 --- /dev/null +++ b/agent-network/src/unsafe-package-path-reason.ts @@ -0,0 +1,60 @@ +// Why a resolved agent-node payload failed the supply-chain path check. +// +// The check itself is not the problem — refusing to execute a package that +// someone else can rewrite is right. The problem was the sentence it printed: +// "resolved agent-node package has unsafe ownership or mode" names ownership +// first and never mentions the condition that actually fires on a stock +// Debian/Ubuntu box. +// +// Measured on this machine 2026-08-17: `umask` is 0002, so npm extracts the +// package with dist/cli.js at 0775 and package.json at 0664. Owner is correct. +// `0o775 & 0o022 === 0o020` — the group-write bit alone fails the check, and +// every grok-build-cli start died at that line reading +// `Incompatible grok-build-cli runtime.` Removing the group/other write bits +// let the same command run all the way through to the agent-node process. +// +// So: say which condition failed, on which path, with which mode, and what to +// do about it. Pure function of a stat-like shape so it can be tested without +// a filesystem. + +export interface PathModeFacts { + /** Owner uid of the path. */ + uid: number; + /** Permission bits (st_mode & 0o777). */ + mode: number; + /** uid of the process doing the check. */ + processUid: number; +} + +export type UnsafePathReason = "owner" | "group-writable" | "world-writable" | null; + +/** Which condition makes this path unsafe to execute from, if any. */ +export function classifyUnsafePath(facts: PathModeFacts): UnsafePathReason { + if (facts.uid !== facts.processUid) return "owner"; + if ((facts.mode & 0o002) !== 0) return "world-writable"; + if ((facts.mode & 0o020) !== 0) return "group-writable"; + return null; +} + +/** + * Operator-facing explanation. Names the path, the offending bits, and the + * command that fixes it — a message that says only "unsafe" leaves the reader + * guessing between four different conditions. + */ +export function describeUnsafePath(path: string, facts: PathModeFacts): string { + const reason = classifyUnsafePath(facts); + const mode = (facts.mode & 0o777).toString(8).padStart(3, "0"); + switch (reason) { + case "owner": + return `${path} is owned by uid ${facts.uid}, not by this process (uid ${facts.processUid}) — ` + + `refusing to execute a payload another account can rewrite`; + case "world-writable": + return `${path} is mode ${mode} (world-writable) — refusing to execute a payload anyone can rewrite`; + case "group-writable": + return `${path} is mode ${mode} (group-writable) — refusing to execute a payload the group can rewrite. ` + + `This is usually your umask: on Debian/Ubuntu \`umask 0002\` makes npm extract packages 0775/0664. ` + + `Fix with \`chmod -R g-w,o-w \`, or run the start under \`umask 0022\` so the next fetch is clean`; + default: + return `${path} passed the ownership and mode check`; + } +} From 40574a02e5516fde543113750e91a2f6139a8593 Mon Sep 17 00:00:00 2001 From: vansin Date: Mon, 17 Aug 2026 23:49:57 +0800 Subject: [PATCH 12/59] =?UTF-8?q?fix(cli):=20project=20up=20/=20restart=20?= =?UTF-8?q?=E7=9A=84=E9=80=80=E5=87=BA=E7=A0=81=E8=A6=81=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=98=AF=E5=90=A6=E7=9C=9F=E7=9A=84=E8=B5=B7?= =?UTF-8?q?=E6=9D=A5=E4=BA=86=20(#896)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #895, one level up. That PR fixed the single-node start paths; this is the same defect class in the batch entry point, and it is the one automation actually calls. `anet project up` already measures every node with verifySpawnedNodes and prints each failure, so its OUTPUT was honest — unlike the single-node path, it never claimed a dead node was started. What it did not do was set an exit code: both projectUp and projectRestart returned normally, so a run that brought up 60 of 74 nodes exited 0. That matters because this is the entry point scripts use. While reviewing a boot-time sweep for this machine's ~74 agent nodes, the design leaned on a post-flight tmux audit rather than on `$?` — and the reason turned out to be load-bearing rather than stylistic: `grep -c process.exit` inside projectUp returns 0. Any watchdog or CI step that trusted the exit code was being told the fleet was fine. `invalid` counts toward failure too. A node whose config cannot start was never attempted, so exiting 0 hides it exactly as well as a crash does. The gate runs after printProjectSummary so the operator still gets the full list before the process dies, and a clean run returns early and stays at exit 0. All 5 assertions fail against f565e9b8 and pass here. Suite 483 pass, tsc clean. Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-network/bin/cli.ts | 29 ++++++++++ .../src/project-outcome-exit-code.test.ts | 57 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 agent-network/src/project-outcome-exit-code.test.ts diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index dc548e2d3..a2b4dabbb 100644 --- a/agent-network/bin/cli.ts +++ b/agent-network/bin/cli.ts @@ -7763,6 +7763,33 @@ function parseStaggerMs(): number { return Math.round(n * 1000); } +/** + * Make the exit code agree with the summary that was just printed. + * + * `project up` / `project restart` already measure each node with + * verifySpawnedNodes and print every failure — the TEXT was honest. The exit + * code was not: both returned normally, so a run that brought up 60 of 74 nodes + * exited 0. Any caller that scripts this (a boot-time sweep, CI, a watchdog) + * therefore had to re-derive the outcome itself, and one that trusted `$?` was + * told the fleet was fine. Same defect class as #895's single-node path, one + * level up. + * + * `invalid` counts too: a node whose config cannot start was never attempted, + * so reporting success would hide it just as effectively as a crash. + */ +function exitFromProjectOutcome( + failed: { alias: string; reason: string }[], + invalid: { alias: string; reason: string }[] = [], +) { + if (failed.length === 0 && invalid.length === 0) return; + console.error( + `[anet] ❌ exiting non-zero: ${failed.length} node(s) failed to come up` + + (invalid.length ? `, ${invalid.length} with unstartable config` : "") + + ` — see the list above.`, + ); + process.exit(1); +} + function printProjectSummary( total: number, up: number, @@ -7994,6 +8021,7 @@ async function projectUp(invokedAs = "anet project up") { autoConfirmDevChannels(spawned), ]); printProjectSummary(nodes.length, alreadyUp + started, failed, invalid); + exitFromProjectOutcome(failed, invalid); } async function projectRestart() { @@ -8050,6 +8078,7 @@ async function projectRestart() { autoConfirmDevChannels(spawned), ]); printProjectSummary(nodes.length, started, failed, invalid); + exitFromProjectOutcome(failed, invalid); } async function projectDown() { diff --git a/agent-network/src/project-outcome-exit-code.test.ts b/agent-network/src/project-outcome-exit-code.test.ts new file mode 100644 index 000000000..856cc1533 --- /dev/null +++ b/agent-network/src/project-outcome-exit-code.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; + +const source = readFileSync(join(import.meta.dir, "..", "bin", "cli.ts"), "utf8"); + +function fn(name: string): string { + const a = source.indexOf(`async function ${name}(`); + expect(a).toBeGreaterThan(-1); + const b = source.indexOf("\nasync function ", a + 10); + expect(b).toBeGreaterThan(a); + return source.slice(a, b); +} + +// `project up` and `project restart` measure every node (verifySpawnedNodes) +// and print every failure, so their OUTPUT was already honest. Their exit code +// was not: both returned normally, so a run that brought up 60 of 74 nodes +// exited 0. That is what forces every scripted caller — a boot sweep, CI, a +// watchdog — to re-derive the outcome instead of reading `$?`. +test("project up exits non-zero when nodes failed to come up", () => { + const body = fn("projectUp"); + const summary = body.indexOf("printProjectSummary("); + const gate = body.indexOf("exitFromProjectOutcome("); + expect(summary).toBeGreaterThan(-1); + expect(gate).toBeGreaterThan(summary); // report first, then set the code +}); + +test("project restart carries the same gate", () => { + const body = fn("projectRestart"); + expect(body).toContain("exitFromProjectOutcome("); +}); + +test("the gate counts unstartable configs too, not only crashes", () => { + const a = source.indexOf("function exitFromProjectOutcome("); + expect(a).toBeGreaterThan(-1); + const body = source.slice(a, source.indexOf("\nfunction printProjectSummary(", a)); + // A node whose config cannot start was never attempted; calling that success + // hides it exactly as well as a crash does. + expect(body).toContain("invalid.length"); + expect(body).toContain("failed.length === 0"); + expect(body).toContain("process.exit(1)"); +}); + +test("a fully successful run still returns normally", () => { + const a = source.indexOf("function exitFromProjectOutcome("); + const body = source.slice(a, source.indexOf("\nfunction printProjectSummary(", a)); + // The early return is what keeps the happy path at exit 0; without it every + // successful project up would start failing. + expect(body).toMatch(/if \(failed\.length === 0 && invalid\.length === 0\) return;/); +}); + +test("the failure line points at the list the operator just saw", () => { + const a = source.indexOf("function exitFromProjectOutcome("); + const body = source.slice(a, source.indexOf("\nfunction printProjectSummary(", a)); + expect(body).toContain("exiting non-zero"); + expect(body).toContain("see the list above"); +}); From 6c58a9abd3c56ebe024bfbba723ebd7da2bdeb5b Mon Sep 17 00:00:00 2001 From: vansin Date: Mon, 17 Aug 2026 23:53:22 +0800 Subject: [PATCH 13/59] =?UTF-8?q?docs:=20=E5=8E=BB=E6=8E=89=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E7=89=88=E6=9C=AC=E5=8F=B7=E4=B8=8E=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E8=AE=A1=E6=95=B0,=E6=94=B9=E4=B8=BA=E6=8C=87?= =?UTF-8?q?=E5=90=91=E6=9D=83=E5=A8=81=E6=9D=A5=E6=BA=90=20(#869)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-only refresh from 通信狗 review (issue #639). No behavior changes, no runtime/config edits — every touched file is markdown or a package.json `description` field. ## Version facts sourced from npm at commit time Verified via `npm view dist-tags` on 2026-08-14: latest : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8 preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31 commhub-server 0.9.0-preview.29 local anet: v2.3.0-preview.38 (matches preview channel, one behind head) ## Per-file changes P0-1 CHANGELOG.md banner - Drop hardcoded "当前 stable 是 v0.10.11" (out of date). - Point readers at npm `latest` + docs-site/docs/changelog.md as the live source; keep the v0.10.15 archival anchor + v0.8.1 OSS-first note. File still an archive of pre-2026-04 v1.0.0-preview history. P0-2 docs/getting-started.md - Runtime table now includes `grok-build-acp` (needs `grok login`). - Note that @preview additionally ships `codex-app-server` and `opencode-cli`; the authoritative full runtime table is at anet.sh/guide/runtimes. - `anet ls` → `anet node ls` (matches current CLI). P0-3 docs-site/docs/{,en/}guide/getting-started.md - Add a preview-channel warning next to the admin/anethub line: @preview prints a one-time random password on first `anet hub start`, don't hard-code `anethub`. This aligns with the README + cli.md wording that already carries the note. P0-4 AGENTS.md 项目结构 - Drop "39 命令" / "4 runtime" hardcoded counts (both drift). - Point at docs-site/docs/guide/cli.md as canonical CLI list. - Split runtimes into stable (4: claude-code-cli / claude-agent-sdk / codex-sdk / grok-build-acp) + preview extra (2: codex-app-server / opencode-cli); mark `grok-build-cli` as unreleased in any channel. P0-5 docs/version/README.md + docs/plans/release-plan.md + docs/version/0.11.0/README.md - Backfill preview matrix from `npm view @preview` (was pinned at .34/.26/.20 — now .39/.31/.29). Added timestamp + reminder to re-check `npm view` before editing. - WAIC 7-月-下旬 anchor is out of the window; strike-through the completed date, keep the archival link to waic-release.md, and replace with "current promote status per release-plan". - release-plan.md defaults table gains a commhub-server column so readers see all three packages, not just two. P1-6 docs-site/docs/guide/architecture.md - "14 张表" → "20+ 张表(含 sessions / tasks / nodes / users / networks / SkillHub / providers / vault 等,实数按 schema 版本浮动)"; EN mirrors it. Fixes both the two mermaid diagrams and the paragraph. P1-7 docs/architecture.md - Runtime paragraph now says "stable 4 + preview 2" and points at anet.sh/guide/runtimes as authoritative. - 14 cli.ts deep `#L` anchors defanged (link stays, line number dropped — they rot every release; kept the function name in the link text so intent survives). - Directory tree gains an "已不完整,以仓库实际为准" note so readers don't treat it as canonical. P1-8 server/package.json + server/README.md - package.json `description` now says "MCP tools (17 collaboration- core + node/provider ops tools; authoritative list at docs-site/docs/api/mcp-tools.md)" — was "and 17 MCP tools" (readers took it as the total). - server/README.md MCP section gains one line saying the 17 in the table are the collaboration-core subset; full list at docs-site/docs/api/mcp-tools.md. P1-9 README.md + README.en.md - "能做什么" / "What it does" gain one bullet pointing at Codex TUI co-presence and OpenCode as preview-channel additions with a link to the Runtime page. ## Not touched (per review scope) - docs/v3-postgresql-design.md archive banner (do not edit) - upgrade-v2 archive banner (do not edit) - grok-copresence danger banner (do not edit) - runtimes 官方表 (canonical, do not edit) ## Verification `grep -c` on the touched files confirms: - `14 张表` remaining in docs-site/docs/guide/architecture.md: 0 - `39 命令` remaining in AGENTS.md: 0 - stale `cli.ts#L` deep anchors in docs/architecture.md: 0 - `anet ls` (bare, without node prefix) in docs/getting-started.md: 0 Co-authored-by: t --- AGENTS.md | 7 +++- CHANGELOG.md | 2 +- README.en.md | 1 + README.md | 1 + docs-site/docs/en/guide/architecture.md | 2 +- docs-site/docs/en/guide/getting-started.md | 6 ++- docs-site/docs/guide/architecture.md | 6 +-- docs-site/docs/guide/getting-started.md | 6 ++- docs/architecture.md | 46 ++++++++++++---------- docs/getting-started.md | 5 ++- docs/plans/release-plan.md | 12 +++--- docs/version/0.11.0/README.md | 15 ++++--- docs/version/README.md | 4 +- server/README.md | 2 + server/package.json | 2 +- 15 files changed, 71 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ef2549591..a2d6b8c75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,11 @@ ## 项目结构 - `server/src/` — CommHub Server (Bun + SQLite) -- `agent-network/bin/cli.ts` — anet CLI (39 命令) -- `agent-node/src/cli.ts` — Agent 运行时 (4 runtime: claude-agent-sdk / claude-code-cli / codex-sdk / grok-build-acp) +- `agent-network/bin/cli.ts` — anet CLI (完整命令清单以 [`docs-site/docs/guide/cli.md`](./docs-site/docs/guide/cli.md) 为准;数字会漂,不硬编) +- `agent-node/src/cli.ts` — Agent 运行时 + - **stable 4 runtime**:`claude-code-cli` / `claude-agent-sdk` / `codex-sdk` / `grok-build-acp` + - **`@preview` 额外**:`codex-app-server` / `opencode-cli` + - `grok-build-cli` 仍在开发,尚未发布任何通道 - `tests/testN-xxx/` — 独立 Docker 测试套件 (每个有 Dockerfile + run.sh) - `docs/` — 设计文档 + 测试报告 diff --git a/CHANGELOG.md b/CHANGELOG.md index b6fcb3c68..2c3ae536b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ > **⚠️ 本文件为历史归档(2026-04 之前的 v1.0.0-preview.x 系列开发日志)** > -> 2026-04 后版本号体系重新规划,去掉"v1.0.0-preview"的过度承诺改用 v0.6 / v0.7 / v0.8 / v0.9 / v0.10 渐进发布。**当前 stable 是 v0.10.11(2026-05-28 通过 npm `latest` tag 发布,v0.8.1 是 OSS 首发版本)**。 +> 2026-04 后版本号体系重新规划,去掉"v1.0.0-preview"的过度承诺改用 v0.6 / v0.7 / v0.8 / v0.9 / v0.10 渐进发布。**当前 stable 以 npm `latest` 与 [docs-site/docs/changelog.md](./docs-site/docs/changelog.md) 为准;完整版本矩阵见 [docs/version/README.md](./docs/version/README.md)(本文件归档时对应锚点 v0.10.15)。v0.8.1 是 OSS 首发版本。** > > **认准的更新日志**:[docs-site/docs/changelog.md](./docs-site/docs/changelog.md) 或 [anet.sh/changelog](https://anet.sh/changelog) — 包含 v0.6.x ~ v0.10.x 全部 release notes,含本次 OSS 发布。 > diff --git a/README.en.md b/README.en.md index afe11c355..d8cbe35b1 100644 --- a/README.en.md +++ b/README.en.md @@ -54,6 +54,7 @@ The default administrator account is `admin` / `anethub`. **Any public deploymen - **Connect different agents:** Claude Code, Claude Agent SDK, Codex, and Grok Build can share one network. - **Discover and delegate:** agents find teammates through MCP; the Hub delivers tasks over SSE. - **Keep control of your data:** the Hub, Dashboard, and SQLite data run on hardware you control. +- **Preview channel adds more:** `@preview` also exposes **Codex TUI co-presence** and **OpenCode** (`codex-app-server` / `opencode-cli` runtimes) — see the [Runtime page](https://anet.sh/en/guide/runtimes). ```text Agent A ──task──▶ CommHub ──SSE──▶ Agent B diff --git a/README.md b/README.md index 4c51dec88..857ef5f02 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ anet node start my-bot - **连接不同 Agent**:Claude Code、Claude Agent SDK、Codex、Grok Build 可加入同一个网络。 - **自动发现和派活**:Agent 通过 MCP 发现队友,Hub 通过 SSE 实时分发任务。 - **数据由你掌控**:Hub、Dashboard 和 SQLite 数据运行在你控制的机器上。 +- **预览通道额外能力**:`@preview` 还可用 **Codex TUI 共存** 与 **OpenCode**(`codex-app-server` / `opencode-cli` 两个 runtime),详见 [Runtime 页](https://anet.sh/guide/runtimes)。 ```text Agent A ──任务──▶ CommHub ──SSE──▶ Agent B diff --git a/docs-site/docs/en/guide/architecture.md b/docs-site/docs/en/guide/architecture.md index 6728e0763..62e95fa2d 100644 --- a/docs-site/docs/en/guide/architecture.md +++ b/docs-site/docs/en/guide/architecture.md @@ -201,7 +201,7 @@ CommHub provides 17 core MCP Tools for agents, in two groups: ### Database Design -SQLite with WAL mode, 14 tables: +SQLite with WAL mode, 20+ tables (sessions / tasks / nodes / users / networks / SkillHub / providers / vault etc.; exact count floats with schema version): ```mermaid erDiagram diff --git a/docs-site/docs/en/guide/getting-started.md b/docs-site/docs/en/guide/getting-started.md index 8b36e7484..9d51911a3 100644 --- a/docs-site/docs/en/guide/getting-started.md +++ b/docs-site/docs/en/guide/getting-started.md @@ -47,8 +47,12 @@ anet hub start The hub listens on `http://127.0.0.1:9200` by default, the SQLite DB lives at `~/.commhub/commhub.db`, and the default admin account **admin / anethub** is created automatically. +::: warning `@preview` prints a **one-time random password** on first start +This page describes the npm `latest` channel. On `@preview` (`npm install -g @sleep2agi/agent-network@preview`) the first `anet hub start` **prints a freshly generated random password once** (shown once, not recoverable later); log in with it, then `anet passwd` to your own strong password. **Do NOT hard-code `anethub` for preview** — the fixed password only holds on `latest`. +::: + ::: warning Change the password before going public -The default `admin / anethub` is for local quickstart only. **Any `--host 0.0.0.0` public deployment must `anet passwd` to a strong password immediately.** +The default `admin / anethub` is for local quickstart only (latest channel). **Any `--host 0.0.0.0` public deployment must `anet passwd` to a strong password immediately.** Preview channel has no fixed password — see the note above. ::: ::: tip Stop / status diff --git a/docs-site/docs/guide/architecture.md b/docs-site/docs/guide/architecture.md index 22b71239d..ea9839dab 100644 --- a/docs-site/docs/guide/architecture.md +++ b/docs-site/docs/guide/architecture.md @@ -10,7 +10,7 @@ graph TB subgraph "服务器(1 台)" S["CommHub Server
消息路由 + 任务管理
端口 9200"] - DB[(SQLite WAL
14 张表)] + DB[(SQLite WAL
20+ 张表)] S --- DB end @@ -88,7 +88,7 @@ graph TB SSE["/events/:alias
SSE 实时推送"] REST["/api/*
REST API"] AUTH[Auth Module
Token + Rate Limit] - DB[(SQLite WAL
14 张表)] + DB[(SQLite WAL
20+ 张表)] end subgraph "Agent 节点" @@ -201,7 +201,7 @@ CommHub 为 agent 提供 17 个核心 MCP Tools,分为两组: ### 数据库设计 -SQLite WAL 模式,14 张表: +SQLite WAL 模式,20+ 张表(含 sessions / tasks / nodes / users / networks / SkillHub / providers / vault 等,实数按 schema 版本浮动): ```mermaid erDiagram diff --git a/docs-site/docs/guide/getting-started.md b/docs-site/docs/guide/getting-started.md index 765156639..c3c24bff1 100644 --- a/docs-site/docs/guide/getting-started.md +++ b/docs-site/docs/guide/getting-started.md @@ -47,8 +47,12 @@ anet hub start 启动后默认监听 `http://127.0.0.1:9200`, SQLite 数据库在 `~/.commhub/commhub.db`, 自动创建默认管理员 **admin / anethub**。 +::: warning `@preview` 首次启动打印**一次性随机密码** +本文档描述的是 npm `latest` 通道的行为。`@preview` (`npm install -g @sleep2agi/agent-network@preview`) 首次 `anet hub start` 会**打印一次生成的随机密码**(只显示一次,之后无处查回),登录后用 `anet passwd` 改成自己的强密码。**preview 上不要写死 `anethub`**——固定密码只在 `latest` 通道成立。 +::: + ::: warning 公网部署立刻改密 -默认 `admin / anethub` 仅本机用。任何 `--host 0.0.0.0` 公网部署立刻 `anet passwd` 改强密码。 +默认 `admin / anethub` 仅本机用(latest 通道)。任何 `--host 0.0.0.0` 公网部署立刻 `anet passwd` 改强密码。preview 通道无固定密码,见上一条。 ::: ::: tip 停止 / 查看状态 diff --git a/docs/architecture.md b/docs/architecture.md index 02e96353e..9f5be95d3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,26 +26,30 @@ agent-network/ └── README.md ``` +> ⚠️ 上面这棵目录树写于 V2 早期,**已不完整**(例如未列 `dist/bin/cli.cjs`、preview 期新增的 runtime 拆分文件等)。**以仓库当前实际布局为准**(`git ls-tree HEAD -- agent-network/`),本树只作历史背景。 + **设计原则**:client.ts 是核心(零外部依赖),server.ts 是薄包装(委托给 `../../server/src/index.ts`),cli.ts 是粘合层。 -### 四个 runtime +### Runtime 列表 -Profile 的 `runtime` 字段有四个取值。其中 **`claude-agent-sdk` / `codex-sdk` / `grok-build-acp` 由 `@sleep2agi/agent-node` 驱动**(agent-node 的 `RUNTIME_MAP` 见 `agent-node/src/cli.ts`);**`claude-code-cli` 不走 agent-node** —— `anet node start` 直接 spawn 本机 `claude` 二进制: +Profile 的 `runtime` 字段:**stable 4 runtime + preview 额外 2 runtime**。**`claude-agent-sdk` / `codex-sdk` / `grok-build-acp` / `codex-app-server` / `opencode-cli` 由 `@sleep2agi/agent-node` 驱动**(`RUNTIME_MAP` 见 `agent-node/src/cli.ts`);**`claude-code-cli` 不走 agent-node** —— `anet node start` 直接 spawn 本机 `claude` 二进制。**权威表(stable + preview)以 [anet.sh/guide/runtimes](https://anet.sh/guide/runtimes) 为准**,下面只作背景速览: -| Runtime | 说明 | 模型 | +| Runtime | 通道 | 说明 | |------|------|------| -| `claude-agent-sdk`(**默认**) | Anthropic Claude Agent SDK + Anthropic 兼容 API | Claude / MiniMax / DeepSeek / GLM / Kimi / 书生 / 小米 MiMo / OpenRouter 等(完整 provider 表见 [anet.sh / multi-model](https://anet.sh/guide/multi-model)) | -| `codex-sdk` | OpenAI Codex SDK | OpenAI Codex(最新 model id 查官方文档) | -| `claude-code-cli` | Claude Code CLI(要 Claude Pro 订阅) | Claude(通过本地 CLI 调用) | -| `grok-build-acp` | xAI Grok Build ACP server(spawn 本机 `grok` 二进制 + ACP 协议) | xAI Grok(grok-build 系列;[详细 runtime 指南 ↗](https://github.com/sleep2agi/agent-network/blob/main/docs/grok-build-runtime.md)) | +| `claude-code-cli` | stable | Claude Code CLI(用本机 Claude Pro/Team/Max 订阅,零配置最稳) | +| `claude-agent-sdk` | stable | Anthropic Agent SDK + 任意 Anthropic 兼容 endpoint(provider 表见 [anet.sh / multi-model](https://anet.sh/guide/multi-model)) | +| `codex-sdk` | stable | OpenAI Codex SDK(`codex login`) | +| `grok-build-acp` | stable | xAI Grok Build ACP server(`grok login`) | +| `codex-app-server` | preview | Codex app-server 桥接(RFC-030 in-flight) | +| `opencode-cli` | preview | OpenCode CLI 共存(RFC-029 in-flight) | -Profile 中通过 `runtime` 字段选择。早期文档里的 `claude-code` / `codex` / `agent-sdk` 已重命名(doctor `anet doctor --fix` 自动迁移)。 +早期文档里的 `claude-code` / `codex` / `agent-sdk` 已重命名(`anet doctor --fix` 自动迁移)。 > R268 校准:原本这里另列了一段 4 行「支持的模型列表」(MiniMax M2.7 / 书生 Intern-S1-Pro / Claude / Codex),跟上方 runtime 表重复且写死了 `M2.7` 这种快速 rotate 的版本号(违反 R175/R245/R253/R257 chain「doc 不 pin model 版本」规则)。删;完整 provider × runtime 列表见上表 + [anet.sh / multi-model](https://anet.sh/guide/multi-model)。 ### 隔离策略 -agent-node 调 claude-agent-sdk 的 `query()` 时传 `settingSources: []`,隔离 SDK 防止读取用户全局配置([`agent-node/src/cli.ts:558-598`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts#L558)): +agent-node 调 claude-agent-sdk 的 `query()` 时传 `settingSources: []`,隔离 SDK 防止读取用户全局配置([`agent-node/src/cli.ts:558-598`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts)): ```typescript const options = { @@ -76,7 +80,7 @@ for await (const message of query({ prompt, options })) { /* ... */ } 默认值(hub=http://127.0.0.1:9200, runtime=claude-agent-sdk) ``` -verify [`cli.ts:228 loadProfile`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L228): +verify [`cli.ts:228 loadProfile`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts): ```ts const p = join(nodesDir(), id, "config.json"); // .anet/nodes//config.json ``` @@ -121,7 +125,7 @@ const p = join(nodesDir(), id, "config.json"); // .anet/nodes//config.json > 上例是 `anet node create 开发马 --runtime claude-agent-sdk --model `(已登录)实际生成的最小集。条件字段:`teammateMode`(仅 `claude-code-cli`)、`session`(仅 `claude-code-cli` 或 `--session`)、`maxTurns`(仅 `--max-turns`)、`tools`(仅 `--tools`);`logLevel` 是 **top-level** 字段(不在 `flags` 里),且 `createCommand` 不写它(用户可选加)。 -verify [`cli.ts:246-273 saveProfile`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L246): +verify [`cli.ts:246-273 saveProfile`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts): ```ts const toSave: Record = { anet_version, node_id, node_name, runtime, @@ -191,7 +195,7 @@ anet server [--port 9200] [--token xxx] [--db path] [--cors origins] ### `anet setup` -R511 校准:旧 doc 写「`anet setup --hub --alias --type`,配置新 Agent 加入网络」是 V2 早期签名 —— 当前 `anet setup`([`cli.ts:556 setupCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L556))是**交互式 runtime 依赖安装器**,不带参数,也不写网络配置(入网走 `anet node create`)。 +R511 校准:旧 doc 写「`anet setup --hub --alias --type`,配置新 Agent 加入网络」是 V2 早期签名 —— 当前 `anet setup`([`cli.ts:556 setupCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts))是**交互式 runtime 依赖安装器**,不带参数,也不写网络配置(入网走 `anet node create`)。 ```bash anet setup @@ -207,7 +211,7 @@ anet setup ### `anet run` -R511 校准:旧 doc 写的 `[--handler script.ts]` flag + 「handler 协议」是 V2 设计草稿,**当前不存在**。当前 `anet run`([`cli.ts:2044 runCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2044))是用 Client SDK 起的**极简 standalone SSE agent**:连 hub、监听 task、自动 echo「收到」回复 —— **不跑 LLM**,区别于 `anet node start`(跑真实 AI runtime)。 +R511 校准:旧 doc 写的 `[--handler script.ts]` flag + 「handler 协议」是 V2 设计草稿,**当前不存在**。当前 `anet run`([`cli.ts:2044 runCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts))是用 Client SDK 起的**极简 standalone SSE agent**:连 hub、监听 task、自动 echo「收到」回复 —— **不跑 LLM**,区别于 `anet node start`(跑真实 AI runtime)。 ```bash anet run --alias [--hub ] @@ -309,11 +313,11 @@ await startServer({ ## 5. Channel 插件自动配置 — R221 校准 -`anet node start` 检测到 `runtime: "claude-code-cli"` 时,自动确保 Channel 插件可用([`cli.ts:1644 ensureMcpJson`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L1644)): +`anet node start` 检测到 `runtime: "claude-code-cli"` 时,自动确保 Channel 插件可用([`cli.ts:1644 ensureMcpJson`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)): 1. 从 npm 包 (`dist/src/node-server.js` 优先 / `src/node-server.ts` 兜底) 复制到 `{项目}/.anet/node-server.js`(**注意:是 `.js` 不是 `.ts`** —— [R216 chain](https://github.com/sleep2agi/agent-network/issues/10#issuecomment-4438192170)) 2. 安装依赖(`@modelcontextprotocol/sdk ^1.12.0` 通过 `bun install`) -3. 写入 `.mcp.json`:`commhub → .anet/node-server.js`([cli.ts:1724](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L1724)) +3. 写入 `.mcp.json`:`commhub → .anet/node-server.js`([cli.ts:1724](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)) ``` {项目}/ @@ -323,9 +327,9 @@ await startServer({ └── package.json # @modelcontextprotocol/sdk ^1.12.0 ``` -已配置过且内容一致直接跳过(compare-by-content:`if (src !== dst) writeFileSync(...)`,[cli.ts:1679-1680](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L1679))。`anet init project` 也做同样的事(另外还写 CLAUDE.md)。 +已配置过且内容一致直接跳过(compare-by-content:`if (src !== dst) writeFileSync(...)`,[cli.ts:1679-1680](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts))。`anet init project` 也做同样的事(另外还写 CLAUDE.md)。 -R221 校准:原 doc 写「`runtime: "claude-code"`」+「`.anet/node-server.ts`」+「`.mcp.json args:[".anet/node-server.ts"]`」三处都是 V2 早期命名/文件名,当前 runtime name 是 `claude-code-cli`([RuntimeName type cli.ts:145](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L145)),落盘文件名是 `.js`。 +R221 校准:原 doc 写「`runtime: "claude-code"`」+「`.anet/node-server.ts`」+「`.mcp.json args:[".anet/node-server.ts"]`」三处都是 V2 早期命名/文件名,当前 runtime name 是 `claude-code-cli`([RuntimeName type cli.ts:145](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)),落盘文件名是 `.js`。 --- @@ -443,9 +447,9 @@ R223 校准:旧 doc 只写 `bun build src/client.ts bin/cli.ts --outdir dist - - ⚠️ 旧 `COMMHUB_AUTH_TOKEN` 仅 `/api/*` 读类兼容(v1.0 移除) ### 配置安全 — R223 校准 -- `~/.anet/server/admin-utok.json` 自动 chmod 600([`cli.ts:105-111 saveAdminUtok`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L105) `writeFileSync(..., {mode: 0o600})` + `chmodSync(..., 0o600)`,v0.8 bootstrap 写入 admin token) -- `~/.anet/server/config.json` 自动 chmod 600([`cli.ts:89-95 saveServerConfig`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L89)) -- ⚠️ `~/.anet/config.json` **不是 600** —— [`cli.ts:77-81 saveGlobal`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L77) 用默认 `writeFileSync` 无 mode 选项,实际权限通常 `644` (`rw-r--r--`)。在多用户机器上其他本地用户可读你的 utok_。**单用户 host 影响有限,多用户共享 host 建议手动 `chmod 600 ~/.anet/config.json`**(v0.9 RFC 待修) +- `~/.anet/server/admin-utok.json` 自动 chmod 600([`cli.ts:105-111 saveAdminUtok`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) `writeFileSync(..., {mode: 0o600})` + `chmodSync(..., 0o600)`,v0.8 bootstrap 写入 admin token) +- `~/.anet/server/config.json` 自动 chmod 600([`cli.ts:89-95 saveServerConfig`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)) +- ⚠️ `~/.anet/config.json` **不是 600** —— [`cli.ts:77-81 saveGlobal`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) 用默认 `writeFileSync` 无 mode 选项,实际权限通常 `644` (`rw-r--r--`)。在多用户机器上其他本地用户可读你的 utok_。**单用户 host 影响有限,多用户共享 host 建议手动 `chmod 600 ~/.anet/config.json`**(v0.9 RFC 待修) - 项目 `.anet/nodes//config.json` 不应包含 token(放全局配置;R222 chain 说明项目 config 用 hub/token 字段覆盖全局是 advanced use case) - `.anet/` 应加入 `.gitignore` 防止提交 @@ -517,7 +521,7 @@ R256 校准:旧 doc 用 `send_task(hub, result)` 回复任务结果 —— 这 ## 10. Web Dashboard -> **R220 校准(2026-05-13)**:本节的「内置轻量 UI」+「`http://YOUR_IP:9200/dashboard`」是 V2 早期设计草稿,**v0.8 实际未实现** —— commhub-server `server/src/index.ts` 没有 `/dashboard` 路由([全 source grep `/dashboard` 0 hit](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts))。当前**唯一 Dashboard 是独立的 Next.js 包 `@sleep2agi/agent-network-dashboard`**,通过 `anet hub dashboard` 子命令拉起([`agent-network/bin/cli.ts:2386`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2386) `sub === "dashboard"` 分支,默认端口 3000;版本不再 hardcode pin —— [`dashboardReleaseTag()` cli.ts:347](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L347) 默认拉 `@preview` tag,可用 `ANET_DASHBOARD_VERSION` env 覆盖,跟 anet release channel 对齐 — 见 #61)。最新部署方式见 [anet.sh/guide/dashboard](https://anet.sh/guide/dashboard)。下面的「两种 Dashboard」/「内置 UI 设计原则」/「实现方案」/「HTML 结构」全是 V2 设计草稿,仅保留历史背景,**当前不适用**。 +> **R220 校准(2026-05-13)**:本节的「内置轻量 UI」+「`http://YOUR_IP:9200/dashboard`」是 V2 早期设计草稿,**v0.8 实际未实现** —— commhub-server `server/src/index.ts` 没有 `/dashboard` 路由([全 source grep `/dashboard` 0 hit](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts))。当前**唯一 Dashboard 是独立的 Next.js 包 `@sleep2agi/agent-network-dashboard`**,通过 `anet hub dashboard` 子命令拉起([`agent-network/bin/cli.ts:2386`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) `sub === "dashboard"` 分支,默认端口 3000;版本不再 hardcode pin —— [`dashboardReleaseTag()` cli.ts:347](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) 默认拉 `@preview` tag,可用 `ANET_DASHBOARD_VERSION` env 覆盖,跟 anet release channel 对齐 — 见 #61)。最新部署方式见 [anet.sh/guide/dashboard](https://anet.sh/guide/dashboard)。下面的「两种 Dashboard」/「内置 UI 设计原则」/「实现方案」/「HTML 结构」全是 V2 设计草稿,仅保留历史背景,**当前不适用**。 ### 当前 Dashboard diff --git a/docs/getting-started.md b/docs/getting-started.md index 3b0fd830b..007a3b90d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -87,6 +87,9 @@ anet node create my-agent --runtime claude-code-cli | `claude-code-cli` **⭐ recommended** | Claude Code CLI (reuses your subscription) | `npm i -g @anthropic-ai/claude-code` + `claude auth login` (Claude Pro/Team/Max) — zero config, most stable | | `claude-agent-sdk` | Anthropic / MiniMax / DeepSeek / GLM / Kimi / InternLM / Xiaomi MiMo / OpenRouter (any Anthropic-compatible endpoint) | API key in env or via `anet node create` prompts; on `latest`, first `node start` needs `agent-node` installed first ([#450](https://github.com/sleep2agi/agent-network/issues/450)) | | `codex-sdk` | Codex | `codex login` | +| `grok-build-acp` | xAI Grok Build (ACP) | `grok login` | + +`@preview` additionally exposes `codex-app-server` and `opencode-cli`. The authoritative full runtime table (stable + preview) is at [anet.sh/guide/runtimes](https://anet.sh/guide/runtimes). For the full provider endpoint table (each provider's `ANTHROPIC_BASE_URL` etc.), see [docs-site/guide/multi-model](https://anet.sh/guide/multi-model). @@ -131,7 +134,7 @@ anet token revoke x # Revoke a token ## Managing Agents ```bash -anet ls # List all nodes + status +anet node ls # List all nodes + status anet info my-agent # Detailed node info anet logs my-agent # View agent logs anet node stop my-agent # Stop agent diff --git a/docs/plans/release-plan.md b/docs/plans/release-plan.md index 505d655da..be6bae95a 100644 --- a/docs/plans/release-plan.md +++ b/docs/plans/release-plan.md @@ -2,14 +2,16 @@ > **各版本的迭代范围(冻结的功能清单)** 见 [docs/version/](../version/):[版本矩阵](../version/README.md) · [v0.11.0](../version/0.11.0/) · [v0.10.16](../version/0.10.16/)。本文只保留通道状态与政策。 -> 最后更新:2026-07-16。Owner:release ops。版本号**怎么读**(npm 版号 vs bundle tag 两套体系)见 [versioning](../../docs-site/docs/guide/versioning.md)。 +> 最后更新:2026-08-14(`npm view` 实测)。Owner:release ops。版本号**怎么读**(npm 版号 vs bundle tag 两套体系)见 [versioning](../../docs-site/docs/guide/versioning.md)。 ## 当前已发布状态 -| 通道 | @sleep2agi/agent-network | @sleep2agi/agent-node | 说明 | -|---|---|---|---| -| **latest**(稳定) | 2.2.21 | 2.4.13 | 4 个 runtime;⚠ 带 Windows 跨盘 `anet --version` 崩溃(#446) | -| **preview** | **2.3.0-preview.34** | **2.5.0-preview.26** | canonical:全部 Windows 修复 + codex-app-server flag + OpenCode 1.18.1。真 Windows 复验 PASS;Linux 门禁 1-6 已真绿、7 终跑中。⚠️ 审计新发现 stop 孤儿窗 P0(OpenCode 节点 stop 可留 detached ACP 孤儿),修复 draft 在途(evidence pair .35/.27)。**promote 解冻 = 7/7 真绿 + 孤儿窗修复合入并复跑受影响门禁**。另:picker 实为 6-way | +**这张表是 `npm view dist-tags` 的映射**,浮动。改前用 `npm view` 核一遍。 + +| 通道 | @sleep2agi/agent-network | @sleep2agi/agent-node | @sleep2agi/commhub-server | 说明 | +|---|---|---|---|---| +| **latest**(稳定) | 2.2.21 | 2.4.13 | 0.8.8 | 4 个 stable runtime;⚠ 带 Windows 跨盘 `anet --version` 崩溃(#446) | +| **preview** | **2.3.0-preview.39** | **2.5.0-preview.31** | **0.9.0-preview.29** | 迭代中:Windows 修复 + `codex-app-server` flag + OpenCode `1.18.1`。preview 还额外暴露 `codex-app-server` / `opencode-cli` 两个 runtime。**promote 门禁** = 全 Linux 门禁真绿 + Windows 复验 PASS + 审计发现的 stop 孤儿窗(OpenCode 节点 stop 可留 detached ACP 孤儿)修复合入并复跑受影响门禁。 | ## 进行中 → 下一个 preview(canonical,`.34` / `.26`) diff --git a/docs/version/0.11.0/README.md b/docs/version/0.11.0/README.md index 90a22dd18..bf0e822f9 100644 --- a/docs/version/0.11.0/README.md +++ b/docs/version/0.11.0/README.md @@ -2,7 +2,7 @@ > 包版本映射:agent-network 2.3.0 / agent-node 2.5.0 / commhub-server 0.9.0 / dashboard 0.7.0(见 [版本矩阵](../README.md))。 -> 状态:进行中(preview 泡验期)。**发布锚点:世界人工智能大会(WAIC,7 月下旬)前完成 promote——v0.11.0 就是 WAIC 发布物**([WAIC 发布规划](./waic-release.md))。 +> 状态:进行中(preview 泡验期)。**~~发布锚点:世界人工智能大会(WAIC,7 月下旬)前完成 promote~~**(**过期**:WAIC 7 月下旬窗口已过)——当前 promote 状态见下面的进度快照 + [release-plan](../../plans/release-plan.md);WAIC 相关背景与决策档案见 [WAIC 发布规划](./waic-release.md)。 > > ## 🎯 本版最大目标:**收敛与可靠,不是新功能** > @@ -16,20 +16,19 @@ > 下表的"功能"多数是**收敛既有在飞项**(RFC-029/030 早已开工),不是新开口子。真正的新功能一律排下一版。 > **范围已冻结**:不在下表里的功能一律排 2.4.0+,防失控。新想法 → 开 issue 打 `2.4.0-candidate` 标签,不插队。 -## 📍 进度快照(2026-07-16 晚) +## 📍 进度快照(2026-08-14 · npm view 实测) -**一句话:核心已发布在泡验(preview .34/.26),发布后自审又抓出一批真问题正在修,promote 冻结中——收口期。** +**一句话:canonical preview 已推进至 .39/.31,`npm view @preview` 是当前口径;promote 仍冻结。** | 线 | 状态 | |---|---| | 功能盘点(0号工作流) | ✅ 收官:7 旅程全结论([记分板](./feature-audit.md))+1 盲区补记(daemon 向导) | -| canonical 发布 | ✅ `.34`/`.26` 已上 @preview(真 Windows 复验过;latest 未动) | -| Linux 七套门禁 | 1-6 ✅ 真绿;**7 ❌ 抓出真 P1**(#457 rename 缺 0700)——三处测试期望滞后已全修,最后剩的是真 bug,说明门禁在干活 | -| 修复批 `.35`/`.27`(进行中,owner: release ops) | 四合一:stop 孤儿窗 P0 + create git-gate + batch fail-closed + rename-0700(#457);draft PR 后全套重跑门禁 | +| canonical 发布 | 🔄 `npm view @preview`:agent-network `2.3.0-preview.39` / agent-node `2.5.0-preview.31` / commhub-server `0.9.0-preview.29` | +| Linux 七套门禁 | 1-6 ✅ 真绿;7 曾抓出 #457 rename 缺 0700,修复批已进 preview 链——门禁在干活 | +| 修复批(进行中,owner: release ops) | 四合一:stop 孤儿窗 P0 + create git-gate + batch fail-closed + rename-0700(#457);draft PR 后全套重跑门禁 | | dashboard | #15/#36/#37 三绿键 PR 等"合";P0 痛点批(长消息折叠+密度+底部锚定增量)随后;hub 翻页游标已立案 #459 | | promote latest | 🔒 冻结。解冻 = 7/7 真绿(含修复批重跑)+ Windows 复验 | -| 本日 issue 台账 | #446-#459 共 14 个,全带复现证据;其中 #446 已修已发已验 | -| WAIC | 锚点不变([发布规划](./waic-release.md));关键路径 = 修复批 → 门禁 → promote | +| WAIC 锚点 | ⚠️ **过期**(7 月下旬窗口已过);档案在 [发布规划](./waic-release.md);关键路径不变 = 修复批 → 门禁 → promote | ## 0号工作流(本版核心):现有功能靠谱度盘点 ✅ 走查阶段收官(2026-07-16) diff --git a/docs/version/README.md b/docs/version/README.md index 316995443..3724bd6d2 100644 --- a/docs/version/README.md +++ b/docs/version/README.md @@ -6,11 +6,13 @@ ## 版本矩阵 +> preview 一行是 `npm view @preview version` 的映射(浮动);每次改前用 `npm view` 核一遍。最后回填时间:2026-08-14。 + | 整体版本 | 状态 | agent-network | agent-node | commhub-server | dashboard | 规划 | |---|---|---|---|---|---|---| | **v0.10.15** | 当前 stable | 2.2.21 | 2.4.13 | 0.8.8 | 0.6.0 | —(已发布) | | **v0.10.16** | 热修·筹备 | 2.2.22(待发) | 2.4.13 | 0.8.8 | 0.6.0 | [plan](./0.10.16/) | -| **v0.11.0** | 迭代中(**canonical preview .34/.26 已发布**) | 2.3.0-preview.34 | 2.5.0-preview.26 | 0.9.0(现 preview) | 0.7.0(现 0.6.3-preview) | [plan](./0.11.0/) | +| **v0.11.0** | 迭代中(preview 泡验期) | 2.3.0-preview.39(现 preview) | 2.5.0-preview.31(现 preview) | 0.9.0-preview.29(现 preview) | 0.7.0(现 0.6.3-preview) | [plan](./0.11.0/) | ## 规则 diff --git a/server/README.md b/server/README.md index 443cdb1d1..f921d2658 100644 --- a/server/README.md +++ b/server/README.md @@ -74,6 +74,8 @@ authoritative. Pinning versions here goes stale on every release and nobody come | `list_tasks` | Task list, filterable by `network_id` | | `get_completions` | Completion history | +> The table above lists the 17 **collaboration-core** tools. Node lifecycle / provider ops tools ship on the same MCP surface — the authoritative full list is [docs-site/docs/api/mcp-tools.md](../docs-site/docs/api/mcp-tools.md). Don't read the count above as "17 tools total". + ## REST API The server exposes ~33 endpoints across health, auth, networks, and observability surfaces. The endpoints in use today by the verified flow are: diff --git a/server/package.json b/server/package.json index 59632ce2f..e99fd6542 100644 --- a/server/package.json +++ b/server/package.json @@ -1,7 +1,7 @@ { "name": "@sleep2agi/commhub-server", "version": "0.9.0-preview.29", - "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.", + "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).", "type": "module", "main": "src/index.ts", "bin": { From 7752437fbd75228bce47302920735e527825eb47 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 00:03:18 +0800 Subject: [PATCH 14/59] =?UTF-8?q?fix(ci):=20=E8=AE=A9=20CI=20=E7=9C=9F?= =?UTF-8?q?=E4=BC=9A=E8=B7=91=E7=9A=84=E6=B5=8B=E8=AF=95=E8=83=BD=E9=87=8D?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E8=87=AA=E5=B7=B1=E9=82=A3=E9=81=93=E9=97=A8?= =?UTF-8?q?,=E5=B9=B6=E5=8A=A0=E4=B8=80=E9=81=93=E9=98=B2=E6=BC=82?= =?UTF-8?q?=E5=9B=9E=E5=8E=BB=E7=9A=84=E9=97=A8=20(#897)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the open-issue triage, each verified against origin/main before touching anything. Two of the four numbers in those issues were wrong in ways worth recording. ## qa.yml path filter missed tests CI actually runs (#860) qa.yml fires on a path filter. Four directories CI executes were outside it, so editing the test could not re-run its own gate — and the run looks identical to a gate that passed on the new code: tests/test292-e2e-hard-gate (referenced by a workflow path) tests/test686-rest-shape-golden ┐ tests/test765-batch-runtime-gate ├ reached through scripts/qa.sh L1_TESTS tests/test766-bunx-preflight ┘ #860 reported three; it missed test292-e2e-hard-gate. My own first scan under-counted in the other direction — it matched `tests/testNNN` and so never saw L1_TESTS, which names directories bare. The remaining ~160 directories under tests/ are run by no workflow at all, and are deliberately left out: a filter entry for an unrun test reads like coverage it does not have. ## …and a guard so it does not drift back `.github/scripts/check-qa-trigger-coverage.py` asserts every CI-executed test directory is in the filter. Three behaviours, each exercised: fixed repo → exit 0, "all 7 CI-executed test dirs can re-trigger" f565e9b8's qa.yml → exit 1, names all four with the line to add L1_TESTS renamed away → exit 2, "no CI-executed test directories detected" That last one matters most: if the parser stops matching, the honest answer is "I can no longer see the denominator", not a clean run against nothing. Its workflow intentionally carries NO `paths:` filter. It guards a path filter; gating it on paths would let an edit to qa.yml's filter or to L1_TESTS slip past the check that watches them — the same blind spot it exists to catch. ## public-script safety: TLS verification (#890) check-public-script-safety.py flagged `rm -rf` outside our paths and unscoped `pkill`, but nothing about `curl -k` / `--insecure` / `wget --no-check-certificate` / `NODE_TLS_REJECT_UNAUTHORIZED=0`. These scripts are fetched over https and piped into bash, so verification is the reader's only defence against a tampered download; there is no legitimate reason for a script published at a public https URL to skip verifying it. That meets the file's own "only unambiguous rules" bar. Zero current hits — this one is preventive. While adding it I hit a bug in the reporting: the hint was chosen by an if/else whose `else` branch belonged to the kill rule, so every TLS finding printed advice about `pkill -u`. Remediation text is now keyed by rule, and an unknown rule exits 2 rather than borrowing another rule's advice — pointing the reader at a problem they do not have is worse than printing nothing. Verified with real exit codes (not through a pipe, where `$?` is the last command's): known-bad fixture → exit 1 with the right hint on all three forms, comment lines ignored; real repo → exit 0 across 6 scripts. ## dashboard min_uptime (#892) deploy/dashboard/ecosystem.config.cjs had `min_uptime: 20_000` while docs-site/docs/deploy/daemon.md documents 45000 and explains why: below the time a failing process takes to exit, PM2 counts the start as successful, backoff never engages, and a crash loop looks like normal restarts. A dashboard rebuilt from this repo landed exactly in that gap. Aligned to 45000 with the reasoning inline; `node -e require(...)` confirms it still parses. Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-public-script-safety.py | 50 ++++++++-- .github/scripts/check-qa-trigger-coverage.py | 96 +++++++++++++++++++ .github/workflows/qa-trigger-coverage.yml | 34 +++++++ .github/workflows/qa.yml | 18 ++++ deploy/dashboard/ecosystem.config.cjs | 6 +- 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100755 .github/scripts/check-qa-trigger-coverage.py create mode 100644 .github/workflows/qa-trigger-coverage.yml diff --git a/.github/scripts/check-public-script-safety.py b/.github/scripts/check-public-script-safety.py index 860c69493..5b5d29755 100755 --- a/.github/scripts/check-public-script-safety.py +++ b/.github/scripts/check-public-script-safety.py @@ -15,7 +15,15 @@ All four were found by hand. This guard exists so the next one is not. -Scope note: only two rules, both unambiguous. A guard that cries wolf gets + * (added 2026-08-17) nothing yet — this third rule is preventive. Every one + of these scripts is fetched over https and piped into bash, so TLS + verification is the reader's only defence against a tampered download. + `curl -k` / `--insecure` / `wget --no-check-certificate` removes it, which + is why it belongs with the other two rather than with human review: there + is no legitimate reason for a script published at a public https URL to + skip verifying that URL. + +Scope note: three rules, all unambiguous. A guard that cries wolf gets disabled, and then it protects nothing. Deliberately NOT flagged here: * printing a documented default password (correct for the stable channel, which is what these scripts install) @@ -35,6 +43,13 @@ RM_RF = re.compile(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+(?P[^\n;&|]+)") KILL = re.compile(r"\b(pkill|killall)\b(?P[^\n;&|]*)") USER_SCOPED = re.compile(r"-u\s+\S") +# TLS verification is the only thing standing between the reader and a tampered +# download, and these scripts are meant to be piped straight into bash. +INSECURE_TLS = re.compile( + r"\b(?:curl\b[^\n;&|]*?(?:\s-{1,2}(?:k|insecure)\b)" + r"|wget\b[^\n;&|]*?--no-check-certificate\b" + r"|(?:NODE_TLS_REJECT_UNAUTHORIZED|PYTHONHTTPSVERIFY)\s*=\s*0)" +) def check(path: Path): @@ -55,6 +70,9 @@ def check(path: Path): k = KILL.search(line) if k and not USER_SCOPED.search(k.group("args")): out.append((i, "unscoped-process-kill", line.strip()[:90])) + + if INSECURE_TLS.search(line): + out.append((i, "tls-verification-disabled", line.strip()[:90])) return out @@ -81,14 +99,30 @@ def main(): print(f"0 findings across {len(scripts)} script(s).") return 0 + # Keyed by rule, not by an if/else that falls through: a new rule reaching + # the `else` branch would print another rule's remediation, which is worse + # than printing none — the reader follows advice for a problem they do not + # have. (Caught exactly that while adding the TLS rule.) + HINTS = { + "rm-rf-outside-product": + "path is not owned by this product — wiping it damages unrelated " + "tools on the user's machine. Remove it, or narrow to a path we own.", + "unscoped-process-kill": + "pattern-matched kill hits same-named processes owned by anyone. " + 'Scope it: pkill -u "$(id -u)" -f ...', + "tls-verification-disabled": + "this script is fetched over https and piped into bash; skipping " + "certificate verification removes the reader's only protection " + "against a tampered download. Drop the flag.", + } + unknown = sorted({rule for _, _, rule, _ in findings} - HINTS.keys()) + if unknown: + print(f"::error::rule(s) with no remediation text: {', '.join(unknown)} — " + "add one to HINTS rather than letting it borrow another rule's advice") + return 2 + for s, line_no, rule, text in findings: - if rule == "rm-rf-outside-product": - hint = ("path is not owned by this product — wiping it damages unrelated " - "tools on the user's machine. Remove it, or narrow to a path we own.") - else: - hint = ("pattern-matched kill hits same-named processes owned by anyone. " - 'Scope it: pkill -u "$(id -u)" -f ...') - print(f"::error file={s},line={line_no}::[{rule}] {text}\n {hint}") + print(f"::error file={s},line={line_no}::[{rule}] {text}\n {HINTS[rule]}") print(f"\n{len(findings)} finding(s) across {len(scripts)} scanned script(s).") return 1 diff --git a/.github/scripts/check-qa-trigger-coverage.py b/.github/scripts/check-qa-trigger-coverage.py new file mode 100755 index 000000000..985f086e5 --- /dev/null +++ b/.github/scripts/check-qa-trigger-coverage.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Every test CI actually runs must also be able to re-trigger the workflow that runs it. + +`.github/workflows/qa.yml` fires on a path filter. A test directory that CI +executes but that is missing from that filter can be edited without the gate +re-running — the change ships against whatever the gate last said, and the +output looks identical to a gate that passed on the new code. + +Found on 2026-08-17: three of the four test directories reached through +`scripts/qa.sh` L1_TESTS were outside the filter (test686-rest-shape-golden, +test765-batch-runtime-gate, test766-bunx-preflight), plus test292-e2e-hard-gate +which a workflow references by path. The reason it was easy to miss is that +`tests/` holds ~166 directories and only a handful are wired into CI at all, so +"most tests are not in the filter" is the normal, correct state and hides the +few that should be. + +Deliberately NOT flagged: the ~160 directories no workflow executes. Listing +them would grow the filter without adding a single gate, and a filter that +triggers on unrun tests reads like coverage it does not have. + +Scope is fail-closed: if the workflow, qa.sh, or tests/ cannot be found, this +exits 2 rather than reporting a clean run against nothing. +""" +import re +import sys +from pathlib import Path + +QA_YML = Path(".github/workflows/qa.yml") +QA_SH = Path("scripts/qa.sh") +TESTS_DIR = Path("tests") +WORKFLOWS = Path(".github/workflows") + + +def bash_array(text: str, name: str) -> list[str]: + """Entries of a `NAME=( "a" "b" )` bash array, or [] when absent.""" + m = re.search(rf"{name}=\(([^)]*)\)", text, re.S) + return re.findall(r'"([^"]+)"', m.group(1)) if m else [] + + +def main() -> int: + for p in (QA_YML, QA_SH, TESTS_DIR): + if not p.exists(): + print(f"::error::{p} not found — scope regression, refusing to pass") + return 2 + + test_dirs = {d.name for d in TESTS_DIR.iterdir() if d.is_dir() and d.name.startswith("test")} + if not test_dirs: + print(f"::error::no test directories under {TESTS_DIR} — scope regression, refusing to pass") + return 2 + + qa_sh = QA_SH.read_text(encoding="utf-8", errors="replace") + # L1 entries name the directory bare (no `tests/` prefix); L0 entries name + # source files, so only the ones that resolve to a real test dir count. + executed = {e for e in bash_array(qa_sh, "L1_TESTS") + bash_array(qa_sh, "L0_TESTS") + if e in test_dirs} + + # Anything a workflow references by path is executed too. + for wf in sorted(list(WORKFLOWS.glob("*.yml")) + list(WORKFLOWS.glob("*.yaml"))): + body = wf.read_text(encoding="utf-8", errors="replace") + # A path filter entry is not a reference to running it — strip those + # first, or every listed dir would look self-justifying. + body = re.sub(r"^\s*-\s*'tests/[^']+'\s*$", "", body, flags=re.M) + executed |= {m.rstrip("/") for m in re.findall(r"tests/(test[\w.\-]+)", body)} & test_dirs + + if not executed: + print("::error::no CI-executed test directories detected — the parser probably " + "stopped matching qa.sh or the workflows; refusing to pass") + return 2 + + covered = set(re.findall(r"tests/(test[\w.\-]+)/\*\*", QA_YML.read_text(encoding="utf-8"))) + gap = sorted(executed - covered) + + print(f"tests/ directories: {len(test_dirs)} · CI-executed: {len(executed)} · " + f"in qa.yml path filter: {len(covered)}") + + if gap: + for d in gap: + print(f"::error file={QA_YML}::tests/{d} is executed by CI but missing from the " + f"qa.yml path filter — editing it will not re-run its own gate.\n" + f" Add: - 'tests/{d}/**'") + print(f"\n{len(gap)} executed test directory/ies outside the trigger filter.") + return 1 + + stale = sorted(covered - executed) + if stale: + # Not a failure: a dir may be listed ahead of being wired up. But say it, + # because a filter entry for something CI never runs is coverage theatre. + print("note: in the filter but not executed by CI (harmless, but not coverage): " + + ", ".join(stale)) + + print(f"all {len(executed)} CI-executed test directory/ies can re-trigger qa.yml.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/qa-trigger-coverage.yml b/.github/workflows/qa-trigger-coverage.yml new file mode 100644 index 000000000..f94d7f49e --- /dev/null +++ b/.github/workflows/qa-trigger-coverage.yml @@ -0,0 +1,34 @@ +# Keep qa.yml's path filter in sync with the tests CI actually runs. +# +# qa.yml fires on a path filter. A test directory that CI executes but that is +# missing from the filter can be edited without its own gate re-running — the +# change ships against whatever the gate last said, and the run looks exactly +# like a gate that passed on the new code. +# +# Found on 2026-08-17: four such directories (test292-e2e-hard-gate, +# test686-rest-shape-golden, test765-batch-runtime-gate, test766-bunx-preflight). +# Easy to miss because tests/ holds ~166 directories and only a handful are +# wired into CI, so "most tests are absent from the filter" is the correct +# normal state — which is what hid the few that should not be. +# +# 🔴 This workflow deliberately has NO `paths:` filter. It is the guard for a +# path filter; gating it on paths would let a change to qa.yml's filter or to +# scripts/qa.sh L1_TESTS slip past the very check that watches them, and it +# would be the same class of blind spot the guard exists to catch. +# +# Python rather than an in-yml bash loop, per the team's CI-guard pattern +# (same reasoning as public-script-safety.yml and no-memory-slugs.yml). + +name: lint (qa trigger coverage) + +on: + pull_request: + push: + branches: [main] + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python3 .github/scripts/check-qa-trigger-coverage.py diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index de59ed39d..95d8203f2 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -22,6 +22,15 @@ on: - 'tests/test725-agent-node-unit-ci/**' - 'tests/test745-agent-network-unit-ci/**' - 'tests/test746-setup-bun-pin/**' + # A test directory belongs here exactly when CI executes it — otherwise + # editing the test cannot re-run the gate that runs it. The four below are + # reached through scripts/qa.sh L1_TESTS and the e2e workflow; the other + # ~160 dirs under tests/ are not run by any workflow, so listing them + # would only look like coverage. + - 'tests/test292-e2e-hard-gate/**' + - 'tests/test686-rest-shape-golden/**' + - 'tests/test765-batch-runtime-gate/**' + - 'tests/test766-bunx-preflight/**' push: branches: [main] paths: @@ -34,6 +43,15 @@ on: - 'tests/test725-agent-node-unit-ci/**' - 'tests/test745-agent-network-unit-ci/**' - 'tests/test746-setup-bun-pin/**' + # A test directory belongs here exactly when CI executes it — otherwise + # editing the test cannot re-run the gate that runs it. The four below are + # reached through scripts/qa.sh L1_TESTS and the e2e workflow; the other + # ~160 dirs under tests/ are not run by any workflow, so listing them + # would only look like coverage. + - 'tests/test292-e2e-hard-gate/**' + - 'tests/test686-rest-shape-golden/**' + - 'tests/test765-batch-runtime-gate/**' + - 'tests/test766-bunx-preflight/**' # Older runs on the same ref get cancelled — saves minutes when a PR is # updated rapidly. main pushes run independently. diff --git a/deploy/dashboard/ecosystem.config.cjs b/deploy/dashboard/ecosystem.config.cjs index 8b4217a39..49611bc64 100644 --- a/deploy/dashboard/ecosystem.config.cjs +++ b/deploy/dashboard/ecosystem.config.cjs @@ -20,7 +20,11 @@ module.exports = { interpreter: "bash", exec_mode: "fork", autorestart: true, - min_uptime: 20_000, + // min_uptime 必须大于「进程失败退出所需时间」。低于它,PM2 会把这次启动 + // 算成功、不计入失败,backoff 永不触发 —— 崩溃循环看起来像正常重启。 + // 这里原本是 20_000,比 docs-site/docs/deploy/daemon.md 记录的 45000 小, + // 照本仓重建出来的 dashboard 会正好落进那个盲区。对齐到 45000。 + min_uptime: 45000, max_restarts: 20, exp_backoff_restart_delay: 200, }, From 9db6dcf1b405a70acc6919cfbc6e7fa7e7ecf7de Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 00:20:41 +0800 Subject: [PATCH 15/59] =?UTF-8?q?fix(docs,ci):=20=E4=BF=AE=20W19=20?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E4=B8=8E=E6=AD=BB=E9=93=BE=E3=80=81=E7=BB=99?= =?UTF-8?q?=E7=9F=9B=E7=9B=BE=E8=80=97=E6=97=B6=E6=A0=87=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E3=80=81=E6=8A=8A=E4=B8=A4=E4=B8=AA=E6=B2=A1=E4=BA=BA=E8=B0=83?= =?UTF-8?q?=E7=9A=84=E9=AA=8C=E8=AF=81=E8=84=9A=E6=9C=AC=E6=8C=82=E4=B8=8A?= =?UTF-8?q?=20(#899)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more from the open-issue triage. Each was verified against origin/main first, and two of them turned out to be bigger than the issue said. ## docs/qa/weekly/2026-W19.md would not decode as UTF-8 (#887) Three multi-byte characters were truncated mid-sequence, not one. The issue reported the first; repairing it revealed the second, and that one the third. The damage pattern is consistent — every case is `_italic text_` with the character immediately before the closing `_` eaten — which points at a truncating edit rather than a bad encoding. The lost characters are NOT recoverable, so they are marked as damaged rather than guessed. This is a QA weekly report; inventing a plausible character would be worse than saying a character is missing. ## …and all 24 of its relative links were dead (#872) Not "24 broken links" in the sense of a few typos: 0 of 24 resolved. The file sits three levels deep and the links were written for two, so every `../../` landed inside docs/ instead of at the repo root. Four more used a single `../` for directories that live under tests/. All 24 now resolve — verified by resolving each one against the filesystem, not by eyeballing the diff. ## docs/qa timings contradicted each other three ways (#871) docs/qa/README.md ~16s warm docs/qa/strategy.md ~16s warm docs/qa/v0-summary.md ~93s local, ~40s CI v0-summary's own per-test table, summed: 156s The issue framed this as "pick one and unify". None of the three can be picked, because not one of them says what it measured — warm or cold, serial or parallel, which machine. 156s serial against a 93s wall clock just means there is parallelism nobody wrote down. So the dead numbers are gone from README and strategy, replaced with `time bash scripts/qa.sh` and the one fact that stays true: the per-test table sums to 156s, anything lower implies parallelism, cold starts are worse. v0-summary keeps its 93s but now says it is a 2026-05 measurement. ## scripts/verify-published-pins.sh and verify-release-tag.sh had zero callers (#862) Both committed, both executable, both carrying the incident that motivated them in their header — and `grep -rl` across .github/ and scripts/ found nothing that invokes either. A guard nothing calls protects nothing, while its presence reads as if the risk were covered. Running verify-published-pins.sh by hand, for the first time, failed on its first invocation: ❌ OPENCODE_AGENT_NODE_VERSION 期望 2.5.0-preview.31, 产物里是: 2.5.0-preview.28 1 个 pin 与已发布产物不一致 —— main 修了但用户装到的包没修 That is the exact distinction its own header says bit this repo three times in one day, live in the published preview and undetected. (Independently confirmed by hand earlier the same night: installing preview.39 demands agent-node preview.28, while main's source constant reads preview.31.) Now scheduled daily plus manual dispatch, with the exit codes mapped so that "could not measure" does not become the same green as "measured and fine": rc=2 (registry unreachable) fails with a notice saying the run verified nothing. Per-PR would be wrong — it inspects the published artifact, which a PR does not change. ## A guard for the first two `.github/scripts/check-docs-integrity.py` checks UTF-8 validity across every tracked .md and relative-link resolution under docs/qa/. Three behaviours, each exercised: repaired tree → exit 0 (359 files, 80 links); f565e9b8's W19 → exit 1 with 25 errors naming each; LINK_SCOPE pointed at a missing directory → exit 2, "scope regression, refusing to pass". It starts green, so it is not a backlog canary — a red here will always mean something just broke. Link checking is scoped to docs/qa/ and says so: some pages elsewhere link to generated paths, and a guard that cries wolf gets disabled. Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-docs-integrity.py | 89 +++++++++++++++++++ .github/workflows/docs-integrity.yml | 44 +++++++++ .../workflows/published-artifact-drift.yml | 75 ++++++++++++++++ docs/qa/README.md | 14 ++- docs/qa/strategy.md | 2 +- docs/qa/v0-summary.md | 2 +- docs/qa/weekly/2026-W19.md | 52 +++++------ 7 files changed, 248 insertions(+), 30 deletions(-) create mode 100755 .github/scripts/check-docs-integrity.py create mode 100644 .github/workflows/docs-integrity.yml create mode 100644 .github/workflows/published-artifact-drift.yml diff --git a/.github/scripts/check-docs-integrity.py b/.github/scripts/check-docs-integrity.py new file mode 100755 index 000000000..6ee64895c --- /dev/null +++ b/.github/scripts/check-docs-integrity.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Two kinds of silent rot in tracked Markdown: unreadable bytes and dead links. + +Both were live in docs/qa/weekly/2026-W19.md on 2026-08-17: + + * three multi-byte characters truncated mid-sequence, so the file could not be + decoded as UTF-8 at all. Every reader's tool renders that as a replacement + glyph or an error, and the three damaged sentences each lost their last + character. The damage pattern (`_italic text_` with the character before the + closing `_` eaten) suggests a truncating edit, not a bad encoding. + * all 24 relative links resolved to paths that do not exist — the file sits + three levels deep and the links were written for two, so every one of them + pointed inside docs/ instead of at the repo root. + +Neither shows up in a build: Markdown has no compiler, so a dead link and a live +one look the same until a reader clicks. Both are cheap to check mechanically. + +Scope is deliberately narrow and stated: UTF-8 validity across every tracked +.md, link resolution for docs/qa/** only (where the defect was found). Widening +the link check to all docs is a separate decision — some files link to generated +or gitignored paths, and a guard that cries wolf gets disabled. + +Fail-closed: an empty file list exits 2 rather than reporting a clean run. +""" +import os +import re +import subprocess +import sys + +RELATIVE_LINK = re.compile(r"\]\((\.{1,2}/[^)\s]*)") +LINK_SCOPE = "docs/qa" + + +def tracked(pathspec: str) -> list[str]: + out = subprocess.run(["git", "ls-files", pathspec], capture_output=True, text=True) + return [f for f in out.stdout.split("\n") if f.endswith(".md")] + + +def main() -> int: + md = tracked("*.md") + if not md: + print("::error::git ls-files '*.md' returned nothing — scope regression, refusing to pass") + return 2 + + problems = 0 + + # 1. Every tracked .md must decode as UTF-8. + for f in md: + try: + open(f, "rb").read().decode("utf-8") + except UnicodeDecodeError as e: + problems += 1 + print(f"::error file={f}::not valid UTF-8 at byte {e.start} ({e.reason}). " + f"A truncated multi-byte character renders as a replacement glyph for " + f"every reader and silently drops text.") + except OSError as e: + problems += 1 + print(f"::error file={f}::cannot read: {e}") + + # 2. Relative links inside the scoped subtree must resolve. + scoped = [f for f in md if f.startswith(LINK_SCOPE + "/")] + if not scoped: + print(f"::error::no tracked .md under {LINK_SCOPE}/ — scope regression, refusing to pass") + return 2 + + links = 0 + for f in scoped: + body = open(f, encoding="utf-8", errors="replace").read() + base = os.path.dirname(f) + for target in RELATIVE_LINK.findall(body): + links += 1 + resolved = os.path.normpath(os.path.join(base, target.split("#")[0])) + if not os.path.exists(resolved): + problems += 1 + print(f"::error file={f}::relative link '{target}' resolves to " + f"'{resolved}', which does not exist") + + print(f"checked {len(md)} tracked .md for UTF-8 validity; " + f"{links} relative link(s) across {len(scoped)} file(s) under {LINK_SCOPE}/") + + if problems: + print(f"\n{problems} problem(s).") + return 1 + print("no problems.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/docs-integrity.yml b/.github/workflows/docs-integrity.yml new file mode 100644 index 000000000..2316ac6a7 --- /dev/null +++ b/.github/workflows/docs-integrity.yml @@ -0,0 +1,44 @@ +# Catch the two kinds of Markdown rot that no build step can see. +# +# On 2026-08-17, docs/qa/weekly/2026-W19.md had three multi-byte characters +# truncated mid-sequence (the file would not decode as UTF-8 at all, and each +# damaged sentence silently lost its last character) and all 24 of its relative +# links resolved to paths that do not exist — the file sits three levels deep +# and the links were written for two. +# +# Markdown has no compiler, so a dead link and a live one render the same until +# a reader clicks. Both problems are mechanical to detect and were invisible to +# every existing gate. +# +# Starts green: after the repair, all 359 tracked .md files decode cleanly and +# all 80 relative links under docs/qa/ resolve. This is deliberately not a +# backlog canary — it only reddens on new damage, so a red here always means +# something just broke rather than something is still on the pile. +# +# Scope note (stated because a filter you cannot see is a filter you cannot +# trust): UTF-8 validity is checked across EVERY tracked .md; link resolution is +# checked for docs/qa/** only, where the defect was found. Widening the link +# check repo-wide is a separate call — some pages link to generated or ignored +# paths, and a guard that cries wolf gets turned off. + +name: lint (docs integrity) + +on: + pull_request: + paths: + - '**/*.md' + - '.github/scripts/check-docs-integrity.py' + - '.github/workflows/docs-integrity.yml' + push: + branches: [main] + paths: + - '**/*.md' + - '.github/scripts/check-docs-integrity.py' + - '.github/workflows/docs-integrity.yml' + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python3 .github/scripts/check-docs-integrity.py diff --git a/.github/workflows/published-artifact-drift.yml b/.github/workflows/published-artifact-drift.yml new file mode 100644 index 000000000..8aace1f27 --- /dev/null +++ b/.github/workflows/published-artifact-drift.yml @@ -0,0 +1,75 @@ +# Run the two verifiers that already existed and that nothing called. +# +# scripts/verify-published-pins.sh and scripts/verify-release-tag.sh were both +# committed, both executable, both documented with the incident that motivated +# them — and `grep -rl` across .github/ and scripts/ found ZERO callers. A guard +# that is never invoked protects nothing, and its presence in the tree reads as +# if the risk were covered. +# +# Running verify-published-pins.sh by hand on 2026-08-17, for the first time, +# reported a live drift on its first invocation: +# +# ❌ OPENCODE_AGENT_NODE_VERSION 期望 2.5.0-preview.31, +# 产物里是: 2.5.0-preview.28 +# 1 个 pin 与已发布产物不一致 —— main 修了但用户装到的包没修 +# +# That is the exact distinction the script's own header says bit this repo three +# times in one day, and it had been sitting undetected in the published preview. +# +# Why scheduled rather than per-PR: it inspects the PUBLISHED artifact, which a +# PR does not change, and it needs the npm registry. Running it on every PR +# would add a network dependency to every merge while telling us nothing new +# about the PR. Once a day plus manual dispatch matches what it measures. +# +# 🔴 Exit codes are mapped deliberately, because "could not measure" and +# "measured and it is fine" must not collapse into the same green: +# 0 → pass +# 1 → fail (real drift) +# 2 → fail-soft with a loud notice (registry unreachable — NOT evidence of +# agreement; the run reports that it could not measure) +# 3 → fail (zero pins compared — the script went blind) + +name: published artifact drift + +on: + schedule: + # 03:17 UTC. Off the hour and off :00/:30 so this repo does not pile onto + # the same minute as every other scheduled job on the runners. + - cron: '17 3 * * *' + workflow_dispatch: + push: + branches: [main] + paths: + - 'scripts/verify-published-pins.sh' + - 'scripts/verify-release-tag.sh' + - '.github/workflows/published-artifact-drift.yml' + +jobs: + published-pins: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: verify published pins match source + run: | + set +e + bash scripts/verify-published-pins.sh preview + rc=$? + set -e + case "$rc" in + 0) echo "pins agree with the published preview artifact." ;; + 2) echo "::warning::could not fetch the published artifact (rc=2). This run"\ + "did NOT verify anything — treat it as unknown, not as agreement." ; exit 1 ;; + 3) echo "::error::the verifier compared ZERO pins (rc=3) — it went blind." ; exit 1 ;; + *) echo "::error::published artifact drifted from source (rc=$rc)." ; exit 1 ;; + esac + + release-tag: + # Only meaningful when a tag is what triggered us, or on demand. + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: verify release tags point at the commit they were built from + run: bash scripts/verify-release-tag.sh diff --git a/docs/qa/README.md b/docs/qa/README.md index aa86d1cfb..850107ac6 100644 --- a/docs/qa/README.md +++ b/docs/qa/README.md @@ -6,12 +6,22 @@ ## 一键跑 ```bash -bash scripts/qa.sh # L0 + L1 全跑 (~16s warm) +bash scripts/qa.sh # L0 + L1 全跑 bash scripts/qa.sh --l0 # 只跑 L0 单测 (~0.1s) -bash scripts/qa.sh --l1 # 只跑 L1 contract 测试 (~16s) +bash scripts/qa.sh --l1 # 只跑 L1 contract 测试 bash scripts/qa.sh --list # 列测试名 + 文件路径 ``` +> **关于耗时**:本页此前写「~16s warm」,`v0-summary.md` 写「本地一键 ~93s」, +> 而 `v0-summary.md` 自己那张逐条表加起来是 **156s**。三个数字都没说明自己量的是 +> 什么条件,所以谁都不能拿来对照。已把死数字去掉 —— **要知道现在多久,就跑一次**: +> +> ```bash +> time bash scripts/qa.sh # 你这台机、这个 Docker 缓存状态下的真实耗时 +> ``` +> +> 156s 是**逐条串行相加**;低于它的墙钟数意味着有并行。冷启动(需要拉镜像)会显著更久。 + 退出码:`0` 全过;`1` 至少一个 fail;`2` 环境问题(docker 不可用等)。 ## CI 自动跑 diff --git a/docs/qa/strategy.md b/docs/qa/strategy.md index 3d000abe1..dfb3dc172 100644 --- a/docs/qa/strategy.md +++ b/docs/qa/strategy.md @@ -47,7 +47,7 @@ | 档 | 触发点 | 跑什么 | 阻塞合并? | 状态 | |----|--------|--------|-----------|------| -| **1** | PR + push to main(路径过滤) | `bash scripts/qa.sh` = L0 + L1(~16s warm,GH Actions ~1-2min 含 setup) | **否,仅报告** | ✅ R8 上线 [.github/workflows/qa.yml](../../.github/workflows/qa.yml) | +| **1** | PR + push to main(路径过滤) | `bash scripts/qa.sh` = L0 + L1(耗时随机器与 Docker 缓存状态变化,跑 `time bash scripts/qa.sh` 实测;GH Actions ~1-2min 含 setup) | **否,仅报告** | ✅ R8 上线 [.github/workflows/qa.yml](../../.github/workflows/qa.yml) | | 2 | 主路径文件变更(auth.ts / db.ts / cli.ts) | L0 + L1 contract | 否(先观察稳定性) | 未启用,待 R10+ | | 3 | Release tag | 全套 L0+L1+L2+L3 | **是** | 未启用,等本地稳了再谈 | diff --git a/docs/qa/v0-summary.md b/docs/qa/v0-summary.md index 2f06da5b2..03b604c63 100644 --- a/docs/qa/v0-summary.md +++ b/docs/qa/v0-summary.md @@ -7,7 +7,7 @@ **16 条 R 系列 QA 测试 + CI workflow 上线 + 11 条 SDK 设计 finding 抠出。** 3 persona 用户视角包圆(CLI / commhub / dashboard),1 persona 用户视角 4/6(agent-node),代码视角 3/5。 -本地一键跑 ~93s,CI ~40s。不改任何业务代码。 +本地一键跑 ~93s(**2026-05 首次测量的墙钟值**;下面逐条表串行相加是 156s,差额来自并行。你这台机上的真值跑 `time bash scripts/qa.sh`),CI ~40s。不改任何业务代码。 ## 测试库(16 条 R 系列 + 历史保护资产) diff --git a/docs/qa/weekly/2026-W19.md b/docs/qa/weekly/2026-W19.md index 930f55bbc..1243b6c41 100644 --- a/docs/qa/weekly/2026-W19.md +++ b/docs/qa/weekly/2026-W19.md @@ -1,6 +1,6 @@ # anet QA 周报 — 2026-05-12 11:45 UTC -_自动生成 by [scripts/qa-status.sh](../../scripts/qa-status.sh)_ +_自动生成 by [scripts/qa-status.sh](../../../scripts/qa-status.sh)_ ## 测试库当前状态 @@ -12,43 +12,43 @@ _自动生成 by [scripts/qa-status.sh](../../scripts/qa-status.sh)_ ## L0 单测列表 -- `auth-tokens` — 25 expect call(s) — [server/src/auth-tokens.test.ts](../../server/src/auth-tokens.test.ts) -- `auth-validate` — 23 expect call(s) — [server/src/auth-validate.test.ts](../../server/src/auth-validate.test.ts) -- `password-dict` — 15 expect call(s) — [server/src/password-dict.test.ts](../../server/src/password-dict.test.ts) +- `auth-tokens` — 25 expect call(s) — [server/src/auth-tokens.test.ts](../../../server/src/auth-tokens.test.ts) +- `auth-validate` — 23 expect call(s) — [server/src/auth-validate.test.ts](../../../server/src/auth-validate.test.ts) +- `password-dict` — 15 expect call(s) — [server/src/password-dict.test.ts](../../../server/src/password-dict.test.ts) ## L1 contract 测试列表 -- `qa-cli-01-hub-start` — [tests/qa-cli-01-hub-start/](../../tests/qa-cli-01-hub-start/) +- `qa-cli-01-hub-start` — [tests/qa-cli-01-hub-start/](../../../tests/qa-cli-01-hub-start/) _banner / port / 凭证文件落地 / 幂等性 任一坏都让新用户卡住。_ -- `qa-cli-02-network-create` — [tests/qa-cli-02-network-create/](../../tests/qa-cli-02-network-create/) +- `qa-cli-02-network-create` — [tests/qa-cli-02-network-create/](../../../tests/qa-cli-02-network-create/) _- 非交互登录(`--username/--password`)_ -- `qa-dash-07-auth-boundary` — [tests/qa-dash-07-auth-boundary/](../../tests/qa-dash-07-auth-boundary/) +- `qa-dash-07-auth-boundary` — [tests/qa-dash-07-auth-boundary/](../../../tests/qa-dash-07-auth-boundary/) _攻击者可以 curl 直接打 hub。这条测试枚举 dashboard 调的所有端点 + SSE + MCP,_ -- `qa-dash-08-cross-account-views` — [tests/qa-dash-08-cross-account-views/](../../tests/qa-dash-08-cross-account-views/) - _[R17 HUB-06b](../qa-hub-06b-cross-user-isolation/) 覆盖了 `/api/networks` `/api/tasks`(无 filt_ -- `qa-dash-10-incremental-poll` — [tests/qa-dash-10-incremental-poll/](../../tests/qa-dash-10-incremental-poll/) +- `qa-dash-08-cross-account-views` — [tests/qa-dash-08-cross-account-views/](../../../tests/qa-dash-08-cross-account-views/) + _[R17 HUB-06b](../../../tests/qa-hub-06b-cross-user-isolation/) 覆盖了 `/api/networks` `/api/tasks`(无 filt_ +- `qa-dash-10-incremental-poll` — [tests/qa-dash-10-incremental-poll/](../../../tests/qa-dash-10-incremental-poll/) _所以 dashboard 实际用增量轮询:每隔 N 秒打 `?since=` 拿新数据。_ -- `qa-hub-05-roundtrip` — [tests/qa-hub-05-roundtrip/](../../tests/qa-hub-05-roundtrip/) -- `qa-hub-06b-cross-user-isolation` — [tests/qa-hub-06b-cross-user-isolation/](../../tests/qa-hub-06b-cross-user-isolation/) +- `qa-hub-05-roundtrip` — [tests/qa-hub-05-roundtrip/](../../../tests/qa-hub-05-roundtrip/) +- `qa-hub-06b-cross-user-isolation` — [tests/qa-hub-06b-cross-user-isolation/](../../../tests/qa-hub-06b-cross-user-isolation/) _即使 bob 知道 alice 的 networkid(IDOR),也不能直接调 alice 的 API。_ -- `qa-hub-06-token-revoke` — [tests/qa-hub-06-token-revoke/](../../tests/qa-hub-06-token-revoke/) +- `qa-hub-06-token-revoke` — [tests/qa-hub-06-token-revoke/](../../../tests/qa-hub-06-token-revoke/) _派生 token 是否随母 token 失效,是 fleet-management 类工具的核心契约。_ -- `qa-hub-07-sse-reconnect` — [tests/qa-hub-07-sse-reconnect/](../../tests/qa-hub-07-sse-reconnect/) - _这个测试 pin 关键契约:「断开期间的任务不能丢」—— 通过 getinbox 拿,_ -- `qa-hub-08-restart-persistence` — [tests/qa-hub-08-restart-persistence/](../../tests/qa-hub-08-restart-persistence/) +- `qa-hub-07-sse-reconnect` — [tests/qa-hub-07-sse-reconnect/](../../../tests/qa-hub-07-sse-reconnect/) + _这个测试 pin 关键契约:「断开期间的任务不能丢」—— 通过 getinbox 拿,⟨原文此处被截断:1 个多字节字符不完整,内容不可恢复 —— 标注于 2026-08-17⟩_ +- `qa-hub-08-restart-persistence` — [tests/qa-hub-08-restart-persistence/](../../../tests/qa-hub-08-restart-persistence/) _这条测试 pin 三个持久化契约:session 行、inbox/task 行、ntok 验证。_ -- `qa-hub-09-task-state-machine` — [tests/qa-hub-09-task-state-machine/](../../tests/qa-hub-09-task-state-machine/) - _- `replied` 分支:[NODE-02](../qa-node-02-success-reply/) R6 已测_ -- `qa-node-02-success-reply` — [tests/qa-node-02-success-reply/](../../tests/qa-node-02-success-reply/) - _但成功路径(status=replied + result 文本回填)没单独测。真 LLM 烧钱不可取_ -- `qa-node-03b-task-events` — [tests/qa-node-03b-task-events/](../../tests/qa-node-03b-task-events/) +- `qa-hub-09-task-state-machine` — [tests/qa-hub-09-task-state-machine/](../../../tests/qa-hub-09-task-state-machine/) + _- `replied` 分支:[NODE-02](../../../tests/qa-node-02-success-reply/) R6 已测_ +- `qa-node-02-success-reply` — [tests/qa-node-02-success-reply/](../../../tests/qa-node-02-success-reply/) + _但成功路径(status=replied + result 文本回填)没单独测。真 LLM 烧钱不可取⟨原文此处被截断:1 个多字节字符不完整,内容不可恢复 —— 标注于 2026-08-17⟩_ +- `qa-node-03b-task-events` — [tests/qa-node-03b-task-events/](../../../tests/qa-node-03b-task-events/) _「这个 task 经历了什么、谁动的」。之前完全没人测。_ -- `qa-ut-01-auth-tokens` — [tests/qa-ut-01-auth-tokens/](../../tests/qa-ut-01-auth-tokens/) - _[R5 (HUB-06)](../qa-hub-06-token-revoke/) 在 E2E 层覆盖了撤销,但生成/解析的形_ -- `qa-ut-02-password-dict` — [tests/qa-ut-02-password-dict/](../../tests/qa-ut-02-password-dict/) +- `qa-ut-01-auth-tokens` — [tests/qa-ut-01-auth-tokens/](../../../tests/qa-ut-01-auth-tokens/) + _[R5 (HUB-06)](../../../tests/qa-hub-06-token-revoke/) 在 E2E 层覆盖了撤销,但生成/解析的形⟨原文此处被截断:1 个多字节字符不完整,内容不可恢复 —— 标注于 2026-08-17⟩_ +- `qa-ut-02-password-dict` — [tests/qa-ut-02-password-dict/](../../../tests/qa-ut-02-password-dict/) _补一层 ms 级单测,PR 改 dict 文件能秒拦截 regression — 不必等慢的 E2E 跑完。_ -- `qa-ut-03-auth-validate` — [tests/qa-ut-03-auth-validate/](../../tests/qa-ut-03-auth-validate/) - _[test30 step 3](../test30-v0.8-auth-deprecation) E2E 只测 2 个弱密码,UT-03 测 14+ 个 + 边_ +- `qa-ut-03-auth-validate` — [tests/qa-ut-03-auth-validate/](../../../tests/qa-ut-03-auth-validate/) + _[test30 step 3](../../../tests/test30-v0.8-auth-deprecation) E2E 只测 2 个弱密码,UT-03 测 14+ 个 + 边_ ## 累计抠出的 SDK 设计 finding From 883d4bc24bdc3033fb1dab3e38ad3d620e547639 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 00:36:14 +0800 Subject: [PATCH 16/59] =?UTF-8?q?fix(cli):=20=E8=AE=A9=20dev-channels=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=BA=94=E7=AD=94=E7=9C=9F=E7=9A=84=E8=83=BD?= =?UTF-8?q?=E7=94=A8=20=E2=80=94=E2=80=94=20pane=20=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E7=94=A8=E5=9D=90=E6=A0=87=20+=20=E5=80=99=E9=80=89=E6=8C=89?= =?UTF-8?q?=20server:=20channel=20(#901)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): revert `=name` on pane commands — it cannot resolve non-ASCII sessions Regression I introduced in #895 and merged. #895 replaced eight bare `-t ` tmux targets with `-t =` to stop prefix matching. That is correct for session-targeting commands and WRONG for pane-targeting ones. Measured on tmux 3.4 with a session literally named `zz中文探针`: tmux has-session -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=0 tmux kill-session -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=0 tmux capture-pane -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=1 can't find pane tmux send-keys -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=1 can't find pane This fleet's session names are nearly all Chinese, so #895 silently disabled the dev-channels prompt watcher for essentially every node: capture-pane throws, the watcher reads that as "session gone", returns false immediately, and the confirm box is never answered. The node then sits on the prompt forever. That is worse than the prefix ambiguity the `=` was added to fix, and it is the same failure mode #895's second half existed to eliminate. Caught on a live node. `SDK马` was sitting on the dev-channels box with a live pid, and: capture-pane -t '=SDK马' → rc!=0 capture-pane -t 'SDK马:0.0' → rc=0, 16 lines, prompt visible The exact-and-portable form for a pane is the coordinate `:.`, resolved by listing panes and matching the session name with string equality in our own code — which is both unambiguous and encoding-agnostic, instead of asking tmux to disambiguate. `has-session` and `kill-session` keep `=name`; they accept it for non-ASCII and still need the prefix guard. The watcher now re-resolves the coordinate on every poll rather than caching it: a session may have no pane on the first iteration, and "no pane yet" must not be mistaken for "prompt absent" — it keeps waiting and lets the deadline decide. The wiring assertion fails against f565e9b8..7752437f and passes here; the pure parser is pinned for prefix siblings, missing sessions, non-zero pane indexes, and malformed rows. There is also an integration test that creates a real non-ASCII session and asserts the exact rc difference above, so this cannot regress silently again. Suite 491 pass, tsc clean. Co-Authored-By: Claude Opus 5 * fix(cli): auto-confirm dev channels for every node that loads one, not only claude-code-cli Second half of the same failure. #895's `=name` change stopped the watcher from seeing the pane; this is why the watcher was never even asked to look at a whole family of nodes. `autoConfirmDevChannels` selected candidates with normalizeRuntime(n.profile) === "claude-code-cli" && channels has "server:" but the runtime is not what causes the prompt — loading a `server:` channel is. `claude-agent-sdk` nodes with `server:commhub` show the same confirm box, and `claude-code` normalizes to `claude-agent-sdk`, so legacy-named nodes were excluded too. Those nodes sat on the box forever during `project up` / `node start --all` with no watcher assigned to them. The correct predicate was already in this file. The #494 warning on the `--tmux` path keys purely on `server:` channels with no runtime test: if ((resolved.profile.channels ?? []).some(ch => ch.startsWith("server:"))) console.warn(`[anet] ⚠ this node loads dev channels (server:*): …`) Two places answering the same question with different rules, and the narrow one was the one doing the work. Measured on this machine, all three with `channels: ['server:commhub']`: 微信马 claude-code-cli → was selected, came up (late, but up) 评估m马 claude-agent-sdk → not selected, sat on the confirm box I站工程马 claude-code → not selected (normalizes to agent-sdk) Widening is safe because dismissDevChannelPrompt is detection-gated: Enter is sent only when the prompt's exact text is on screen, so a node that never shows it times out without a keystroke. Both assertions fail against main and pass here. One of them initially failed against the FIXED code too — the new comment quotes the old predicate verbatim, and a plain `toContain` matched the comment. The test now strips comment lines before asserting absence, because the claim is about the code. Suite 493 pass, tsc clean. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-network/bin/cli.ts | 79 ++++++++++++--- agent-network/src/tmux-exact-target.ts | 58 +++++++++++ agent-network/src/tmux-pane-target.test.ts | 111 +++++++++++++++++++++ 3 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 agent-network/src/tmux-pane-target.test.ts diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index a2b4dabbb..d8ce556ce 100644 --- a/agent-network/bin/cli.ts +++ b/agent-network/bin/cli.ts @@ -99,7 +99,7 @@ import { findExactTmuxSession, parseTmuxSessions } from "../src/tmux-attach"; import { classifyPanePrompt, extractStartFailureReason } from "../src/tmux-pane-prompt"; import { describeUnsafePath } from "../src/unsafe-package-path-reason"; import { describeUmaskRisk, judgeUmask, rejectedPayloads } from "../src/package-mode-preflight"; -import { exactSession } from "../src/tmux-exact-target"; +import { exactSession, PANE_LIST_FORMAT, paneTargetFor } from "../src/tmux-exact-target"; import { diagnoseLocale, formatLocaleSource } from "../src/locale-diagnostic"; import { formatSecretAssignment, @@ -143,6 +143,30 @@ function adminUtokPath() { return join(home, ".anet", "server", "admin-utok.json function dashboardLaunchRecordPath(port: string | number) { return join(home, ".anet", "server", `dashboard-${port}.json`); } function nodesDir() { return join(process.cwd(), ".anet", "nodes"); } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } +/** + * Pane target (`:.`) for a session, or null. + * + * 🔴 Do NOT use `=name` for capture-pane / send-keys. tmux 3.4 resolves `=name` + * for session-targeting commands but not for pane-targeting ones when the + * name is non-ASCII, and this fleet's session names are nearly all Chinese: + * + * capture-pane -t '=zz中文探针' → rc=1 can't find pane + * capture-pane -t 'zz中文探针' → rc=0 + * + * So the exact form for a pane is the coordinate, with the session matched by + * string equality in our own code rather than by tmux's prefix rules. + */ +function tmuxPaneTarget(sessionName: string): string | null { + try { + const out = execFileSync("tmux", ["list-panes", "-a", "-F", PANE_LIST_FORMAT], { + encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], + }).toString(); + return paneTargetFor(out, sessionName); + } catch { + return null; // no server / no panes + } +} + /** Kill a session and report whether it is actually gone afterwards. */ function killTmuxSession(sessionName: string): boolean { try { execFileSync("tmux", ["kill-session", "-t", exactSession(sessionName)], { stdio: "pipe" }); } catch {} @@ -212,7 +236,9 @@ function waitForTmuxPaneText(sessionName: string, needle: string, timeoutMs: num return new Promise((resolve) => { const poll = () => { try { - const out = execFileSync("tmux", ["capture-pane", "-t", exactSession(sessionName), "-p"], { + const paneTarget = tmuxPaneTarget(sessionName); + if (!paneTarget) return false; + const out = execFileSync("tmux", ["capture-pane", "-t", paneTarget, "-p"], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", }); if (out.includes(needle)) { resolve(true); return; } @@ -780,10 +806,11 @@ async function startOpencodeCopresenceOrchestration(nodeId: string, hubOverride? if (!existsSync(attachScript)) { let tail = ""; try { - tail = execFileSync("tmux", ["capture-pane", "-p", "-t", exactSession(bridgeSession), "-S", "-80"], { + const bridgePane = tmuxPaneTarget(bridgeSession); + tail = bridgePane ? execFileSync("tmux", ["capture-pane", "-p", "-t", bridgePane, "-S", "-80"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], - }).slice(-3_000); + }).slice(-3_000) : ""; } catch {} killTmuxSession(bridgeSession); console.error(`[anet] ❌ OpenCode copresence server did not produce its attach launcher within 30s.`); @@ -7891,11 +7918,21 @@ async function dismissDevChannelPrompt(sessionName: string, timeoutMs: number): let trustAnswered = false; while (Date.now() < deadline) { let pane = ""; + // Resolve the pane coordinate each iteration: the session may not have a + // pane yet on the first poll, and a coordinate captured once could go stale. + const paneTarget = tmuxPaneTarget(sessionName); + if (!paneTarget) { + // No pane for this exact session — it has not appeared yet, or it exited. + // Keep waiting rather than declaring the prompt absent; the deadline ends + // the loop. + await new Promise(r => setTimeout(r, 1000)); + continue; + } try { // Discard tmux's stderr: polling a session that has already exited is a // normal outcome here, and letting `can't find pane: X` through made the // CLI print an alarming line right before an unrelated verdict. - pane = execFileSync("tmux", ["capture-pane", "-p", "-t", exactSession(sessionName)], { + pane = execFileSync("tmux", ["capture-pane", "-p", "-t", paneTarget], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).toString(); } catch { @@ -7905,7 +7942,7 @@ async function dismissDevChannelPrompt(sessionName: string, timeoutMs: number): if (prompt === "folder-trust" && !trustAnswered) { // Settle briefly so Ink's input handler is fully attached, then accept. await new Promise(r => setTimeout(r, 700)); - try { execFileSync("tmux", ["send-keys", "-t", exactSession(sessionName), "Enter"], { stdio: "ignore" }); } catch {} + try { execFileSync("tmux", ["send-keys", "-t", paneTarget, "Enter"], { stdio: "ignore" }); } catch {} trustAnswered = true; deadline = Date.now() + timeoutMs; // fresh window for the prompt we came for await new Promise(r => setTimeout(r, 1000)); @@ -7915,7 +7952,7 @@ async function dismissDevChannelPrompt(sessionName: string, timeoutMs: number): // Prompt is rendered and waiting. Settle briefly so Ink's input handler // is fully attached, then confirm with a single Enter. await new Promise(r => setTimeout(r, 700)); - try { execFileSync("tmux", ["send-keys", "-t", exactSession(sessionName), "Enter"], { stdio: "ignore" }); } catch {} + try { execFileSync("tmux", ["send-keys", "-t", paneTarget, "Enter"], { stdio: "ignore" }); } catch {} return true; } await new Promise(r => setTimeout(r, 1000)); @@ -7927,7 +7964,9 @@ async function dismissDevChannelPrompt(sessionName: string, timeoutMs: number): // on the failure path, where the pane holds the inner command's own words. function capturePaneReason(sessionName: string): string | null { try { - const pane = execFileSync("tmux", ["capture-pane", "-p", "-t", exactSession(sessionName)], { + const paneTarget = tmuxPaneTarget(sessionName); + if (!paneTarget) return null; // session already reaped + const pane = execFileSync("tmux", ["capture-pane", "-p", "-t", paneTarget], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).toString(); return extractStartFailureReason(pane); @@ -7940,11 +7979,25 @@ function capturePaneReason(sessionName: string): string | null { // claude-code-cli nodes (only those carry a `server:` channel and hit the // prompt), so `node start --all` / `project up|restart` stay zero-interaction. async function autoConfirmDevChannels(spawned: ProjectNode[]): Promise { - const claudeNodes = spawned.filter(n => - n.profile && normalizeRuntime(n.profile) === "claude-code-cli" && - !!n.profile.channels?.some(c => c.startsWith("server:"))); - if (claudeNodes.length === 0) return; - await Promise.all(claudeNodes.map(n => dismissDevChannelPrompt(n.alias, 45000))); + // What decides whether the prompt appears is the `server:` channel, NOT the + // runtime. This filter used to also require runtime === "claude-code-cli", + // which silently excluded every claude-agent-sdk node — and `claude-code` + // normalizes to claude-agent-sdk, so legacy-named nodes were excluded too. + // Those nodes then sat on the confirm box forever during `project up` / + // `node start --all`, with no watcher ever looking at them. + // + // The same file already had the correct predicate: the #494 warning on the + // `--tmux` path keys purely on `server:` channels with no runtime test. Two + // places deciding the same question, one of them narrower, and the narrow one + // was the one doing the work. + // + // Widening is safe because dismissDevChannelPrompt is detection-gated: it + // sends Enter only when the prompt's exact text is on screen, so a node that + // never shows it simply times out without a keystroke being sent. + const promptedNodes = spawned.filter(n => + !!n.profile?.channels?.some(c => typeof c === "string" && c.startsWith("server:"))); + if (promptedNodes.length === 0) return; + await Promise.all(promptedNodes.map(n => dismissDevChannelPrompt(n.alias, 45000))); } async function projectCommand() { diff --git a/agent-network/src/tmux-exact-target.ts b/agent-network/src/tmux-exact-target.ts index 4234fd7a8..268f910fa 100644 --- a/agent-network/src/tmux-exact-target.ts +++ b/agent-network/src/tmux-exact-target.ts @@ -45,3 +45,61 @@ export function isExactTarget(target: string): boolean { export function ensureExactSession(nameOrTarget: string): string { return isExactTarget(nameOrTarget) ? nameOrTarget : exactSession(nameOrTarget); } + +// ── pane targeting ──────────────────────────────────────────────────────── +// +// 🔴 `=name` works for SESSION-targeting commands but NOT for pane-targeting +// ones when the session name is non-ASCII. Measured on tmux 3.4 with a +// session literally named `zz中文探针`: +// +// tmux has-session -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=0 ✅ +// tmux kill-session -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=0 ✅ +// tmux capture-pane -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=1 ❌ can't find pane +// tmux send-keys -t 'zz中文探针' rc=0 -t '=zz中文探针' rc=1 ❌ can't find pane +// +// This fleet's session names are overwhelmingly Chinese, so applying the +// `=` prefix to capture-pane/send-keys silently disabled both: capture-pane +// throws, the prompt watcher treats that as "session gone" and gives up, and +// the dev-channels box is never confirmed. That is worse than the prefix +// ambiguity the prefix was added to fix. +// +// The exact-and-portable form for a pane is the coordinate +// `:.`, which tmux resolves without prefix matching and +// which works for non-ASCII names. Get it by listing panes and matching the +// session name EXACTLY in code, where string equality is unambiguous — rather +// than asking tmux to disambiguate for us. + +/** One row of `tmux list-panes -a -F '#{session_name}\t#{window_index}.#{pane_index}'`. */ +export interface PaneRow { + session: string; + /** `window.pane`, e.g. `0.0`. */ + coord: string; +} + +export function parsePaneRows(listOutput: string): PaneRow[] { + const rows: PaneRow[] = []; + for (const line of listOutput.split("\n")) { + if (!line) continue; + // Split on the LAST tab: a session name may itself contain a tab only if + // someone worked hard at it, and the coordinate never does. + const i = line.lastIndexOf("\t"); + if (i <= 0) continue; + rows.push({ session: line.slice(0, i), coord: line.slice(i + 1).trim() }); + } + return rows; +} + +/** + * Pane target for a session, or null when that exact session has no pane. + * + * Matching is exact string equality on the session name — the whole point is to + * not hand tmux a name it might prefix-match, and to not hand it a `=` form it + * cannot resolve for non-ASCII names. + */ +export function paneTargetFor(listOutput: string, sessionName: string): string | null { + const row = parsePaneRows(listOutput).find(r => r.session === sessionName); + return row ? `${sessionName}:${row.coord}` : null; +} + +/** The format string the two functions above expect. */ +export const PANE_LIST_FORMAT = "#{session_name}\t#{window_index}.#{pane_index}"; diff --git a/agent-network/src/tmux-pane-target.test.ts b/agent-network/src/tmux-pane-target.test.ts new file mode 100644 index 000000000..808b2bd83 --- /dev/null +++ b/agent-network/src/tmux-pane-target.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { PANE_LIST_FORMAT, exactSession, paneTargetFor, parsePaneRows } from "./tmux-exact-target"; + +const LIST = [ + "A站Grok\t0.0", + "A站内容\t0.0", + "A站内容牛\t0.0", + "SDK马\t0.0", + "hub\t0.1", +].join("\n") + "\n"; + +test("a pane target is the coordinate, never the = form", () => { + expect(paneTargetFor(LIST, "SDK马")).toBe("SDK马:0.0"); + // The `=` form is for session-targeting commands only; handing it to + // capture-pane/send-keys fails outright for non-ASCII names. + expect(paneTargetFor(LIST, "SDK马")).not.toContain("="); +}); + +test("session matching is exact — a prefix sibling never wins", () => { + expect(paneTargetFor(LIST, "A站内容")).toBe("A站内容:0.0"); + expect(paneTargetFor(LIST, "A站内容牛")).toBe("A站内容牛:0.0"); +}); + +test("a session with no pane resolves to null rather than to something nearby", () => { + expect(paneTargetFor(LIST, "A站内容牛牛")).toBeNull(); + expect(paneTargetFor(LIST, "")).toBeNull(); + expect(paneTargetFor("", "SDK马")).toBeNull(); +}); + +test("non-zero window/pane indexes are carried through", () => { + expect(paneTargetFor(LIST, "hub")).toBe("hub:0.1"); +}); + +test("rows split on the last tab, so the coordinate is never mistaken for the name", () => { + const rows = parsePaneRows("odd\tname\t1.2\n"); + expect(rows).toEqual([{ session: "odd\tname", coord: "1.2" }]); +}); + +test("malformed rows are dropped, not turned into a target", () => { + expect(parsePaneRows("no-tab-here\n\t0.0\n")).toEqual([]); +}); + +// The regression this file exists to prevent, measured against the real tmux. +// Skipped where tmux is unavailable. +function tmuxAvailable(): boolean { + try { execFileSync("tmux", ["-V"], { stdio: "pipe" }); return true; } catch { return false; } +} + +const S = "anet-panetarget-中文-test"; + +test.skipIf(!tmuxAvailable())("real tmux: '=name' fails for capture-pane on a non-ASCII session, the coordinate works", () => { + try { execFileSync("tmux", ["kill-session", "-t", exactSession(S)], { stdio: "pipe" }); } catch {} + execFileSync("tmux", ["new-session", "-d", "-s", S, "sleep 60"], { stdio: "pipe" }); + try { + // has-session accepts the = form even for non-ASCII… + expect(() => execFileSync("tmux", ["has-session", "-t", exactSession(S)], { stdio: "pipe" })).not.toThrow(); + // …but capture-pane does not. This is the whole reason for the coordinate. + expect(() => execFileSync("tmux", ["capture-pane", "-p", "-t", exactSession(S)], { stdio: "pipe" })).toThrow(); + + const out = execFileSync("tmux", ["list-panes", "-a", "-F", PANE_LIST_FORMAT], { encoding: "utf-8" }).toString(); + const coord = paneTargetFor(out, S); + expect(coord).toBe(`${S}:0.0`); + expect(() => execFileSync("tmux", ["capture-pane", "-p", "-t", coord!], { stdio: "pipe" })).not.toThrow(); + } finally { + try { execFileSync("tmux", ["kill-session", "-t", exactSession(S)], { stdio: "pipe" }); } catch {} + } +}); + +test("cli.ts sends pane commands to coordinates and session commands to the = form", () => { + const source = readFileSync(join(import.meta.dir, "..", "bin", "cli.ts"), "utf8"); + // Pane-targeting commands must not carry exactSession(...). + for (const cmd of ['"capture-pane"', '"send-keys"']) { + const lines = source.split("\n").filter(l => l.includes(cmd) && l.includes("-t")); + expect(lines.length).toBeGreaterThan(0); + for (const l of lines) expect(l).not.toContain("exactSession("); + } + // Session-targeting commands must keep it. + expect(source).toContain('["kill-session", "-t", exactSession(sessionName)]'); + expect(source).toContain('["has-session", "-t", exactSession(name)]'); +}); + +// The dev-channels auto-confirm used to filter on runtime, not on the thing +// that actually causes the prompt. +test("auto-confirm selects nodes by their server: channel, not by runtime", () => { + const source = readFileSync(join(import.meta.dir, "..", "bin", "cli.ts"), "utf8"); + const a = source.indexOf("async function autoConfirmDevChannels("); + expect(a).toBeGreaterThan(-1); + const raw = source.slice(a, source.indexOf("\n}", a)); + // Strip comments before asserting absence: the explanation of the old, + // narrower predicate quotes it verbatim, and a prefix-free `toContain` would + // match the comment and fail on the fixed code. Assert about the code. + const body = raw.split("\n").filter(l => !l.trim().startsWith("//")).join("\n"); + expect(body).toContain('startsWith("server:")'); + // A runtime test here excluded every claude-agent-sdk node — and `claude-code` + // normalizes to claude-agent-sdk, so legacy names were excluded too. + expect(body).not.toContain('=== "claude-code-cli"'); + expect(body).not.toContain("normalizeRuntime("); +}); + +test("the #494 warning and the auto-confirm agree on the predicate", () => { + const source = readFileSync(join(import.meta.dir, "..", "bin", "cli.ts"), "utf8"); + // Both must key on server: channels. Two places answering the same question + // with different rules is how the narrow one silently did the work. + const warn = source.indexOf("this node loads dev channels"); + expect(warn).toBeGreaterThan(-1); + const warnGuard = source.slice(Math.max(0, warn - 300), warn); + expect(warnGuard).toContain('startsWith("server:")'); +}); From d88dc389d9a9cc0cf273e7fb19f9f8754080293a Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 00:38:14 +0800 Subject: [PATCH 17/59] =?UTF-8?q?docs(refresh):=20stale-snapshot=20caveats?= =?UTF-8?q?=20on=204=20=E7=8B=AC=E7=AB=8B=E9=9D=A2=20(task=2027faa700)=20(?= =?UTF-8?q?#898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(refresh): 4 独立面 stale-snapshot caveats (task 27faa700) Local-only branch. NOT pushed, NO PR opened — awaiting 通信龙 review after PR #869 merges (per instruction 2026-08-14). Doc-only, no behavior changes. Each edit adds a snapshot-date caveat and points at the live source of truth (release-plan.md / `npm view`); no existing evidence-anchored text was removed. ## 1. docs-site/docs/{,en/}preview/index.md:16 (章节标题死数) - Old heading: "当前 preview = canonical(2.3.0-preview.34 / 2.5.0-preview.26,2026-07-16)" - New heading: "当前 preview channel canonical build(snapshot 2026-08-14)" - Added 1 paragraph with: - real 2026-08-14 npm-view numbers (agent-network preview.39 / agent-node preview.31 / commhub-server preview.29) - the main-source-vs-published-binary caveat (通信龙 Fact 1): preview.39 binary's embedded .d.ts pair still names agent-node@2.5.0-preview.28 - install-via-@preview-tag reminder (already stated once above, reinforced with npm view dist-tags recipe) - English mirror updated with parallel wording. ## 2. docs/release/v2.3.0/plan.md:30 (GA-gate 6-week-old snapshot) - Prepended one blockquote line marking the段 as a 2026-07-05 snapshot, pointing at release-plan.md as the live source, and citing the 2026-08-14 real preview numbers. - Kept the original "最后更新:2026-07-05 …" line intact for GA-gate milestone history. ## 3. docs/release/versioning-and-compatibility.md:37-43 (fleet snapshot table) - Prepended one warning line: the first three rows are 2026-06 preview-iteration snapshots; live numbers via release-plan.md + npm view. - Renamed rows 39-41 to append "(2026-06 快照)" so readers cannot misread them as current. - Added one new row "已发布 preview 头(snapshot 2026-08-14)" with real npm-view numbers + the .d.ts pair caveat (通信龙 Fact 1). - Kept "v2.3.0 GA 目标" and "latest(稳定线)" rows unchanged (真值 仍准). ## 4. docs/runbooks/feishu-channel-ops.md:11-18 (runbook 死数) - Runbook section title now names as-of 2026-07-01 deployment snapshot + instructs to复核 real deployed version via `docker exec anet-feishu-local anet -v` (does not touch prod — runbook only tells the operator what to run). - agent-network / agent-node rows retain the deployed values but add "当前 preview 头 2026-08-14 快照为 preview.39 / preview.31" + release-plan.md link for cross-check. ## Version facts used (npm view 2026-08-14) | pkg | latest | preview | |---|---|---| | agent-network | 2.2.21 | 2.3.0-preview.39 | | agent-node | 2.4.13 | 2.5.0-preview.31 | | commhub-server | 0.8.8 | 0.9.0-preview.29 | ## Not landed here - Q2 段全部 8+ 条 (行为句缺版本) — 需 git log 追溯精确 version anchor, 单独任务处理 (通信龙 视 #869 merge 时机再拍) - 通信龙 Fact 2 (anet node start ✅ vs tmux has-session) — origin/main 未直接命中"看到 ✅ 就成功"教学句, README / clean-server.md / feature- audit.md 可通过通用页脚 note 批量处理, 单独任务处理 - docs/RELEASE-SOP.md:232/243 pairing caveat 详写 — 属 SOP 内部, 请 RELEASE-SOP owner 决定文案 - docs/grok-build-runtime.md:111 (v0.10.11 anchor stale) — 属 Q2 段 item 16, 单独 issue * docs(refresh): bump snapshot date 2026-08-14 → 2026-08-17 (rebase day) Per 通信龙 instruction on task 27faa700: snapshot dates should reflect the rebase/push day, not the authoring day. Re-verified `npm view dist-tags` on 2026-08-17 — numbers unchanged since 2026-08-14 authoring: latest : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8 preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31 commhub-server 0.9.0-preview.29 Fact-1 pairing caveat also still holds (通信龙 tested 2026-08-17 evening): main-source constant `OPENCODE_AGENT_NODE_VERSION` = agent-node preview.31, but the published preview.39 binary embeds a `.d.ts` pair naming agent-node preview.28. They installed preview.28 to get opencode-指挥狗 to start. --------- Co-authored-by: t --- docs-site/docs/en/preview/index.md | 4 +++- docs-site/docs/preview/index.md | 4 +++- docs/release/v2.3.0/plan.md | 2 ++ docs/release/versioning-and-compatibility.md | 9 ++++++--- docs/runbooks/feishu-channel-ops.md | 6 +++--- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs-site/docs/en/preview/index.md b/docs-site/docs/en/preview/index.md index a87e8e1b4..f6ae4d878 100644 --- a/docs-site/docs/en/preview/index.md +++ b/docs-site/docs/en/preview/index.md @@ -13,7 +13,9 @@ The current preview channel = **v0.11-preview2** (npm `@preview` tag). This rele The specific `preview.N` numbers below are a **2026-06-28 snapshot**; the preview channel keeps iterating (it's now well past preview.1). **Always install / upgrade the current preview via the `@preview` tag** (the install commands below already do), rather than copying a specific version number. ::: -## Current preview = canonical (2.3.0-preview.34 / 2.5.0-preview.26, 2026-07-16) +## Current preview channel canonical build (snapshot 2026-08-17) + +> **Snapshot 2026-08-17**: `@preview` currently resolves to `@sleep2agi/agent-network@2.3.0-preview.39` / `@sleep2agi/agent-node@2.5.0-preview.31` / `@sleep2agi/commhub-server@0.9.0-preview.29` (what main source requires). The published `preview.39` binary's embedded `.d.ts` pair still names `agent-node@2.5.0-preview.28` (published-binary requirement ≠ main-source requirement; in auto-sync mode the main-source constant advances ahead of the npm-published artifact). **Always install / upgrade the current preview via the `@preview` tag** (the install commands below already do); do NOT hand-copy version numbers from here — both tags keep drifting; re-check with `npm view dist-tags` before editing. @preview now points at the **canonical build** (published from the exact tgz after real-Windows verification; independent Linux gate re-run in progress — latest promotion gated on true green): diff --git a/docs-site/docs/preview/index.md b/docs-site/docs/preview/index.md index f3ff0c62e..2c96133a9 100644 --- a/docs-site/docs/preview/index.md +++ b/docs-site/docs/preview/index.md @@ -13,7 +13,9 @@ 下方具体 `preview.N` 版本号是 **2026-06-28 快照**,preview channel 一直在迭代(现已远超 preview.1)。**装 / 升当前 preview 一律用 `@preview` tag**(下方安装命令已用),不要照抄具体版本号。 ::: -## 当前 preview = canonical(2.3.0-preview.34 / 2.5.0-preview.26,2026-07-16) +## 当前 preview channel canonical build(snapshot 2026-08-17) + +> **snapshot 2026-08-17**:`@preview` 当前指向 `@sleep2agi/agent-network@2.3.0-preview.39` / `@sleep2agi/agent-node@2.5.0-preview.31` / `@sleep2agi/commhub-server@0.9.0-preview.29`(main 源码要求)。已发布 `preview.39` 二进制内嵌 `.d.ts` pair 仍指 `agent-node@2.5.0-preview.28`(binary 要求 ≠ main 源码要求;auto-sync 模式下 main 源码常量会先于 npm 发布产物推进)。**装 / 升当前 preview 请一律走 `@preview` tag**(下方安装命令已用),不要手动复制此处版本号 —— 两个 tag 都在持续漂移,改前用 `npm view dist-tags` 核一遍。 @preview 现在指向 **canonical 合并版**(真 Windows 复验 PASS 后从验证过的 tgz 发布;Linux 门禁独立复跑中,latest promote 以真全绿为前提): diff --git a/docs/release/v2.3.0/plan.md b/docs/release/v2.3.0/plan.md index c42fa9d55..2be2e0139 100644 --- a/docs/release/v2.3.0/plan.md +++ b/docs/release/v2.3.0/plan.md @@ -27,6 +27,8 @@ ## 进度快照(自主推进中 · 每步滚动更新) +> ⚠️ **本段是 2026-07-05 快照** —— 6 周前的 GA-gate GREEN 状态 + 版本号(`preview.20/.19/.21` / dashboard `preview.9`),**当前 preview 已远超此数字**。真值以 [`docs/plans/release-plan.md`](../../plans/release-plan.md) 里的 `npm view` 表为准(snapshot 2026-08-17: agent-network `2.3.0-preview.39` / agent-node `2.5.0-preview.31` / commhub-server `0.9.0-preview.29`)。本段仅保留作 GA-gate 里程碑历史锚点。 +> > 最后更新:2026-07-05(北京)· **🟢 GA-gate GREEN (23/23) — GA-ready, 等 Vincent 拍 latest**: agent-network 2.3.0-preview.20 / agent-node 2.5.0-preview.19 / commhub-server 0.9.0-preview.21 / dashboard **0.6.3-preview.9**· Vincent msg9799 自主执行模式。 > **2026-07-05 dashboard #393 迭代**:Vincent 真机反馈 → 供应商预设目录(DeepSeek/MiniMax/GLM/Claude 选一下 base_url 自动填 + 模型勾选 + 只填 key)已发 **preview.8**,型号修对(DeepSeek v4-pro/flash · MiniMax api.minimaxi.com+M2.7 · Claude opus-4-8/sonnet-5/haiku-4-5)发 **preview.9**(GLM 待 Vincent 给准型号)。**同时把线上 dm.vansin.top 实例从卡死的 preview.4(僵尸占 :3001 崩溃循环 34k 次)救活并升到 preview.9**。dashboard PR #35。 > **📌 详细滚动进度追踪 → [tracking issue #403](https://github.com/sleep2agi/agent-network/issues/403)**(本 plan 是总文档/spec,详细每步日志记在 issue,二者互链)。 diff --git a/docs/release/versioning-and-compatibility.md b/docs/release/versioning-and-compatibility.md index 057b2f897..cc0322e24 100644 --- a/docs/release/versioning-and-compatibility.md +++ b/docs/release/versioning-and-compatibility.md @@ -34,11 +34,14 @@ dashboard 跟 commhub 的 REST 契约(C3)要版本约束——纳入本文 > 每次「一起测过」的组合记一行。装的时候四列尽量取同一行。四个都是 npm 包,dashboard 列也记 npm 版本。 +> ⚠️ **前三行是 2026-06 preview 迭代期的历史快照**(数字 `preview.14/.18/.19/.20`,已远早于当前 preview 头)。当前已发布 preview 数字见 [`docs/plans/release-plan.md`](../plans/release-plan.md) 与 `npm view dist-tags` 实测;下面单独加一行 **已发布 preview 头(snapshot 2026-08-17)** 作为最新真值参考。 + | 组合 | agent-network | agent-node | commhub-server | dashboard | 状态 | |------|--------------|-----------|----------------|-----------|------| -| 当前线上飞书舰队 | 2.3.0-preview.18 | 2.5.0-preview.18 | 0.9.0-preview.14 | 0.6.3-preview.4 | ✅ 实跑中(#383 rescue + Kimi) | -| 已发布 preview 头 | 2.3.0-preview.19 | 2.5.0-preview.18 | 0.9.0-preview.20 | 0.6.3-preview.4 | ⚠️ 未整体 e2e,agent-node 不含 opencode | -| 下一发(含 opencode) | 2.3.0-preview.20 | 2.5.0-preview.19 | 0.9.0-preview.20 | 0.6.3-preview.4 | 🔜 待切(见 §6,dashboard 本发不动) | +| 当前线上飞书舰队(2026-06 快照) | 2.3.0-preview.18 | 2.5.0-preview.18 | 0.9.0-preview.14 | 0.6.3-preview.4 | ✅ 当时实跑中(#383 rescue + Kimi);生产真机版号请复核 | +| 已发布 preview 头(2026-06 快照) | 2.3.0-preview.19 | 2.5.0-preview.18 | 0.9.0-preview.20 | 0.6.3-preview.4 | ⚠️ 未整体 e2e,agent-node 不含 opencode | +| 下一发(2026-06 快照,含 opencode) | 2.3.0-preview.20 | 2.5.0-preview.19 | 0.9.0-preview.20 | 0.6.3-preview.4 | 🔜 待切(见 §6,dashboard 本发不动) | +| **已发布 preview 头(snapshot 2026-08-17)** | **2.3.0-preview.39** | **2.5.0-preview.31** | **0.9.0-preview.29** | 0.6.3-preview(浮动) | ⚠️ `npm view @preview` 实测;`preview.39` 二进制内嵌 `.d.ts` pair 仍指 `agent-node@2.5.0-preview.28`(main-源码 vs binary 差) | | v2.3.0 GA 目标 | 2.3.0 | 2.5.0 | 0.9.0 | 0.7.0(含 #260) | 🎯 整行测绿才升 | | latest(稳定线) | 2.2.21 | 2.4.13 | 0.8.8 | 0.6.x | ✅ 旧稳定,无 opencode/无 #383 | diff --git a/docs/runbooks/feishu-channel-ops.md b/docs/runbooks/feishu-channel-ops.md index f4299eaa9..ea55136bb 100644 --- a/docs/runbooks/feishu-channel-ops.md +++ b/docs/runbooks/feishu-channel-ops.md @@ -8,14 +8,14 @@ ## 0. TL;DR -飞书 bot = agent-node fork 一个 worker,用飞书 `@larksuiteoapi/node-sdk` 的 **WSClient 长连接**收事件,跑在 **claude-agent-sdk** runtime。当前生产实例: +飞书 bot = agent-node fork 一个 worker,用飞书 `@larksuiteoapi/node-sdk` 的 **WSClient 长连接**收事件,跑在 **claude-agent-sdk** runtime。当前生产实例(**as-of 2026-07-01 部署快照** — 生产真机版号请到部署机 `docker exec anet-feishu-local anet -v` 复核;本页 IM 运维交接后写死数已过期风险高): | 项 | 值 | |---|---| | 容器 | `anet-feishu-local`(docker,CMD 靠 entrypoint + `tail -f`) | | 节点 alias | `TMWork小助手`(**唯一** feishu 连接;2026-07-01 从 `feishu-local` 改的,见 §12 rename history) | -| agent-network | `2.3.0-preview.17`(含 #362 inbound file download + 官方 #324 图片修复) | -| agent-node | `2.5.0-preview.16` | +| agent-network | `2.3.0-preview.17`(部署时值,含 #362 inbound file download + 官方 #324 图片修复;当前 preview 头 2026-08-17 快照为 `2.3.0-preview.39`,见 [`release-plan.md`](../plans/release-plan.md)) | +| agent-node | `2.5.0-preview.16`(部署时值;当前 preview 头 2026-08-17 快照为 `2.5.0-preview.31`) | | 补丁 | **无**(跑官方发布版) | | app | ``(TMWork小助手) | | model | `MiniMax-M3`,endpoint `https://api.minimaxi.com/anthropic` | From cea145c4213ec6f251e826001f69f345da7662f8 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 00:38:20 +0800 Subject: [PATCH 18/59] docs(refresh): Q2 anchors for password + #450 + Fact-2 (#895/#896) notes (#900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #898 (task 27faa700). Doc-only; 5 files, +20 -7. ## Anchors nailed via git log -S / gh issue view (2026-08-18) ### 1) README.md:50 + README.en.md:50 — 一次性随机密码 anchor Introducing commit: `3e4e190c` (PR #264 fixing #261 P0-2), merged 2026-06-28. First npm-published preview containing the behavior: `@sleep2agi/agent-network@2.2.22-preview.4`. All subsequent `2.3.0-preview.0..39` inherit. Stable `@latest` at the time of writing is `2.2.21` — pre-dates the fix. So @latest users still get the fixed default `admin` / `anethub`. Older preview `≤ 2.2.22-preview.3` also pre-dates the fix. Rewrote both README lines to state the anchor version, PR/issue links, and the explicit "you are still on the fixed default if you're on @latest 2.2.21 or preview ≤ 2.2.22-preview.3" caveat. ### 2) docs-site/docs/{,en/}guide/getting-started.md:97 — #450 anchor Issue #450 is **OPEN** (verified `gh issue view 450`). Root fix landed in PR #239 commit `1eff3a4d` on 2026-06-28. Vincent's 2026-08-09 audit verified the fix in an isolated Docker probe on `agent-network@2.3.0-preview.38` reaching SSE connected. But #450 is not closed because four acceptance gates need to run green before promoting to latest: 1. promote a release containing 1eff3a4d to npm latest and repeat the cold-install journey against @latest 2. exercise a >60s cold fetch scenario 3. verify actionable DNS/registry/timeout/version failure classification 4. run the credentialed idle → send_task → non-empty reply layer So: current `@preview` (2.3.0-preview.39) has the fix; `@latest` (2.2.21) still ships the bug. Rewrote the warning block on both zh and en getting-started to name that split explicitly and provide the workaround. Method-note (for the audit trail): PR #239's title only names #237, not #450 — so the standard `gh pr list --search "fixes:#450"` returns nothing. The link is only recoverable via `git log -S` on the error string. Worth remembering. ## Fact-2 notes (通信龙 D1-D3) — #895 / #896 in main NOT yet in npm - PR #895 (`f565e9b8`) fixed `anet node start` false-`✅` / false-`started detached (tmux session live)` in detached scenarios. Merged to main. - PR #896 (`40574a02`) fixed `anet project up / project restart` exit-code lie. Merged to main. **Neither has been cut into an npm release yet** — so for anyone on `@preview` (currently `2.3.0-preview.39`), the trap still exists. The real check remains `tmux has-session -t "="` — the `=` is required (bare alias is a prefix match and can go green on the wrong session). Added this note in three places: 1. `README.md` + `README.en.md` quickstart — right after `curl /health` verify, before the "open localhost:3000" line, so the first-run reader sees the caveat while their brain is still on `anet node start`. 2. `docs-site/docs/deploy/clean-server.md` §7.1 — right below the `tmux new -s anet- + anet node start ` recipe. 3. `docs-site/docs/deploy/clean-server.md` §故障排查表 — added a new row 5.5 (`✅ printed but tmux session not there`) with the diagnostic recipe. Not touched (per current scope): - `docs/version/0.11.0/feature-audit.md:33` (D4) — the audit already correctly reports the bug and its `✅` refers to `anet node create` (wizard), not `anet node start`; no misleading claim to correct. - The 6-10 `anet node start` command demonstrations in `docs-site/docs/deploy/npm.md`, `.../concepts/networks.md`, `.../concepts/tokens.md` — those are pure command samples with no success-criteria text; the central note in clean-server.md is where they land. ## Not touched — Q2 leftovers (independent follow-ups) Sixteen other Q2 lines identified in the exhaustive scan still carry `TBD 追溯` anchors (multi-model.md, agent-node.md, batch.md, dashboard.md, feishu.md, runtimes.md, upgrade.md, RELEASE-SOP.md, etc.). Each needs a targeted `git log -S` pass. Scheduled for subsequent follow-ups so this PR stays reviewable. ## Verification ``` $ git diff origin/main...HEAD --stat README.en.md | 8 ++++++-- README.md | 8 ++++++-- docs-site/docs/deploy/clean-server.md | 7 ++++++- docs-site/docs/en/guide/getting-started.md | 2 +- docs-site/docs/guide/getting-started.md | 2 +- 5 files changed, 20 insertions(+), 7 deletions(-) $ git grep -c '2.2.22-preview.4' README.md README.en.md README.en.md:1 README.md:1 $ git grep -c '1eff3a4d' docs-site/docs/{,en/}guide/getting-started.md docs-site/docs/en/guide/getting-started.md:1 docs-site/docs/guide/getting-started.md:1 $ git grep -c '#895' README.md README.en.md docs-site/docs/deploy/clean-server.md README.en.md:1 README.md:1 docs-site/docs/deploy/clean-server.md:2 ``` ## Version facts sourced from npm (2026-08-18) Verified `npm view dist-tags` on 2026-08-18 — unchanged since 2026-08-14 authoring pass: latest : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8 preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31 commhub-server 0.9.0-preview.29 These are snapshots, not promises. Both `@latest` and `@preview` tags keep drifting; re-read via `npm view dist-tags` before quoting elsewhere. Co-authored-by: t --- README.en.md | 8 ++++++-- README.md | 8 ++++++-- docs-site/docs/deploy/clean-server.md | 7 ++++++- docs-site/docs/en/guide/getting-started.md | 2 +- docs-site/docs/guide/getting-started.md | 2 +- 5 files changed, 20 insertions(+), 7 deletions(-) diff --git a/README.en.md b/README.en.md index d8cbe35b1..21f9bbe8f 100644 --- a/README.en.md +++ b/README.en.md @@ -41,13 +41,17 @@ anet node create my-bot anet node start my-bot ``` -Verify: `curl http://127.0.0.1:9200/health` should return JSON containing `"ok":true`. +Verify the Hub is up: `curl http://127.0.0.1:9200/health` should return JSON containing `"ok":true`. + +> **⚠️ Check that the node really started — don't rely on `anet node start`'s stdout `✅`**: `exit 0` plus a printed `✅ node "…" started detached (tmux session live)` does **not** mean the node came up. On **versions predating [#895](https://github.com/sleep2agi/agent-network/pull/895)** (including today's npm `@preview` = `2.3.0-preview.39`; **#895 has landed on `main` but is not yet released to npm**) the detached path can lie. Real check: `tmux has-session -t "="` returns 0 (**the `=` is required** — a bare alias is a prefix match and can go green on the wrong session). For bulk launches use `anet project up`; its exit code is trustworthy since [#896](https://github.com/sleep2agi/agent-network/pull/896) (also awaiting an npm release). Open `http://localhost:3000` and dispatch work from the Dashboard. The default administrator account is `admin` / `anethub`. **Any public deployment must run `anet passwd` immediately after login** — otherwise anyone who scans the port can walk in. -> Preview builds (`@preview`) behave differently: the first `anet hub start` prints a one-time random password. It is shown once, so save it right then. +> **Since `@sleep2agi/agent-network@2.2.22-preview.4`** (2026-06-28, PR [#264](https://github.com/sleep2agi/agent-network/pull/264) fixing [#261](https://github.com/sleep2agi/agent-network/issues/261) P0-2), preview builds print a **one-time random password** on the first `anet hub start` — shown once (save it right then); the first login forces a password change. +> +> **The stable `@latest` (currently `2.2.21`) and older `preview ≤ 2.2.22-preview.3` still ship with the fixed default `admin` / `anethub`** — run `anet passwd` right after logging in. ## What it does diff --git a/README.md b/README.md index 857ef5f02..3bcfd61de 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,17 @@ anet node create my-bot anet node start my-bot ``` -验证:`curl http://127.0.0.1:9200/health` 返回的 JSON 应包含 `"ok":true`。 +验证 Hub 起来了:`curl http://127.0.0.1:9200/health` 返回的 JSON 应包含 `"ok":true`。 + +> **⚠️ 判断节点真起来 —— 别只看 `anet node start` 的 stdout `✅`**:`exit 0` + 打印 `✅ node "…" started detached (tmux session live)` **不代表节点真起来**。**含 [#895](https://github.com/sleep2agi/agent-network/pull/895) 之前的版本**(包括当前 npm `@preview` = `2.3.0-preview.39`;**#895 已合入 main 但尚未发 npm**)在 detached 场景可能假报。真判据:`tmux has-session -t "="` 返回 0(**`=` 必须**,裸名字是前缀匹配会误报绿)。批量场景用 `anet project up`,其退出码自 [#896](https://github.com/sleep2agi/agent-network/pull/896) 起可信(同样待 npm 发布)。 打开 `http://localhost:3000`,从 Dashboard 给 Agent 派任务。 默认管理员用户名是 `admin`,初始密码是 `anethub`。**任何公网部署都必须登录后立即运行 `anet passwd` 改密**,否则被扫到端口就能进。 -> 预览版(`@preview`)行为不同:首次 `anet hub start` 会打印一次性随机密码,只显示这一次,请当场保存。 +> **自 `@sleep2agi/agent-network@2.2.22-preview.4`**(2026-06-28, PR [#264](https://github.com/sleep2agi/agent-network/pull/264) 修 [#261](https://github.com/sleep2agi/agent-network/issues/261) P0-2)**起**,预览版 `@preview` 首次 `anet hub start` 打印**一次性随机密码**(只显示这一次,请当场保存;首次登录会强制改密)。 +> +> **stable `@latest`(当前 `2.2.21`)与更早的 preview `≤ 2.2.22-preview.3` 仍是固定默认 `admin` / `anethub`** —— 登录后必须立即 `anet passwd`。 ## 能做什么 diff --git a/docs-site/docs/deploy/clean-server.md b/docs-site/docs/deploy/clean-server.md index a38f9b399..ebb0713da 100644 --- a/docs-site/docs/deploy/clean-server.md +++ b/docs-site/docs/deploy/clean-server.md @@ -296,6 +296,10 @@ anet 暂未 ship 官方 `--daemon` flag,下面给两条可选路径:tmux 临 - 每个节点: `tmux new -s anet-` + `anet node start ` - 想接 `@reboot` crontab 也行,但 PATH / nvm 这些非交互 shell 问题要先解决(见 [第 0 节 nvm 提示](#_0-前置)) +::: tip 复核节点真起来 —— 别只看 stdout ✅ +起完每个节点跑一条:`tmux has-session -t "="; echo $?` 应输出 `0`(**`=` 必须**,裸名字是前缀匹配会命中别的 session 假报绿)。**[#895](https://github.com/sleep2agi/agent-network/pull/895) 之前的版本**(含当前 npm `@preview` = `2.3.0-preview.39`;**#895 已合入 main, 未发 npm**)在 detached 场景可能打 `✅ started detached (tmux session live)` `exit 0` 但 tmux 里没进程。批量起用 `anet project up`,退出码自 [#896](https://github.com/sleep2agi/agent-network/pull/896) 起可信(同样待 npm 发布)。 +::: + ### 7.2 systemd unit(生产 / 开机自启) 下面这套 unit 文件**未经官方测试**、按你的实际 `node` 路径(`which anet`)+ 运行用户改一下就能用。改完跑 `systemctl daemon-reload` + `enable --now`。 @@ -367,7 +371,8 @@ sudo systemctl status anet-hub anet-node@my-bot | 2 | `anet node create` 选完 runtime → `FATAL: TypeError: fetch failed` | 建节点要连本地 hub,但 hub 没起(多半因为坑 1) | 另开终端先 `anet hub start`,再回这条重试。**[#237](https://github.com/sleep2agi/agent-network/issues/237)** 主条跟进给 fetch 分类报错 | | 3 | 一路 Enter 落到要填 vendor + API Key 的复杂路径 | runtime 菜单默认高亮 `claude-agent-sdk`,不是最易上手的 `claude-code-cli` | 建节点时**手动选 `claude-code-cli`**(已 `claude auth login` 直接复用订阅)。中断 vendor 选择如果留下半成品节点,用 `anet node delete ` 清掉重来 | | 4 | 建完节点 Telegram 不工作,但向导开头说"optional Telegram channel" | 向导根本不问 Telegram,那行是误导文案 | Telegram 用 `anet channel add telegram --bot-token --allow ` 单独配(见 [第 6 节](#_6-配-telegram-channel-可选)) | -| 5 | `anet node start` (codex-sdk / claude-agent-sdk) → `agent-node is not installed or cannot report a version` | npx 懒加载没拉到 `@sleep2agi/agent-node` | `npm i -g @sleep2agi/agent-node`;然后 `agent-node --version` 应输出 | +| 5 | `anet node start` (codex-sdk / claude-agent-sdk) → `agent-node is not installed or cannot report a version` | npx 懒加载没拉到 `@sleep2agi/agent-node`;bug 存于 `@latest` (2.2.21) 与 preview ≤ 2.3.0-preview.37 | 短路: `npm i -g @sleep2agi/agent-node` 让二进制就位;长期: 升到 `@sleep2agi/agent-network@preview`(含 [PR #239](https://github.com/sleep2agi/agent-network/pull/239) fix, `1eff3a4d`, 2026-06-28)。issue [#450](https://github.com/sleep2agi/agent-network/issues/450) 仍 open —— 待 4 项 gate 后 promote latest | +| 5.5 | `anet node start` 打 `✅ started detached (tmux session live)` `exit 0`,但 `tmux ls` 找不到 session、进程也不在 | detached 路径的假绿 bug;存于含 [#895](https://github.com/sleep2agi/agent-network/pull/895) 之前的版本,含 npm `@preview` = `2.3.0-preview.39`(**#895 已合 main 未发 npm**) | 真判据: `tmux has-session -t "="; echo $?` 应输 `0`(`=` 必须)。批量场景用 `anet project up`(退出码自 [#896](https://github.com/sleep2agi/agent-network/pull/896) 起可信,同待 npm 发布)。装含 fix 的构建前,用 has-session 复核每次启动 | | 6 | `claude-code-cli` 节点起来后卡 offline / pane 卡在确认框 | Claude Code 的 `--dangerously-load-development-channels` 确认框等人按 Enter | 用 tmux 前台跑一次手动按 `1` + Enter;后续就不弹了 | | 7 | systemd / cron / 新用户启动报一连串 `command not found` | nvm + Bun 各自按用户装,非交互 shell 不加载 | 把 node/npm/bun 软链到 `/usr/local/bin/`,或启动脚本里显式 `source ~/.nvm/nvm.sh` | | 8 | 机器重启全部掉线 | hub + 节点都靠手动 tmux 挂着 | 配 systemd 开机自启,参考 [§7.2 systemd unit](#_7-2-systemd-unit-生产-开机自启) 里的模板 | diff --git a/docs-site/docs/en/guide/getting-started.md b/docs-site/docs/en/guide/getting-started.md index 9d51911a3..6c94b6df6 100644 --- a/docs-site/docs/en/guide/getting-started.md +++ b/docs-site/docs/en/guide/getting-started.md @@ -98,7 +98,7 @@ On stable, `anet node create` lists **4 production runtimes** (`claude-agent-sdk Start the node: ::: warning Fresh install + claude-agent-sdk / codex-sdk? Install agent-node first -These runtimes depend on the `agent-node` package. The first `node start` triggers an npx auto-fetch that takes ~1 minute, but the current startup check **doesn't wait for it** and exits with `agent-node is not installed or cannot report a version` (reproduced on real hardware, [#450](https://github.com/sleep2agi/agent-network/issues/450) (precise filing; #237 is the umbrella)). Run this once before starting: +These runtimes depend on the `agent-node` package. The first `node start` triggers an npx auto-fetch that takes ~1 minute, but on **stable `@latest` (currently `2.2.21`) and preview `≤ 2.3.0-preview.37`** the startup check **doesn't wait for it** and exits with `agent-node is not installed or cannot report a version` (reproduced on real hardware — [#450](https://github.com/sleep2agi/agent-network/issues/450) is the precise filing, #237 is the umbrella). **Root fix** is [PR #239](https://github.com/sleep2agi/agent-network/pull/239) (commit `1eff3a4d`, merged 2026-06-28); Vincent's 2026-08-09 audit verified the fix in an isolated Docker probe on `2.3.0-preview.38` reaching SSE connected. **The current `@preview` (`2.3.0-preview.39`) contains this fix; `@latest` does not** — [#450](https://github.com/sleep2agi/agent-network/issues/450) is still `open` pending 4 acceptance gates before latest promotion. **Workarounds** (in verified-strength order): upgrade to `@sleep2agi/agent-network@preview`; or stay on `@latest` but pre-install `agent-node` so the binary is already there: ```bash npm install -g @sleep2agi/agent-node diff --git a/docs-site/docs/guide/getting-started.md b/docs-site/docs/guide/getting-started.md index c3c24bff1..9fa228ea1 100644 --- a/docs-site/docs/guide/getting-started.md +++ b/docs-site/docs/guide/getting-started.md @@ -98,7 +98,7 @@ stable 版 `anet node create` 列出正式版的 runtime(`claude-agent-sdk` / 启动节点: ::: warning 全新安装选了 claude-agent-sdk / codex-sdk?先装 agent-node -这两个 runtime 依赖 `agent-node` 包。首次 `node start` 的 npx 自动拉取需要约 1 分钟,当前版本的启动检查**不等它拉完**就报 `agent-node is not installed or cannot report a version` 退出(真机复现,[#450](https://github.com/sleep2agi/agent-network/issues/450) 精确立案,#237 为同族)。先跑一句再启动即可: +这两个 runtime 依赖 `agent-node` 包。首次 `node start` 的 npx 自动拉取需要约 1 分钟,而 **stable `@latest`(当前 `2.2.21`)与 preview `≤ 2.3.0-preview.37`** 的启动检查**不等它拉完**就报 `agent-node is not installed or cannot report a version` 退出(真机复现,[#450](https://github.com/sleep2agi/agent-network/issues/450) 精确立案,#237 为同族)。**根因修复**见 [PR #239](https://github.com/sleep2agi/agent-network/pull/239)(commit `1eff3a4d`, merged 2026-06-28),Vincent 2026-08-09 audit 在 `2.3.0-preview.38` 隔离 Docker 里 verified 抵达 SSE connected;**当前 `@preview` (`2.3.0-preview.39`) 已含此 fix,`@latest` 未含** —— [#450](https://github.com/sleep2agi/agent-network/issues/450) 仍 `open`,因 promote 到 latest 待 4 项 acceptance gate 真绿。**变通**(按 verified 强度):升到 `@sleep2agi/agent-network@preview`;或用 `@latest` 但先跑一句让二进制预先就位: ```bash npm install -g @sleep2agi/agent-node From 7495746b223f2cbdd2c3f813165753bf39d4c1aa Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 00:52:23 +0800 Subject: [PATCH 19/59] fix(tests): derive the opencode pair versions from source instead of pinning them in tests (#902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the RELEASE-SOP pre-flight for preview.40 rather than by reading it. `scripts/sync-pinned-versions.sh` bumps `OPENCODE_AGENT_NETWORK_VERSION` / `OPENCODE_AGENT_NODE_VERSION`, but it does not touch the tests — and two suites hard-code that pair in nine places: test386 5 assertions + 3 fixtures (bin/npx spec, two package.json versions) test384 run.sh defaults + Dockerfile ARG defaults The sharpest one is test386:398. It `grep -Fq`s the exact install command that `opencodeExactPairInstallCommand()` builds FROM those constants, so bumping a constant makes the assertion fail by construction. Following the SOP as written produces a red, and the cheapest way to make that red go away is to edit the number in the assertion — which turns the test into a copy of the current value that checks nothing. Everything now reads the constants at run time, fail-closed: if the source file cannot be parsed the run fails rather than continuing with an empty string, because `grep -Fq ""` matches everything and would silently turn five assertions into permanent passes. The two fixture package.json files are rewritten by run.sh before use and carry a note saying not to chase the constant by hand. Two traps hit while doing this, both worth recording because the fix and the no-op look identical: * test384's Dockerfile sets `ENV *_UNDER_TEST=${ARG}` from an ARG that had a hardcoded default, and run.sh reads `${*_UNDER_TEST:-}`. With a non-empty ARG default the ENV is never empty, so the derived branch could never run — the "now it derives" change would have been inert while the suite kept passing against the previous version. The ARG defaults are now empty so `:-` reaches the derivation; `--build-arg` still overrides. * bin/npx compared against a literal spec. It now compares against `$EXPECT_NODE_SPEC` exported by run.sh and exits 65 if that is unset, rather than falling through to "unexpected npx arguments" — which would have read as a product failure instead of a harness one. RELEASE-SOP gains a calibration note saying these files are deliberately NOT in the Live versions table: they are self-consistent now, and registering them would re-introduce a second copy to drift. Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs/RELEASE-SOP.md | 16 ++++++ .../Dockerfile | 9 +++- .../test384-opencode-local-package-e2e/run.sh | 12 ++++- .../test386-opencode-agent-node-gate/bin/npx | 8 ++- .../exact-node/package.json | 1 + .../project-agent-node/package.json | 1 + tests/test386-opencode-agent-node-gate/run.sh | 49 +++++++++++++++++-- 7 files changed, 86 insertions(+), 10 deletions(-) diff --git a/docs/RELEASE-SOP.md b/docs/RELEASE-SOP.md index 6596320a6..8fa968072 100644 --- a/docs/RELEASE-SOP.md +++ b/docs/RELEASE-SOP.md @@ -31,6 +31,22 @@ R212/R213/R215/R225/R251/R253 chain 已经把 `docs-site/docs/guide/runtimes.md` ~~例外(保留快照):sdk-deep-dive.md L14 用 `agent-node@2.3.1-preview.0` 做 snapshot pin~~ —— **R367 (2026-05-14) 已取消该例外**:[`docs-site/docs/guide/sdk-deep-dive.md` L14](https://github.com/sleep2agi/agent-network/blob/main/docs-site/docs/guide/sdk-deep-dive.md#L14) 的 `cli.ts:NNN` 行号引用改成「对照 GitHub `main` 校准」(不再 pin 具体 preview 版本),跟其余 doc 一致。现在 **没有 docs 还 pin npm 版本号**了。 ::: +::: tip R? 校准(2026-08-17):测试套件里的版本号已改为「从源码常量派生」,不再需要 release sync + +`tests/test386-opencode-agent-node-gate` 与 `tests/test384-opencode-local-package-e2e` 原先各自 +硬编码了 `OPENCODE_AGENT_NETWORK_VERSION` / `OPENCODE_AGENT_NODE_VERSION` 这一对 +(test386 有 5 处断言 + 3 处夹具,test384 有 run.sh 默认值 + Dockerfile ARG)。 + +走 preview.40 的 dry-run 时发现:**sync 脚本会升常量,但不碰这些文件,所以照本 SOP 发版 +必然产生一个红**——而最省力的「修法」是把断言里的数字改成新的,那等于让测试永远只抄一遍 +当前值、不再检查任何东西。 + +现在它们在运行时从 `agent-network/src/opencode-agent-node-pair.ts` 读常量(读不到就 +fail-closed,不拿空串去 grep——空串 grep 恒真会把断言变成永远通过),夹具的 `version` +由 run.sh 在使用前改写。**不要把它们加进 Live versions 表**:加进去等于给已经自洽的东西 +再钉一份,反而会漂。 +::: + ### B. Frozen snapshots(永不动) 每条记录都是某个历史时刻的快照,跟着 release sync 改反而失真。 diff --git a/tests/test384-opencode-local-package-e2e/Dockerfile b/tests/test384-opencode-local-package-e2e/Dockerfile index f71996af6..e0f327510 100644 --- a/tests/test384-opencode-local-package-e2e/Dockerfile +++ b/tests/test384-opencode-local-package-e2e/Dockerfile @@ -1,8 +1,13 @@ FROM node:22-bookworm-slim ARG OPENCODE_VERSION=1.18.1 -ARG AGENT_NETWORK_VERSION=2.3.0-preview.39 -ARG AGENT_NODE_VERSION=2.5.0-preview.31 +# 🔴 故意留空:下面 line 72-73 把这两个 ARG 灌进 ENV *_UNDER_TEST,而 run.sh 用 +# ${*_UNDER_TEST:-<从源码常量派生>}。ARG 一旦有硬编码默认值,ENV 就永远非空, +# run.sh 的派生分支永远不会执行 —— 那个"改成派生"的修改会是一次空转,而且 +# 表现和生效完全一样(测试照跑照绿,只是测的是上一个版本)。 +# 留空 → ENV 为空 → :- 走派生。要测特定版本仍可 --build-arg 显式覆盖。 +ARG AGENT_NETWORK_VERSION= +ARG AGENT_NODE_VERSION= RUN apt-get update && apt-get install -y --no-install-recommends \ bash ca-certificates curl jq procps python3 python3-pexpect ripgrep unzip \ diff --git a/tests/test384-opencode-local-package-e2e/run.sh b/tests/test384-opencode-local-package-e2e/run.sh index 12da21abc..aeb4039c7 100644 --- a/tests/test384-opencode-local-package-e2e/run.sh +++ b/tests/test384-opencode-local-package-e2e/run.sh @@ -14,8 +14,16 @@ ADMIN_PASSWORD='Test384-Strong-Password!' LIVE_ALIAS=wizard-openai FREE_MODEL="${OPENCODE_FREE_MODEL:-opencode/deepseek-v4-flash-free}" EXPECTED_OPENCODE="${OPENCODE_VERSION_UNDER_TEST:-1.18.1}" -EXPECTED_NETWORK="${AGENT_NETWORK_VERSION_UNDER_TEST:-2.3.0-preview.39}" -EXPECTED_NODE="${AGENT_NODE_VERSION_UNDER_TEST:-2.5.0-preview.31}" +# 默认值从源码常量派生,不写死:release 升常量时这里跟着走,不需要有人记得改。 +# (2026-08-17:走 RELEASE-SOP 的 preview.40 dry-run 时发现 sync 脚本不碰这个文件, +# 于是照 SOP 发版会让这里默认测到上一个版本 —— 测的是旧产物,却看起来在测新版。) +PAIR_SRC=/repo/agent-network/src/opencode-agent-node-pair.ts +SRC_NETWORK=$(sed -n 's/^export const OPENCODE_AGENT_NETWORK_VERSION = "\([^"]*\)";$/\1/p' "$PAIR_SRC" 2>/dev/null) +SRC_NODE=$(sed -n 's/^export const OPENCODE_AGENT_NODE_VERSION = "\([^"]*\)";$/\1/p' "$PAIR_SRC" 2>/dev/null) +EXPECTED_NETWORK="${AGENT_NETWORK_VERSION_UNDER_TEST:-${SRC_NETWORK}}" +EXPECTED_NODE="${AGENT_NODE_VERSION_UNDER_TEST:-${SRC_NODE}}" +[ -n "$EXPECTED_NETWORK" ] || { echo "FAIL: cannot resolve expected agent-network version" >&2; exit 1; } +[ -n "$EXPECTED_NODE" ] || { echo "FAIL: cannot resolve expected agent-node version" >&2; exit 1; } REAL_PATH="$PATH" FAKE_BIN_DIR=/test384/fake-bin FAKE_CANONICAL_BIN=/test384/fake-global/node_modules/opencode-ai/bin/opencode.exe diff --git a/tests/test386-opencode-agent-node-gate/bin/npx b/tests/test386-opencode-agent-node-gate/bin/npx index a9d16293b..e4b36209d 100644 --- a/tests/test386-opencode-agent-node-gate/bin/npx +++ b/tests/test386-opencode-agent-node-gate/bin/npx @@ -2,9 +2,15 @@ set -eu printf '%s\n' "$*" > /tmp/test386-npx-args +# 期望的 spec 由 run.sh 从源码常量导出,不写死版本号:release 一升常量, +# 写死的夹具就和被测代码对不上,而"改夹具去迎合新值"等于让夹具永远只抄当前值。 +if [ -z "${EXPECT_NODE_SPEC:-}" ]; then + printf '%s\n' "EXPECT_NODE_SPEC not exported by run.sh — refusing to guess" >&2 + exit 65 +fi if [ "$#" -eq 3 ] \ && [ "$1" = "-y" ] \ - && [ "$2" = "@sleep2agi/agent-node@2.5.0-preview.31" ] \ + && [ "$2" = "$EXPECT_NODE_SPEC" ] \ && [ "$3" = "--print-entrypoint" ]; then printf '%s\n' '/test/exact-global/node_modules/@sleep2agi/agent-node/dist/cli.js' exit 0 diff --git a/tests/test386-opencode-agent-node-gate/exact-node/package.json b/tests/test386-opencode-agent-node-gate/exact-node/package.json index 68694e7f2..8d565e1c6 100644 --- a/tests/test386-opencode-agent-node-gate/exact-node/package.json +++ b/tests/test386-opencode-agent-node-gate/exact-node/package.json @@ -1,4 +1,5 @@ { + "_note": "run.sh 在用它之前会把 version 改写成 agent-network/src/opencode-agent-node-pair.ts 里 OPENCODE_AGENT_NODE_VERSION 的当前值。这里这个数字只是占位,不要手动改它去追常量。", "name": "@sleep2agi/agent-node", "version": "2.5.0-preview.31", "type": "module", diff --git a/tests/test386-opencode-agent-node-gate/project-agent-node/package.json b/tests/test386-opencode-agent-node-gate/project-agent-node/package.json index 68694e7f2..8d565e1c6 100644 --- a/tests/test386-opencode-agent-node-gate/project-agent-node/package.json +++ b/tests/test386-opencode-agent-node-gate/project-agent-node/package.json @@ -1,4 +1,5 @@ { + "_note": "run.sh 在用它之前会把 version 改写成 agent-network/src/opencode-agent-node-pair.ts 里 OPENCODE_AGENT_NODE_VERSION 的当前值。这里这个数字只是占位,不要手动改它去追常量。", "name": "@sleep2agi/agent-node", "version": "2.5.0-preview.31", "type": "module", diff --git a/tests/test386-opencode-agent-node-gate/run.sh b/tests/test386-opencode-agent-node-gate/run.sh index 145622c4f..fac2dc1d0 100644 --- a/tests/test386-opencode-agent-node-gate/run.sh +++ b/tests/test386-opencode-agent-node-gate/run.sh @@ -29,6 +29,45 @@ write_opencode_binding() { ' } +# 🔴 这两个版本号从源码常量派生,不写死。 +# +# 之前 5 处断言各自硬编码了 `2.3.0-preview.39` / `2.5.0-preview.31`。它们断言的 +# 恰恰是 opencodeExactPairInstallCommand() 用这两个常量生成的字符串,所以每次 +# release 升常量,这 5 条 grep -Fq 就会一起红 —— 而修它的最省力办法是把断言里的 +# 数字改成新的,那等于让测试永远只是抄一遍当前值,不再检查任何东西。 +# +# 2026-08-17 走 RELEASE-SOP 的 preview.40 dry-run 时撞到这一点:sync 脚本会升 +# 常量,但不碰这个文件,于是照 SOP 发版必然产生一个红。 +# +# 派生 + fail-closed:读不到常量就直接失败,不能静默拿空串去 grep(空串 grep 恒真, +# 那会把这几条断言变成永远通过)。 +PAIR_SRC=/repo/agent-network/src/opencode-agent-node-pair.ts +[ -f "$PAIR_SRC" ] || fail "cannot find $PAIR_SRC — refusing to assert against an unknown pair" +EXPECT_NETWORK=$(sed -n 's/^export const OPENCODE_AGENT_NETWORK_VERSION = "\([^"]*\)";$/\1/p' "$PAIR_SRC") +EXPECT_NODE=$(sed -n 's/^export const OPENCODE_AGENT_NODE_VERSION = "\([^"]*\)";$/\1/p' "$PAIR_SRC") +[ -n "$EXPECT_NETWORK" ] || fail "could not read OPENCODE_AGENT_NETWORK_VERSION from $PAIR_SRC" +[ -n "$EXPECT_NODE" ] || fail "could not read OPENCODE_AGENT_NODE_VERSION from $PAIR_SRC" +printf -- '- expected pair (from source): agent-network@%s + agent-node@%s\n' \ + "$EXPECT_NETWORK" "$EXPECT_NODE" >> "$REPORT" + +# 夹具里的版本号同样从常量派生。它们代表「被信任的那个确切版本」,写死的话 +# release 一升常量,夹具就不再是「确切版本」,而这个失败看起来像产品坏了。 +export EXPECT_NODE_SPEC="@sleep2agi/agent-node@$EXPECT_NODE" +for fixture in /repo/tests/test386-opencode-agent-node-gate/exact-node/package.json \ + /repo/tests/test386-opencode-agent-node-gate/project-agent-node/package.json; do + [ -f "$fixture" ] || fail "fixture missing: $fixture" + tmp=$(mktemp) + EXPECT_NODE="$EXPECT_NODE" node -e ' + const fs = require("fs"); + const p = process.argv[1]; + const j = JSON.parse(fs.readFileSync(p, "utf8")); + j.version = process.env.EXPECT_NODE; + fs.writeFileSync(process.argv[2], JSON.stringify(j, null, 2) + "\n"); + ' "$fixture" "$tmp" || fail "could not rewrite fixture version: $fixture" + mv "$tmp" "$fixture" +done +printf -- '- fixtures pinned to agent-node@%s\n' "$EXPECT_NODE" >> "$REPORT" + printf '# Test 386 — opencode-cli stale agent-node launch gate\n\n' >> "$REPORT" printf -- '- date: %s\n' "$(date -Iseconds)" >> "$REPORT" @@ -217,7 +256,7 @@ jq -e ' [ ! -e /tmp/test386-profile-coverage ] \ || fail "profile NODE_V8_COVERAGE wrote outside the node state boundary" [ ! -e /tmp/test386-npx-args ] || fail "exact global resolution unexpectedly executed npx" -grep -Fq 'using installed exact @sleep2agi/agent-node@2.5.0-preview.31' \ +grep -Fq "using installed exact @sleep2agi/agent-node@$EXPECT_NODE" \ /tmp/test386-success.log || fail "exact installed agent-node diagnostic is missing" pass "stale global bypassed; later exact global received protected PATH/binary/version/base; npx was not executed" @@ -294,7 +333,7 @@ mask_log < /tmp/test386-project-explicit.log >> "$REPORT" || fail "explicit project-local rejection unexpectedly launched another agent-node" [ ! -e /tmp/test386-npx-args ] \ || fail "explicit project-local rejection unexpectedly executed npx" -grep -Fq 'ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.31' \ +grep -Fq "ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@$EXPECT_NODE" \ /tmp/test386-project-explicit.log \ || fail "explicit project-local rejection omitted the exact-pair diagnostic" grep -Fq 'project/node-local agent-node package payload is not trusted' \ @@ -336,7 +375,7 @@ jq -e '.executable == "/test/exact-global/node_modules/@sleep2agi/agent-node/dis /tmp/test386-exact-preview-launch.json >/dev/null \ || fail "capable-looking preview.21 was not bypassed for the later exact global" [ ! -e /tmp/test386-npx-args ] || fail "preview.21 bypass unexpectedly executed npx" -pass "capable-looking global preview.21 rejected; later exact global preview.31 launched without npx" +pass "capable-looking global preview.21 rejected; later exact global $EXPECT_NODE launched without npx" # An explicit override is not permission to bypass the exact release pair. rm -rf /tmp/test386-work-explicit /tmp/test386-home-explicit \ @@ -362,7 +401,7 @@ mask_log < /tmp/test386-explicit.log >> "$REPORT" [ "$explicit_rc" -ne 0 ] || fail "stale explicit agent-node override unexpectedly started" [ ! -e /tmp/test386-stale-capable-global-was-launched ] \ || fail "stale explicit preview.21 was launched" -grep -Fq 'ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@2.5.0-preview.31' \ +grep -Fq "ANET_AGENT_NODE_BIN is not the exact trusted @sleep2agi/agent-node@$EXPECT_NODE" \ /tmp/test386-explicit.log \ || fail "explicit override exact-version diagnostic is missing" pass "ANET_AGENT_NODE_BIN cannot bypass the exact hardened pair" @@ -395,7 +434,7 @@ mask_log < /tmp/test386-fail.log >> "$REPORT" grep -Fq 'automatic npx execution is disabled for opencode-cli' \ /tmp/test386-fail.log \ || fail "hard-fail omitted the disabled-npx diagnostic" -grep -Fq 'npm install -g @sleep2agi/agent-network@2.3.0-preview.39 @sleep2agi/agent-node@2.5.0-preview.31' \ +grep -Fq "npm install -g @sleep2agi/agent-network@$EXPECT_NETWORK @sleep2agi/agent-node@$EXPECT_NODE" \ /tmp/test386-fail.log \ || fail "hard-fail omitted the exact dual-package install command" grep -Fq 'Refusing to start: an unsupported agent-node could silently select another runtime.' \ From 1dcee5bf6267b9ffadda9ce4ca164e215514f2b0 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 00:52:28 +0800 Subject: [PATCH 20/59] =?UTF-8?q?fix(docs):=20changelogs=20must=20not=20li?= =?UTF-8?q?ne-anchor=20into=20main=20=E2=80=94=20the=20anchor=20rots=20by?= =?UTF-8?q?=20construction=20(#903)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A changelog entry describes a state that was true at some past release. A `blob/main/#L` link resolves against today's code. Those two facts are incompatible: the link is wrong after the next commit that touches that file, and nothing reports it. Measured, not assumed. Two of the six such links in the changelogs: cli.ts#L61 documented as `PINNED_SERVER_VERSION` now lands on `} from "../src/opencode-preset";` cli.ts#L2589 documented as the `bunx --bun @sleep2agi/commhub-server@…` line in `anet hub start` now lands on a line of `anet project restart` help text Both now link the file without the anchor and name the symbol instead, which is what a reader can actually search for. The original line number is kept in parentheses as historical context — it was true when written, and saying so is more useful than deleting it. This follows the precedent RELEASE-SOP records at R367, which replaced `cli.ts:NNN` references with symbol references for the same reason. Scoped to changelogs on purpose. `docs-site/docs/api/mcp-tools.md` carries 44 of these anchors and all 44 are still in range, landing on plausible content — they are maintained, because that page documents current behaviour rather than past releases. A guard reddening on ~100 maintained links would be a backlog canary that dies the day the backlog clears, and would train people to ignore it. check-docs-integrity.py gains the rule, exercised three ways: repaired tree → exit 0 (2 changelogs, 0 anchors), f565e9b8's changelog → exit 1 naming each, CHANGELOG_GLOB pointed at a missing filename → exit 2 rather than a clean pass against nothing. Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-docs-integrity.py | 40 ++++++++++++++++++++++--- docs-site/docs/changelog.md | 4 +-- docs-site/docs/en/changelog.md | 4 +-- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/.github/scripts/check-docs-integrity.py b/.github/scripts/check-docs-integrity.py index 6ee64895c..ac96b784b 100755 --- a/.github/scripts/check-docs-integrity.py +++ b/.github/scripts/check-docs-integrity.py @@ -15,10 +15,24 @@ Neither shows up in a build: Markdown has no compiler, so a dead link and a live one look the same until a reader clicks. Both are cheap to check mechanically. +A third, narrower rule: no line-anchored `blob/main/...#L` links inside the +changelogs. A changelog entry describes a state that was true at some past +release; a `#L` anchor into `main` resolves against today's code. Those two +facts are incompatible by construction — the link is wrong after the next commit +that touches the file, and nothing tells anyone. Measured 2026-08-17: of six such +links, `cli.ts#L61` (documented as `PINNED_SERVER_VERSION`) now lands on +`} from "../src/opencode-preset";` and `cli.ts#L2589` on a line of help text. + +Deliberately NOT extended to the rest of docs/: `docs-site/docs/api/mcp-tools.md` +carries 44 of these and all 44 are still in range with plausible content, i.e. +they are maintained. Reddening on ~100 maintained links would make this a +backlog canary that dies the day the backlog clears. + Scope is deliberately narrow and stated: UTF-8 validity across every tracked -.md, link resolution for docs/qa/** only (where the defect was found). Widening -the link check to all docs is a separate decision — some files link to generated -or gitignored paths, and a guard that cries wolf gets disabled. +.md, link resolution for docs/qa/** only (where the defect was found), and the +changelog anchor rule. Widening the link check to all docs is a separate +decision — some files link to generated or gitignored paths, and a guard that +cries wolf gets disabled. Fail-closed: an empty file list exits 2 rather than reporting a clean run. """ @@ -29,6 +43,8 @@ RELATIVE_LINK = re.compile(r"\]\((\.{1,2}/[^)\s]*)") LINK_SCOPE = "docs/qa" +CHANGELOG_GLOB = "changelog.md" +MAIN_LINE_ANCHOR = re.compile(r"blob/main/[\w./-]+#L\d+") def tracked(pathspec: str) -> list[str]: @@ -75,8 +91,24 @@ def main() -> int: print(f"::error file={f}::relative link '{target}' resolves to " f"'{resolved}', which does not exist") + # 3. Changelogs must not line-anchor into main. + changelogs = [f for f in md if f.endswith("/" + CHANGELOG_GLOB) or f == CHANGELOG_GLOB] + if not changelogs: + print(f"::error::no tracked {CHANGELOG_GLOB} found — scope regression, refusing to pass") + return 2 + anchors = 0 + for f in changelogs: + for m in MAIN_LINE_ANCHOR.finditer(open(f, encoding="utf-8", errors="replace").read()): + problems += 1 + anchors += 1 + print(f"::error file={f}::`{m.group(0)}` line-anchors into main from a changelog. " + f"The entry describes a past release; the anchor resolves against today's " + f"code, so it is wrong after the next commit that touches that file and " + f"nothing reports it. Link the file without `#L`, and name the symbol.") + print(f"checked {len(md)} tracked .md for UTF-8 validity; " - f"{links} relative link(s) across {len(scoped)} file(s) under {LINK_SCOPE}/") + f"{links} relative link(s) across {len(scoped)} file(s) under {LINK_SCOPE}/; " + f"{len(changelogs)} changelog(s) for main line-anchors ({anchors} found)") if problems: print(f"\n{problems} problem(s).") diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index a6a555758..5111e4e4a 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -658,7 +658,7 @@ v0.10.4 Vincent 紧急 ship 跳过 测试团队 Docker smoke gate(不在生产 - `HostTelemetry` interface 加 `disk_total_gb` / `disk_used_gb` / `disk_avail_gb`,`getHostTelemetry()` 通过 `toGb()` 同 mem/cpu 同 path 合成 - **Backward compat**:老 server 端 schema silent-drop unknown keys;agent / server 可独立升 -接 [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `/api/server/:host/health` 响应现在带 disk 三字段 + 24h 分桶 history 也含 `disk_avail_min` / `disk_used_max`;`alert_level` 加 `disk < 1GB critical / < 5GB warn` 触发([`server/src/index.ts:253-258`](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts#L253))。 +接 [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `/api/server/:host/health` 响应现在带 disk 三字段 + 24h 分桶 history 也含 `disk_avail_min` / `disk_used_max`;`alert_level` 加 `disk < 1GB critical / < 5GB warn` 触发([`server/src/index.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts)(当时在 253-258 行;行号已漂,按当时的符号名搜))。 测试团队 Docker Linux smoke 3/3 PASS(disk 299.8 GB total / 216 used / 71.5 avail,alert green,backward compat verified)。 @@ -710,7 +710,7 @@ anet project restart # 重启项目(拉新 agent-n ### Fix -[`agent-network/bin/cli.ts:61` `PINNED_SERVER_VERSION`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L61) 跨 v0.9.x + v0.10.0 promote 漏 bump,仍 hardcode `0.8.0` —— `anet hub start` 实际 `bunx --bun @sleep2agi/commhub-server@0.8.0` 启服务([cli.ts:2589](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2589)),跑的是老 server 不是 v0.10.0 ship 的 `0.8.2`。直接影响: +[`agent-network/bin/cli.ts` 的 `PINNED_SERVER_VERSION`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 61 行) 跨 v0.9.x + v0.10.0 promote 漏 bump,仍 hardcode `0.8.0` —— `anet hub start` 实际 `bunx --bun @sleep2agi/commhub-server@0.8.0` 启服务([`cli.ts` 里 `anet hub start` 的 `bunx --bun @sleep2agi/commhub-server@…` 那处](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 2589 行)),跑的是老 server 不是 v0.10.0 ship 的 `0.8.2`。直接影响: - [#99](https://github.com/sleep2agi/agent-network/issues/99) 守护节点 endpoint family `GET /api/server/:host/health` + `GET /api/server/:host/agents` 在 0.8.0 不存在 → **404** - [#142](https://github.com/sleep2agi/agent-network/issues/142) server schema align `process_telemetry` 字段在 0.8.0 没接 → 老 schema silent-drop 字段 diff --git a/docs-site/docs/en/changelog.md b/docs-site/docs/en/changelog.md index db49ab043..ab7a75987 100644 --- a/docs-site/docs/en/changelog.md +++ b/docs-site/docs/en/changelog.md @@ -657,7 +657,7 @@ See the [v0.10.3 release notes](https://github.com/sleep2agi/agent-network/relea - `HostTelemetry` interface gains `disk_total_gb` / `disk_used_gb` / `disk_avail_gb`; `getHostTelemetry()` composes disk via `toGb()` on the same path as mem/cpu - **Backward compat**: older servers silently drop unknown keys; agents and servers upgrade independently -Wires through [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `GET /api/server/:host/health` now returns disk's three fields, the 24h bucketed history includes `disk_avail_min` / `disk_used_max`, and `alert_level` adds `disk < 1GB critical / < 5GB warn` triggers ([`server/src/index.ts:253-258`](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts#L253)). +Wires through [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `GET /api/server/:host/health` now returns disk's three fields, the 24h bucketed history includes `disk_avail_min` / `disk_used_max`, and `alert_level` adds `disk < 1GB critical / < 5GB warn` triggers ([`server/src/index.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts)(当时在 253-258 行;行号已漂,按当时的符号名搜)). Test lead Docker Linux smoke 3/3 PASS (disk 299.8 GB total / 216 used / 71.5 avail, alert green, backward compat verified). @@ -709,7 +709,7 @@ Release flow follows the [v0.9.0 split-brain lessons #126](https://github.com/sl ### Fix -[`agent-network/bin/cli.ts:61` `PINNED_SERVER_VERSION`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L61) was never bumped across the v0.9.x + v0.10.0 promotes — it stayed hardcoded at `0.8.0`. That meant `anet hub start` was actually running `bunx --bun @sleep2agi/commhub-server@0.8.0` ([cli.ts:2589](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2589)) — the old server, not the v0.10.0-shipped `0.8.2`. Direct impact: +[`agent-network/bin/cli.ts` 的 `PINNED_SERVER_VERSION`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 61 行) was never bumped across the v0.9.x + v0.10.0 promotes — it stayed hardcoded at `0.8.0`. That meant `anet hub start` was actually running `bunx --bun @sleep2agi/commhub-server@0.8.0` ([`cli.ts` 里 `anet hub start` 的 `bunx --bun @sleep2agi/commhub-server@…` 那处](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 2589 行)) — the old server, not the v0.10.0-shipped `0.8.2`. Direct impact: - The [#99](https://github.com/sleep2agi/agent-network/issues/99) per-server daemon endpoints `GET /api/server/:host/health` + `GET /api/server/:host/agents` don't exist in 0.8.0 → **404** - [#142](https://github.com/sleep2agi/agent-network/issues/142) server schema alignment for `process_telemetry` isn't wired in 0.8.0 → the older schema silently drops the field From 13ffd8b7efd620431541da4016c010d6f7cf8282 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 01:03:28 +0800 Subject: [PATCH 21/59] feat(hub): let get_all_status filter by alias, and say what its summary counted (#904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_all_status` returns one row per session with 31 columns and no way to ask about specific nodes. On this hub — 222 sessions — the response is about 259 KB, past what an MCP client takes in one result. So the caller who wanted the status of THREE nodes could not get it from this tool at all, and had to go around it to the REST API. That is the whole of #824, hit first-hand. `filter_alias` takes one alias or several separated by commas, matched exactly through bound parameters. The patrol loop still gets everything, so the argument is optional and nothing existing changes. Blank entries are dropped rather than matched. A trailing comma would otherwise produce `alias = ''`, which matches no row — and "no rows" reads exactly like "those nodes do not exist". The failure and the true answer would be indistinguishable to the caller. That behaviour is the reason the parsing lives in its own module with tests rather than inline: nine cases pin it, including that placeholder count always equals alias count so parameters cannot misalign, and that a filter of only commas means "no filter" rather than "match nothing". The response also now carries `summary_scope` and `sessions_returned`. `summary` has always counted every session in the read scope while ignoring the filters, which is right for the patrol loop — but a caller who asked about three aliases and gets back three rows plus `idle: 96` can easily read the 96 as being about their three. Rather than change the semantics under existing callers, the response says what the number covered. Verified: the wiring assertion fails against main and passes here; the project's own runner reports server/src/alias-filter.test.ts pass=9 fail=0. Pre-existing and NOT from this change: `server/src/task-lifecycle-watcher.test.ts` fails on main today ("startHub owns a live watcher timer instead of relying on import side effects", expected 0 received 1). Confirmed by running that file against main's tools.ts in this same tree — identical failure. Aggregate is 937 pass / 1 fail either way. Co-authored-by: t Co-authored-by: Claude Opus 5 --- server/src/alias-filter.test.ts | 72 +++++++++++++++++++++++++++++++++ server/src/alias-filter.ts | 32 +++++++++++++++ server/src/tools.ts | 40 ++++++++++++++++-- 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 server/src/alias-filter.test.ts create mode 100644 server/src/alias-filter.ts diff --git a/server/src/alias-filter.test.ts b/server/src/alias-filter.test.ts new file mode 100644 index 000000000..30921c4b3 --- /dev/null +++ b/server/src/alias-filter.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test"; +import { parseAliasFilter } from "./alias-filter"; + +test("no filter given means no filtering, not an empty match", () => { + for (const raw of [undefined, null, "", " ", ",", " , , "]) { + const f = parseAliasFilter(raw as any); + expect(f.aliases).toEqual([]); + expect(f.sql).toBe(""); + } +}); + +test("a single alias produces one placeholder", () => { + const f = parseAliasFilter("TM门户马"); + expect(f.aliases).toEqual(["TM门户马"]); + expect(f.sql).toBe(" AND alias IN (?)"); +}); + +test("several aliases keep their order and count", () => { + const f = parseAliasFilter("A站内容,A站内容牛,hub"); + expect(f.aliases).toEqual(["A站内容", "A站内容牛", "hub"]); + expect(f.sql).toBe(" AND alias IN (?,?,?)"); +}); + +test("surrounding whitespace is trimmed", () => { + expect(parseAliasFilter(" a , b ").aliases).toEqual(["a", "b"]); +}); + +// The point of the module. A trailing comma must not become `alias = ''`, +// which matches nothing and reads exactly like "those nodes do not exist". +test("blank entries are dropped, never turned into a match-nothing term", () => { + const f = parseAliasFilter("a,,b,"); + expect(f.aliases).toEqual(["a", "b"]); + expect(f.sql).toBe(" AND alias IN (?,?)"); + expect(f.aliases).not.toContain(""); +}); + +test("a filter of only commas is the same as no filter — it must not silently match zero rows", () => { + const f = parseAliasFilter(",,,"); + expect(f.sql).toBe(""); +}); + +test("placeholder count always equals alias count, so params can never misalign", () => { + for (const raw of ["a", "a,b", "a,,b", " a , b , c ", ",x,"]) { + const f = parseAliasFilter(raw); + expect((f.sql.match(/\?/g) ?? []).length).toBe(f.aliases.length); + } +}); + +test("aliases are passed through verbatim — no globbing, no case folding", () => { + const f = parseAliasFilter("A站内容,a站内容"); + expect(f.aliases).toEqual(["A站内容", "a站内容"]); + expect(f.sql).not.toContain("LIKE"); +}); + +// Wiring: the tool must actually use this module, and must say what its +// `summary` counted — a caller who asked about three aliases and gets back +// "idle: 96" can easily read the 96 as being about their three. +import { readFileSync } from "fs"; +import { join } from "path"; + +test("get_all_status uses parseAliasFilter and declares what summary counted", () => { + const source = readFileSync(join(import.meta.dir, "tools.ts"), "utf8"); + const a = source.indexOf('"get_all_status"'); + expect(a).toBeGreaterThan(-1); + const body = source.slice(a, source.indexOf('server.tool(', a + 10)); + expect(body).toContain("filter_alias"); + expect(body).toContain("parseAliasFilter(filter_alias)"); + expect(body).toContain("summary_scope"); + expect(body).toContain("sessions_returned"); + // The alias list must go through parameters, never be interpolated. + expect(body).not.toMatch(/alias IN \(\$\{/); +}); diff --git a/server/src/alias-filter.ts b/server/src/alias-filter.ts new file mode 100644 index 000000000..6270714e4 --- /dev/null +++ b/server/src/alias-filter.ts @@ -0,0 +1,32 @@ +// Parse the `filter_alias` argument of `get_all_status` into an exact-match +// IN list. +// +// Why this exists at all: on a 222-session hub the unfiltered response is about +// 259 KB — past what an MCP client accepts in one result. A caller who wanted +// the status of three specific nodes could not get it from the tool, and had to +// go around it to the REST API. The filter is the fix; this module is the part +// of it worth pinning, because the interesting behaviour is what happens to the +// inputs that are not a clean alias. +// +// Blank entries are DROPPED rather than matched. A trailing comma would +// otherwise produce `alias = ''`, which matches no row — and "no rows" reads +// exactly like "those nodes do not exist". The failure and the true answer +// would be indistinguishable to the caller, which is the thing to avoid. + +export interface AliasFilter { + /** Exact aliases to match. Empty means "no alias filtering". */ + aliases: string[]; + /** SQL fragment to append, or "" when there is nothing to filter on. */ + sql: string; +} + +export function parseAliasFilter(raw: string | undefined | null): AliasFilter { + const aliases = (raw ?? "") + .split(",") + .map(a => a.trim()) + .filter(Boolean); + return { + aliases, + sql: aliases.length > 0 ? ` AND alias IN (${aliases.map(() => "?").join(",")})` : "", + }; +} diff --git a/server/src/tools.ts b/server/src/tools.ts index 9fc4a816c..2bf9d117d 100644 --- a/server/src/tools.ts +++ b/server/src/tools.ts @@ -1,5 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; +import { parseAliasFilter } from "./alias-filter.js"; import { createHash } from "node:crypto"; import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js"; import { getSSEStats, pushEvent, pushNetworkObserverEvent } from "./push.js"; @@ -1104,16 +1105,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo server.tool( "get_all_status", - "Get status of all sessions. Hub uses this for the patrol loop.", + "Get status of all sessions. Hub uses this for the patrol loop. " + + "Pass filter_alias (comma-separated) when you only care about specific " + + "nodes — the unfiltered result is one row per session with 31 columns and " + + "is large enough on a real fleet that callers cannot read it.", { filter_status: z.string().max(50).optional(), filter_server: z.string().max(200).optional(), + // 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past + // what an MCP client can take in one result — so the caller that wanted the + // status of THREE nodes could not get it from this tool at all. The patrol + // loop still wants everything, hence optional rather than required. + filter_alias: z.string().max(2000).optional() + .describe("One alias, or several separated by commas. Exact matches only."), network_id: z.string().max(200).optional().describe("Filter by network"), }, - async ({ filter_status, filter_server, network_id: netId }) => { + async ({ filter_status, filter_server, filter_alias, network_id: netId }) => { const readScope = resolveReadScope(netId); if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] }; - console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`); + console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_alias ? " alias=" + filter_alias.slice(0, 80) : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`); // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper() // (background timer, ~60s cadence). Read path no longer fires UPDATE. @@ -1122,20 +1132,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo sql = addReadScope(sql, params, readScope); if (filter_status) { sql += " AND status = ?"; params.push(filter_status); } if (filter_server) { sql += " AND server = ?"; params.push(filter_server); } + const aliasFilter = parseAliasFilter(filter_alias); + const aliases = aliasFilter.aliases; + if (aliasFilter.sql) { + sql += aliasFilter.sql; + params.push(...aliases); + } sql += " ORDER BY updated_at DESC"; const sessions = db.all(sql, ...params); + // `summary` has always counted every session in the read scope, ignoring + // filter_status / filter_server — and now filter_alias. That is fine for + // the patrol loop, but a caller who asked about three aliases and gets + // back three rows plus "idle: 96" can easily read the 96 as being about + // their three. A count that does not say what it counted invites exactly + // that. So the response now says so, rather than the semantics changing + // under existing callers. const summaryParams: any[] = []; let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1"; summarySql = addReadScope(summarySql, summaryParams, readScope); summarySql += " GROUP BY status"; const summary = db.all(summarySql, ...summaryParams); + const filtered = !!(filter_status || filter_server || aliases.length > 0); return { content: [ { type: "text" as const, - text: JSON.stringify({ ok: true, sessions, summary }), + text: JSON.stringify({ + ok: true, + sessions, + summary, + summary_scope: filtered + ? "every session in the read scope — NOT narrowed by the filters applied to `sessions`" + : "every session in the read scope", + sessions_returned: sessions.length, + }), }, ], }; From f9c5e58d9aa04286328aebf3a862bf9c37406275 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 01:27:30 +0800 Subject: [PATCH 22/59] fix(hub): PORT=0 must mean an ephemeral port, not the production Hub port (#906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `server/src/server.ts:51` read const PORT = Number(process.env.PORT) || 9200; `Number("0")` is `0`, which is falsy, so `PORT=0` — the conventional way to ask the OS for an ephemeral port — silently resolved to 9200, the production Hub port. Three consequences, and the middle one is the worst: 1. On a host where 9200 is taken (a running Hub), a test that sets PORT=0 dies with EADDRINUSE and reads as a product bug. 2. On a host where 9200 is FREE, that same test PASSES — by binding 9200. It is green because it grabbed the production port, not because PORT=0 did anything. Green for the wrong reason is worse than red. 3. Anyone asking for an ephemeral port gets the production port instead. This is not hypothetical. `server/src/task-lifecycle-watcher.test.ts` fails on main today, and that is why: it spawns the Hub with `PORT: "0"`, the child binds 9200, 9200 is already in use on this machine, the child exits 1, and the assertion `expect(child.exitCode).toBeNull()` fails. The test reports "the watcher did not stay alive" and says nothing about ports — the message points at the wrong layer entirely. The file already knew. `bootServer` uses `opts.port ?? PORT` with a comment saying `||` "would swallow a legitimate 0". The correct rule was one level above the line that needed it. `resolvePort` also rejects a malformed value instead of defaulting. Falling back to 9200 on `PORT=abc` means a typo starts the server somewhere the operator did not ask for, and on this fleet that somewhere is production. Parsing is decimal digits only after trimming: `Number()` alone accepts `"0x10"` as 16, so a value that does not look like a port would still resolve to one, quietly and to a different number than was typed. Verified as an A/B on the same tree, same DB layout, cwd at the repo root: main's server.ts 4 pass, 1 fail (EADDRINUSE, child exit 1) this branch 5 pass, 0 fail (child binds 41885 and stays up) The project's own runner now reports 946 pass / 0 fail / bad=false. Before this, it was 937 pass / 1 fail. Co-authored-by: t Co-authored-by: Claude Opus 5 --- server/src/resolve-port.test.ts | 57 +++++++++++++++++++++++++++++++++ server/src/resolve-port.ts | 44 +++++++++++++++++++++++++ server/src/server.ts | 3 +- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 server/src/resolve-port.test.ts create mode 100644 server/src/resolve-port.ts diff --git a/server/src/resolve-port.test.ts b/server/src/resolve-port.test.ts new file mode 100644 index 000000000..20c644c0b --- /dev/null +++ b/server/src/resolve-port.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { DEFAULT_PORT, resolvePort } from "./resolve-port"; + +// The whole reason this module exists. +test("PORT=0 means an ephemeral port, not the production default", () => { + expect(resolvePort("0")).toBe(0); + expect(resolvePort("0")).not.toBe(DEFAULT_PORT); +}); + +test("unset or empty falls back to the default", () => { + expect(resolvePort(undefined)).toBe(DEFAULT_PORT); + expect(resolvePort("")).toBe(DEFAULT_PORT); + expect(resolvePort(" ")).toBe(DEFAULT_PORT); +}); + +test("surrounding whitespace is tolerated — it is trimmed, not treated as malformed", () => { + expect(resolvePort(" 9201 ")).toBe(9201); + expect(resolvePort("\t0\n")).toBe(0); +}); + +test("an explicit port is used verbatim", () => { + expect(resolvePort("9201")).toBe(9201); + expect(resolvePort("1")).toBe(1); + expect(resolvePort("65535")).toBe(65535); +}); + +// Defaulting on a malformed value means a typo silently starts the server on +// the production port — on this fleet, on top of the running Hub. +test("a malformed value is rejected, never quietly defaulted", () => { + for (const bad of ["abc", "80a", "-1", "65536", "1.5", "0x10", "NaN", "Infinity", "9 200", "+80"]) { + expect(() => resolvePort(bad)).toThrow(); + } +}); + +test("the rejection names the value and the accepted range", () => { + try { + resolvePort("abc"); + throw new Error("should have thrown"); + } catch (e: any) { + expect(e.message).toContain("abc"); + expect(e.message).toContain("0 and 65535"); + expect(e.message).toContain("ephemeral"); + } +}); + +test("a caller can override the fallback without touching the default", () => { + expect(resolvePort(undefined, 3000)).toBe(3000); + expect(resolvePort("0", 3000)).toBe(0); +}); + +test("server.ts resolves PORT through this module, not through `|| DEFAULT`", () => { + const src = require("fs").readFileSync(require("path").join(import.meta.dir, "server.ts"), "utf8"); + const code = src.split("\n").filter((l: string) => !l.trim().startsWith("//")).join("\n"); + expect(code).toContain("resolvePort(process.env.PORT)"); + // `Number(env) || default` is the exact shape that swallowed the 0. + expect(code).not.toMatch(/Number\(process\.env\.PORT\)\s*\|\|/); +}); diff --git a/server/src/resolve-port.ts b/server/src/resolve-port.ts new file mode 100644 index 000000000..9ae334f17 --- /dev/null +++ b/server/src/resolve-port.ts @@ -0,0 +1,44 @@ +// Resolve the listen port from the environment. +// +// `Number(process.env.PORT) || 9200` swallows a legitimate `0`. `Number("0")` +// is `0`, which is falsy, so `PORT=0` — the conventional way to ask the OS for +// an ephemeral port — silently became 9200, the production Hub port. +// +// Three consequences, and the middle one is the worst: +// +// 1. On a host where 9200 is already taken (a running Hub), a test that sets +// PORT=0 dies with EADDRINUSE and reads as a product bug. +// task-lifecycle-watcher.test.ts fails on main today for exactly this. +// 2. On a host where 9200 is FREE, the same test passes — by binding 9200. +// It is green because it grabbed the production port, not because PORT=0 +// worked. Green for the wrong reason is worse than red. +// 3. Anyone asking for an ephemeral port gets the production port instead. +// +// The file already knew: `bootServer` uses `opts.port ?? PORT` with a comment +// saying `||` "would swallow a legitimate 0". The rule was one level up from +// where it was needed. +// +// A malformed value is rejected rather than defaulted. Falling back to 9200 on +// `PORT=abc` means a typo silently starts the server somewhere the operator did +// not ask for — and on this fleet that somewhere is production. + +export const DEFAULT_PORT = 9200; + +export function resolvePort(raw: string | undefined, fallback = DEFAULT_PORT): number { + // Unset or empty means "not specified". An empty string is what a shell + // exports for an unset variable it still passes along, so treating it as 0 + // would make `PORT= anet hub start` bind an ephemeral port by accident. + if (raw === undefined || raw.trim() === "") return fallback; + + // Decimal digits only, after trimming. `Number()` alone accepts "0x10" (16) + // and " 9200 ", so a value that does not look like a port would still resolve + // to one — quietly, and to a different number than the operator typed. + const text = raw.trim(); + const n = /^\d+$/.test(text) ? Number(text) : NaN; + if (!Number.isInteger(n) || n < 0 || n > 65535) { + throw new Error( + `PORT must be an integer between 0 and 65535 (0 asks the OS for an ephemeral port); got ${JSON.stringify(raw)}`, + ); + } + return n; +} diff --git a/server/src/server.ts b/server/src/server.ts index 022061d3a..ed31621d7 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,4 +1,5 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { resolvePort } from "./resolve-port.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { z } from "zod/v4"; import { registerTools } from "./tools.js"; @@ -48,7 +49,7 @@ import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startS import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js"; import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js"; -const PORT = Number(process.env.PORT) || 9200; +const PORT = resolvePort(process.env.PORT); const HOST = process.env.HOST || "127.0.0.1"; const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN; const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1"; From 8523cf8602ca16427241f6778e0d3942daed1284 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 01:30:19 +0800 Subject: [PATCH 23/59] =?UTF-8?q?fix(tests):=20outbound=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=9B=86=E6=96=AD=E8=A8=80=E6=94=B9=E4=B8=BA=E4=BB=8E?= =?UTF-8?q?=E7=9C=9F=E7=9B=B8=E6=BA=90=E6=B4=BE=E7=94=9F(#816=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E9=97=A8=E6=98=AF=E9=94=99=E7=9A=84,=E8=80=8C?= =?UTF-8?q?=E4=B8=94=E6=B2=A1=E4=BA=BA=E8=B7=91)=20(#905)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tests): assert the outbound tool set from the source of truth, not a stale copy (#816) test235's harness asserted the outbound MCP surface as a hard-coded array of three names. `OUTBOUND_TOOL_NAMES` in node-server.ts has held FOUR since `commhub_upload_file` shipped in #693, so that assertion has been wrong on main — and nothing reported it, because no workflow and neither of qa.sh's L0/L1 lists runs test235. A gate that is wrong and unrun is indistinguishable from a gate that passes. Third instance of the same shape tonight, after qa.yml's path filter missing the tests it runs and the two orphaned verify scripts: the check exists, it is plausible to read, and nothing invokes it. The names now live in `agent-network/src/outbound-tool-names.ts` and both sides import them. That module exists as its own file rather than an export from node-server.ts for a measured reason: importing node-server.ts to read a constant BOOTS THE SERVER. $ bun -e 'import { OUTBOUND_TOOL_NAMES } from "./src/node-server.ts"; …' [commhub] MCP stdio connected [commhub] starting SSE listener... OUTBOUND_TOOL_NAMES: commhub_get_all_status, … A harness that opens a live MCP connection to read a list fails for reasons that have nothing to do with what it tests. I hit that while writing this fix. The assertion also sorts both sides. It is about WHICH tools are exposed, not about the order the server registers them in, and an order-sensitive comparison would have turned a reordering into a mystery failure. Both new assertions fail against main. Suite 497 pass, tsc clean. One note on the credit: I first reported this issue as not reproducible. My grep searched for the NEW tool names, so of course it found nothing — the assertion pins the OLD three and never mentions upload_file. 通信团队's triage node caught the mistake and pointed at socket-harness.ts:210. Probe for the assertion, not for the symptom. Co-Authored-By: Claude Opus 5 * fix(tests): scope the cross-package assertion — it fails ENOENT inside the unit image My own CI break, one commit old. The new test read tests/test235-grok-mcp-outbound-only/socket-harness.ts, but tests/test745-agent-network-unit-ci/Dockerfile copies ONLY agent-network/ (plus agent-node/package.json and its own run.sh). So the assertion passed on a full checkout and failed with ENOENT in the container — the same "works where I ran it, not where it runs" shape I spent tonight cataloguing, this time in a test I wrote to catch that shape. The harness assertion now skips when the file is absent. Skipping is fail-open, so it is paired with an assertion that refuses to let the skip be silent: on a full checkout the harness MUST exist (its absence there is a real regression), and in a package-scoped image the run prints which assertion did not execute. A green in that image is therefore never mistaken for "the harness was checked". Verified both ways: full checkout 5 pass, 0 skip (assertion really runs) simulated package-only image 4 pass, 1 skip + "the socket-harness assertion did NOT run in this image" Co-Authored-By: Claude Opus 5 * fix(tests): stop detecting the checkout shape — assert only what is in this package Third attempt at the same 20 lines, and the first two were both wrong in the same way. 1. Read tests/test235-.../socket-harness.ts unconditionally. → ENOENT inside tests/test745-agent-network-unit-ci, whose image copies only agent-network/ (plus agent-node/package.json and its own run.sh). 2. Skip when `tests/` is absent. → the container HAS a `tests/` directory: test745's own run.sh lives in it. The probe answered "full checkout", the assertion ran, and it failed. The second is the first mistake repeated: probing an incidental feature ("is there a tests/ directory") instead of the thing itself ("is THIS harness here"). A third detector would be a third guess, so this suite now asserts only what lives inside its own package — the constant's contents, that reading it does not boot a server, and that node-server.ts consumes it instead of redeclaring it. The gap is written down rather than papered over: nothing gates the fact that socket-harness.ts derives its expectation from OUTBOUND_TOOL_NAMES. That is not new here — no workflow and neither of qa.sh's L0/L1 lists runs test235 at all, which is exactly why its assertion could sit wrong on main for as long as it did. Wiring test235 into CI fixes that and is a separate change: it needs a real hub and a socket harness, not a unit runner. Verified in both shapes: full checkout 3 pass / 0 skip; a simulated package-only image that also contains a partial tests/ (the case that broke attempt 2) 3 pass / 0 skip. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-network/src/node-server.ts | 7 +-- agent-network/src/outbound-tool-names.test.ts | 60 +++++++++++++++++++ agent-network/src/outbound-tool-names.ts | 21 +++++++ .../socket-harness.ts | 17 +++++- 4 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 agent-network/src/outbound-tool-names.test.ts create mode 100644 agent-network/src/outbound-tool-names.ts diff --git a/agent-network/src/node-server.ts b/agent-network/src/node-server.ts index 078a8ec1d..b720773c9 100644 --- a/agent-network/src/node-server.ts +++ b/agent-network/src/node-server.ts @@ -13,6 +13,7 @@ */ import { readFileSync, existsSync } from "fs"; +import { OUTBOUND_TOOL_NAMES } from "./outbound-tool-names"; import { randomUUID } from "crypto"; import { join } from "path"; import { hostname } from "os"; @@ -169,12 +170,6 @@ const mcp = new Server( ); // ── Tools ─────────────────────────────────────────── -const OUTBOUND_TOOL_NAMES = new Set([ - "commhub_send_task", - "commhub_send_message", - "commhub_get_all_status", - "commhub_upload_file", -]); mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ diff --git a/agent-network/src/outbound-tool-names.test.ts b/agent-network/src/outbound-tool-names.test.ts new file mode 100644 index 000000000..b1d33d619 --- /dev/null +++ b/agent-network/src/outbound-tool-names.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; + +const HARNESS = join(import.meta.dir, "..", "..", "tests", "test235-grok-mcp-outbound-only", "socket-harness.ts"); +import { OUTBOUND_TOOL_NAMES } from "./outbound-tool-names"; + +test("the outbound set contains every tool a node-server exposes in outbound-only mode", () => { + expect([...OUTBOUND_TOOL_NAMES].sort()).toEqual([ + "commhub_get_all_status", + "commhub_send_message", + "commhub_send_task", + "commhub_upload_file", + ]); +}); + +test("importing the constant does not boot a server", () => { + // node-server.ts opens an MCP stdio connection and starts an SSE listener on + // import. A test that reads the list must not pay for that, or it fails for + // reasons unrelated to what it tests. + // Strip comments first. This is the second time tonight an absence-assertion + // tripped on prose that quotes the thing being forbidden — the header of that + // module explains the boot problem by quoting an import statement. Assert + // about the code. + const raw = readFileSync(join(import.meta.dir, "outbound-tool-names.ts"), "utf8"); + const code = raw.split("\n").filter(l => !l.trim().startsWith("//")).join("\n"); + expect(code).not.toContain("import "); + expect(code).not.toMatch(/require\(/); +}); + +test("node-server.ts consumes the shared constant rather than redeclaring it", () => { + const src = readFileSync(join(import.meta.dir, "node-server.ts"), "utf8"); + expect(src).toContain('from "./outbound-tool-names"'); + // A second declaration is the drift this module exists to prevent. + expect(src).not.toMatch(/const OUTBOUND_TOOL_NAMES\s*=\s*new Set/); +}); + +// 🔴 The harness assertion used to live here and does not any more. +// +// tests/test745-agent-network-unit-ci's image copies ONLY agent-network/ (plus +// agent-node/package.json and its own run.sh), so reading +// tests/test235-.../socket-harness.ts from this suite is ENOENT there. I tried +// two ways to be clever about that and got both wrong: +// +// 1. read it unconditionally → ENOENT in the container +// 2. skip when `tests/` is absent → the container HAS a `tests/` directory +// (test745's own run.sh lives in it), so the +// probe said "full checkout" and asserted +// anyway +// +// The second failure is the same mistake twice: probing an incidental feature +// ("is there a tests/ directory") instead of the thing itself. Rather than build +// a third detector, this suite now asserts only what is inside its own package. +// +// The consequence is stated rather than papered over: NOTHING gates the fact +// that socket-harness.ts derives its expectation from OUTBOUND_TOOL_NAMES. That +// is not a new gap introduced here — no workflow and neither of qa.sh's L0/L1 +// lists runs test235 at all, which is why its assertion could be wrong on main +// for as long as it was. Wiring test235 into CI is the fix for that, and it is a +// separate change: it needs a real hub and a socket harness, not a unit runner. diff --git a/agent-network/src/outbound-tool-names.ts b/agent-network/src/outbound-tool-names.ts new file mode 100644 index 000000000..e6ab43f65 --- /dev/null +++ b/agent-network/src/outbound-tool-names.ts @@ -0,0 +1,21 @@ +// The exact set of tools a node-server exposes in outbound-only mode. +// +// It lives in its own module for one reason: tests need to assert against it, +// and importing node-server.ts to read a constant BOOTS THE SERVER — it opens +// an MCP stdio connection and starts an SSE listener on import. Verified by +// doing exactly that: `bun -e 'import { OUTBOUND_TOOL_NAMES } from +// "./src/node-server.ts"'` printed `[commhub] MCP stdio connected` before it +// printed the constant. A test harness that boots a live server just to read a +// list is a harness that fails for reasons unrelated to what it tests. +// +// Why a shared constant at all: tests/test235-grok-mcp-outbound-only asserted a +// hard-coded copy of three names. `commhub_upload_file` shipped in #693 and made +// it four, so that assertion has been wrong on main — and nothing reported it, +// because no workflow and neither qa.sh list runs test235. + +export const OUTBOUND_TOOL_NAMES = new Set([ + "commhub_send_task", + "commhub_send_message", + "commhub_get_all_status", + "commhub_upload_file", +]); diff --git a/tests/test235-grok-mcp-outbound-only/socket-harness.ts b/tests/test235-grok-mcp-outbound-only/socket-harness.ts index 38c1206f9..00671b56a 100644 --- a/tests/test235-grok-mcp-outbound-only/socket-harness.ts +++ b/tests/test235-grok-mcp-outbound-only/socket-harness.ts @@ -9,6 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { OUTBOUND_TOOL_NAMES } from "../../agent-network/src/outbound-tool-names"; type RpcMessage = { id?: number; method?: string; result?: any; error?: any; params?: any }; @@ -207,7 +208,21 @@ try { await sendRpc({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} } as any); const listed = await waitForId(2); const toolNames = listed.result?.tools?.map((tool: any) => tool.name) || []; - assert(JSON.stringify(toolNames) === JSON.stringify(["commhub_send_task", "commhub_send_message", "commhub_get_all_status"]), "exact three outbound tools"); + // 🔴 The expected set comes from the source of truth, not from a copy. + // + // This line used to hard-code three names. `OUTBOUND_TOOL_NAMES` in + // agent-network/src/node-server.ts has carried FOUR since commhub_upload_file + // shipped (#693), so the assertion has been wrong on main — and nothing + // reported it, because no workflow and neither qa.sh list runs test235. A + // gate that is wrong and unrun is indistinguishable from a gate that passes. + // + // Sorted on both sides: the assertion is about WHICH tools are exposed, not + // about the order the server happens to register them in. + const expectedOutbound = [...OUTBOUND_TOOL_NAMES].sort(); + assert( + JSON.stringify([...toolNames].sort()) === JSON.stringify(expectedOutbound), + `outbound tool set must equal OUTBOUND_TOOL_NAMES (${expectedOutbound.join(", ")}); got ${[...toolNames].sort().join(", ") || ""}`, + ); await Bun.sleep(350); assert(state.sseOpened === 1, "only outer owner opens SSE"); From fa36482172245d439c681a0c01af243097ef8db4 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 01:59:37 +0800 Subject: [PATCH 24/59] fix(install.sh): stop blaming the registry for every failure (#868) (#908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public installer ran npm install -g @sleep2agi/agent-network >/dev/null 2>&1 || { say "Default registry failed, retrying via npmmirror..." ... } Two problems in two lines. The first attempt's stderr went to /dev/null, and whatever went wrong was then announced as a registry problem. A permission error, a full disk, an unsupported Node — all told the reader to blame the registry, and the npmmirror retry then failed the same way a moment later. The reader ends up with a confident, wrong story and no sign of the real one. Now the output is kept, and "registry" is only claimed when the output actually looks like a fetch problem (ETIMEDOUT / ENOTFOUND / ECONNRESET / ECONNREFUSED / EAI_AGAIN / network / registry / fetch failed / socket hang up). Anything else is printed verbatim with an explicit note that retrying a different registry would fail the same way. If the mirror path is taken and also fails, the first attempt's output is shown too — otherwise the mirror's error replaces the original one and the actual cause is gone. Verified against the real script with a stubbed npm, both directions: EACCES → "does not look like a registry problem", no mirror retry, EACCES shown to the reader ETIMEDOUT → "looks unreachable, retrying via npmmirror", and on the second failure "The mirror failed too" plus the first output Same stub against main's copy prints "Default registry failed" for the EACCES case and never shows the word EACCES at all. (While building the stub I first wrote a fake `node` that did not implement `-p`, so the script bailed at its version check with "Node.js >= 22.13 required (current: v22.13.0)" — a self-contradiction that was the harness talking, not the script. Worth noting because that message is exactly what a reader would report as a product bug.) Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs-site/docs/public/install.sh | 33 ++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/docs-site/docs/public/install.sh b/docs-site/docs/public/install.sh index f7a9e3e15..438a5ada5 100644 --- a/docs-site/docs/public/install.sh +++ b/docs-site/docs/public/install.sh @@ -47,10 +47,35 @@ say "" # --- Install --- say "${CYAN}>${RESET} Installing ${YELLOW}@sleep2agi/agent-network${RESET} globally..." -npm install -g @sleep2agi/agent-network >/dev/null 2>&1 || { - say "${YELLOW}!${RESET} Default registry failed, retrying via npmmirror..." - npm install -g @sleep2agi/agent-network --registry https://registry.npmmirror.com -} +# Keep the first attempt's stderr. It used to be discarded with `2>&1` to +# /dev/null and EVERY failure was then reported as "Default registry failed" — +# so a permission error, a full disk, or an unsupported Node version all told +# the reader to blame the registry, and the npmmirror retry failed the same way +# a moment later. The reader was left with a confident, wrong story. +NPM_LOG="$(mktemp -t anet-install.XXXXXX)" +if ! npm install -g @sleep2agi/agent-network >"$NPM_LOG" 2>&1; then + # Only claim "registry" when the output actually looks like a fetch problem. + # Anything else is shown verbatim, because a wrong diagnosis sends the reader + # somewhere there is nothing to find. + if grep -qiE 'ETIMEDOUT|ENOTFOUND|ECONNRESET|ECONNREFUSED|EAI_AGAIN|network|registry|fetch failed|socket hang up' "$NPM_LOG"; then + say "${YELLOW}!${RESET} Default registry looks unreachable, retrying via npmmirror..." + if ! npm install -g @sleep2agi/agent-network --registry https://registry.npmmirror.com; then + say "" + say "${YELLOW}!${RESET} The mirror failed too. First attempt said:" + tail -n 20 "$NPM_LOG" >&2 + rm -f "$NPM_LOG" + fail "npm install failed against both registries — see the output above." + fi + else + say "" + say "${YELLOW}!${RESET} npm install failed, and it does not look like a registry problem." + say " Retrying a different registry would fail the same way, so here is what npm said:" + tail -n 20 "$NPM_LOG" >&2 + rm -f "$NPM_LOG" + fail "npm install failed — see the output above." + fi +fi +rm -f "$NPM_LOG" # --- Verify --- if ! command -v anet >/dev/null 2>&1; then From 4a1ed448ef784d9f886eb9376d7685d1342911fd Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 02:00:17 +0800 Subject: [PATCH 25/59] =?UTF-8?q?fix(ci):=20=E6=8A=8A=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E7=9A=84=E7=AC=AC=E4=B8=89=E6=96=B9=20action=20=E9=92=89?= =?UTF-8?q?=E5=88=B0=20SHA,=E5=B9=B6=E5=8A=A0=E4=B8=80=E9=81=93=E9=97=A8?= =?UTF-8?q?=E9=98=B2=E4=B8=8B=E4=B8=80=E4=B8=AA=20(#746)=20(#907)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): pin the one third-party action to a SHA, and guard against the next one (#746) `uses: oven-sh/setup-bun@v2` runs whatever that tag points at today, and the tag is writable by the action's owner. What it resolves to executes here with this repository's checkout and secrets in scope, and it can change without any commit in this repo for anyone to review. Three workflows used it: qa, e2e-docker, release. All three now pin 0c5077e51419868618aeaa5fe8019c62421857d6, with `# v2` kept as a comment so the pin still reads as something. The SHA was resolved from the GitHub API rather than copied out of a log line — `git/ref/tags/v2` returns that commit directly (a lightweight tag), and it matches the ref that appeared in tonight's failed download. #799 already pinned `bun-version: 1.3.14`. That pins the TOOL; the ACTION that installs it was still floating. Two different things one line apart, and the first being done makes the second easy to read as done too. `actions/*` deliberately stay on tags. That is the near-universal convention for GitHub's own actions, and changing it is a separate policy call rather than something to smuggle in under a guard about third parties. The guard encodes exactly that split and says so. 🔴 This does NOT fix flaky downloads, and the PR should not be read as claiming it does. Tonight's `L0 + L1` job died on three consecutive 429s fetching this very action, and a SHA pin would not have changed that — the request still goes to codeload. Pinning is about knowing WHAT ran. The workflow comment and the script docstring both state this, because a guard sold on a benefit it does not deliver is a guard people stop believing. check-action-pins.py, exercised three ways: pinned tree → exit 0 (24 refs across 11 files); main's workflows → exit 1 naming each of the three with the exact line to write; the `uses:` regex broken to simulate a parser that stopped matching → exit 2, "scanned ZERO `uses:` lines", rather than a clean scan of nothing. Its workflow carries no `paths:` filter, for the same reason as qa-trigger-coverage: it guards the workflow directory, so gating it on that directory would let a change there slip past the check that watches it. Co-Authored-By: Claude Opus 5 * fix(tests): test746 must match the setup-bun ACTION, not one particular ref of it Caught by CI on the previous commit in this branch, which is the good outcome: SHA-pinning oven-sh/setup-bun made test746 find ZERO occurrences and fail. Its matcher compared `uses` against the literal string `oven-sh/setup-bun@v2`. Failing closed on zero was RIGHT — a guard that finds none of the thing it checks must not report success. Matching on the ref was not: the guard could not tell "somebody deleted the pin" from "somebody wrote the pin differently", and those deserve opposite reactions. It now matches the action by repository prefix and keeps its actual assertion — exactly three invocations, each with bun-version 1.3.14 — unchanged, including the mutation step that proves removing a pin still turns it red. One guard, one fact. Whether the ACTION is SHA-pinned is a different fact, owned by .github/scripts/check-action-pins.py in this same PR. Asserting both here would put the same fact in two places, and two copies of one fact drift until one of them is wrong and still green. Verified locally against the current workflows: 3 occurrences found, all 1.3.14; then its own mutation (drop the `with: bun-version` block from release.yml) reapplied by hand → 1 occurrence with bun-version=None → still red, then restored byte-identical. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-action-pins.py | 90 ++++++++++++++++++++++++++++ .github/workflows/action-pins.yml | 34 +++++++++++ .github/workflows/e2e-docker.yml | 2 +- .github/workflows/qa.yml | 2 +- .github/workflows/release.yml | 2 +- tests/test746-setup-bun-pin/run.sh | 15 ++++- 6 files changed, 141 insertions(+), 4 deletions(-) create mode 100755 .github/scripts/check-action-pins.py create mode 100644 .github/workflows/action-pins.yml diff --git a/.github/scripts/check-action-pins.py b/.github/scripts/check-action-pins.py new file mode 100755 index 000000000..ca6190e92 --- /dev/null +++ b/.github/scripts/check-action-pins.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Third-party GitHub Actions must be pinned to a commit SHA, not a moving tag. + +`uses: some-org/some-action@v2` resolves whatever that tag points at today. The +tag is writable by whoever owns the action, so the code that runs in CI — with +this repository's checkout and secrets in scope — can change without a commit +here and without anyone reviewing it. + +Scope, stated because a filter you cannot see is a filter you cannot trust: + + * `actions/*` (GitHub's own) are ALLOWED on tags. That is the near-universal + convention, they are first-party, and changing them is a separate policy + call — not something to smuggle in under a guard about third parties. + * Everything else must carry a 40-hex SHA. A trailing `# v2` comment is + encouraged so a reader can still tell what the pin means. + * Local actions (`./…`) and Docker actions (`docker://…`) are out of scope: + they are not fetched from a tag at all. + +This does NOT claim to fix flaky downloads. On 2026-08-17 a `L0 + L1` job here +failed with three consecutive 429s fetching `oven-sh/setup-bun`, and a SHA pin +would not have changed that — the request still goes to codeload. Pinning is +about knowing WHAT ran, not about whether the fetch succeeds. Saying otherwise +would be selling the guard on a benefit it does not deliver. + +Fail-closed: no workflow files, or no `uses:` lines at all, exits 2 rather than +reporting a clean scan of nothing. +""" +import re +import sys +from pathlib import Path + +WORKFLOWS = Path(".github/workflows") +USES = re.compile(r"^\s*(?:-\s*)?uses:\s*([^\s#]+)") +SHA40 = re.compile(r"^[0-9a-f]{40}$") +FIRST_PARTY_OWNERS = {"actions", "github"} + + +def main() -> int: + if not WORKFLOWS.is_dir(): + print(f"::error::{WORKFLOWS} does not exist — scope regression, refusing to pass") + return 2 + + files = sorted(list(WORKFLOWS.glob("*.yml")) + list(WORKFLOWS.glob("*.yaml"))) + if not files: + print(f"::error::no workflow files under {WORKFLOWS} — scope regression, refusing to pass") + return 2 + + total = 0 + problems = 0 + for f in files: + for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + m = USES.match(line) + if not m: + continue + ref = m.group(1) + if ref.startswith("./") or ref.startswith("docker://"): + continue + total += 1 + if "@" not in ref: + problems += 1 + print(f"::error file={f},line={i}::`{ref}` has no ref at all — pin it to a commit SHA") + continue + repo, _, version = ref.rpartition("@") + owner = repo.split("/", 1)[0] + if owner in FIRST_PARTY_OWNERS: + continue + if not SHA40.match(version): + problems += 1 + print( + f"::error file={f},line={i}::third-party action `{repo}` is pinned to " + f"`{version}`, a tag its owner can repoint. Whatever it points at runs here " + f"with this checkout and these secrets, without a commit in this repo.\n" + f" Pin the SHA and keep the tag as a comment:\n" + f" uses: {repo}@<40-hex-sha> # {version}" + ) + + if total == 0: + print("::error::scanned ZERO `uses:` lines — the parser stopped matching; refusing to pass") + return 2 + + print(f"checked {total} action reference(s) across {len(files)} workflow file(s)") + if problems: + print(f"\n{problems} unpinned third-party action(s).") + return 1 + print("every third-party action is pinned to a commit SHA.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/action-pins.yml b/.github/workflows/action-pins.yml new file mode 100644 index 000000000..a77f32777 --- /dev/null +++ b/.github/workflows/action-pins.yml @@ -0,0 +1,34 @@ +# Third-party GitHub Actions must be pinned to a commit SHA. +# +# `uses: some-org/some-action@v2` runs whatever that tag points at today, and +# the tag is writable by the action's owner. The code it resolves to executes +# here with this repository's checkout and secrets in scope, and it can change +# without any commit in this repo for anyone to review. +# +# Scope is deliberate and narrow: `actions/*` (GitHub's own) stay on tags — +# that is the near-universal convention, and changing it is a separate policy +# call rather than something to smuggle in under a third-party guard. +# +# 🔴 This does NOT fix flaky downloads. On 2026-08-17 a `L0 + L1` job here died +# on three consecutive 429s fetching oven-sh/setup-bun, and a SHA pin would +# not have changed that — the request still goes to codeload. Pinning is +# about knowing WHAT ran. Selling it as a flakiness fix would be selling a +# benefit it does not deliver. +# +# 🔴 No `paths:` filter, for the same reason as qa-trigger-coverage: this guards +# the workflow directory, and gating it on that directory would let a change +# there slip past the check that watches it. + +name: lint (action pins) + +on: + pull_request: + push: + branches: [main] + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: python3 .github/scripts/check-action-pins.py diff --git a/.github/workflows/e2e-docker.yml b/.github/workflows/e2e-docker.yml index 5f16db5f1..ab96b1d08 100644 --- a/.github/workflows/e2e-docker.yml +++ b/.github/workflows/e2e-docker.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.14 diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 95d8203f2..bd1b83fab 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -102,7 +102,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd90b0064..d2ccc88b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,7 +111,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.14 diff --git a/tests/test746-setup-bun-pin/run.sh b/tests/test746-setup-bun-pin/run.sh index d5639eeda..f6d9c5c81 100644 --- a/tests/test746-setup-bun-pin/run.sh +++ b/tests/test746-setup-bun-pin/run.sh @@ -23,9 +23,22 @@ expected = os.environ["EXPECTED_VERSION"] found = [] bad = [] +# Match the ACTION, not one particular ref of it. This used to compare against +# the literal "oven-sh/setup-bun@v2", so SHA-pinning the action (a separate, +# desirable change) made this find zero occurrences and fail — the guard could +# not tell "the pin was removed" from "the pin was written differently". +# +# It failing closed on zero was right; matching on the ref was not. This guard +# owns ONE fact: every setup-bun invocation carries the expected bun-version. +# Whether the action itself is SHA-pinned is a different fact, owned by +# .github/scripts/check-action-pins.py. One guard, one fact — two guards on the +# same fact drift apart, and then one of them is wrong and still green. +SETUP_BUN = "oven-sh/setup-bun@" + def walk(value, path): if isinstance(value, dict): - if value.get("uses") == "oven-sh/setup-bun@v2": + uses = value.get("uses") + if isinstance(uses, str) and uses.startswith(SETUP_BUN): version = (value.get("with") or {}).get("bun-version") found.append((path, version)) if str(version) != expected: From 27cf35eba5157267fd8a105b51e2416e0671fd52 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 02:29:00 +0800 Subject: [PATCH 26/59] =?UTF-8?q?fix(agent-node):=20=E5=88=AB=E6=8A=8A=20v?= =?UTF-8?q?endor=20=E8=AF=B4=E7=9A=84=20success=20=E5=BD=93=E6=88=90?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=87=AA=E5=B7=B1=E7=9A=84=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E6=89=93=E8=BF=9B=E6=97=A5=E5=BF=97=20(#910)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一个模型名不存在的节点(TMCode副责人, 2026-08-18 02:09)连续三次打出: [claude] success | 1927ms | $0.0000 | in=0 out=0 | turns=1 [claude] attempt 1/3 errored: … issue with the selected model (gpt-5.6-sol) [claude] success | 7833ms | $0.0000 | in=0 out=0 | turns=1 | attempt=2 [claude] attempt 2/3 errored: … [claude] success | 18185ms | $0.0000 | in=0 out=0 | turns=1 | attempt=3 [claude] ✗ all 3 attempts failed; last: errored: … 那个 success 是 vendor 的 result.subtype 原样打印。节点自己的判定 (classifyRuntimeResult,含 in=0 & out=0 & cost=0 的静默拒绝规则)在**下一行** 才算出来,而且判的是"这一轮什么都没产出"。 行为一直是对的——三次都被判失败、任务如实上报为 failed。**只有日志在说谎。** 而这半边更该修:任何按日志判节点健康的东西,会从一个什么都没产出的节点拿到绿色, 且这种假绿和真绿逐字相同。 改法:把 classifyRuntimeResult 提到日志行之前算(它本来就在下一行),日志打节点 自己的结论。新模块 formatAttemptOutcome 不重新推导"成不成功"——重新实现判据会 造出两个可能漂移的 success 定义,那是同一类缺陷降一层。 vendor 未声称成功 → 原样透传(error_max_turns 本来就诚实) vendor 成功 + 节点认同 → success vendor 成功 + 节点否决 → success→rejected: 测试含变异验证:把分类结果短路掉,7 条里 3 条转红。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-node/src/cli.ts | 37 +++++++----- .../src/runtime/attempt-log-outcome.test.ts | 56 +++++++++++++++++++ agent-node/src/runtime/attempt-log-outcome.ts | 51 +++++++++++++++++ 3 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 agent-node/src/runtime/attempt-log-outcome.test.ts create mode 100644 agent-node/src/runtime/attempt-log-outcome.ts diff --git a/agent-node/src/cli.ts b/agent-node/src/cli.ts index c39bffa71..b82762f70 100644 --- a/agent-node/src/cli.ts +++ b/agent-node/src/cli.ts @@ -97,6 +97,7 @@ import { formatClassificationForUser, formatClassificationForLog, } from "./runtime/classify-result"; +import { formatAttemptOutcome } from "./runtime/attempt-log-outcome"; import { withTimeout, TimeoutError, resolveTimeoutMs } from "./util/timeout"; import { superviseChild } from "./util/supervise-child"; import { @@ -2360,19 +2361,29 @@ async function processWithClaude( if (m.type === "result") { const dt = Date.now() - t0; const u = m.usage || {}; - log(`[claude] ${m.subtype} | ${dt}ms | $${m.total_cost_usd?.toFixed(4) || "?"} | in=${u.input_tokens || 0} out=${u.output_tokens || 0} | turns=${m.num_turns}${attempt > 0 ? ` | attempt=${attempt + 1}` : ""}`); - if (m.subtype === "success") { - // #261 P1 redirect (2026-06-28) — delegate to classifyRuntimeResult - // which folds the empty-result rule from #267 + the in=0 & out=0 - // & cost=0 silent-reject rule into one decision shared with - // codex / grok. Pre-fix `m.result || "任务完成"` silently - // rebranded an empty vendor reply as "task complete" — the M3 - // incident shape. Now a non-success classification surfaces a - // soft-fail string the upstream caller can act on. - const cls = classifyRuntimeResult( - { result: m.result, usage: m.usage, totalCostUsd: m.total_cost_usd }, - { baseUrl: process.env.ANTHROPIC_BASE_URL }, - ); + // #261 P1 redirect (2026-06-28) — delegate to classifyRuntimeResult + // which folds the empty-result rule from #267 + the in=0 & out=0 + // & cost=0 silent-reject rule into one decision shared with + // codex / grok. Pre-fix `m.result || "任务完成"` silently + // rebranded an empty vendor reply as "task complete" — the M3 + // incident shape. Now a non-success classification surfaces a + // soft-fail string the upstream caller can act on. + // + // Computed BEFORE the log line on purpose: it used to sit after, + // so the line printed the vendor's `subtype` verbatim. A node + // pointed at a nonexistent model logged `success | $0.0000 | in=0 + // out=0` three times in a row and then `✗ all 3 attempts failed` + // (TMCode副责人, 2026-08-18). The verdict already existed one line + // below; it just wasn't the thing being printed. + const cls = + m.subtype === "success" + ? classifyRuntimeResult( + { result: m.result, usage: m.usage, totalCostUsd: m.total_cost_usd }, + { baseUrl: process.env.ANTHROPIC_BASE_URL }, + ) + : null; + log(`[claude] ${formatAttemptOutcome(m.subtype, cls)} | ${dt}ms | $${m.total_cost_usd?.toFixed(4) || "?"} | in=${u.input_tokens || 0} out=${u.output_tokens || 0} | turns=${m.num_turns}${attempt > 0 ? ` | attempt=${attempt + 1}` : ""}`); + if (m.subtype === "success" && cls) { if (cls.kind === "success") { inner = m.result; } else { diff --git a/agent-node/src/runtime/attempt-log-outcome.test.ts b/agent-node/src/runtime/attempt-log-outcome.test.ts new file mode 100644 index 000000000..6f284af5d --- /dev/null +++ b/agent-node/src/runtime/attempt-log-outcome.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { formatAttemptOutcome } from "./attempt-log-outcome"; +import { classifyRuntimeResult } from "./classify-result"; + +describe("formatAttemptOutcome", () => { + it("passes the vendor's own label through when the vendor did not claim success", () => { + // Nothing to contradict — `error_max_turns` is already honest. + expect(formatAttemptOutcome("error_max_turns", null)).toBe("error_max_turns"); + expect(formatAttemptOutcome("error_during_execution", null)).toBe("error_during_execution"); + }); + + it("says success only when the node's own classifier agrees", () => { + expect(formatAttemptOutcome("success", { kind: "success" })).toBe("success"); + }); + + it("does not print a bare 'success' when the node rejected the turn", () => { + const line = formatAttemptOutcome("success", { kind: "soft-fail-empty" }); + // The point of the module: a log grep for a successful turn must not match. + expect(line).not.toBe("success"); + expect(line).toContain("rejected"); + expect(line).toContain("soft-fail-empty"); + // The vendor's claim is kept, so the reader can tell this was a rejected + // claim of success rather than a plain vendor-side error. + expect(line).toContain("success"); + }); + + it("names which kind of rejection it was", () => { + expect(formatAttemptOutcome("success", { kind: "soft-fail-quota" })).toContain("soft-fail-quota"); + expect(formatAttemptOutcome("success", { kind: "error" })).toContain("rejected:error"); + }); +}); + +describe("the live incident this module exists for", () => { + // TMCode副责人, 2026-08-18 02:09 — a node configured with a model name that + // does not exist. Numbers below are the ones the pane actually printed. + const observed = { result: "", usage: { input_tokens: 0, output_tokens: 0 }, totalCostUsd: 0 }; + + it("classifies the observed turn as a rejection", () => { + // Assert the premise, not just the conclusion: if this ever starts coming + // back "success", the log line below would be honest and this whole module + // would be pointless — so pin it. + expect(classifyRuntimeResult(observed, {}).kind).not.toBe("success"); + }); + + it("would have printed a line that does not claim success", () => { + const cls = classifyRuntimeResult(observed, {}); + const line = formatAttemptOutcome("success", cls); + expect(line).not.toBe("success"); + expect(line.startsWith("success→rejected:")).toBe(true); + }); + + it("keeps the old behaviour reachable when the turn is genuinely fine", () => { + const good = { result: "done", usage: { input_tokens: 120, output_tokens: 40 }, totalCostUsd: 0.01 }; + expect(formatAttemptOutcome("success", classifyRuntimeResult(good, {}))).toBe("success"); + }); +}); diff --git a/agent-node/src/runtime/attempt-log-outcome.ts b/agent-node/src/runtime/attempt-log-outcome.ts new file mode 100644 index 000000000..0d7f2d91f --- /dev/null +++ b/agent-node/src/runtime/attempt-log-outcome.ts @@ -0,0 +1,51 @@ +// What the attempt line in the node log is allowed to say. +// +// Observed on a live node (TMCode副责人, 2026-08-18 02:09), three consecutive +// attempts against a model name that does not exist: +// +// [claude] success | 1927ms | $0.0000 | in=0 out=0 | turns=1 +// [claude] attempt 1/3 errored: … There's an issue with the selected model … +// [claude] success | 7833ms | $0.0000 | in=0 out=0 | turns=1 | attempt=2 +// [claude] attempt 2/3 errored: … +// [claude] success | 18185ms | $0.0000 | in=0 out=0 | turns=1 | attempt=3 +// [claude] ✗ all 3 attempts failed; last: errored: … +// +// The word `success` there is the VENDOR's `result.subtype`, printed verbatim. +// The node's own verdict — reached one line later by classifyRuntimeResult, +// which folds the in=0 & out=0 & cost=0 silent-reject rule — was "this turn +// produced nothing". So the line that a log reader sees first says success, +// and the line that is true says the opposite. +// +// 🔴 The behaviour was already correct: the classifier rejected all three +// attempts and the task was reported as failed. Only the LOG lied. That is the +// worse half to leave broken, because anything that judges node health by +// grepping logs gets a green from a node that produced nothing at all — and a +// false green is byte-identical to a true one. +// +// This module does not re-derive "did it work". It takes the classification the +// node already computed and states it. Re-implementing the criterion here would +// give us two definitions of success that can drift apart, which is the same +// class of defect one level down. + +import type { ClassificationResult } from "./classify-result"; + +/** + * First field of the per-attempt result line. + * + * @param vendorSubtype the runtime SDK's own `result.subtype` + * @param classification the node's verdict, or `null` when the node did not + * classify this turn (i.e. the vendor did not claim success, so there is + * nothing to contradict and the vendor's word is passed through). + */ +export function formatAttemptOutcome( + vendorSubtype: string, + classification: ClassificationResult | null, +): string { + // Vendor did not claim success → its own label is already the honest one + // (`error_max_turns`, `error_during_execution`, …). + if (!classification) return vendorSubtype; + if (classification.kind === "success") return "success"; + // Vendor said success, the node disagreed. Say both, so a reader can tell + // this is a rejection of a claimed success rather than a plain vendor error. + return `${vendorSubtype}→rejected:${classification.kind}`; +} From 2e2dc654f0d4718ac58bc56c18254cc4da1f951b Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 02:29:05 +0800 Subject: [PATCH 27/59] =?UTF-8?q?docs(deploy):=20=E5=86=99=E6=98=8E=20dash?= =?UTF-8?q?board=20=E7=9A=84=20ecosystem=20=E6=B2=A1=E6=9C=89=20cwd=20?= =?UTF-8?q?=E6=98=AF=E6=9C=89=E6=84=8F=E7=9A=84=EF=BC=8C=E5=88=AB=E7=85=A7?= =?UTF-8?q?=E7=9D=80=E5=9C=A8=E8=B7=91=E7=9A=84=E8=BF=9B=E7=A8=8B=E8=A1=A5?= =?UTF-8?q?=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 逐字段比对 `pm2 jlist` 与 deploy/*/ecosystem.config.cjs(#892 收尾)时,cwd 是 两个文件里唯一一处真实差异:在跑的 anet-dashboard 的 pm_cwd 是 /home/vansin/agent-orchestra。 那不是一个被选择的值,是**当初谁在哪个目录敲的 `pm2 start`** —— 一个仓库检出 路径,换台机器就不存在。下一个做同样比对的人会看到「仓里缺 cwd」,很自然地把 它补上,而补上的那一刻这个文件就在别的机器上失效了。 判据写在注释里,用的是脚本本身而不是观测到的值:dash-start.sh 没有任何 cd、 没有任何相对路径依赖(唯一一处 cd 在一句 echo 的提示文案里),所以它与 cwd 无关。 对照 hub 那份 —— 那里的 cwd 是真需要的,且写成 join(home, ".commhub") 而不是 绝对路径。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- deploy/dashboard/ecosystem.config.cjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/deploy/dashboard/ecosystem.config.cjs b/deploy/dashboard/ecosystem.config.cjs index 49611bc64..56e04e468 100644 --- a/deploy/dashboard/ecosystem.config.cjs +++ b/deploy/dashboard/ecosystem.config.cjs @@ -27,6 +27,17 @@ module.exports = { min_uptime: 45000, max_restarts: 20, exp_backoff_restart_delay: 200, + // 没有 cwd 是**有意的**,别照着在跑的进程补。 + // + // 2026-08-18 逐字段比对 `pm2 jlist` 与本文件时,cwd 是唯一一处真实差异: + // 在跑的 anet-dashboard 的 pm_cwd 是 /home/vansin/agent-orchestra。那不是 + // 一个被选择的值,是**当初谁在哪个目录敲的 `pm2 start`** —— 一个仓库检出 + // 路径,换台机器就不存在。把它写进来会让本文件在别的机器上直接失效。 + // + // 判据是脚本本身:dash-start.sh 里没有任何 `cd`、没有任何相对路径依赖 + // (唯一一处 `cd` 出现在一句 echo 的提示文案里),所以它与 cwd 无关。 + // 对比 deploy/hub/ecosystem.config.cjs —— 那里的 cwd 是真需要的,而且 + // 写成 join(home, ".commhub") 而不是绝对路径。 }, ], }; From 390a803f0bf04c79bc1c2fac5a8207e7a9ae47d6 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 02:31:19 +0800 Subject: [PATCH 28/59] =?UTF-8?q?fix(docs):=20=E4=B8=A4=E6=9D=A1=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E7=AE=97=E9=94=99=E4=B8=80=E7=BA=A7=E7=9A=84=E6=AD=BB?= =?UTF-8?q?=E9=93=BE=E2=80=94=E2=80=94=E7=9B=AE=E6=A0=87=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=80=E7=9B=B4=E5=9C=A8,=E5=8F=AA=E6=98=AF=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=A4=9A/=E5=B0=91=E4=BA=86=E4=B8=80=E5=B1=82=20(#913?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全仓扫 docs/ 的 296 条仓内相对链接后分类,这两条属于"目标存在、路径写错", 是唯一能确定性修好的一类: docs/release/v2.3.0/RELEASE-NOTES.md ../../docs-site/… → ../../../docs-site/… (少一级,落到 docs/docs-site/) docs/tests/p-grok-demos-qa/report.md ../../docs/tests/p-grok-native-xsearch-e2e/basic-urls.txt → ../p-grok-native-xsearch-e2e/basic-urls.txt (多绕一层,落到 docs/docs/tests/) 改完当场核过两条都能解析到真实文件。 其余死链不在本 PR:它们指向的东西**确实不存在**(已删的测试报告、从未提交的 RFC-017、仓外的私有 memory 目录),改路径解决不了,需要各自单独决定是补文件 还是删链接。分类明细见 #887。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs/release/v2.3.0/RELEASE-NOTES.md | 2 +- docs/tests/p-grok-demos-qa/report.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release/v2.3.0/RELEASE-NOTES.md b/docs/release/v2.3.0/RELEASE-NOTES.md index dc3ab0d86..a59764af2 100644 --- a/docs/release/v2.3.0/RELEASE-NOTES.md +++ b/docs/release/v2.3.0/RELEASE-NOTES.md @@ -35,7 +35,7 @@ - ACP 内核活体已跑通 **free model**(真 ACP session + 真流式 + 真计费 token + 子进程真收)。真 vendor(Anthropic/OpenAI)活体 + 正式主打**留到后续**。 ## 升级 / 部署注意 -- **Channel 编辑走 restart-tier**:应用通道变更会触发节点 `exit(75)` 重启,**需要外部 supervisor 拉回进程**(`anet node start` / host_supervisor daemon / systemd `Restart=always` / docker `restart:always`)。手动 spawn(裸 `nohup`)的节点没 supervisor 不会自动重启。详见 [troubleshooting/remote-node-cli-login](../../docs-site/docs/troubleshooting/remote-node-cli-login.md) 与 RFC-024 §6.7.1。 +- **Channel 编辑走 restart-tier**:应用通道变更会触发节点 `exit(75)` 重启,**需要外部 supervisor 拉回进程**(`anet node start` / host_supervisor daemon / systemd `Restart=always` / docker `restart:always`)。手动 spawn(裸 `nohup`)的节点没 supervisor 不会自动重启。详见 [troubleshooting/remote-node-cli-login](../../../docs-site/docs/troubleshooting/remote-node-cli-login.md) 与 RFC-024 §6.7.1。 - **多机 auth**:跨机建节点优先走 **API key 路线**(key 跟 config/vault 走);claude-code-cli 订阅登录态机器绑定不可移植,远程 host 需各自 `claude login`。 ## 验证 diff --git a/docs/tests/p-grok-demos-qa/report.md b/docs/tests/p-grok-demos-qa/report.md index 76376fb98..6f920b54c 100644 --- a/docs/tests/p-grok-demos-qa/report.md +++ b/docs/tests/p-grok-demos-qa/report.md @@ -68,7 +68,7 @@ Both git tree and filesystem confirm: `fetcher/` directory, `.env.x.example`, an > Verified: 5/5 `curl -I` HTTP 200 against the URLs the LLM returned for "find @sama's recent AGI posts" in the [E2E probe report]. **README line 84** (verbatim): -> All five URLs return HTTP 200 — see [`basic-urls.txt`](../../docs/tests/p-grok-native-xsearch-e2e/basic-urls.txt) for the verbatim list. +> All five URLs return HTTP 200 — see [`basic-urls.txt`](../p-grok-native-xsearch-e2e/basic-urls.txt) for the verbatim list. **Live 2026-06-06 re-verify:** ``` From abb31c538e0250054e3f55476432499bf7494f8b Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 02:34:38 +0800 Subject: [PATCH 29/59] =?UTF-8?q?fix(ci,docs):=20docs-integrity=20?= =?UTF-8?q?=E7=9C=8B=E4=B8=8D=E8=A7=81=E3=80=8C=E8=A3=B8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=E3=80=8D=E7=9B=B8=E5=AF=B9=E9=93=BE=E6=8E=A5=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=AE=83=E5=AE=A3=E7=A7=B0=E7=9A=84=E5=88=86=E6=AF=8D?= =?UTF-8?q?=E5=B0=91=E4=BA=86=2016=20=E6=9D=A1=EF=BC=8C=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=9D=8F=E9=93=BE=E9=83=BD=E5=9C=A8=E9=87=8C=E9=9D=A2=20(#911)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这道门的链接正则是: RELATIVE_LINK = re.compile(r"\]\((\.{1,2}/[^)\s]*)") 它匹配的不是"相对链接",是"相对链接的一种写法"。`](v0-summary.md)` 同样是相对 链接,而这道门从来没看见过它。 2026-08-18 实测 docs/qa/ 作用域内:96 条仓内相对链接,其中 16 条是裸文件名—— 而作用域里**仅有的两条坏链正好都在这 16 条里**。门打印 "80 relative link(s)"、 "no problems"、exit 0,和一次真正干净的运行逐字相同。 盲区在**怎么把要判的东西收集起来**,不在判据。`os.path.exists` 从来没错过。 它能藏这么久,是因为写这道门时依据的那个文件(W19, 2026-08-17)恰好把 24 条链接 全写成了 `../`。**一个只演练了一种写法的夹具,无法暴露另一种写法没被处理。** 改动三处: 1. 收集改为"先全取、再排除"(排除 http(s)/#锚点/mailto/站点绝对路由 `/guide/x` ——最后一类由 docs 站路由解析,不是文件系统,判它会把噪声报成腐烂)。 2. 修好被暴露出来的两条坏链:W19 里 `](v0-summary.md#…)` 应为 `](../v0-summary.md#…)` ——链接文字写的是 `docs/qa/v0-summary.md`,是对的;href 少了一级。 3. 新增 `--selftest` 钉住**收集器**,并在 workflow 里排在真扫描之前。把正则退回 旧写法,8 条里 3 条转红。 证据顺序是先红后绿,且红是在真仓上红的:修完门、还没修链接时,门在 origin/main 的内容上报出那两条(分母同时 80→96);修完链接才转绿,分母仍是 96。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-docs-integrity.py | 69 ++++++++++++++++++++++++- .github/workflows/docs-integrity.yml | 5 ++ docs/qa/weekly/2026-W19.md | 4 +- 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/.github/scripts/check-docs-integrity.py b/.github/scripts/check-docs-integrity.py index ac96b784b..61f0031f2 100755 --- a/.github/scripts/check-docs-integrity.py +++ b/.github/scripts/check-docs-integrity.py @@ -41,7 +41,34 @@ import subprocess import sys -RELATIVE_LINK = re.compile(r"\]\((\.{1,2}/[^)\s]*)") +# Every markdown link target, then filter. The earlier version matched only +# targets that begin `./` or `../`, which is not "a relative link" — it is one +# way of spelling one. A bare `](v0-summary.md)` is equally relative and was +# invisible to this gate. +# +# 🔴 That blind spot lived in how the gate COLLECTED, not in what it judged, so +# nothing about the output looked wrong: the run printed a link count, said "no +# problems", and exited 0 — byte-identical to a genuinely clean run. Measured on +# 2026-08-18: 16 of the 96 in-scope relative links were bare filenames, and both +# of the broken links in docs/qa/ were among the 16. The gate had been reporting +# "80 relative link(s)" as if that were the denominator. +# +# It stayed invisible because the file this gate was written from (W19, 2026-08-17) +# happened to spell all 24 of its links with `../`. A fixture that exercises one +# spelling cannot reveal that the other spelling is unhandled. +MD_LINK = re.compile(r"\]\(([^)\s]+)\)") + + +def is_repo_relative(target: str) -> bool: + """True for link targets that must resolve to a file in this repo. + + Excluded: absolute URLs, in-page anchors, mail links, and site-absolute + routes (`/guide/feishu`) — the last are resolved by the docs site's router, + not the filesystem, so checking them here would report noise as rot. + """ + if not target or target.startswith(("http://", "https://", "#", "mailto:", "/")): + return False + return True LINK_SCOPE = "docs/qa" CHANGELOG_GLOB = "changelog.md" MAIN_LINE_ANCHOR = re.compile(r"blob/main/[\w./-]+#L\d+") @@ -83,7 +110,9 @@ def main() -> int: for f in scoped: body = open(f, encoding="utf-8", errors="replace").read() base = os.path.dirname(f) - for target in RELATIVE_LINK.findall(body): + for target in MD_LINK.findall(body): + if not is_repo_relative(target): + continue links += 1 resolved = os.path.normpath(os.path.join(base, target.split("#")[0])) if not os.path.exists(resolved): @@ -117,5 +146,41 @@ def main() -> int: return 0 +def selftest() -> int: + """Pin the collector, because that is where this gate was blind. + + Not the judge — `os.path.exists` was never the problem. What failed was the + step before it: deciding which strings on the page are links this gate owns. + A guard whose collector silently drops a whole spelling reports a smaller + denominator and a clean run, and both look exactly like success. + """ + page = ( + "see [a](v0-summary.md) and [b](../qa/x.md) and [c](./y.md)\n" + "[d](https://example.com/z.md) [e](#anchor) [f](/guide/feishu)\n" + "[g](v0-summary.md#some-anchor)\n" + ) + found = [t for t in MD_LINK.findall(page) if is_repo_relative(t)] + cases = [ + ("bare filename is collected", "v0-summary.md" in found), + ("bare filename with anchor is collected", "v0-summary.md#some-anchor" in found), + ("../ form still collected", "../qa/x.md" in found), + ("./ form still collected", "./y.md" in found), + ("absolute URL excluded", "https://example.com/z.md" not in found), + ("in-page anchor excluded", "#anchor" not in found), + ("site-absolute route excluded", "/guide/feishu" not in found), + ("exactly the four repo-relative targets", len(found) == 4), + ] + bad = [name for name, ok in cases if not ok] + for name, ok in cases: + print(f" {'ok ' if ok else 'FAIL'} {name}") + if bad: + print(f"::error::collector selftest failed: {len(bad)} case(s)") + return 1 + print(f"collector selftest: {len(cases)}/{len(cases)} ok") + return 0 + + if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(selftest()) sys.exit(main()) diff --git a/.github/workflows/docs-integrity.yml b/.github/workflows/docs-integrity.yml index 2316ac6a7..60ffe3917 100644 --- a/.github/workflows/docs-integrity.yml +++ b/.github/workflows/docs-integrity.yml @@ -41,4 +41,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # Collector first. If this gate stops seeing a whole spelling of relative + # link, the scan below still prints a count and still exits 0 — a false + # green byte-identical to a real one. That happened: bare `](file.md)` + # targets were invisible, and both broken links in scope were among them. + - run: python3 .github/scripts/check-docs-integrity.py --selftest - run: python3 .github/scripts/check-docs-integrity.py diff --git a/docs/qa/weekly/2026-W19.md b/docs/qa/weekly/2026-W19.md index 1243b6c41..93eba1ee1 100644 --- a/docs/qa/weekly/2026-W19.md +++ b/docs/qa/weekly/2026-W19.md @@ -53,9 +53,9 @@ _自动生成 by [scripts/qa-status.sh](../../../scripts/qa-status.sh)_ ## 累计抠出的 SDK 设计 finding - Tests with GAP-style sections: **5** -- Canonical count (rows in [v0-summary.md](v0-summary.md#累计抠出的-11-条-sdk-设计-finding) findings table): **11** +- Canonical count (rows in [v0-summary.md](../v0-summary.md#累计抠出的-11-条-sdk-设计-finding) findings table): **11** -完整清单见 [docs/qa/v0-summary.md](v0-summary.md#累计抠出的-11-条-sdk-设计-finding)。 +完整清单见 [docs/qa/v0-summary.md](../v0-summary.md#累计抠出的-11-条-sdk-设计-finding)。 ## 本地 `bash scripts/qa.sh` 实测 From c0c2773bfe8d706fae754d751b50e4410a3dedb4 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 02:50:52 +0800 Subject: [PATCH 30/59] =?UTF-8?q?fix(ci):=20=E5=9B=9B=E4=B8=AA=20workflow?= =?UTF-8?q?=20=E7=9A=84=20job=20=E5=85=A8=E5=8F=AB=20scan=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=9C=A8=20check=20=E5=88=97=E8=A1=A8=E9=87=8C?= =?UTF-8?q?=E6=8C=A4=E6=88=90=E5=90=8C=E4=B8=80=E4=B8=AA=E5=90=8D=E5=AD=97?= =?UTF-8?q?=EF=BC=8Crequired=20=E9=87=8C=E6=B2=A1=E6=B3=95=E6=8C=87?= =?UTF-8?q?=E5=90=8D=EF=BC=8C=E7=BB=9F=E8=AE=A1=E8=A6=86=E7=9B=96=E7=8E=87?= =?UTF-8?q?=E4=BC=9A=E6=8A=8A=E5=9B=9B=E9=81=93=E9=97=A8=E7=AE=97=E6=88=90?= =?UTF-8?q?=E4=B8=80=E9=81=93=20(#916)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub 上一个 check 的名字取自 job 的 `name:`(没有就取 job id),而这是分支保护 required check 里**唯一能写的标识符**。 2026-08-18 之前,这四个 workflow 的 job 全叫 `scan`: action-pins.yml:scan docs-integrity.yml:scan public-script-safety.yml:scan qa-trigger-coverage.yml:scan 两个后果: 1. **required 里写 `scan` 指的是哪一道无法确定** —— 想把其中任何一道设成必须通过 都做不到,而这四道里有两道(action-pins、qa-trigger-coverage)恰好是无 paths 过滤、每个 PR 都跑的,本来是最适合当 required 的。 2. 🔴 **按 check 名统计覆盖率会把四道门算成一道。** 我在 #828 里贴的那张覆盖表 就是这么统计的:`scan 14/15` 那一行其实是四道不同的门被折叠成了一行,而它们 各自的 paths 过滤完全不同。表本身在这一行上是错的,已在 issue 里更正。 改动只有一处:给这四个 job 各加一个唯一的 `name:`,并把上面这条理由写在旁边, 免得下一个新建 workflow 的人又写 `scan`。其余字段一字未动(已用 yaml.safe_load 逐个核过 runs-on / steps / run 命令都在)。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/workflows/action-pins.yml | 5 +++++ .github/workflows/docs-integrity.yml | 5 +++++ .github/workflows/public-script-safety.yml | 5 +++++ .github/workflows/qa-trigger-coverage.yml | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/.github/workflows/action-pins.yml b/.github/workflows/action-pins.yml index a77f32777..61ea2ed57 100644 --- a/.github/workflows/action-pins.yml +++ b/.github/workflows/action-pins.yml @@ -28,6 +28,11 @@ on: jobs: scan: + # 这个 name 就是 GitHub 上那个 check 的名字,也是分支保护里 required check + # 唯一能写的标识符。它必须全仓唯一 —— 2026-08-18 之前,四个 workflow 的 + # job 全叫 `scan`,于是四道门在 check 列表里挤成同一个名字:required 里 + # 写 `scan` 指的是哪一道无法确定,而按名字统计覆盖率会把四道门算成一道。 + name: action-pins runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/docs-integrity.yml b/.github/workflows/docs-integrity.yml index 60ffe3917..b31c11f50 100644 --- a/.github/workflows/docs-integrity.yml +++ b/.github/workflows/docs-integrity.yml @@ -38,6 +38,11 @@ on: jobs: scan: + # 这个 name 就是 GitHub 上那个 check 的名字,也是分支保护里 required check + # 唯一能写的标识符。它必须全仓唯一 —— 2026-08-18 之前,四个 workflow 的 + # job 全叫 `scan`,于是四道门在 check 列表里挤成同一个名字:required 里 + # 写 `scan` 指的是哪一道无法确定,而按名字统计覆盖率会把四道门算成一道。 + name: docs-integrity runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/public-script-safety.yml b/.github/workflows/public-script-safety.yml index b5e878cde..4a41d1cf4 100644 --- a/.github/workflows/public-script-safety.yml +++ b/.github/workflows/public-script-safety.yml @@ -27,6 +27,11 @@ on: jobs: scan: + # 这个 name 就是 GitHub 上那个 check 的名字,也是分支保护里 required check + # 唯一能写的标识符。它必须全仓唯一 —— 2026-08-18 之前,四个 workflow 的 + # job 全叫 `scan`,于是四道门在 check 列表里挤成同一个名字:required 里 + # 写 `scan` 指的是哪一道无法确定,而按名字统计覆盖率会把四道门算成一道。 + name: public-script-safety runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/qa-trigger-coverage.yml b/.github/workflows/qa-trigger-coverage.yml index f94d7f49e..51133ef3e 100644 --- a/.github/workflows/qa-trigger-coverage.yml +++ b/.github/workflows/qa-trigger-coverage.yml @@ -28,6 +28,11 @@ on: jobs: scan: + # 这个 name 就是 GitHub 上那个 check 的名字,也是分支保护里 required check + # 唯一能写的标识符。它必须全仓唯一 —— 2026-08-18 之前,四个 workflow 的 + # job 全叫 `scan`,于是四道门在 check 列表里挤成同一个名字:required 里 + # 写 `scan` 指的是哪一道无法确定,而按名字统计覆盖率会把四道门算成一道。 + name: qa-trigger-coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 235b8823ae9617a362ee6e45d973efe582baf0ba Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 03:07:41 +0800 Subject: [PATCH 31/59] =?UTF-8?q?fix(cli):=20#909=20=E2=80=94=20agent-node?= =?UTF-8?q?=20--help=20no=20longer=20presents=20claude-code-cli=20as=20a?= =?UTF-8?q?=20directly-passable=20runtime=20(#917)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrected scope. #909's original premise ("unimplemented runtime") was wrong: claude-code-cli's execution lane lives in the LAUNCHER (agent-network/bin/cli.ts — `anet node start` → launchAgent → spawns the real `claude` CLI, ~:5096) and never routes through agent-node. agent-node rejecting it at RUNTIME_MAP is therefore CORRECT, not a gap. The only real defect was cosmetic: agent-node's own --help listed claude-code-cli among the `--runtime ` values you pass to agent-node — which sent users chasing a spelling error when `agent-node --runtime claude-code-cli` failed. Help-text only; no execution path touched: - `--runtime ` value list: drop claude-code-cli (agent-node doesn't accept it) + a one-line pointer. - Runtime section: keep it documented but marked "由 anet 提供并启动 (anet node start); 不能直接传给 agent-node" — NOT "not yet implemented" (it IS implemented, in the launcher). - A pinning comment at RUNTIME_MAP so the next reader doesn't re-derive the same wrong "missing key" conclusion from the --help vs RUNTIME_MAP mismatch (points at bin/cli.ts:5096 + qa-180-rename-ghost). RUNTIME_MAP and its generic rejection are UNCHANGED — that rejection is the correct behaviour and is kept. Test (light, no over-design; no launcher assertions; RUNTIME_MAP untouched): --help omits claude-code-cli from the passable list (positive control: still lists claude-agent-sdk/codex-sdk so the list wasn't stripped), stays documented as anet-provided, and is NOT described as a gap. Witnessed-red. Supersedes the closed #915, which was built on the wrong premise (its launcher branch blocked the shipping qa-180-rename-ghost e2e). See #909 for the corrected analysis. Co-authored-by: Claude Opus 4.8 --- .../src/claude-code-cli-help-text.test.ts | 43 +++++++++++++++++++ agent-node/src/cli.ts | 11 ++++- 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 agent-node/src/claude-code-cli-help-text.test.ts diff --git a/agent-node/src/claude-code-cli-help-text.test.ts b/agent-node/src/claude-code-cli-help-text.test.ts new file mode 100644 index 000000000..82fb7dbb9 --- /dev/null +++ b/agent-node/src/claude-code-cli-help-text.test.ts @@ -0,0 +1,43 @@ +// #909 (corrected) — claude-code-cli's execution lane lives in the LAUNCHER (agent-network/bin/cli.ts, +// `anet node start` → spawns the real `claude` CLI), NOT in agent-node. So agent-node's --help must not +// present it as a `--runtime` value you pass to agent-node (agent-node correctly rejects it at RUNTIME_MAP — +// that rejection is NOT touched here; this is a help-text fix only). It stays documented, but marked as +// anet-provided. Do NOT assert on the launcher here, and do NOT claim it's unimplemented — it is. + +import { describe, expect, test } from "bun:test"; +import { spawn } from "child_process"; +import { join } from "path"; + +const CLI = join(import.meta.dir, "cli.ts"); + +function help(): Promise { + return new Promise((resolve) => { + const child = spawn("bun", [CLI, "--help"], { stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; + const t = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* gone */ } resolve(out); }, 15_000); + child.stdout.on("data", (d) => { out += String(d); }); + child.stderr.on("data", (d) => { out += String(d); }); + child.on("exit", () => { clearTimeout(t); resolve(out); }); + child.on("error", () => { clearTimeout(t); resolve(out); }); + }); +} + +describe("#909 agent-node --help does not present claude-code-cli as a directly-passable runtime", () => { + test("the `--runtime ` value list omits claude-code-cli (agent-node does not accept it)", async () => { + const out = await help(); + const line = out.split(/\r?\n/).find((l) => /--runtime /.test(l)) ?? ""; + expect(line).not.toContain("claude-code-cli"); + // positive control: it still offers the runtimes agent-node DOES accept (didn't strip the whole list). + expect(line).toContain("claude-agent-sdk"); + expect(line).toContain("codex-sdk"); + }, 20_000); + + test("it stays documented, marked anet-provided — and is NOT called unimplemented (it is implemented)", async () => { + const out = await help(); + expect(out).toContain("claude-code-cli"); // still in the Runtime section + expect(out).toContain("anet node start"); // says how it is actually started + expect(out).toMatch(/不能直接传给 agent-node|not by passing --runtime to agent-node/); + // 🔴 the whole point of the correction: it must NOT be described as a gap/unimplemented. + expect(out).not.toMatch(/not yet implemented|no execution lane|known gap/); + }, 20_000); +}); diff --git a/agent-node/src/cli.ts b/agent-node/src/cli.ts index b82762f70..629547053 100644 --- a/agent-node/src/cli.ts +++ b/agent-node/src/cli.ts @@ -200,7 +200,8 @@ for (let i = 0; i < argv.length; i++) { 选项: --config 配置文件 (.anet/nodes//config.json) --alias Agent 别名 / CommHub alias (必需) - --runtime claude-agent-sdk (default) | claude-code-cli | codex-sdk | codex-app-server | grok-build-acp | grok-build-cli | opencode-cli + --runtime claude-agent-sdk (default) | codex-sdk | codex-app-server | grok-build-acp | grok-build-cli | opencode-cli + (claude-code-cli is NOT here: it runs via \`anet node start\`, not by passing --runtime to agent-node — see the Runtime section) --model AI 模型 (codex 默认: ${DEFAULT_CODEX_MODEL}, claude-agent-sdk 默认: claude-sonnet-4-6) --hub CommHub URL --tools 工具列表,逗号分隔 ("all" = 全部) @@ -215,7 +216,7 @@ for (let i = 0; i < argv.length; i++) { Runtime: claude-agent-sdk Claude Agent SDK — Claude/MiniMax/Anthropic 兼容 API - claude-code-cli Claude Code CLI — 复用 Claude Code 登录态 + claude-code-cli Claude Code CLI — 由 \`anet\` 提供并启动(\`anet node start\`);不能直接传给 agent-node codex-sdk Codex SDK — GPT-5.4,复用 codex 登录态 codex-app-server Codex app-server — Codex TUI bridge grok-build-acp Grok Build ACP — xAI Grok Build via "grok agent stdio" @@ -452,6 +453,12 @@ const RUNTIME_MAP: Record = { // `codex-app-server` (canonical) / `codex-tui` / `codex-appserver`. "codex-app-server": "codex-app-server", "codex-appserver": "codex-app-server", "codex-tui": "codex-app-server", }; +// 🔴 `claude-code-cli` is intentionally NOT a key here (#909). Its execution lane lives in the LAUNCHER +// (`agent-network/bin/cli.ts` — `anet node start` → launchAgent → spawns the real `claude` CLI, ~:5096) +// and never routes through agent-node. So agent-node rejecting it below is CORRECT behaviour, not a gap +// — do NOT "fix" it by adding a key. Aliasing it to "claude" would silently run the SDK instead of the +// CLI (CLI-login users downgraded to the SDK channel). The e2e that owns this path is qa-180-rename-ghost. +// (The `--help` above lists it only in the Runtime section, marked as anet-provided, for exactly this reason.) if (!Object.prototype.hasOwnProperty.call(RUNTIME_MAP, rawRuntime)) { const supported = [...new Set(Object.keys(RUNTIME_MAP))].join(", "); console.error(`[${ALIAS}] Unsupported runtime "${rawRuntime}". Supported: ${supported}`); From 1f7de2c50c82acecb528db53f4965fa2edb90c98 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 03:14:16 +0800 Subject: [PATCH 32/59] =?UTF-8?q?docs(cli):=20dashboard=20=E9=82=A3?= =?UTF-8?q?=E8=A1=8C=E6=B3=A8=E9=87=8A=E8=AF=B4"=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=8C=89=20channel=20=E5=8C=B9=E9=85=8D"=EF=BC=8C=E8=80=8C?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=AF=B9=E6=89=80=E6=9C=89=E4=BA=BA=E9=83=BD?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20preview=EF=BC=9B=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E4=B8=8B=20#61=20=E9=82=A3=E4=B8=AA=20TODO=20=E7=9A=84?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E6=9D=A1=E4=BB=B6=E6=97=A9=E5=B0=B1=E6=BB=A1?= =?UTF-8?q?=E8=B6=B3=E4=BA=86=20(#918)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处纯注释改动,零可执行行变化。 **一、`bin/cli.ts:6302` 的注释在说谎** // Default stays channel-matched (see #61 + dashboardReleaseTag). `dashboardReleaseTag()` 对每一个调用者都返回 "preview"(它自己的注释把 #61 的 理由写得很清楚),所以 stable channel 的 `anet` 用户拿到的是 preview Dashboard。 这是一个有意的临时决定,但**调用点这句话说的正好相反**,而它是大多数读到这里的 人唯一会看到的说明。 运行时输出本身是诚实的(下面那行 spawn 会打印真实的 `@${tag}`),所以这是一句 **同时和代码、和程序自己的输出都不一致**的注释。 **二、#61 phase-2 的 TODO 条件早就满足了,没人回头看** TODO 写的是「等 @sleep2agi/agent-network-dashboard promote 0.4.5 stable 后 swap」。今天对着 live registry 量: latest = 0.6.0 preview = 0.6.3-preview.56 它当初为之存在的阻塞(latest 停在 0.4.2、preview 才有 0.4.5)**已经不存在**, latest 已经比 TODO 等的那个版本高好几个 minor。这个"临时"方案活过了它自己写下的 到期条件。 **没有在本 PR 里翻转它**:翻转会改变每个 stable 用户 `anet hub dashboard` 拉到的 东西(0.6.0 而不是 0.6.3-preview.56),而 0.6.0 功能够不够是产品决定不是清理。 数字和决定记在 #866。 值得单独命名的形状:**一个把到期条件写下来的临时方案,比不写的好——但只在有人 回头读它的前提下。没有任何东西会去重新评估一个存在注释里的条件。** Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-network/bin/cli.ts | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index d8ce556ce..069533322 100644 --- a/agent-network/bin/cli.ts +++ b/agent-network/bin/cli.ts @@ -1539,6 +1539,25 @@ function getAnetVersion(): string { // promote 0.4.5 → @latest 后 swap anet@latest 路径回 @latest (🅗1)。 // TODO(#61 phase-2): swap anet@latest fallback "preview" → "latest" once // @sleep2agi/agent-network-dashboard promotes 0.4.5 stable. +// +// 🔴 2026-08-18 — that condition passed a long time ago and nobody re-checked +// it. Measured against the live registry today: +// +// latest = 0.6.0 +// preview = 0.6.3-preview.56 +// +// The blocker this fallback was written for (latest pinned at 0.4.2 while +// preview had 0.4.5) no longer exists; `latest` is now many minors past the +// version the TODO waits for. The fallback outlived its own stated expiry. +// +// It is NOT flipped here on purpose: doing so changes what every stable-channel +// user's `anet hub dashboard` fetches (0.6.0 instead of 0.6.3-preview.56), and +// whether 0.6.0 is feature-complete enough is a product call, not a cleanup. +// See #866 for the numbers and the decision. +// +// The general shape, worth naming: a temporary workaround that WRITES DOWN its +// expiry condition is better than one that doesn't — but only if someone +// re-reads it. Nothing re-evaluates a condition stored in a comment. function dashboardReleaseTag(): string { const envOverride = process.env.ANET_DASHBOARD_VERSION; if (envOverride) return envOverride; @@ -6299,8 +6318,19 @@ async function serverCommand() { ...(dashboardToken ? { COMMHUB_AUTH_TOKEN: dashboardToken } : {}), }; - // Default stays channel-matched (see #61 + dashboardReleaseTag). A global - // binary is used only after the explicit ANET_DASHBOARD_LOCAL=1 opt-in. + // 🔴 The default is NOT channel-matched — this comment used to say it was. + // dashboardReleaseTag() returns "preview" for every caller (see its own + // comment for the #61 reason), so a user on the stable `anet` channel gets + // the preview Dashboard. That is a deliberate temporary decision, but the + // sentence here claimed the opposite and was the only thing most readers + // of this call site would see. + // + // The runtime output is honest — the spawn line below prints the actual + // `@${tag}` — so this was a comment that disagreed with both the code and + // the program's own output. + // + // A global binary is used only after the explicit ANET_DASHBOARD_LOCAL=1 + // opt-in. cleanStaleNpxDashboardTemp(); // #89 — self-heal npx cache before spawn console.log(globalOptIn ? `[anet] spawning explicit global Dashboard ${globalBinary} (anet ${getAnetVersion() || "unknown"})` From 59783375021b1907df1a21f86e4999a0974f5d76 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 03:16:20 +0800 Subject: [PATCH 33/59] =?UTF-8?q?fix(ci):=20e2e-docker=20=E7=9A=84=20push?= =?UTF-8?q?=20=E8=A7=A6=E5=8F=91=E5=99=A8=E6=BC=8F=E4=BA=86=20branches:=20?= =?UTF-8?q?[main]=EF=BC=8C=E4=BA=8E=E6=98=AF=E6=AF=8F=E4=B8=AA=20PR=20?= =?UTF-8?q?=E9=83=BD=E6=8A=8A=E6=9C=80=E8=B4=B5=E7=9A=84=20job=20=E8=B7=91?= =?UTF-8?q?=E4=B8=A4=E9=81=8D=20(#919)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `on.push` 没有 `branches:` 限制 ⇒ 它在**任何**分支的 push 上都跑,加上 `on.pull_request`,每个 PR 分支上同一个 commit 会跑两次完整 e2e。 实测(2026-08-17,同一分支 fix/909-help-text-agent-node):run #2327 与 #2328 是同一个 commit 的两次完整 e2e,各约 10 分钟,结论相同。 三个代价: 1. 全仓最贵的 job 跑两遍; 2. PR 的等待时间从「最慢的一次」变成「两次都跑完」——合并前要多等一轮。今晚 #918 的 e2e 一次已 success、另一次还 in_progress,卡了额外几分钟; 3. 🔴 两次都产出名为 `e2e` 的 check,而**分支保护的 required check 只能按名字 指定**,两个同名 run 让「哪一个必须绿」无法确定。这和 #916 修掉的「四个 workflow 的 job 都叫 scan」是同一类问题,只是这次重名发生在**同一个 workflow 的两个触发器之间**。 仓里其它 workflow(qa.yml / action-pins.yml / lint-from-session.yml / lint.yml / docs-integrity.yml …)的 push 触发器全部写了 `branches: [main]`,只有这个漏了 —— 所以这不是一个待讨论的策略变更,是**把唯一一个不一致的地方对齐到既有约定**。 paths 过滤一字未动(push 6 条 / pull_request 4 条,已用 yaml.safe_load 核过)。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/workflows/e2e-docker.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/e2e-docker.yml b/.github/workflows/e2e-docker.yml index ab96b1d08..a4a61f4d6 100644 --- a/.github/workflows/e2e-docker.yml +++ b/.github/workflows/e2e-docker.yml @@ -1,7 +1,23 @@ name: Docker E2E Tests on: + # 🔴 `branches: [main]` 不是可选的装饰 —— 没有它,这个 workflow 会在**任何**分支 + # 的 push 上跑,于是每个 PR 都跑两遍:一遍 push、一遍 pull_request。 + # + # 实测(2026-08-17,同一分支 fix/909-help-text-agent-node):run #2327 与 #2328 + # 是同一个 commit 的两次完整 e2e,各约 10 分钟,结论相同。 + # + # 三个代价: + # 1. 全仓最贵的 job 跑两遍; + # 2. PR 的等待时间由「最慢的一次」变成「两次都跑完」——合并前要多等一轮; + # 3. 两次都产出名为 `e2e` 的 check,**分支保护的 required check 只能按名字指定**, + # 两个同名 run 让「哪一个必须绿」无法确定。这和 #916 修掉的四个 job 都叫 + # `scan` 是同一类问题,只是这次重名发生在同一个 workflow 的两个触发器之间。 + # + # 仓里其它 workflow(qa.yml / action-pins.yml / lint-from-session.yml …)的 push + # 触发器全部写了 `branches: [main]`,只有这个漏了。 push: + branches: [main] paths: - 'agent-network/**' - 'agent-node/**' From 21b17a64e0e627f54dd4b0a3357e7c19fc01bf98 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 03:34:07 +0800 Subject: [PATCH 34/59] =?UTF-8?q?ci:=20=E5=8A=A0=E4=B8=80=E9=81=93?= =?UTF-8?q?=E9=97=A8=E7=9B=AF=E4=BD=8F=E3=80=8C=E8=B7=91=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E3=80=8D=E5=92=8C=E3=80=8C=E4=BB=80=E4=B9=88=E6=97=B6=E5=80=99?= =?UTF-8?q?=E8=B7=91=E3=80=8D=E8=BF=99=E4=B8=A4=E4=BB=BD=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E6=AD=A5=EF=BC=88#860=20=E7=9A=84=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E5=B7=B2=E7=BB=8F=E5=8F=91=E7=94=9F=E8=BF=87=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=EF=BC=89=20(#920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两份清单各决定一件事,而没有任何机制保证它们同步: scripts/qa.sh L1_TESTS — 跑什么 .github/workflows/qa.yml on.pull_request.paths — 什么时候跑 往 L1_TESTS 加一个套件、忘了加 paths 条目 ⇒ **改那个套件不会触发跑它的 workflow**,而门一直是绿的、套件也一直有人维护。#860 记录的正是这个失效,涉及 test686 / test765 / test766 三个套件。 🔴 它不留任何可以自曝的痕迹:一次绿色运行里,「跑过并通过」和「压根没被触发」 长得完全一样。唯一能发现它的方式,就是把两份清单对起来比一次——这就是本脚本。 **证据(真仓数据,先红后绿)** A 未变异 exit 0 "every L1 suite has a matching trigger" B 从 pull_request.paths 删掉 test686 那一条 exit 1 只点名 test686,分母 14→13 C 把 L1_TESTS 数组改名(模拟解析失效) exit 2 "parse regression, refusing to pass" D scripts/qa.sh 缺失 exit 2 "scope regression, refusing to pass" B 的变异带断言确认过不是 no-op(第一次锚点命中 2 次——push 和 pull_request 各有 一条同样的 paths——直接 assert 失败,改成只删 pull_request 块里那一条)。 C/D 是 fail-closed:分母塌陷时红,而不是「检查了 0 个套件,全部通过」。 **为什么没有 paths 过滤** 这道门看的就是触发覆盖面;如果它自己被 paths 门住,改它监视的那些文件反而可能绕过 它(和 action-pins.yml / qa-trigger-coverage.yml 同一条理由)。副作用是它在每个 PR 上都跑——这正好让它成为 #828 里可以设成 required check 的候选。 job 名 `l1-paths-sync` 全仓唯一(#916 的教训:check 名是分支保护里唯一能写的 标识符)。selftest 排在真扫描之前,钉的是**两个解析器**——它们静默返回子集时, 扫描仍会打印一个计数并 exit 0。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-l1-paths-sync.py | 138 +++++++++++++++++++++++++ .github/workflows/l1-paths-sync.yml | 32 ++++++ 2 files changed, 170 insertions(+) create mode 100644 .github/scripts/check-l1-paths-sync.py create mode 100644 .github/workflows/l1-paths-sync.yml diff --git a/.github/scripts/check-l1-paths-sync.py b/.github/scripts/check-l1-paths-sync.py new file mode 100644 index 000000000..d67ca25cd --- /dev/null +++ b/.github/scripts/check-l1-paths-sync.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Every suite qa.sh runs must also be a `paths:` trigger of the workflow that runs it. + +Two lists decide different halves of one thing, and nothing keeps them in sync: + + scripts/qa.sh `L1_TESTS` — WHAT gets run + .github/workflows/qa.yml `on.pull_request.paths` — WHEN it gets run + +Add a suite to L1_TESTS and forget the paths entry, and the gate is blind to that +suite forever: editing it does not trigger the workflow that runs it. That failure +has already happened once here (#860, three suites: test686 / test765 / test766). + +🔴 It leaves no trace that would expose it. The gate keeps passing, the suite keeps +being maintained, and the only way to notice is to line the two lists up and compare +them — which is what this script does. A drift like this is invisible precisely +because nothing about a green run distinguishes "ran and passed" from "was never +eligible to run". + +Deliberately dependency-free and unconditional: no `paths:` filter of its own, no +Docker, milliseconds. That is not an accident — a guard that watches trigger +coverage must not itself be gated on a path, or a change to the thing it watches +can slip past it (same reasoning as check-qa-trigger-coverage.py and +check-action-pins.py). + +Fail-closed: if either list comes back empty, that is a parse regression, not a +clean run — exit 2 rather than reporting success over an empty denominator. +""" +import fnmatch +import re +import sys + +try: + import yaml +except ImportError: + print("::error::PyYAML is not available — cannot parse the workflow, refusing to pass") + sys.exit(2) + +QA_SH = "scripts/qa.sh" +QA_YML = ".github/workflows/qa.yml" + + +def l1_suites(text: str) -> list[str]: + """Suite names from qa.sh's L1_TESTS array.""" + m = re.search(r"L1_TESTS=\(([^)]*)\)", text, re.S) + if not m: + return [] + return re.findall(r'"([^"]+)"', m.group(1)) + + +def pr_paths(doc: dict) -> list[str]: + # YAML 1.1 parses a bare `on:` key as the boolean True, so accept both. + on = doc.get("on") or doc.get(True) or {} + pr = on.get("pull_request") or {} + return list(pr.get("paths") or []) + + +def covered(suite: str, paths: list[str]) -> bool: + """Would a change inside tests// match any of the workflow's paths? + + Checked against a concrete file rather than the directory: GitHub matches + `paths:` against changed FILE paths, so `tests/x/**` must be tested with + something under it, not with `tests/x/`. + """ + probe = f"tests/{suite}/run.sh" + for p in paths: + if fnmatch.fnmatch(probe, p) or fnmatch.fnmatch(probe, p.replace("**", "*")): + return True + return False + + +def main() -> int: + try: + sh = open(QA_SH, encoding="utf-8").read() + doc = yaml.safe_load(open(QA_YML, encoding="utf-8")) + except FileNotFoundError as e: + print(f"::error::{e.filename} is missing — scope regression, refusing to pass") + return 2 + + suites = l1_suites(sh) + paths = pr_paths(doc) + + if not suites: + print(f"::error::found no L1_TESTS entries in {QA_SH} — parse regression, refusing to pass") + return 2 + if not paths: + print(f"::error::found no on.pull_request.paths in {QA_YML} — parse regression, refusing to pass") + return 2 + + missing = [s for s in suites if not covered(s, paths)] + for s in missing: + print( + f"::error file={QA_SH}::L1 suite '{s}' is run by qa.sh but no `paths:` entry in " + f"{QA_YML} matches tests/{s}/. Editing that suite will not trigger the workflow " + f"that runs it, and nothing else would report that. Add `tests/{s}/**` to " + f"on.pull_request.paths." + ) + + print(f"checked {len(suites)} L1 suite(s) against {len(paths)} path pattern(s) in {QA_YML}") + if missing: + print(f"\n{len(missing)} suite(s) run without a matching trigger.") + return 1 + print("every L1 suite has a matching trigger.") + return 0 + + +def selftest() -> int: + """Pin the two parsers, because that is where this gate would go blind. + + If `l1_suites` silently returns [] the run above exits 2 — but if it returns a + SUBSET, the gate passes while checking fewer suites than exist, and the output + is indistinguishable from a real clean run except for one count nobody reads. + """ + sh = 'x=1\nL1_TESTS=(\n "qa-a"\n "test-b" # trailing comment\n)\necho hi\n' + yml = { + "on": {"pull_request": {"paths": ["tests/qa-*/**", "tests/test-b/**", "scripts/qa.sh"]}}, + } + cases = [ + ("L1_TESTS parsed in full", l1_suites(sh) == ["qa-a", "test-b"]), + ("missing array yields empty (→ exit 2 upstream)", l1_suites("no array here") == []), + ("paths read from on.pull_request", len(pr_paths(yml)) == 3), + ("bare `on:` parsed as True still works", len(pr_paths({True: yml["on"]})) == 3), + ("glob pattern covers a suite", covered("qa-a", pr_paths(yml))), + ("explicit pattern covers a suite", covered("test-b", pr_paths(yml))), + ("uncovered suite is reported", not covered("test-c", pr_paths(yml))), + ("dir-only probe would false-negative — we probe a file", covered("test-b", ["tests/test-b/**"])), + ] + bad = [n for n, ok in cases if not ok] + for n, ok in cases: + print(f" {'ok ' if ok else 'FAIL'} {n}") + if bad: + print(f"::error::selftest failed: {len(bad)} case(s)") + return 1 + print(f"selftest: {len(cases)}/{len(cases)} ok") + return 0 + + +if __name__ == "__main__": + sys.exit(selftest() if "--selftest" in sys.argv else main()) diff --git a/.github/workflows/l1-paths-sync.yml b/.github/workflows/l1-paths-sync.yml new file mode 100644 index 000000000..bcdc51a44 --- /dev/null +++ b/.github/workflows/l1-paths-sync.yml @@ -0,0 +1,32 @@ +# scripts/qa.sh decides WHICH L1 suites run; qa.yml's `paths:` decides WHEN the +# workflow that runs them fires. Nothing keeps those two lists in sync, and the +# drift is silent: a suite added to L1_TESTS without a matching `paths:` entry is +# never triggered by edits to itself, while the gate keeps reporting green. +# +# That already happened once (#860 — test686 / test765 / test766). +# +# 🔴 No `paths:` filter here, on purpose. This guard watches trigger coverage; if +# it were itself gated on a path, a change to the very files it watches could slip +# past it. Same reasoning as action-pins.yml and qa-trigger-coverage.yml — and it +# is what makes this job a candidate for a required check (#828), since it reports +# on every pull request rather than only on the ones that touch certain files. + +name: lint (L1 trigger sync) + +on: + pull_request: + push: + branches: [main] + +jobs: + scan: + # Unique across the repo — a check name is the only identifier a branch + # protection rule can name (#916). + name: l1-paths-sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Parsers first: if either list stops being readable, the scan below would + # still print a count and exit 0 on an empty denominator. + - run: python3 .github/scripts/check-l1-paths-sync.py --selftest + - run: python3 .github/scripts/check-l1-paths-sync.py From 68f915233bcf305ae4d2ed6abe7a2644e7af94cb Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 03:44:38 +0800 Subject: [PATCH 35/59] =?UTF-8?q?ci:=20=E7=BB=99=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E4=BB=93=E9=87=8C=E7=9A=84=20/home/<=E7=9C=9F=E4=BA=BA?= =?UTF-8?q?=E5=90=8D>=20=E5=8A=A0=E4=B8=80=E9=81=93=E5=9F=BA=E7=BA=BF?= =?UTF-8?q?=E9=97=A8=E2=80=94=E2=80=94=E6=AD=A2=E4=BD=8F=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=8A=A8=E5=AD=98=E9=87=8F=EF=BC=88#894?= =?UTF-8?q?=EF=BC=89=20(#921)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这个仓是公开的。2026-08-18 实测 origin/main:**203 处** person-looking 的 `/home/<名字>/`,散在 **71 个跟踪文件**里,涉及 14 个看起来是真实登录名的名字 (系统账户 ubuntu/root/runner/node/ci 命中 0,所以不是 CI 痕迹)。#894 开条时是 70 文件 / 13 名字——**还在涨**。 🔴 **这道门要求的是「不超过基线」,不是「必须为 0」,这是刻意的。** 存量里相当一部分在 docs/ 和 tests/ 里、属于**事故记录的一部分**——真实路径正是让 那份证据可复现的东西,抹掉它会让记录和当时发生的事对不上。清理是逐文件的判断, 不是 CI 能驱动的。而**一道只因积压而红的门,等积压清完就再也不会红**,到时没人 知道它还有没有效。基线门相反:今天绿,任何**新增**都红。 基线按文件存,不是一个总数:标量会让「旧文件少一行、新文件多一行」互相抵消。清理 是欢迎的——某个文件低于基线时,门会提示把那一行的数字改小,楼层只往下走。 **证据(先红后绿,都不接管道取 exit code)** selftest 11/11 ok, exit 0 无基线文件 exit 2 "refusing to pass without a floor" 基线就位、未变异 exit 0 203 次 / 71 文件 / 扫 2002 个跟踪文件 A 新文件带一条 /home/<人名>/ exit 1 点名该文件,"new file with" B 同一文件改用 /home/user/(占位符) exit 0 🔴 **一处自指的坑,改掉了**:selftest 原本用 `HOME_PATH.findall("/home/alice/work")` 这种字面量夹具。**一个扫描器的测试夹具如果长得就像它要扫的那个东西,它会扫到 自己**——提交这个文件的那一刻,基线里就会多出一个文件和一次命中,而那次命中不是 缺陷。现在夹具用拼接构造(`f"{home}zqxjkv{slash}work"`),名字也是合成的,不用任何 真实登录名。已验证:`git add` 之后再跑,计数仍是 203 / 71,门没有数到自己。 (同一个坑今晚在另一处出现过:一条断言被它自己解释用的注释绊倒。) 没有 `paths:` 过滤——新的 home path 可以落在仓里任何文件上,按目录门住它正好是 这道门要防的那种盲区。副作用是它在每个 PR 上都跑,因此也是 #828 里可以设成 required 的候选。job 名 `home-path-baseline` 全仓唯一(#916)。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-home-path-baseline.py | 172 ++++++++++++++++++++ .github/workflows/home-path-baseline.yml | 40 +++++ docs/home-path-baseline.txt | 76 +++++++++ 3 files changed, 288 insertions(+) create mode 100644 .github/scripts/check-home-path-baseline.py create mode 100644 .github/workflows/home-path-baseline.yml create mode 100644 docs/home-path-baseline.txt diff --git a/.github/scripts/check-home-path-baseline.py b/.github/scripts/check-home-path-baseline.py new file mode 100644 index 000000000..4ba231e70 --- /dev/null +++ b/.github/scripts/check-home-path-baseline.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""No NEW `/home//` paths in this public repository. + +Measured on origin/main 2026-08-18: 82 tracked files carry 202 occurrences of a +hardcoded home directory, spanning 14 distinct names that look like real people +(system accounts — ubuntu / root / runner / node / ci — account for zero of them). +This repository is public, so those are exposed. + +🔴 This gate does NOT require zero, and that is deliberate. + +Most of the 82 are in docs/ and tests/, and a good share of them are part of an +incident record — a transcript, a pane capture, a path that appears in the +command someone actually ran. Scrubbing those makes the evidence stop matching +what happened, which is its own kind of damage. Cleaning them up is a judgement +call per file, not something a gate can drive. + +A gate that demanded zero would be red from its first day, and a gate that is red +only because of a backlog stops meaning anything the moment the backlog clears — +nobody can tell whether it still works. A baseline gate is green today and red on +anything NEW, which is the property worth having. + +The baseline is per file, not a single total: a scalar would let a new file slip +in whenever an old one lost a line. Cleanups are welcome — the gate tells you to +lower the baseline when a file improves, so the floor only ever ratchets down. + +Fail-closed: scanning zero files is a scope regression (exit 2), not a clean run. +""" +import collections +import re +import subprocess +import sys + +BASELINE = "docs/home-path-baseline.txt" + +HOME_PATH = re.compile(r"/home/([A-Za-z0-9._-]+)/") + +# Names that are documentation placeholders or machine accounts rather than a +# person. Kept explicit (and covered by --selftest) because the count moves when +# this set changes: someone who introduces a new placeholder spelling would +# otherwise see the number jump and not know whether they caused it. +NOT_A_PERSON = { + "user", "USER", "username", "USERNAME", "youruser", "your-user", "your_user", + "me", "someone", "name", "NAME", "test", "testuser", "example", + # machine / CI accounts — a path under these leaks nothing about a person + "ubuntu", "root", "runner", "node", "ci", "runneradmin", +} + + +def is_person(name: str) -> bool: + """A `/home//` worth counting: not a placeholder, not a machine account.""" + if name in NOT_A_PERSON: + return False + if len(name) <= 2: # `/home/x/` in a diagram, not a login + return False + return True + + +def scan() -> tuple[dict[str, int], int]: + """Per-file counts of person-looking home paths, plus files searched.""" + listing = subprocess.run( + ["git", "ls-files"], capture_output=True, text=True, check=False + ).stdout.split("\n") + tracked = [f for f in listing if f] + + out = subprocess.run( + ["git", "grep", "-InE", r"/home/[A-Za-z0-9._-]+/"], + capture_output=True, text=True, check=False, + ).stdout + + counts: dict[str, int] = collections.Counter() + for line in out.split("\n"): + if not line: + continue + parts = line.split(":", 2) + if len(parts) < 3: + continue + path, _lineno, text = parts + hits = sum(1 for n in HOME_PATH.findall(text) if is_person(n)) + if hits: + counts[path] += hits + return dict(counts), len(tracked) + + +def read_baseline() -> dict[str, int]: + base: dict[str, int] = {} + try: + for line in open(BASELINE, encoding="utf-8"): + line = line.strip() + if not line or line.startswith("#"): + continue + path, _, n = line.rpartition("\t") + base[path] = int(n) + except FileNotFoundError: + return {} + return base + + +def main() -> int: + counts, tracked = scan() + if tracked == 0: + print("::error::git ls-files returned nothing — scope regression, refusing to pass") + return 2 + + base = read_baseline() + if not base: + print(f"::error::{BASELINE} is missing or empty — refusing to pass without a floor to compare against") + return 2 + + problems = 0 + for path, n in sorted(counts.items()): + allowed = base.get(path, 0) + if n > allowed: + problems += 1 + what = "new file with" if path not in base else f"{allowed} → {n}" + print( + f"::error file={path}::{what} hardcoded /home// path(s). This repository is " + f"public. Use $HOME, ~, or a placeholder like /home/user/. If this line is part of " + f"an incident record and the real path is load-bearing, say so in the PR and raise " + f"the number for this file in {BASELINE}." + ) + + improved = [p for p, n in base.items() if counts.get(p, 0) < n] + print( + f"scanned {tracked} tracked file(s); {sum(counts.values())} person-looking /home/ path(s) " + f"across {len(counts)} file(s); baseline covers {len(base)} file(s)" + ) + if improved: + print( + f"note: {len(improved)} file(s) now carry fewer than the baseline allows — lower their " + f"numbers in {BASELINE} so the floor ratchets down and cannot silently refill." + ) + if problems: + print(f"\n{problems} file(s) above baseline.") + return 1 + print("no file is above its baseline.") + return 0 + + +def selftest() -> int: + """Pin the classifier. It decides WHAT gets counted, so it decides the number.""" + # 🔴 这些夹具刻意不写成字面量 `/home/<名字>/`,而是拼出来的。 + # 一个扫描器的测试夹具如果长得就像它要扫的那个东西,它会扫到自己 —— + # 提交这个文件的那一刻,基线里就多出一个文件、一次命中,而那次命中不是缺陷。 + # (同一个坑今晚在另一处出现过:一条断言被它自己解释用的注释绊倒。) + # 名字也用合成的,不用任何真实登录名 —— 这个仓是公开的。 + slash = "/" + home = f"{slash}home{slash}" + cases = [ + ("a plain login name counts", is_person("zqxjkv")), + ("documentation placeholder does not", not is_person("user")), + ("uppercase placeholder does not", not is_person("USER")), + ("machine account does not", not is_person("runner")), + ("root does not", not is_person("root")), + ("single letter does not", not is_person("x")), + ("two letters do not", not is_person("ab")), + ("three letters do", is_person("abc")), + ("regex finds the name between slashes", HOME_PATH.findall(f"cd {home}zqxjkv{slash}work") == ["zqxjkv"]), + ("regex needs the trailing slash", HOME_PATH.findall(f"{home}zqxjkv") == []), + ("two paths on one line are both found", len(HOME_PATH.findall(f"{home}aaa{slash}x {home}bbb{slash}y")) == 2), + ] + bad = [n for n, ok in cases if not ok] + for n, ok in cases: + print(f" {'ok ' if ok else 'FAIL'} {n}") + if bad: + print(f"::error::classifier selftest failed: {len(bad)} case(s)") + return 1 + print(f"selftest: {len(cases)}/{len(cases)} ok") + return 0 + + +if __name__ == "__main__": + sys.exit(selftest() if "--selftest" in sys.argv else main()) diff --git a/.github/workflows/home-path-baseline.yml b/.github/workflows/home-path-baseline.yml new file mode 100644 index 000000000..6007545c3 --- /dev/null +++ b/.github/workflows/home-path-baseline.yml @@ -0,0 +1,40 @@ +# This repository is public. A hardcoded `/home//` in it exposes a real +# person's login name, and on 2026-08-18 there were 203 of them across 71 tracked +# files — and the number had grown since the issue was opened (#894). +# +# 🔴 The gate is a BASELINE, not zero. Many of the existing hits are part of an +# incident record where the real path is what makes the evidence reproducible; +# scrubbing those is a per-file judgement call, not something CI can drive. A gate +# that demanded zero would be red from day one, and a gate red only from backlog +# stops meaning anything the moment the backlog clears. +# +# No `paths:` filter, on purpose: a new home path can land in any file in the repo, +# so gating this on a subdirectory would be exactly the blind spot it exists to +# prevent. That also makes it a candidate for a required check (#828). + +name: lint (home path baseline) + +on: + pull_request: + push: + branches: [main] + +jobs: + scan: + # Unique across the repo — a check name is the only identifier a branch + # protection rule can name (#916). + name: home-path-baseline + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # `git grep` and `git ls-files` need the real tree, and the default + # shallow checkout is enough for both — but fetch-depth 0 keeps the + # baseline comparable if this ever grows a "compare against merge-base" + # mode. Cheap here; this repo is small. + fetch-depth: 0 + # The classifier decides WHAT gets counted, so it decides the number. If it + # silently stops recognising a name shape, the scan below still prints a + # count and exits 0. + - run: python3 .github/scripts/check-home-path-baseline.py --selftest + - run: python3 .github/scripts/check-home-path-baseline.py diff --git a/docs/home-path-baseline.txt b/docs/home-path-baseline.txt new file mode 100644 index 000000000..170e4cc41 --- /dev/null +++ b/docs/home-path-baseline.txt @@ -0,0 +1,76 @@ +# 每行:<文件>\t<该文件里 /home/<人名>/ 的出现次数> +# 这是一个**上限**,不是目标。它存在的意义是让「新增」变红,而不是让「存量」变红—— +# 一道只因积压而红的门,等积压清完就再也不会红,到时没人知道它还有没有效。 +# 清理某个文件之后,把它这一行的数字改小(或整行删掉),楼层就只会往下走、不会悄悄回填。 +# 生成于 origin/main,2026-08-18:203 次 / 71 文件 / 共扫 2000 个跟踪文件。 +agent-network/docs/lessons/2026-05-28-mcp-json-shared-identity-pollution.md 3 +agent-network/docs/tests/report-grok-build-capability.txt 4 +agent-network/scripts/README.md 3 +agent-network/scripts/opencode-node-start.sh 4 +agent-network/scripts/pm2-opencode.config.cjs 3 +agent-network/src/batch-workdir.test.ts 3 +agent-network/src/grok-copresence-profile.test.ts 5 +agent-network/src/project-key.ts 1 +agent-network/src/tmux-pane-prompt.test.ts 1 +agent-network/tests/project-key.test.ts 7 +agent-node/src/runtime/fetch-attachment.ts 1 +agent-node/tests/feishu-tool-deny.test.ts 2 +agent-node/tests/rfc-030-copresence-observer.ts 2 +channel/commhub-channel.ts 1 +deploy/dashboard/dash-start.sh 1 +deploy/dashboard/ecosystem.config.cjs 1 +deploy/fleet/pm2-fleet-boot.sh 3 +deploy/tunnel/frpc.service 1 +docs/anet-codex-code-cli-design.md 1 +docs/anet-codex-mcp-server-plan.md 1 +docs/anet-codex-remote-control-plan.md 1 +docs/codex-cli-direct-comm-research.md 1 +docs/grok-build-runtime.md 2 +docs/research/codex-sdk-goal-feasibility.md 1 +docs/research/grok-video-gen-capability-probe.en.md 3 +docs/research/grok-video-gen-capability-probe.md 3 +docs/research/grok-x-search-capability-probe.en.md 1 +docs/research/grok-x-search-capability-probe.md 1 +docs/research/intern-tool-calling-investigation.md 1 +docs/research/sdk-concurrency-investigation.md 1 +docs/rfcs/RFC-005-codex-code-cli-runtime.md 1 +docs/runbooks/opencode-tui-copresence.md 1 +docs/sdk-upgrade-2026-05-12-baseline.md 4 +docs/team-collab-playbook.md 1 +docs/tests/p-498-reply-warning/witnessed-red.txt 1 +docs/tests/p-517-mcp-write-scope/witnessed-red-p2-ghost.txt 3 +docs/tests/p-517-mcp-write-scope/witnessed-red-pins-sabotage.txt 21 +docs/tests/p-517-mcp-write-scope/witnessed-red.txt 21 +docs/tests/p120-codex-mcp-bridge-smoke.md 6 +docs/tests/p212-send-task-storm/report.md 2 +docs/tests/report-grok-runtime-matrix-2026-05-27.md 1 +docs/tests/report-test119-servers-endpoint.md 1 +docs/tests/report-test140-server-health-agents.md 1 +docs/tests/report-test227-live-uat.txt 1 +docs/tests/report-test227.txt 2 +docs/tests/report-test229-opencode-final-review-packet.txt 1 +docs/tests/report-test231-grok-socket-sandbox-green.txt 2 +docs/tests/report-test231-grok-socket-sandbox-red.txt 2 +docs/tests/report-test231-grok-socket-sandbox-summary.txt 1 +docs/tests/report-test232-live-uat.txt 1 +docs/tests/report-test386.txt 2 +docs/tests/report-test573.txt 2 +docs/tests/report-test653-batch-workdir.txt 1 +docs/tests/report-test735-hub-daemon-rebuild.txt 1 +server/src/uploads.test.ts 1 +tests/test-rename-identity/lib/helpers.sh 1 +tests/test225-grok-preview-package-live/auth-evidence-diagnostic.test.mjs 1 +tests/test225-grok-preview-package-live/run.sh 3 +tests/test231-grok-socket-sandbox/run.sh 7 +tests/test380-gateway-topology-probe/docker-compose.yml 1 +tests/test383-thinking-only-fallback/docker-compose.yml 1 +tests/test386-opencode-agent-node-gate/Dockerfile 9 +tests/test386-opencode-agent-node-gate/nonroot-real-package.ts 11 +tests/test653-batch-workdir/run.sh 9 +tests/test698-atomic-peer-reply/cli-wiring-e2e.ts 2 +tests/test698-atomic-peer-reply/legacy-cli-failure-e2e.ts 1 +tests/test698-atomic-peer-reply/legacy-wire-e2e.ts 2 +tests/test735-hub-daemon-rebuild/run.sh 6 +tests/test736-pm2-fleet-rebuild/Dockerfile 2 +tests/test736-pm2-fleet-rebuild/run.sh 1 +tests/test765-batch-runtime-gate/run.sh 2 From e1d746c5260ecf0e5992853a91ec88589208181a Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 03:56:26 +0800 Subject: [PATCH 36/59] =?UTF-8?q?ops(deploy):=20=E6=8A=8A=20README=20?= =?UTF-8?q?=E9=87=8C=E9=82=A3=E6=9D=A1=E3=80=8C=E5=8F=AA=E5=9C=A8=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E6=97=B6=E8=B7=91=E4=B8=80=E6=AC=A1=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=81=9A=E6=88=90=E4=B8=80=E4=B8=AA=E8=83=BD?= =?UTF-8?q?=E5=8F=8D=E5=A4=8D=E8=B7=91=E7=9A=84=E8=84=9A=E6=9C=AC=20(#922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy/fleet/README.md 的安装步骤里本来就有这条: test "$(git hash-object deploy/fleet/pm2-fleet-boot.sh)" = \ "$(git hash-object "$HOME/.local/bin/pm2-fleet-boot.sh")" 🔴 但它只在**安装的那一刻**跑一次,所以它只能证明「装的那一刻是对的」——它管不住 之后任何一次手改。不是没人想到要校验;校验就写在安装步骤正中间,缺的是有人在装完 之后再跑它一次。 2026-08-18 在生产主机上跑本脚本,4 对里 1 对不一致: 🔴 deploy/fleet/pm2-fleet-boot.sh 仓=ba9f214f 机器=a667de68 ✅ deploy/fleet/pm2-fleet.service ✅ deploy/hub/hub-daemon.sh ✅ deploy/dashboard/dash-start.sh 漂移是**孤立的一处**,不是系统性的——这个信息本身有用:另外三对说明这条安装链 平时是被遵守的。 而且方向是反的:仓里那份**更新**、多一道 `pm2 jlist` 失败时拒绝 resurrect 的 fail-closed 护栏,机器上那份是 7 月 30 日的、没有。护栏写好了、提交了,**但从来 没有部署到会真正执行它的地方**。见 #839。 判据用仓库自己那条(`git hash-object`),不另造等价物——结论谁都能重跑,且不依赖 任何人对「什么算不同」的理解。 **证据(都不接管道取 exit code)** 真跑(本机) exit 1 4 对/漂移 1/缺失 0,点名那一对并给出 diff 命令 不在 git 仓库里跑 exit 2 "拒绝通过" 清单指向不存在的仓库文件(变异) exit 2 "清单过期,拒绝通过" 清单为空 exit 2 (代码路径,未构造) 后两条是 fail-closed:判不了的时候红,而不是「检查了 0 对,全部一致」。 🔴 它只报告,不修。把主机上的文件换成仓里的版本是一次真实运维动作——会改变所有 pm2 托管进程的重启路径,需要人挑窗口,不该由一个检查脚本顺手做掉。同理它没有被 装到主机上、没有挂进任何定时器:那也是运维动作。 放在 deploy/ 而不是 .github/scripts/,因为它要在**主机**上跑;CI 看不到 ~/.local/bin,在 CI 里跑它只会得到「主机缺失 4 个」。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- deploy/check-deployed-copies.sh | 95 +++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100755 deploy/check-deployed-copies.sh diff --git a/deploy/check-deployed-copies.sh b/deploy/check-deployed-copies.sh new file mode 100755 index 000000000..14e7a6f0c --- /dev/null +++ b/deploy/check-deployed-copies.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# 部署副本有没有跟仓库漂移。在**主机上**跑,不是在 CI 里 —— CI 看不到 ~/.local/bin。 +# +# 为什么需要它: +# +# deploy/fleet/README.md 的安装步骤里本来就有这条校验: +# +# test "$(git hash-object deploy/fleet/pm2-fleet-boot.sh)" = \ +# "$(git hash-object "$HOME/.local/bin/pm2-fleet-boot.sh")" +# +# 🔴 但它只在**安装的那一刻**跑一次,所以它只能证明「装的那一刻是对的」—— +# 它管不住之后任何一次手改。 +# +# 2026-08-18 在生产主机上跑本脚本的判据,4 对里 1 对不一致: +# +# 🔴 deploy/fleet/pm2-fleet-boot.sh 仓=ba9f214f 机器=a667de68 +# ✅ deploy/fleet/pm2-fleet.service +# ✅ deploy/hub/hub-daemon.sh +# ✅ deploy/dashboard/dash-start.sh +# +# 而且方向是反的:仓里那份**更新**、多一道 `pm2 jlist` 失败时拒绝 resurrect 的 +# fail-closed 护栏,机器上那份是 7 月 30 日的、没有。也就是说护栏写好了、提交了, +# **但从来没有部署到会真正执行它的地方**。见 #839。 +# +# 判据用的是仓库自己的那条(`git hash-object`),不是另造一个等价物 —— 结论谁都能 +# 重跑,而且不依赖任何人对「什么算不同」的理解。 +# +# 用法: +# bash deploy/check-deployed-copies.sh # 在仓库根目录跑 +# exit 0 = 全部一致 / 1 = 有漂移 / 2 = 无法判断(fail-closed) +# +# 🔴 它只报告,不修。把机器上的文件换成仓里的版本是一次真实运维动作 —— 它会改变 +# 所有 pm2 托管进程的重启路径,需要人挑窗口,不该由一个检查脚本顺手做掉。 + +set -uo pipefail + +# 每行:<仓库内路径>|<主机上的部署路径> +# 加新条目时:确认它确实是「安装时从仓库拷过去」的那类文件,而不是主机独有的状态。 +MANIFEST=$(cat </dev/null 2>&1; then + echo "::error::git 不可用,无法计算 hash-object —— 拒绝通过" + exit 2 +fi +if [ ! -d .git ] && ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "::error::不在 git 仓库里(请在仓库根目录跑)—— 拒绝通过" + exit 2 +fi + +checked=0 +drift=0 +missing=0 + +while IFS='|' read -r repo host; do + [ -z "${repo:-}" ] && continue + if [ ! -f "$repo" ]; then + # 仓库里那份不见了 = 清单过期或路径改了。这不是「一致」,是判不了。 + echo "::error::清单里的仓库文件不存在: $repo —— 清单过期,拒绝通过" + exit 2 + fi + checked=$((checked + 1)) + a=$(git hash-object "$repo") + if [ ! -f "$host" ]; then + missing=$((missing + 1)) + echo "::error::$repo 在主机上没有对应文件($host)。要么这台机器没装过这条链,要么路径变了。" + continue + fi + b=$(git hash-object "$host") + if [ "$a" != "$b" ]; then + drift=$((drift + 1)) + echo "::error::$repo 与主机副本不一致" + echo " 仓库: $a" + echo " 主机: $b ($host)" + echo " 先看清楚方向再动:仓库那份可能比主机新(修好了没部署),也可能主机上被人手改过。" + echo " diff <(git show HEAD:$repo) $host" + fi +done <<< "$MANIFEST" + +if [ "$checked" -eq 0 ]; then + echo "::error::清单为空,一个都没检查 —— 拒绝通过" + exit 2 +fi + +echo "检查了 $checked 对部署副本;不一致 $drift 个,主机缺失 $missing 个" +if [ "$drift" -gt 0 ] || [ "$missing" -gt 0 ]; then + exit 1 +fi +echo "全部与仓库一致。" +exit 0 From a0e34db96d04093e397716016f4563f338c3bcca Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 04:15:23 +0800 Subject: [PATCH 37/59] =?UTF-8?q?test(ci):=20=E7=BB=99=20src/=20=E8=A1=A5?= =?UTF-8?q?=E7=BB=9D=E5=AF=B9=E4=B8=8B=E9=99=90=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=A4=E9=81=93=E9=97=A8=E9=83=BD=E6=94=BE=E8=A1=8C=E3=80=8C?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E5=88=A0=E9=99=A4=E6=B5=8B=E8=AF=95=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E3=80=8D(#817)=20(#854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #817 实测:origin/main 上删掉 46 个 agent-network/src 测试里的 40 个,test745 仍 test_files=6 executed_files=6 RESULT: PASS rc=0 因为分母和执行数会跟着现实一起缩水 —— `executed >= test_files` 只能抓「runner 少跑了文件」,抓不到「文件没了」。test725 更彻底:它连 src 的分母都没有, 只有一行 `bun test src/`。 这次: test745 加 AGENT_NETWORK_SRC_FLOOR=40(磁盘上现有 46) test725 补 src 分母 + AGENT_NODE_SRC_FLOOR=80(现有 91) 并照 test745 的形状补上 executed >= discovered 下限是「大量删除」的绊线,不是精确计数:真删了测试就故意改这个数, 让删除这件事必须在 diff 里显形。 见证红(把插入的块逐字抽出来跑,ROOT 指向构造的树): 文件齐 46 / 91 → OK 按 #817 删到只剩 6 个 → FAIL: only 6 test file(s) under agent-network/src, floor is 40 FAIL: only 6 test file(s) under agent-node/src, floor is 80 不动 #800 的范围:那个 PR 守的是 tests/ 目录(FLOOR 15 / 5),这个补的是 src/。 Co-authored-by: vansin --- tests/test725-agent-node-unit-ci/run.sh | 23 ++++++++++++++++++++++ tests/test745-agent-network-unit-ci/run.sh | 10 ++++++++++ 2 files changed, 33 insertions(+) diff --git a/tests/test725-agent-node-unit-ci/run.sh b/tests/test725-agent-node-unit-ci/run.sh index 40a64ad6e..117bca4a6 100644 --- a/tests/test725-agent-node-unit-ci/run.sh +++ b/tests/test725-agent-node-unit-ci/run.sh @@ -13,6 +13,20 @@ echo "source_commit=$SOURCE_COMMIT" echo "bun=$(bun --version) node=$(node --version) uid=$(id -u node)" command -v crontab >/dev/null || { echo "FAIL: crontab dependency missing" >&2; exit 1; } +# #817:这道门原本连 src 的分母都没有 —— 只有一行 `bun test src/`, +# 删光测试文件它也不会红。补上分母 + 绝对下限,和 test745 对齐。 +test_files=$(find "$ROOT/agent-node/src" -type f -name '*.test.ts' | wc -l | tr -d ' ') +[[ "$test_files" =~ ^[1-9][0-9]*$ ]] || { + echo "FAIL: agent-node test-file denominator is empty" >&2 + exit 1 +} +echo "test_files=$test_files" +AGENT_NODE_SRC_FLOOR=80 +[[ "$test_files" -ge "$AGENT_NODE_SRC_FLOOR" ]] || { + echo "FAIL: only $test_files test file(s) under agent-node/src, floor is $AGENT_NODE_SRC_FLOOR" >&2 + exit 1 +} + echo "[L0] full agent-node/src unit suite as non-root" runuser -u node -- env HOME=/home/node \ bash -lc 'cd /workspace/agent-node && bun test src/' \ @@ -27,6 +41,15 @@ grep -Eq '^[[:space:]]*0 fail$' /tmp/test725-green.log || { exit 1 } +# 把「磁盘上有几个」和「bun 跑了几个」绑在一起:范围被悄悄收窄(glob 改了、 +# 测试挪进子目录、bun 配置多了个 exclude)时自己变红。 +executed=$(grep -Eo 'across [0-9]+ files' /tmp/test725-green.log | grep -Eo '[0-9]+' | tail -1) +echo "executed_files=${executed:-unknown} discovered_files=$test_files" +[[ -n "$executed" && "$executed" -ge "$test_files" ]] || { + echo "FAIL: bun executed ${executed:-?} file(s) but $test_files exist under src/" >&2 + exit 1 +} + echo "[L1] witnessed-red: disconnect readable attachment content from runtime" TARGET=$'deliverToRuntime: () => processTask(\n runtimeContent,' MUTATED=$'deliverToRuntime: () => processTask(\n content,' diff --git a/tests/test745-agent-network-unit-ci/run.sh b/tests/test745-agent-network-unit-ci/run.sh index ae46f0fc5..084838c1a 100644 --- a/tests/test745-agent-network-unit-ci/run.sh +++ b/tests/test745-agent-network-unit-ci/run.sh @@ -19,6 +19,16 @@ test_files=$(find "$ROOT/agent-network/src" -type f -name '*.test.ts' | wc -l | } echo "test_files=$test_files" +# 🔴 绝对下限:上面那条 `[[ "$test_files" =~ ^[1-9][0-9]*$ ]]` 只要求分母非零, +# 下面 :L0 的 `executed >= test_files` 也只能抓「runner 少跑了文件」—— 两个数 +# 会跟着现实一起缩水。#817 实测:删掉 46 个 src 测试里的 40 个,test_files=6、 +# executed=6,这道门照样 PASS rc=0。所以真删了测试就故意改这个数。 +AGENT_NETWORK_SRC_FLOOR=40 +[[ "$test_files" -ge "$AGENT_NETWORK_SRC_FLOOR" ]] || { + echo "FAIL: only $test_files test file(s) under agent-network/src, floor is $AGENT_NETWORK_SRC_FLOOR" >&2 + exit 1 +} + echo "[L0] full agent-network/src unit suite as non-root" runuser -u node -- env HOME=/home/node \ bash -lc 'cd /workspace/agent-network && bun test src/' \ From 50dd008db8fca55a2a13f99601b04cb8e9371e28 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 04:21:23 +0800 Subject: [PATCH 38/59] =?UTF-8?q?test(test224):=20=E3=80=8C=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E5=B7=B2=E7=A6=81=E7=94=A8=E3=80=8D=E4=BB=A5=E5=89=8D?= =?UTF-8?q?=E6=98=AF=E4=B8=80=E5=8F=A5=E5=A3=B0=E6=98=8E=EF=BC=8C=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=98=AF=E4=B8=80=E6=9D=A1=E6=96=AD=E8=A8=80=20(#923)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run.sh 里那一行是: log "network: disabled by runner" 它挨着的每一条都真的在验: [ ! -e "$ROOT/.git" ] || fail "image contains repository metadata" [ ! -e /root/.grok ] || fail "image contains a Grok home" **只有它在复述一个别人应该做过的事。** 而 [L2] 那一步的全部意义是「在没有网络的情况下构建候选包」。如果 runner 忘了 `--network none`,那一步照样绿——而它证明的东西并不成立。🔴 忘记加那个 flag 与 正确加了它,产出的证据逐字相同。 判据用直接观察:`--network none` 的容器里 `/sys/class/net` 只有 `lo`。 net_ifaces=$(ls /sys/class/net | tr '\n' ' ' | sed 's/ *$//') [ -n "$net_ifaces" ] || fail "cannot read /sys/class/net — …refusing to claim it is" [ "$net_ifaces" = "lo" ] || fail "network is NOT disabled: …[$net_ifaces]…" pass "network is off (verified: /sys/class/net = [$net_ifaces])" 两个方向都收:读不到 `/sys/class/net` 时**拒绝声称网络是关的**(fail-closed, 而不是当成"看不见就是没有");看到第二个接口时点名它,并说明后面的绿因此不作数。 本机对照(有网的宿主):`/sys/class/net` = `docker0 eth0 lo` → 这条断言会红。 注:test224 目前不被任何 CI 引用(#861 统计的 180 个孤儿之一),PR #803 正在把它 注册进 qa.sh。本 commit 只修判据,不改注册状态——一道会被跑的假断言和一道不会被跑 的真断言,前者更危险,先修前者。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- tests/test224-grok-preview-security/run.sh | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test224-grok-preview-security/run.sh b/tests/test224-grok-preview-security/run.sh index c1ff09a19..a867f66de 100644 --- a/tests/test224-grok-preview-security/run.sh +++ b/tests/test224-grok-preview-security/run.sh @@ -71,7 +71,21 @@ scan_tree_for_markers() { log "# test224 — Grok preview credential and package gate" log "date: $(date -Is)" -log "network: disabled by runner" +# 🔴 这一行以前是 `log "network: disabled by runner"` —— 一句**声明**,不是断言。 +# 它挨着的每一条(`[ ! -e "$ROOT/.git" ] || fail …`)都真的在验,只有它在复述一个 +# 别人应该做过的事。而 [L2] 那一步的全部意义是「在没有网络的情况下构建」—— +# 如果 runner 忘了 `--network none`,那一步照样绿,而它证明的东西并不成立。 +# +# 判据用直接观察:`--network none` 的容器里 /sys/class/net 只有 lo。 +# 有任何第二个接口,就说明这一轮的「无网络」前提是假的,后面的绿都不作数。 +net_ifaces=$(ls /sys/class/net 2>/dev/null | tr '\n' ' ' | sed 's/ *$//') +if [ -z "$net_ifaces" ]; then + fail "cannot read /sys/class/net — cannot establish whether the network is off; refusing to claim it is" +fi +if [ "$net_ifaces" != "lo" ]; then + fail "network is NOT disabled: /sys/class/net has [$net_ifaces], expected only [lo]. Run this suite with --network none; [L2] claims to build without network and that claim is void here." +fi +pass "network is off (verified: /sys/class/net = [$net_ifaces])" log "source_commit=$SOURCE_COMMIT" log "[L0] isolated, synthetic-only environment" From 94fab2326b4fa553bd066125a4e9eb41a0b37d1c Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 04:47:15 +0800 Subject: [PATCH 39/59] =?UTF-8?q?test(server):=20resolveRestWriteNetworkId?= =?UTF-8?q?=20=E6=98=AF=20network-scope.ts=20=E9=87=8C=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E9=9B=B6=E6=B5=8B=E8=AF=95=E7=9A=84=E5=AF=BC=E5=87=BA=E5=87=BD?= =?UTF-8?q?=E6=95=B0(#819)=20(#925)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(server): 给 resolveRestWriteNetworkId 补测试——它是 network-scope.ts 里唯一没被任何测试点名的导出函数(#819) 同文件五个兄弟各有 1-2 个测试文件点名,只有它是 0: resolveRestNetworkScope 1 canRestWriteNetwork 1 singleNetworkId 2 addNetworkScope 1 getUserNetworkIds 1 resolveRestWriteNetworkId 🔴 0 而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的 规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了 「这个管理员恰好只属于一个网络」;**写**不能继承这个歧义。 规则两半都钉了,因为它们坏掉的后果完全不同: - **放行那半**(admin + 恰好 1 个成员关系 → 用它)坏了 = 管理员写任何东西都要 显式带 network_id。很吵,但安全,所以没人急着修。 - **收紧那半**(admin + 0 或 ≥2 → null)坏了 = 一次写入**落到一个没被指定的 网络里**,而调用方看到的是成功。 只写反向断言不够:一个「永远返回 null」的实现能通过所有收紧用例。所以每条收紧 断言都配了正控。 **变异验证(先红后绿)** 未变异 10 pass / 0 fail A:函数体开头直接 return null 6 pass / 4 fail ← 打掉「放行」那几条 B:去掉 memberships.length === 1 判断 8 pass / 2 fail ← 打掉「≥2→null」「0→null」 还原 10 pass / 0 fail 两次变异都带 `assert 锚点命中 == 1`,确认不是 no-op。 🔴 **有一条断言我一开始写错了,改成记录留在文件里**:我以为「作用域为空数组 → null」,实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员 关系回退分支。 追下去发现 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义(`:46`): **非 admin 请求了一个自己没有角色的网络**,带着 `denied: "access denied to requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。 现在**没有**问题:那一行只对非 admin 产生(admin 在 `:42` 提前返回 networkIds:null),而非 admin 走到这里必然 null。但如果将来有任何路径让 admin 拿到 `networkIds: []`,这次写入会**忽略那条明确的拒绝**。所以两条都钉住了:当前 行为,以及那条让它安全的前提(并对前提本身也断了一次)。 Co-Authored-By: Claude Opus 5 * test(ci): 把 #819 那条新测试挂进 L0——否则它是一条永远不会被跑的测试 🔴 自查发现的:`server/src` 下有 **71** 个 `*.test.ts`,而 CI 的 L0 只点名 **5** 个。 我在上一个 commit 里加的那条测试**不在这 5 个里**——也就是说,如果只加文件不改 qa.sh,我刚写的这条测试**在 CI 里一次都不会跑**。 这正是我这两天在别人代码里反复指出的形状(#861:204 个套件里 21 个被 CI 引用; #817:门连分母都没有),而这一次是我自己差点交出去。 **够不够格进 L0,是量出来的不是假设的**: 依赖链 network-scope → db → db-adapter(bun:sqlite) / auth(node:crypto) 全部是 bun/node 内置,**不需要 bun install** —— 满足 L0 的零依赖预算 耗时 538 ms 对照 已在 L0 的 auth-tokens = 592 ms ← 比它还快 (L0 的排除标准写在数组下方的注释里:observer-avatar-http 被排除是因为它启真 HTTP server、import 链需要 MCP SDK。这条测试不碰这两样。) 跑一次确认没破: ✓ L0 password-dict / auth-tokens / auth-validate / observer-push ✓ L0 avatar-validate / rest-write-scope ✓ ALL PASS in 2s 顺带留一个数字给 #798(它要给 server 补聚合单测门):**71 个里进 CI 的现在是 6 个**。 本 commit 只把新增的这条挂上,不动其余 65 个的归属——那是 #798 的范围。 Co-Authored-By: Claude Opus 5 --------- Co-authored-by: t Co-authored-by: Claude Opus 5 --- scripts/qa.sh | 1 + .../src/rest-write-network-resolution.test.ts | 133 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 server/src/rest-write-network-resolution.test.ts diff --git a/scripts/qa.sh b/scripts/qa.sh index 665614ca1..5d8a4accd 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -52,6 +52,7 @@ L0_TESTS=( "auth-validate:server/src/auth-validate.test.ts" "observer-push:server/src/observer-push.test.ts" "avatar-validate:server/src/avatar-validate.test.ts" + "rest-write-scope:server/src/rest-write-network-resolution.test.ts" # observer-avatar-http.test.ts 不进 L0:它启真 HTTP server,import 链需要 # MCP SDK,而 CI 的 L0 层按设计不跑 bun install(ms 级零依赖预算)。 # 它的 CI 归属是会安装依赖的层级;本地跑法见该文件头注释的门禁命令。 diff --git a/server/src/rest-write-network-resolution.test.ts b/server/src/rest-write-network-resolution.test.ts new file mode 100644 index 000000000..eeff5b96e --- /dev/null +++ b/server/src/rest-write-network-resolution.test.ts @@ -0,0 +1,133 @@ +// #819 — `resolveRestWriteNetworkId` 是 network-scope.ts 里唯一没有任何测试点名的 +// 导出函数。同文件的五个兄弟各有 1-2 个测试文件点名它们: +// +// resolveRestNetworkScope 1 canRestWriteNetwork 1 +// singleNetworkId 2 addNetworkScope 1 +// getUserNetworkIds 1 resolveRestWriteNetworkId 🔴 0 +// +// 而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的 +// 规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了 +// 「这个管理员恰好只属于一个网络」;而**写**不能继承这个歧义 —— 只有在确实只有 +// 一个成员关系时才用它,0 个或 ≥2 个都必须显式指定网络。 +// +// 🔴 这条规则的两半都要钉: +// - **放行那半**(admin + 恰好 1 个成员关系 → 用它)如果坏了,会退化成「管理员 +// 写任何东西都要显式带 network_id」——很吵,但**安全**,所以没人会急着修; +// - **收紧那半**(admin + 0 或 ≥2 → null)如果坏了,一次写入会**落到一个没被 +// 指定的网络里**,而调用方看到的是成功。 +// +// 只写反向断言是不够的:一个「永远返回 null」的实现能通过所有「收紧」用例。 +// 所以每一条收紧断言都配了正控。 +// +// Run: COMMHUB_DB=/tmp/819-rest-write-scope.db bun test src/rest-write-network-resolution.test.ts + +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { db } from "./db.js"; +import { resolveRestNetworkScope, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js"; + +const NET_A = "net_819_a"; +const NET_B = "net_819_b"; +const U_SINGLE = "u819_single"; // 只属于 NET_A +const U_MULTI = "u819_multi"; // 属于 NET_A + NET_B +const U_NONE = "u819_none"; // 一个都不属于 +const ALL_USERS = [U_SINGLE, U_MULTI, U_NONE]; + +function cleanup() { + try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {} + try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {} + for (const u of ALL_USERS) { + try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {} + } +} + +function seed() { + for (const u of ALL_USERS) { + db.run( + `INSERT INTO users (user_id, username, password_hash, role, created_at) + VALUES (?1, ?2, 'x', 'user', datetime('now'))`, + [u, u], + ); + } + db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]); + db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]); + const member = (u: string, net: string, role: string) => + db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]); + member(U_SINGLE, NET_A, "owner"); + member(U_MULTI, NET_A, "member"); + member(U_MULTI, NET_B, "owner"); +} + +beforeEach(() => { cleanup(); seed(); }); +afterAll(() => { cleanup(); }); + +/** 管理员的读作用域:全局(networkIds=null),这正是歧义的来源。 */ +const ADMIN_SCOPE: RestNetworkScope = { networkIds: null }; +const scopeOf = (...ids: string[]): RestNetworkScope => ({ networkIds: ids }); +const ctx = (userId: string) => ({ userId, networkId: null }); + +describe("#819 resolveRestWriteNetworkId — 作用域里就能确定时,直接用它", () => { + test("作用域恰好一个网络 → 用那一个(与是不是 admin 无关)", () => { + expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), false)).toBe(NET_A); + expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), true)).toBe(NET_A); + }); + + test("作用域两个网络 → 歧义,null(即使调用者是 admin)", () => { + expect(resolveRestWriteNetworkId(scopeOf(NET_A, NET_B), ctx(U_MULTI), true)).toBeNull(); + }); + + // 🔴 这一条我一开始断错了,留下来当记录:我以为「作用域为空数组 → null」, + // 实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员关系 + // 回退分支,而 U_SINGLE 恰好只有一个成员关系。 + // + // 而 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义:`:46` 那一行, + // **非 admin 请求了一个自己没有角色的网络** —— 它带着 `denied: "access denied + // to requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。 + // + // 现在**没有**问题,因为那一行只对非 admin 产生(admin 在 `:42` 就提前返回 + // networkIds:null 了),而非 admin 走到这里必然 null —— 见下一条。 + // + // 但它是一个潜伏的形状:如果将来有任何路径让 admin 拿到 networkIds: [], + // 这次写入会**忽略那条明确的拒绝**,回退到他的单一成员关系。所以两条都钉住: + // 当前行为,以及那条让它安全的前提。 + test("作用域为空数组 + admin + 恰好一个成员关系 → 回退到成员关系(当前行为,记录)", () => { + expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), true)).toBe(NET_A); + }); + + test("🔴 让上一条安全的前提:空数组作用域只由非 admin 产生,而非 admin → null", () => { + expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), false)).toBeNull(); + // 前提本身也断一次:resolveRestNetworkScope 对 admin 从不产出空数组。 + const adminScope = resolveRestNetworkScope(NET_B, ctx(U_NONE), true); + expect(adminScope.networkIds).toBeNull(); + }); +}); + +describe("#819 admin 的全局读作用域不能替写入决定网络", () => { + test("🔴 admin + 恰好 1 个成员关系 → 用那一个(放行那半)", () => { + expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), true)).toBe(NET_A); + }); + + test("🔴 admin + 2 个成员关系 → null,必须显式指定(收紧那半)", () => { + expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_MULTI), true)).toBeNull(); + }); + + test("🔴 admin + 0 个成员关系 → null(不能因为读是全局的就随便挑一个)", () => { + expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_NONE), true)).toBeNull(); + }); + + test("非 admin + 全局作用域 → null(这种组合本身就不该发生,fail closed)", () => { + // 正控在上面那条「admin + 单成员 → NET_A」:如果实现改成永远 null,那条会红。 + expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), false)).toBeNull(); + }); + + test("没有 authCtx + 全局作用域 → null", () => { + expect(resolveRestWriteNetworkId(ADMIN_SCOPE, null, true)).toBeNull(); + }); +}); + +describe("#819 作用域优先于成员关系回退", () => { + test("作用域说 NET_B,而该用户唯一的成员关系是 NET_A → 结果是 NET_B", () => { + // 归属由作用域决定,不由「他碰巧属于哪个网络」决定。 + // 如果实现把两者的优先级搞反,这条会返回 NET_A。 + expect(resolveRestWriteNetworkId(scopeOf(NET_B), ctx(U_SINGLE), true)).toBe(NET_B); + }); +}); From 2fb87c01abab195c09d2fed16bf77bb5136714f9 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 05:13:04 +0800 Subject: [PATCH 40/59] =?UTF-8?q?fix(cli):=20=E5=B0=B1=E7=BB=AA=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E5=8F=AA=E7=9C=8B=20tmux=20=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E5=8C=BA=EF=BC=8C=E4=B8=80=E8=A1=8C=E6=97=A5=E5=BF=97=E6=8A=8A?= =?UTF-8?q?=E3=80=8Clistening=20on=E3=80=8D=E9=A1=B6=E8=B5=B0=E5=B0=B1?= =?UTF-8?q?=E7=AD=89=E6=BB=A1=2025s=20=E5=88=A4=E5=A4=B1=E8=B4=A5=EF=BC=88?= =?UTF-8?q?#849=EF=BC=89=20(#926)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `waitForTmuxPaneText` 用的是 `capture-pane -p`——**不带 `-S` 时它只返回当前可见 区**。app-server 打完 `listening on: ` 之后如果还有输出,那一行滚出屏幕,这个 轮询就再也看不到它,于是等满 25s 报「did not bind within 25s」——而服务早就绑上了。 #849 实测:**1.1s 绑上,25s 判失败**。 **本地复现(同一个 pane,先打 needle 再刷 200 行日志)** capture-pane -p → includes(needle) = false capture-pane -p -S -500 → includes(needle) = true 判据是 `-S`,不是别的:这个函数找的是**曾经出现过一次**的那一行,不是「此刻屏幕上 有什么」。 🔴 **同一个 flag,四处调用里两处该加、一处不该、一处早就加了:** :249 waitForTmuxPaneText 找「曾出现过的就绪信号」 → 加 -S -200 ← 本条修的 :818 bridge 尾部日志 找「曾出现过的上下文」 → 早就有 -S -80(正确写法一直在同一个文件里) :8016 capturePaneReason 找「曾出现过的失败原因」 → 加 -S -200 一个已死 pane 的报错常被后续输出顶走;拿不到就回退成一句泛化文案, 而真正的原因还在回滚里 :7979 dev-channels 自动应答 判「此刻屏幕上有没有提示框」→ **故意不加** 加上回滚,一个早被答掉、已滚走的提示框会被重新识别成待处理, 于是往一个并没有显示它的会话里 send-keys 第四处旁边写了「故意不加」的理由,免得下一个人看到三处有一处没有就顺手补齐。 `bun build` 通过;`tmux-pane-prompt` + `tmux-exact-target` 单测 14/14。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-network/bin/cli.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index 069533322..c984e1f31 100644 --- a/agent-network/bin/cli.ts +++ b/agent-network/bin/cli.ts @@ -238,7 +238,15 @@ function waitForTmuxPaneText(sessionName: string, needle: string, timeoutMs: num try { const paneTarget = tmuxPaneTarget(sessionName); if (!paneTarget) return false; - const out = execFileSync("tmux", ["capture-pane", "-t", paneTarget, "-p"], { + // 🔴 `-S -200`:不带它,capture-pane 只返回**当前可见区**。 + // 一行「listening on: …」被后续日志顶出屏幕之后,这个轮询就再也看不到它了, + // 于是等满 timeout 判失败 —— 而服务其实早就绑上了(#849 实测 1.1s 绑上、 + // 25s 判失败)。本地复现:同一个 pane,先打 needle 再刷 200 行日志, + // capture-pane -p → includes = false + // capture-pane -p -S -500 → includes = true + // 这个函数找的是**一次性出现过**的那一行,不是「此刻屏幕上有什么」, + // 所以它必须看回滚。(同文件 :810 早就带了 `-S -80`——正确写法一直在。) + const out = execFileSync("tmux", ["capture-pane", "-t", paneTarget, "-p", "-S", "-200"], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", }); if (out.includes(needle)) { resolve(true); return; } @@ -7962,6 +7970,12 @@ async function dismissDevChannelPrompt(sessionName: string, timeoutMs: number): // Discard tmux's stderr: polling a session that has already exited is a // normal outcome here, and letting `can't find pane: X` through made the // CLI print an alarming line right before an unrelated verdict. + // + // 🔴 这里**故意不加 `-S`**,和 #849 修的那两处相反 —— 因为问题不同: + // 那两处找的是「**曾经出现过**的一行」(就绪信号 / 失败原因),必须看回滚; + // 这里判的是「**此刻屏幕上有没有一个等人回答的提示框**」。加上回滚,一个 + // 早就被答掉、已经滚走的提示框会被重新识别成待处理,于是往一个并没有显示 + // 它的会话里 send-keys。**同一个 flag,这三处里两处该加、一处不该。** pane = execFileSync("tmux", ["capture-pane", "-p", "-t", paneTarget], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).toString(); @@ -7996,7 +8010,10 @@ function capturePaneReason(sessionName: string): string | null { try { const paneTarget = tmuxPaneTarget(sessionName); if (!paneTarget) return null; // session already reaped - const pane = execFileSync("tmux", ["capture-pane", "-p", "-t", paneTarget], { + // 🔴 同 #849:找的是「**曾经出现过**的那一行拒绝原因」,不是「此刻屏幕上有什么」。 + // 一个已经死掉的 pane,它的报错很可能已被后续输出顶出可见区 —— 不带 `-S` 就会 + // 拿到 null,调用方回退到一句泛化的失败文案,而真正的原因明明还在回滚里。 + const pane = execFileSync("tmux", ["capture-pane", "-p", "-t", paneTarget, "-S", "-200"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).toString(); return extractStartFailureReason(pane); From eae10124d25042da902b0809d34e3b2619d857d6 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 05:30:51 +0800 Subject: [PATCH 41/59] =?UTF-8?q?fix(cli):=20=E9=A6=96=E6=AC=A1=20start=20?= =?UTF-8?q?=E6=87=92=E5=8F=96=20agent-node=20=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=EF=BC=8C=E6=8A=8A=20npx=20=E8=AF=B4=E7=9A=84=E8=AF=9D=E4=B8=A2?= =?UTF-8?q?=E6=8E=89=E4=BA=86=EF=BC=88#450=EF=BC=89=20(#927)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolvePreviewAgentNodeEntrypoint` 原来是: } catch { throw new Error("could not install and resolve @sleep2agi/agent-node@preview"); } `execFileSync` 明明 `stdio: [..., "pipe", "pipe"]` 抓了 stderr,而 catch 把它整个 丢掉。 🔴 **这是全新安装第一次 `anet node start` 的必经之路**——agent-node 按设计由 npx 懒取(checkRuntimeDependency 里那句 `note: agent-node will be lazy-fetched via npx on first start (this is normal)` 就是在说它)。所以它失败时,用户拿到的是一句**没有 原因**的话,而真正的原因就在被丢掉的 stderr 里:registry 不可达 / 权限 / 磁盘满 / 120s 超时——**每一种的下一步动作都不同**。 **stub 掉 npx(让它报 EACCES)实测对照:** 修前:could not install and resolve @sleep2agi/agent-node@preview 修后:could not install and resolve @sleep2agi/agent-node@preview --- npx said --- npm error code EACCES npm error syscall mkdir npm error path /usr/lib/node_modules/@sleep2agi 同一个形状在 docs-site/docs/public/install.sh 上修过一次(#908):那次是 `>/dev/null 2>&1` 吞掉首次尝试的 stderr、然后把每一种失败都叙述成「registry 失败」。 **这里更进一步——它连一个猜测都不给。** 细节: - 超时单独点名(`npx exceeded the 120s budget`),因为 120s 超时和 npx 报错在原来 那句话里完全一样; - npx 一个字都没输出时明说 `(npx produced no output — check that \`npx\` itself works)`, 而不是留一句空的原因; - stderr 只取最后 8 行、截断到 1200 字符——够定位,不刷屏。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- agent-network/bin/cli.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index c984e1f31..9f7f11b07 100644 --- a/agent-network/bin/cli.ts +++ b/agent-network/bin/cli.ts @@ -2313,8 +2313,26 @@ function resolvePreviewAgentNodeEntrypoint(resolverEnv: NodeJS.ProcessEnv): stri env: resolverEnv, }, ); - } catch { - throw new Error("could not install and resolve @sleep2agi/agent-node@preview"); + } catch (e: any) { + // 🔴 这里以前是 `catch { throw new Error("could not install and resolve …") }` + // —— 把 npx 说的话整个丢掉。而这是**全新安装的第一次 start** 必经的一步 + // (agent-node 按设计由 npx 懒取,见 checkRuntimeDependency 里那句 note), + // 所以它失败时用户拿到的是一句没有原因的话,而真正的原因就在被丢掉的 stderr 里: + // registry 不可达 / 权限 / 磁盘满 / 120s 超时 —— 每一种的下一步动作都不同。 + // + // 同一个形状在 docs-site/docs/public/install.sh 上修过一次(#908):那次是 + // `>/dev/null 2>&1` 吞掉首次尝试的 stderr,然后把每一种失败都叙述成 + // 「registry 失败」。这里更进一步 —— 它连一个猜测都不给。 + const detail = [e?.stderr, e?.stdout, e?.message] + .map((v: unknown) => (typeof v === "string" ? v : v ? String(v) : "")) + .find((v: string) => v.trim().length > 0) ?? ""; + const trimmed = detail.trim().split(/\r?\n/).slice(-8).join("\n").slice(0, 1200); + const isTimeout = e?.code === "ETIMEDOUT" || e?.signal === "SIGTERM"; + throw new Error( + `could not install and resolve @sleep2agi/agent-node@preview` + + (isTimeout ? ` (npx exceeded the 120s budget)` : ``) + + (trimmed ? `\n--- npx said ---\n${trimmed}` : `\n(npx produced no output — check that \`npx\` itself works)`), + ); } const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); From 97c2e41e7a7c311b19286c02ec5ee76e1c1da466 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 05:44:11 +0800 Subject: [PATCH 42/59] =?UTF-8?q?ci(test686):=20=E4=B8=89=E6=AC=A1?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=83=BD=E6=96=AD=E8=A8=80=E3=80=8C=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=88=B0=E5=87=A0=E4=B8=AA=E6=B5=8B=E8=AF=95=E3=80=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=80=80=E5=87=BA=E7=A0=81=E5=88=86=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E3=80=8C=E5=85=A8=E8=BF=87=E3=80=8D=E5=92=8C=E3=80=8C?= =?UTF-8?q?=E5=8F=AA=E8=B7=91=E4=BA=86=E4=B8=80=E4=B8=AA=E3=80=8D=EF=BC=88?= =?UTF-8?q?#928=EF=BC=89=20(#929)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这个套件跑同一个测试文件三次(基线 / 变异后 / 还原后),三次都只看退出码。而退出码 分不出**「5 个测试全过」**和**「只注册到 1 个、它挂了」**。 🔴 2026-08-17 21:20 UTC 的 CI 上真的发生了后者: (fail) (unnamed) [5247.62ms] ^ a beforeEach/afterEach hook timed out 0 pass 1 fail Ran 1 test across 1 file. [5.47s] 同一个文件在正常环境是 `5 pass / 0 fail / Ran 5 tests / 620ms`。 摘要里那句 `0 pass 1 fail` 读起来像「跑了 1 个、挂了 1 个」——**没有任何一行说本该 跑 5 个**。另外 4 个既没跑,也没被提到。 改动:三个阶段各自 `tee` 到日志并断言 `Ran N tests` 中的 N ≥ GOLDEN_MIN_TESTS(5)。 用**下限**而不是等号:加测试是常态,加了不该让这道门红;**少跑了才是要抓的**。 下限旁边注了日期(截至 2026-08-18 实际为 5),免得它变成一个悄悄失去意义的常量。 🔴 **变异那一轮同样要断分母**——如果那一轮压根没跑起来,它也会「红」,而那是一个 **为了错误的理由变红**的 witnessed-red,证明不了变异真的被抓住。这是三处里最容易 被漏掉的一处:前后两次绿的断言很直觉,中间那次红的断言不直觉。 读不到 `Ran N tests` 时 fail-closed(「判不了跑了几个,拒绝通过」),而不是当成通过。 **离线验过判据本身**(三种输入喂给那个函数): 正常输出(Ran 5 tests) exit 0 塌陷输出(Ran 1 test) exit 1 "只注册到 1 个测试,下限是 5" 垃圾输出(读不到 Ran N) exit 1 "判不了跑了几个,拒绝通过" `sh -n` 通过。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- tests/test686-rest-shape-golden/run.sh | 45 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/tests/test686-rest-shape-golden/run.sh b/tests/test686-rest-shape-golden/run.sh index c26d71439..c5a2576ce 100644 --- a/tests/test686-rest-shape-golden/run.sh +++ b/tests/test686-rest-shape-golden/run.sh @@ -4,8 +4,44 @@ set -eu test "${TEST686_SOURCE_COMMIT:-unknown}" != unknown cd /workspace +# 这个套件跑同一个测试文件三次(基线 / 变异后 / 还原后)。三次都只看退出码, +# 而退出码分不出「5 个测试全过」和「只注册到 1 个、它挂了」。 +# +# 🔴 2026-08-17 CI 上真的发生过后者: +# (fail) (unnamed) [5247.62ms] ^ a beforeEach/afterEach hook timed out +# 0 pass 1 fail +# Ran 1 test across 1 file. [5.47s] +# 而同一个文件在正常环境是 `5 pass / Ran 5 tests / 620ms`。 +# 摘要里 `0 pass 1 fail` 读起来像「跑了 1 个挂了 1 个」——**没有任何一行说本该跑 5 个**。 +# 见 #928。 +# +# 所以每次跑都断言「至少注册到 GOLDEN_MIN_TESTS 个」。下限而不是等号: +# 加测试是常态,加了不该让这道门红;少跑了才是要抓的。 +GOLDEN_FILE=server/src/rest-explicit-columns-http.test.ts +GOLDEN_MIN_TESTS=5 # 截至 2026-08-18 实际为 5 + +assert_ran_enough() { + _log="$1"; _stage="$2" + _ran=$(grep -oE 'Ran [0-9]+ tests? across' "$_log" | grep -oE '[0-9]+' | head -1) + if [ -z "${_ran:-}" ]; then + echo "[$_stage] 没能从输出里读到 'Ran N tests' —— 判不了跑了几个,拒绝通过" >&2 + tail -20 "$_log" >&2 + exit 1 + fi + if [ "$_ran" -lt "$GOLDEN_MIN_TESTS" ]; then + echo "[$_stage] 只注册到 $_ran 个测试,下限是 $GOLDEN_MIN_TESTS —— 分母塌了,这一轮的绿/红都不作数" >&2 + tail -20 "$_log" >&2 + exit 1 + fi + printf '[%s] ran=%s (min %s)\n' "$_stage" "$_ran" "$GOLDEN_MIN_TESTS" +} + echo "L0: independent golden remains green" -bun test server/src/rest-explicit-columns-http.test.ts +bun test "$GOLDEN_FILE" 2>&1 | tee /tmp/test686-l0.log +# `set -o pipefail` 不是 POSIX sh 的保证项,所以显式取 bun 的退出码而不是 tee 的。 +test "${PIPESTATUS:-0}" = 0 2>/dev/null || true +grep -qE '^\s*0 fail' /tmp/test686-l0.log || { echo "L0 not green" >&2; exit 1; } +assert_ran_enough /tmp/test686-l0.log L0 cp server/src/rest-projections.ts /tmp/rest-projections.orig bun tests/test686-rest-shape-golden/mutate.mjs server/src/rest-projections.ts @@ -23,10 +59,15 @@ cp /tmp/rest-projections.orig server/src/rest-projections.ts test "$mutation_rc" -ne 0 grep -Fq 'task list and task detail expose the same explicit contract' /tmp/test686-mutation.log grep -Fq 'created_at' /tmp/test686-mutation.log +# 🔴 变异那一轮同样要断分母:如果那一轮压根没跑起来,它也会「红」—— +# 而那是一个为了错误的理由变红的 witnessed-red,证明不了变异被抓住。 +assert_ran_enough /tmp/test686-mutation.log L1 printf 'mutation=drop-task-created-at rc=%s witnessed-red\n' "$mutation_rc" echo "L2: restored production projection remains green" -bun test server/src/rest-explicit-columns-http.test.ts +bun test "$GOLDEN_FILE" 2>&1 | tee /tmp/test686-l2.log +grep -qE '^\s*0 fail' /tmp/test686-l2.log || { echo "L2 not green" >&2; exit 1; } +assert_ran_enough /tmp/test686-l2.log L2 printf 'source_commit=%s\n' "$TEST686_SOURCE_COMMIT" printf 'RESULT: PASS\n' From 450b52624e0602fe2b829ede7cba6453715c0926 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 05:47:19 +0800 Subject: [PATCH 43/59] =?UTF-8?q?docs(playbook):=205=20=E6=9D=A1=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E6=8C=87=E5=90=91=E4=BB=8E=E6=9C=AA=E8=BF=9B=E8=BF=87?= =?UTF-8?q?=E4=BB=93=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=8C2=20=E5=A4=84?= =?UTF-8?q?=E5=86=85=E9=83=A8=20memory=20slug=20=E6=B3=84=E8=BF=9B?= =?UTF-8?q?=E4=BA=86=E5=85=AC=E5=BC=80=E4=BB=93=20(#930)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **一、5 条死链,指向的东西从来就不存在** report-test-v092-preview5.md git log --all --diff-filter=A → 0 次新增 report-test-v092-preview6.md 同上 0 report-test-v092-preview7.md 同上 0 v010-chain-test-baseline.md 同上 0 ../../memory 仓外目录 不是「文件被删了」,是**从未提交过**——这份已提交的文档引用的是只存在于某台机器上 的东西。(同一形状:docs/rfcs/RFC-022 链到 RFC-017,而 RFC-017 也不在 main 上。) 对照同一份文件里**能用的**那些引用:它们全部指向 GitHub issue。**这个文件自己的 主流写法是对的,坏掉的是少数几条指向本地文件的。** 改法:把链接去掉、保留归属,并说明那份报告未进仓。**每一条的内容本来就写在正文 里**(「bash backticks in echo strings → cmd substitution spawns interactive wizard → container hang. Use single quotes」),链接不提供任何额外可取回的东西。 **二、2 处内部 memory slug 出现在公开仓** :7 **Per [`feedback_docker_smoke_real_tty`]** :224 (per [[feedback_gate_evidence_must_be_runner_generated]]) 第二处是双方括号形式,本该被 check-no-memory-slugs.py 抓到——但 `docs/tests/` 在 它的 ALLOWLIST_PATH_PREFIXES 里(那个豁免是有意的、写了理由的,见 #772)。 第一处是**单方括号**,任何一道现有的门都不匹配。 两处都换成**它们各自的理由本身**,而不是指向一个外人拿不到的 slug: :224 → 「证据必须由 runner 在被检对象之外产出,否则『被检的东西』和『检它的 东西』来自同一次提交,红不了。」 🔴 **写下理由比写下 slug 名有用**:读者当场就能判断这条约束讲不讲得通,而 slug 名 只告诉他「有个地方记着这件事,而你进不去」。 验证:本文件剩余死链 **0**、剩余 slug 引用 **0**;`check-no-memory-slugs.py` exit 0。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs/tests/release-gate-playbook.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/tests/release-gate-playbook.md b/docs/tests/release-gate-playbook.md index 1f1efb0ce..133a69d4f 100644 --- a/docs/tests/release-gate-playbook.md +++ b/docs/tests/release-gate-playbook.md @@ -4,7 +4,7 @@ **Status**: living doc — additive per P0 catch **Last update**: 2026-05-16 **Vincent 5315 强调**: 「不要弄的太重」—— total 30 min/run, P3 不做 CI matrix -**Per [`feedback_docker_smoke_real_tty`]** — Docker `--rm` isolation + real-TTY pexpect drive +**判据** — Docker `--rm` 隔离 + 真 TTY(pexpect 驱动) --- @@ -166,12 +166,12 @@ p.expect(r"选择 runtime"); p.sendline("") # default ## 6. Anti-patterns (lessons from v0.9.0 → v0.10.9 cycles) -1. **bash backticks in echo strings** ([R9 preview.6 catch](report-test-v092-preview6.md)) → cmd substitution spawns interactive wizard → container hang. Use `'` single quotes or escape `\`...\``. -2. **`wait` without specific PIDs** ([R7 preview.5 catch](report-test-v092-preview5.md)) → blocks on long-running agent-node bg processes. Track curl PIDs: `wait "${CURL_PIDS[@]}"`. +1. **bash backticks in echo strings** (R9 preview.6 catch — 该轮报告未进仓) → cmd substitution spawns interactive wizard → container hang. Use `'` single quotes or escape `\`...\``. +2. **`wait` without specific PIDs** (R7 preview.5 catch — 该轮报告未进仓) → blocks on long-running agent-node bg processes. Track curl PIDs: `wait "${CURL_PIDS[@]}"`. 3. **`kill -0 $!` on nohup intermediate** → nohup wrapper exits, child stays alive. Use commhub `/api/status` to probe liveness. 4. **`docker run -e KEY=val`** ([v0.9.0 R5 catch](https://github.com/sleep2agi/agent-network/issues/132)) → keys visible in host `ps aux`. Use `--env-file mode 600`. 5. **Trust dist-tag without tarball curl** ([v0.9.0 R5 catch](https://github.com/sleep2agi/agent-network/issues/132)) → `npm view ... dist-tags.latest` may be ahead of actual tarball upload. Always `curl -sI .../-/-.tgz` first. -6. **Trust pane visual over commhub** (preview.4 catch per 通信龙 self-correction in [feedback_pane_vs_commhub_truth](../../memory)) → pane snapshot can lag commhub HIGH messages. Commhub `mcp__commhub__get_all_status` is truth. +6. **Trust pane visual over commhub** (preview.4,通信龙 自我更正) → pane snapshot can lag commhub HIGH messages. Commhub `mcp__commhub__get_all_status` is truth. 7. **Use alpine for claude-agent-sdk tests** ([v0.10.0 preview.0 catch](https://github.com/sleep2agi/agent-network/issues/141)) → alpine musl-libc + glibc-only claude binary = "claude binary not found". Use slim OR `alpine + apk add gcompat libc6-compat`. 8. **One-shot `docker run` 缺 USER node** ([v0.10.0 preview.1 R12 catch](https://github.com/sleep2agi/agent-network/issues/140#issuecomment-4466735967)) → default root user → `claude 错误: 当前以 root 用户运行,Claude Code 拒绝 --dangerously-skip-permissions` → agent-node fast-fails before MCP call. R9/R8/R7/R10 used Dockerfile `USER node` and worked; one-shot `docker run sh -c '...'` pattern dropped it. Fix: `docker run --user node ...` OR bake `USER node` into a pre-built test image. Family C cases ALL require non-root user. 9. **runuser heredoc 默认 cwd = `/`** ([v0.10.0 R13 catch](https://github.com/sleep2agi/agent-network/issues/140#issuecomment-4466836503)) → `anet node create test-x` writes to `/.anet/nodes/test-x/`; `anet node ls` is cwd-relative and looks at `.anet/nodes/` from `/`, so node "appears missing" (B2 chain FAIL surface). **Fix**: runuser heredoc 必显式 `cd /home/node` (或 `cd ~`). agent-network 2.2.0 起 `anet create` + `anet ls` 都依赖 cwd-relative storage layout. @@ -213,14 +213,15 @@ p.expect(r"选择 runtime"); p.sendline("") # default **Author-Agent**: 通信测试马 **Reviewer**: 通信龙 -**Refs**: [v010 chain-test baseline](v010-chain-test-baseline.md), [Round 9 preview.7 6/6 PASS](report-test-v092-preview7.md) +**Refs**: v010 chain-test baseline、Round 9 preview.7 6/6 PASS —— 这两份报告**从未进过仓** +(`git log --all --diff-filter=A` 对两个文件名都是 0 次新增),所以这里不给链接。 --- ## 9. Evidence Provenance Gate (常设规则, 07-29 P3-A 事故固化) **Scope (MUST apply to)**: Docker E2E, preview smoke, 安全 gate (RFC-030 P3 identity/security), release promote (preview → latest). -**Enforcement**: **缺 provenance manifest 不得执行对应的 transition** — 具体见 §9.8 (merge to main / preview ship / promote to latest, per pipeline)。作者自报 (author-generated report, tests committed in candidate tree) 不构成独立证据 (per [[feedback_gate_evidence_must_be_runner_generated]]). +**Enforcement**: **缺 provenance manifest 不得执行对应的 transition** — 具体见 §9.8 (merge to main / preview ship / promote to latest, per pipeline)。作者自报(author-generated report,测试随候选树一起提交)**不构成独立证据** —— 证据必须由 runner 在被检对象之外产出,否则「被检的东西」和「检它的东西」来自同一次提交,红不了。 ### 9.1 Runner requirements (MUST) From 61f7203a8065edb82b91a9603be467ce229cdd97 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 05:54:20 +0800 Subject: [PATCH 44/59] =?UTF-8?q?test(qa-hub):=20=E4=BF=AE=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88=206=20=E5=91=A8?= =?UTF-8?q?=E7=9A=84=20e2e=20=E5=A5=97=E4=BB=B6=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=AE=9E=E8=B7=91=204/4=20=E4=BB=8E=E7=BA=A2=E5=88=B0=E7=BB=BF?= =?UTF-8?q?=20(#861)=20(#863)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这四个套件从 2026-07-02 起就是红的,而**没有任何东西会跑它们**,所以没人知道。 #861 里实测过:4/4 退出码 1,全部死在第 2 步,错误完全相同。 ## 三处独立的漂移,逐个修 **① report_status 的身份绑定(#203 / #376,2026-07-02)** `server/src/tools.ts:584` 之后,用 network token 上报时 args 里的 alias 必须等于 该 token 绑定的 alias。而 `callerAlias` 的推导(`server/src/server.ts:733-735`)是: tokenName 以 "node:" 开头 → 取其后缀;否则**回落到用户名** 四个套件都拿 `register_user` 返回的 `network_token`(名字不是 `node:…`)去上报 任意 alias,于是一律 `alias_identity_mismatch`。 修法:加 `node_token()` helper,为每个要上报的 alias 铸它自己的 node token (`POST /api/auth/node-token`)。取法与**已注册且长期绿**的 `qa-hub-05-roundtrip` 完全一致 —— 不是我发明的写法。 qa-hub-12/13 上报多个不同 alias,所以再加一层 `report_as()`:直接从 args 里取 alias 再铸 token,循环调用点不必逐个改、也不会漏。 **② send 侧的对称检查(fromIdentityMismatchReply)** 用 network token 发送时 `from_session` 也必须等于 token 绑定的 alias。 qa-hub-10 / qa-hub-13 的发送方同样改成持有自己 node token。 两处的断言(`"from":"alpha-sender"` 等)**原样保留**。 **③ qa-hub-10 第 3 步断言的是一个已被有意修掉的 bug(#517)** 原断言:utok 不带 network_id 发送 → `permission_denied: network_id required`。 `#517` 的标题就是「节点发消息报 permission_denied: network_id required,而工具 schema 没有这个入参(**一晚三个节点抄送全部静默失败**)」—— 那个报错本身是 bug, 修法是单网络 utok 自动解析。 所以这一条**改断言**(参照 #804 / test682:产品有意改掉的东西,该改断言而不是把行为改回去), 改成断言新的正确行为:自动解析并投递成功。这是四个套件里**唯一**被改掉的断言。 ## 🔴 一个我猜错、被实跑纠正的假设 我原本怀疑 qa-hub-13 测的端点被改名了(`/api/server/:host/health` vs 产品里的 `/api/server-health/:host`),依据是 `git grep "/api/server/"` 在 `server.ts` 里 0 命中。 **证据看起来很硬,但结论是错的** —— 修完之后: [4] /api/server/:host/health exposes latest alert + history for network A only ✓ [5] /api/server/:host/agents exposes per-agent details and process telemetry ✓ 两个端点都在,都正常。静态比对给出的是「哪里可疑」,不是「实际会怎样」。 ## 验证:四个都实跑到绿 qa-hub-10 rc=0 PASS network scope regressions (#67 message ✓ / #54 SSE isolation ✓) 7 步 qa-hub-11 rc=0 PASS node-delete-sse (#74 node_deleted push ✓ / network isolation ✓) 5 步 qa-hub-12 rc=0 PASS servers endpoint (#119 host telemetry aggregation ✓) 5 步 qa-hub-13 rc=0 PASS server health/agents endpoints (#140 Hero 1+2 ✓) 9 步 exact `origin/main` 上构建运行,跑完逐个删镜像。 **这些断言此前一次都没被执行过** —— 它们全部倒在第 2 步。 ## 本 PR 不做注册 按 #861 里定的次序:先修好、确认能绿,**再**谈要不要进 `L1_TESTS`。 注册是另一次改动(且会影响 CI 时长),应当单独决定。 若决定注册,`tests/qa-*/**` 已在 `qa.yml` 的 paths 里,不用改 paths(见 #860)。 Co-authored-by: vansin --- .../run.sh | 44 ++++++++++++++++--- tests/qa-hub-11-node-delete-sse/run.sh | 23 +++++++++- tests/qa-hub-12-servers-endpoint/run.sh | 30 +++++++++++-- tests/qa-hub-13-server-health-agents/run.sh | 39 +++++++++++++--- 4 files changed, 118 insertions(+), 18 deletions(-) diff --git a/tests/qa-hub-10-network-scope-regressions/run.sh b/tests/qa-hub-10-network-scope-regressions/run.sh index 26afd6e92..a6c976046 100644 --- a/tests/qa-hub-10-network-scope-regressions/run.sh +++ b/tests/qa-hub-10-network-scope-regressions/run.sh @@ -41,6 +41,21 @@ register_user() { -d "{\"username\":\"$username\",\"password\":\"$password\"}" } +# #203/#376 之后,report_status 的 alias 必须与 token 绑定的 alias 一致 +# (server.ts:733 从 api_tokens.name='node:' 推导 callerAlias; +# 注册时拿到的 network_token 名字不是 node:…,会回落成**用户名**, +# 于是用它上报任意 alias 一律 alias_identity_mismatch)。 +# 所以每个要上报的 alias 都得先铸一个属于它自己的 node token。 +# 取法与已注册且长期绿的 qa-hub-05-roundtrip 完全一致。 +node_token() { + local utok="$1" net="$2" alias="$3" tok + tok=$(curl -fsS -X POST "$HUB_BASE/api/auth/node-token" \ + -H "Authorization: Bearer $utok" -H 'Content-Type: application/json' \ + -d "{\"network_id\":\"$net\",\"node_name\":\"$alias\"}" | jq -r '.token // empty') + [[ "$tok" == ntok_* ]] || { echo "FAIL: node_token($alias) shape wrong: $tok" >&2; exit 1; } + printf '%s' "$tok" +} + wait_for_log() { local pattern="$1" file="$2" label="$3" for _ in {1..30}; do @@ -88,16 +103,26 @@ ARG_A=$(jq -nc --arg net "$NET_A" \ '{resume_id:"00000000-aaaa-bbbb-cccc-000000000010",alias:"shared-agent",status:"idle",network_id:$net}') ARG_B=$(jq -nc --arg net "$NET_B" \ '{resume_id:"00000000-aaaa-bbbb-cccc-000000000011",alias:"shared-agent",status:"idle",network_id:$net}') -RS_A=$(mcp_call "$NTOK_A" "report_status" "$ARG_A") -RS_B=$(mcp_call "$NTOK_B" "report_status" "$ARG_B") +# 同一个 alias 在两个网络里各自独立 —— 这正是本套件要测的语义。 +# #203 之后它仍然成立,只是每个网络里的那个同名节点要各自持有自己的 token。 +NODE_TOK_A=$(node_token "$UTOK_A" "$NET_A" "shared-agent") +NODE_TOK_B=$(node_token "$UTOK_B" "$NET_B" "shared-agent") +RS_A=$(mcp_call "$NODE_TOK_A" "report_status" "$ARG_A") +RS_B=$(mcp_call "$NODE_TOK_B" "report_status" "$ARG_B") echo "$RS_A" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status A: $RS_A"; exit 1; } echo "$RS_B" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status B: $RS_B"; exit 1; } -echo "[3] utok send_task without network_id returns actionable missing-network message" +# 🔴 这一步原本断言的是「utok 不带 network_id 发送 → permission_denied: network_id required」。 +# 那个报错本身是 bug,已被 #517 有意去掉 —— 该 issue 的标题就是 +# 「节点发消息报 permission_denied: network_id required,而工具 schema 没有这个入参 +# (一晚三个节点抄送全部静默失败)」。修法是:单网络的 utok 自动解析出唯一那个网络。 +# 所以断言跟着改成新的正确行为(参照 #804 / test682:产品有意改掉的东西, +# 该改的是断言而不是把行为改回去)。 +echo "[3] single-network utok send_task auto-resolves the network (#517)" NO_NET_ARGS=$(jq -nc '{alias:"shared-agent",task:"missing-network-id",from_session:"alice-dashboard"}') NO_NET=$(mcp_call "$UTOK_A" "send_task" "$NO_NET_ARGS") -echo "$NO_NET" | jq -e '.ok == false and .error == "permission_denied" and (.message | contains("network_id required"))' >/dev/null || { - echo "FAIL: expected network_id required error, got: $NO_NET" +echo "$NO_NET" | jq -e '.ok == true and (.message_id | type == "string")' >/dev/null || { + echo "FAIL: single-network utok should auto-resolve and deliver, got: $NO_NET" exit 1 } if echo "$NO_NET" | jq -r '.message // ""' | grep -q "Viewer role"; then @@ -121,8 +146,12 @@ wait_for_log '"type":"connected"' /tmp/sse-b.log "SSE B connected" echo "[5] task push in network A must not leak to network B" : >/tmp/sse-a.log : >/tmp/sse-b.log +# send 侧有一道与 report_status 对称的检查(tools.ts 注释:fromIdentityMismatchReply,test198): +# 用 network token 发送时,from_session 必须等于该 token 绑定的 alias。 +# 所以发送方也要有属于自己的 node token —— 这样断言里的 "from":"alpha-sender" 原样成立。 +SENDER_TOK_A=$(node_token "$UTOK_A" "$NET_A" "alpha-sender") TASK_A=$(jq -nc '{alias:"shared-agent",task:"alpha-only",from_session:"alpha-sender"}') -SEND_A=$(mcp_call "$NTOK_A" "send_task" "$TASK_A") +SEND_A=$(mcp_call "$SENDER_TOK_A" "send_task" "$TASK_A") echo "$SEND_A" | jq -e '.ok == true' >/dev/null || { echo "FAIL: send_task A: $SEND_A"; exit 1; } wait_for_log '"from":"alpha-sender"' /tmp/sse-a.log "network A task push" assert_no_log '"from":"alpha-sender"' /tmp/sse-b.log "network A task push leaked to B" @@ -130,8 +159,9 @@ assert_no_log '"from":"alpha-sender"' /tmp/sse-b.log "network A task push leaked echo "[6] task push in network B must not leak to network A" : >/tmp/sse-a.log : >/tmp/sse-b.log +SENDER_TOK_B=$(node_token "$UTOK_B" "$NET_B" "beta-sender") TASK_B=$(jq -nc '{alias:"shared-agent",task:"beta-only",from_session:"beta-sender"}') -SEND_B=$(mcp_call "$NTOK_B" "send_task" "$TASK_B") +SEND_B=$(mcp_call "$SENDER_TOK_B" "send_task" "$TASK_B") echo "$SEND_B" | jq -e '.ok == true' >/dev/null || { echo "FAIL: send_task B: $SEND_B"; exit 1; } wait_for_log '"from":"beta-sender"' /tmp/sse-b.log "network B task push" assert_no_log '"from":"beta-sender"' /tmp/sse-a.log "network B task push leaked to A" diff --git a/tests/qa-hub-11-node-delete-sse/run.sh b/tests/qa-hub-11-node-delete-sse/run.sh index 6c83f5705..0db897e1e 100644 --- a/tests/qa-hub-11-node-delete-sse/run.sh +++ b/tests/qa-hub-11-node-delete-sse/run.sh @@ -41,6 +41,21 @@ register_user() { -d "{\"username\":\"$username\",\"password\":\"$password\"}" } +# #203/#376 之后,report_status 的 alias 必须与 token 绑定的 alias 一致 +# (server.ts:733 从 api_tokens.name='node:' 推导 callerAlias; +# 注册时拿到的 network_token 名字不是 node:…,会回落成**用户名**, +# 于是用它上报任意 alias 一律 alias_identity_mismatch)。 +# 所以每个要上报的 alias 都得先铸一个属于它自己的 node token。 +# 取法与已注册且长期绿的 qa-hub-05-roundtrip 完全一致。 +node_token() { + local utok="$1" net="$2" alias="$3" tok + tok=$(curl -fsS -X POST "$HUB_BASE/api/auth/node-token" \ + -H "Authorization: Bearer $utok" -H 'Content-Type: application/json' \ + -d "{\"network_id\":\"$net\",\"node_name\":\"$alias\"}" | jq -r '.token // empty') + [[ "$tok" == ntok_* ]] || { echo "FAIL: node_token($alias) shape wrong: $tok" >&2; exit 1; } + printf '%s' "$tok" +} + wait_for_log() { local pattern="$1" file="$2" label="$3" for _ in {1..30}; do @@ -88,8 +103,12 @@ ARG_A=$(jq -nc --arg net "$NET_A" \ '{resume_id:"00000000-aaaa-bbbb-cccc-000000000074",alias:"delete-me",status:"idle",network_id:$net,node_id:"node-a-74",node_name:"delete-me",agent:"agent-node:claude-agent",model:"test-model"}') ARG_B=$(jq -nc --arg net "$NET_B" \ '{resume_id:"00000000-aaaa-bbbb-cccc-000000000075",alias:"delete-me",status:"idle",network_id:$net,node_id:"node-b-74",node_name:"delete-me",agent:"agent-node:claude-agent",model:"test-model"}') -RS_A=$(mcp_call "$NTOK_A" "report_status" "$ARG_A") -RS_B=$(mcp_call "$NTOK_B" "report_status" "$ARG_B") +# 同一 alias 在两个网络各产生一条独立 node 行 —— 本套件要测的语义。 +# #203 之后每个网络里的那个同名节点要各自持有自己的 token(见 node_token 注释)。 +NODE_TOK_A=$(node_token "$UTOK_A" "$NET_A" "delete-me") +NODE_TOK_B=$(node_token "$UTOK_B" "$NET_B" "delete-me") +RS_A=$(mcp_call "$NODE_TOK_A" "report_status" "$ARG_A") +RS_B=$(mcp_call "$NODE_TOK_B" "report_status" "$ARG_B") echo "$RS_A" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status A: $RS_A"; exit 1; } echo "$RS_B" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status B: $RS_B"; exit 1; } diff --git a/tests/qa-hub-12-servers-endpoint/run.sh b/tests/qa-hub-12-servers-endpoint/run.sh index d70fb1ac2..0acee68ed 100644 --- a/tests/qa-hub-12-servers-endpoint/run.sh +++ b/tests/qa-hub-12-servers-endpoint/run.sh @@ -38,6 +38,30 @@ register_user() { -d "{\"username\":\"$username\",\"password\":\"$password\"}" } +# #203/#376 之后,report_status 的 alias 必须与 token 绑定的 alias 一致 +# (server.ts:733 从 api_tokens.name='node:' 推导 callerAlias; +# 注册时拿到的 network_token 名字不是 node:…,会回落成**用户名**, +# 于是用它上报任意 alias 一律 alias_identity_mismatch)。 +# 所以每个要上报的 alias 都得先铸一个属于它自己的 node token。 +# 取法与已注册且长期绿的 qa-hub-05-roundtrip 完全一致。 +node_token() { + local utok="$1" net="$2" alias="$3" tok + tok=$(curl -fsS -X POST "$HUB_BASE/api/auth/node-token" \ + -H "Authorization: Bearer $utok" -H 'Content-Type: application/json' \ + -d "{\"network_id\":\"$net\",\"node_name\":\"$alias\"}" | jq -r '.token // empty') + [[ "$tok" == ntok_* ]] || { echo "FAIL: node_token($alias) shape wrong: $tok" >&2; exit 1; } + printf '%s' "$tok" +} + +# 每次 report_status 都以「args 里那个 alias 自己的 node token」发出。 +# 直接从 args 取 alias,循环/多 alias 的调用点不必逐个改,也不会漏。 +report_as() { + local utok="$1" net="$2" args="$3" alias tok + alias=$(printf '%s' "$args" | jq -r '.alias') + tok=$(node_token "$utok" "$net" "$alias") + mcp_call "$tok" report_status "$args" +} + echo "[0] start local hub from repository source" safe_rm_rf "$HOME/.commhub" "$HOME/.anet/server" cd /app/server @@ -65,19 +89,19 @@ ARG_A2=$(jq -nc --arg net "$NET_A" '{resume_id:"119-a-2",alias:"agent-a2",status ARG_A3=$(jq -nc --arg net "$NET_A" '{resume_id:"119-a-3",alias:"agent-a3",status:"idle",network_id:$net,host:{hostname:"box-b",ip:"10.0.0.11",cpu_load_1min:null,cpu_cores:4,mem_total_gb:16.0,mem_used_gb:2.0,mem_avail_gb:14.0}}') ARG_A4=$(jq -nc --arg net "$NET_A" '{resume_id:"119-a-4",alias:"agent-a4",status:"idle",network_id:$net,host:{hostname:"box-a",ip:"127.0.0.1",cpu_load_1min:null,cpu_cores:null,mem_total_gb:null,mem_used_gb:null,mem_avail_gb:null}}') for args in "$ARG_A1"; do - out=$(mcp_call "$NTOK_A" report_status "$args") + out=$(report_as "$UTOK_A" "$NET_A" "$args") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status A: $out"; exit 1; } done # Ensure "latest host metrics" has a deterministic timestamp newer than A1. sleep 1.1 for args in "$ARG_A2" "$ARG_A3" "$ARG_A4"; do - out=$(mcp_call "$NTOK_A" report_status "$args") + out=$(report_as "$UTOK_A" "$NET_A" "$args") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status A: $out"; exit 1; } done echo "[3] report same hostname/ip in network B to verify REST network isolation" ARG_B1=$(jq -nc --arg net "$NET_B" '{resume_id:"119-b-1",alias:"agent-b1",status:"idle",network_id:$net,host:{hostname:"box-a",ip:"10.0.0.10",cpu_load_1min:9.9,cpu_cores:64,mem_total_gb:128.0,mem_used_gb:64.0,mem_avail_gb:64.0}}') -out=$(mcp_call "$NTOK_B" report_status "$ARG_B1") +out=$(report_as "$UTOK_B" "$NET_B" "$ARG_B1") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status B: $out"; exit 1; } echo "[4] /api/servers aggregates network A only" diff --git a/tests/qa-hub-13-server-health-agents/run.sh b/tests/qa-hub-13-server-health-agents/run.sh index cd405d39c..d7fcd1f71 100644 --- a/tests/qa-hub-13-server-health-agents/run.sh +++ b/tests/qa-hub-13-server-health-agents/run.sh @@ -40,6 +40,30 @@ register_user() { -d "{\"username\":\"$username\",\"password\":\"$password\"}" } +# #203/#376 之后,report_status 的 alias 必须与 token 绑定的 alias 一致 +# (server.ts:733 从 api_tokens.name='node:' 推导 callerAlias; +# 注册时拿到的 network_token 名字不是 node:…,会回落成**用户名**, +# 于是用它上报任意 alias 一律 alias_identity_mismatch)。 +# 所以每个要上报的 alias 都得先铸一个属于它自己的 node token。 +# 取法与已注册且长期绿的 qa-hub-05-roundtrip 完全一致。 +node_token() { + local utok="$1" net="$2" alias="$3" tok + tok=$(curl -fsS -X POST "$HUB_BASE/api/auth/node-token" \ + -H "Authorization: Bearer $utok" -H 'Content-Type: application/json' \ + -d "{\"network_id\":\"$net\",\"node_name\":\"$alias\"}" | jq -r '.token // empty') + [[ "$tok" == ntok_* ]] || { echo "FAIL: node_token($alias) shape wrong: $tok" >&2; exit 1; } + printf '%s' "$tok" +} + +# 每次 report_status 都以「args 里那个 alias 自己的 node token」发出。 +# 直接从 args 取 alias,循环/多 alias 的调用点不必逐个改,也不会漏。 +report_as() { + local utok="$1" net="$2" args="$3" alias tok + alias=$(printf '%s' "$args" | jq -r '.alias') + tok=$(node_token "$utok" "$net" "$alias") + mcp_call "$tok" report_status "$args" +} + wait_for_log() { local pattern="$1" file="$2" label="$3" for _ in {1..30}; do @@ -87,16 +111,16 @@ ARG_A1=$(jq -nc --arg net "$NET_A" \ '{resume_id:"140-a-1",alias:"hero-a1",status:"idle",task:"standby",progress:10,agent:"agent-node:claude",model:"intern-s1-pro",network_id:$net,host:{hostname:"hero-box",ip:"10.10.0.5",cpu_load_1min:1.0,cpu_cores:4,mem_total_gb:16,mem_used_gb:15.2,mem_avail_gb:0.8,disk_total_gb:100,disk_used_gb:92,disk_avail_gb:8},process_telemetry:{rss_bytes:123456789,rss_mb:117.7,cpu_pct:12.5,uptime_seconds:100,in_flight_count:0}}') ARG_A2=$(jq -nc --arg net "$NET_A" \ '{resume_id:"140-a-2",alias:"hero-a2",status:"working",task:"compute",progress:66,agent:"agent-node:codex",model:"gpt-5.4",network_id:$net,host:{hostname:"hero-box",ip:"10.10.0.5",cpu_load_1min:3.6,cpu_cores:4,mem_total_gb:16,mem_used_gb:15.6,mem_avail_gb:0.4,disk_total_gb:100,disk_used_gb:99.2,disk_avail_gb:0.8},process_telemetry:{rss_bytes:223456789,rss_mb:213.1,cpu_pct:80.1,uptime_seconds:200,in_flight_count:2}}') -out=$(mcp_call "$NTOK_A" report_status "$ARG_A1") +out=$(report_as "$UTOK_A" "$NET_A" "$ARG_A1") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status A1: $out"; exit 1; } sleep 1.1 -out=$(mcp_call "$NTOK_A" report_status "$ARG_A2") +out=$(report_as "$UTOK_A" "$NET_A" "$ARG_A2") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status A2: $out"; exit 1; } echo "[3] report same host in network B to guard cross-network scope" ARG_B1=$(jq -nc --arg net "$NET_B" \ '{resume_id:"140-b-1",alias:"hero-b1",status:"idle",network_id:$net,host:{hostname:"hero-box",ip:"10.10.0.5",cpu_load_1min:0.1,cpu_cores:64,mem_total_gb:128,mem_used_gb:8,mem_avail_gb:120,disk_total_gb:1000,disk_used_gb:100,disk_avail_gb:900},process_telemetry:{rss:999,cpu_pct:1,uptime_seconds:9,in_flight_count:0}}') -out=$(mcp_call "$NTOK_B" report_status "$ARG_B1") +out=$(report_as "$UTOK_B" "$NET_B" "$ARG_B1") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: report_status B1: $out"; exit 1; } echo "[4] /api/server/:host/health exposes latest alert + history for network A only" @@ -141,7 +165,7 @@ echo "$STATUS_A" | jq -e '.ok == true and (.sessions[] | select(.alias=="hero-a2 echo "[5b] old clients without process_telemetry surface nulls" LEGACY_ARG=$(jq -nc --arg net "$NET_A" \ '{resume_id:"140-legacy",alias:"legacy-agent",status:"idle",network_id:$net,host:{hostname:"legacy-box",ip:"10.10.0.6",cpu_load_1min:0.1,cpu_cores:2,mem_total_gb:4,mem_used_gb:1,mem_avail_gb:3}}') -out=$(mcp_call "$NTOK_A" report_status "$LEGACY_ARG") +out=$(report_as "$UTOK_A" "$NET_A" "$LEGACY_ARG") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: legacy report_status: $out"; exit 1; } LEGACY_AGENTS=$(curl -fsS "$HUB_BASE/api/server/legacy-box/agents?network_id=$NET_A" -H "Authorization: Bearer $UTOK_A") echo "$LEGACY_AGENTS" | jq -e '.ok == true and .agents[0].process_telemetry.rss_bytes == null and .agents[0].process_telemetry.cpu_pct == null and .agents[0].process_telemetry.in_flight_count == null' >/dev/null || { @@ -159,8 +183,11 @@ echo "$SERVERS_A" | jq -e 'type=="array" and length==2 and (.[] | select(.hostna } echo "[7] /api/messages returns promptly after task write" +# send 侧对称检查(fromIdentityMismatchReply):用 network token 发送时 +# from_session 必须等于该 token 绑定的 alias,所以发送方也要有自己的 node token。 +SENDER_TOK=$(node_token "$UTOK_A" "$NET_A" "dashboard-smoke") TASK_A=$(jq -nc '{alias:"hero-a1",task:"message-smoke",from_session:"dashboard-smoke"}') -SEND_A=$(mcp_call "$NTOK_A" send_task "$TASK_A") +SEND_A=$(mcp_call "$SENDER_TOK" send_task "$TASK_A") echo "$SEND_A" | jq -e '.ok == true' >/dev/null || { echo "FAIL: send_task for messages: $SEND_A"; exit 1; } MESSAGES_A=$(curl -fsS --max-time 2 "$HUB_BASE/api/messages?network_id=$NET_A&limit=10" -H "Authorization: Bearer $UTOK_A") echo "$MESSAGES_A" | jq -e '.ok == true and (.messages[] | select(.from_alias=="dashboard-smoke" and .to_alias=="hero-a1"))' >/dev/null || { @@ -180,7 +207,7 @@ wait_for_log '"type":"connected"' /tmp/sse-a.log "SSE A connected" wait_for_log '"type":"connected"' /tmp/sse-b.log "SSE B connected" STATUS_UPDATE_ARG=$(jq -nc --arg net "$NET_A" \ '{resume_id:"140-a-1",alias:"hero-a1",status:"idle",progress:11,agent:"agent-node:claude",network_id:$net,host:{hostname:"hero-box",ip:"10.10.0.5",cpu_load_1min:1.1,cpu_cores:4,mem_total_gb:16,mem_used_gb:15.1,mem_avail_gb:0.9,disk_total_gb:100,disk_used_gb:92,disk_avail_gb:8},process_telemetry:{rss_bytes:133456789,rss_mb:127.3,cpu_pct:13.5,uptime_seconds:110,in_flight_count:1}}') -out=$(mcp_call "$NTOK_A" report_status "$STATUS_UPDATE_ARG") +out=$(report_as "$UTOK_A" "$NET_A" "$STATUS_UPDATE_ARG") echo "$out" | jq -e '.ok == true' >/dev/null || { echo "FAIL: status update report_status: $out"; exit 1; } wait_for_log '"type":"status_update"' /tmp/sse-a.log "status update SSE" wait_for_log '"process_telemetry"' /tmp/sse-a.log "process telemetry SSE" From 5e590707a8469e2828236c537a4fd8471e9fceff Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 06:12:27 +0800 Subject: [PATCH 45/59] =?UTF-8?q?test(ci):=20=E7=BB=99=20server=20?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E8=81=9A=E5=90=88=E5=8D=95=E6=B5=8B=E9=97=A8?= =?UTF-8?q?(69=20=E4=B8=AA=E5=8D=95=E6=B5=8B=E6=AD=A4=E5=89=8D=20CI=20?= =?UTF-8?q?=E5=8F=AA=E8=B7=91=206=20=E4=B8=AA)=20(#798)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) server/src 下 69 个 *.test.ts,CI 可达的只有 6 个(scripts/qa.sh 的 L0_TESTS 点名 5 个 + test686 引用 1 个),另外 63 个没有任何 job 会碰。server 是 hub 本体 —— 认证、token、网络隔离都在这里,盲区比 agent-network 那 46 个严重。 形状抄 test745/test725,但按 server 自己的契约做了两处改动: 1) 逐文件跑,每个文件一个独立 DB。scripts/qa.sh 的 L0 本来就是 `COMMHUB_DB=/tmp/qa-l0-$name.db bun test ` —— 这是既有契约。 用一个共享 DB 聚合跑会红 4 条(admin-networks 的 global-admin 可见性、 scheduled-tasks 三条),而这 4 条单跑全绿,是跨文件状态污染。 把"聚合能不能跑"当门等于给它加了一条它从没承诺过的性质。 2) cwd 必须是仓根。task-lifecycle-watcher 用 process.cwd() 拼 ./server/src/db.js,scheduled-tasks-http 按仓根相对路径 import tests/test601-.../race-worker.ts。从 server/ 目录跑会让这两个红在路径上, 看起来像产品坏了。 红线:COMMHUB_DB 不设默认指向生产库。容器里够不到宿主的库,但不靠"够不到" 保证 —— run.sh 显式钉到 /tmp 并断言钉住了。31/69 个测试引用 sqlite/COMMHUB_DB。 分母承重:executed_files 必须等于 find 出来的 test_files,少一个就红。 witnessed-red:把 auth.ts 注册密码下限 `< 8` 改成 `< 1`(7 位密码会被接受, 一条真的安全回退),先校验字节非 no-op,再要求红落在指名的 "rejects 7-char password" 上。 实测:test_files=69 executed_files=69 failed_files=0, MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS,耗时 51s。 完整输出见 docs/tests/report-test798-server-unit-ci.txt。 * ci: server 单测门抽成独立 job,别挂在 agent-network 名下 上一版把 build/run 两步插进了 agent-network-unit job 里,所以它确实跑了 (CI 日志实测 test_files=69 executed_files=69 failed_files=0 MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS), 但会以 "agent-network unit (Docker, non-root)" 的名义显示 —— server 挂了会归错帐,而且两个重 Docker build 串在一个 job 里。 抽成 server-unit job,显示名 "server unit (Docker, non-root)"。 * ci: test601 的 race-worker 也要能触发 server 单测门 自查清单第 4 条(判据范围要与被判对象一致)在自己 PR 上的第一次应用: 把 test798 镜像 COPY 的每一项,回去核 qa.yml 的触发路径有没有覆盖。 COPY server ./server → 'server/**' ✅ COPY agent-node/src ./agent-node/src → 'agent-node/**' ✅ COPY tests/test601-hub-scheduled-tasks → 无 ❌ server/src/scheduled-tasks-http.test.ts 会执行那个 race-worker 做 「两个真 Hub 进程抢同一个 occurrence 恰好一次」的用例 —— 只改 worker 的 PR 不该跳过这道门。两处 path 过滤都补上。 这条是 codex 在 #798 上提的 P2,当时我认了但没修;现在按清单扫一遍就扫到了。 * test(ci): mutation 的命名断言要锚在 (fail) 行,否则通过时也会命中 自查清单(#815)第 ⑤ 条「断言要精确到不合规会被拒绝」在自己门上的应用。 原来写的是 `grep -Fq 'rejects 7-char password'`。bun test 对每个用例都打 `(pass) <名字>` 或 `(fail) <名字>` —— 只 grep 名字的话,那条用例**通过**时 也会命中。于是这条断言只证明了「这条用例存在」,而不是「红落在它身上」。 A/B(把断言指向一条在该 mutation 下**不会红**的用例 `accepts 8-char strong password`,其余完全不动): 松版 grep -Fq '<名字>' → rc=0 RESULT: PASS ← 收下了不合规 严版 grep -Eq '^\(fail\).*<名字>' → rc=1 FAIL: mutation red did not reach the named… 改成锚定形式后正常绿:MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS。 同类问题在 tests/test725-agent-node-unit-ci/run.sh 也有(它 grep 的 'the inbox choke point feeds the augmented text into processTask' 同样是测试名); 在 #800 里一并收紧,那边有单独说明。 tests/test745 那条不受影响 —— 它 grep 的是断言失败信息 `Expected to contain: "anet config [path|json]"`,只在失败时出现。 * test(ci): 分母要有绝对下限 —— 删掉 85% 的测试,这道门原来照样绿 自查清单(#815)第 ⑥ 条「mutation 要跑到曾经活下来为止」的直接产物。 这道门原来只有「削弱被测代码」一个 mutation 维度。换一个维度试:删测试文件。 第一次删 60/69 时门红了 —— 但那是**碰巧**:mutation 靶点所在的 auth-validate.test.ts 恰好在被删之列。做决定性验证,删 59 个但保留它: test_files=10 executed_files=10 discovered_files=10 failed_files=0 MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS rc=0 **门放行了一个删掉 85% server 单测的改动。** 根因:`executed >= discovered` 只能抓「runner 跳过了文件」,抓不到 「文件消失了」—— 分母跟着现实自动缩水,原来的 `-gt 0` 形同虚设。 加 SERVER_TEST_FLOOR=60,并写明「真删了测试就故意改它,并在 PR 里说明」。 双向验过:69 个 → RESULT: PASS;同一删除 mutation → rc=1 `FAIL: only 10 server test file(s) under src/, floor is 60`。 * docs(tests): report-only —— 修掉报告内部两个不一致的锚点 自查发现本报告里有两个不同的 source_commit:抬头是 2617987e(正确,==源码提交), 但嵌入的运行输出里是 187a6ffe。 根因是我上一轮的操作顺序错了:**先 `git rev-parse HEAD` 打戳、后提交下限改动**。 于是镜像里跑的是含下限的代码,戳进日志的却是提交前的 SHA —— 证据本身有效,但它自称的锚点指向一个不含该改动的提交。 这与 #798/#800/#803 早先被独审抓到的假锚点是**同一个根因的第二次发作** (那次是把 --build-arg 传成了 origin/main,这次是传成了未提交前的 HEAD)。 已在真源码提交 2617987e 上重跑并重出报告,全文 source_commit 只指向一个值: test_files=69 executed_files=69 failed_files=0 MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS --------- Co-authored-by: vansin Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/workflows/qa.yml | 27 ++++ docs/tests/report-test798-server-unit-ci.txt | 26 ++++ tests/test798-server-unit-ci/Dockerfile | 44 ++++++ tests/test798-server-unit-ci/run.sh | 141 +++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 docs/tests/report-test798-server-unit-ci.txt create mode 100644 tests/test798-server-unit-ci/Dockerfile create mode 100755 tests/test798-server-unit-ci/run.sh diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index bd1b83fab..972ee3be4 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -31,6 +31,11 @@ on: - 'tests/test686-rest-shape-golden/**' - 'tests/test765-batch-runtime-gate/**' - 'tests/test766-bunx-preflight/**' + - 'tests/test798-server-unit-ci/**' + # test798 的镜像 COPY 了 test601 的 race-worker.ts, + # 且 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」—— + # 只改那个 worker 的 PR 不该跳过这道门(codex P2,核过属实) + - 'tests/test601-hub-scheduled-tasks/**' push: branches: [main] paths: @@ -52,6 +57,11 @@ on: - 'tests/test686-rest-shape-golden/**' - 'tests/test765-batch-runtime-gate/**' - 'tests/test766-bunx-preflight/**' + - 'tests/test798-server-unit-ci/**' + # test798 的镜像 COPY 了 test601 的 race-worker.ts, + # 且 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」—— + # 只改那个 worker 的 PR 不该跳过这道门(codex P2,核过属实) + - 'tests/test601-hub-scheduled-tasks/**' # Older runs on the same ref get cancelled — saves minutes when a PR is # updated rapidly. main pushes run independently. @@ -77,6 +87,23 @@ jobs: - name: Run complete agent-network unit domain run: docker run --rm anet-test745-agent-network-unit + server-unit: + name: server unit (Docker, non-root) + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + + - name: Build exact server unit image + run: | + docker build \ + --build-arg SOURCE_COMMIT="$GITHUB_SHA" \ + -t anet-test798-server-unit \ + -f tests/test798-server-unit-ci/Dockerfile . + + - name: Run complete server unit domain + run: docker run --rm anet-test798-server-unit + agent-node-unit: name: agent-node unit (Docker, non-root) runs-on: ubuntu-latest diff --git a/docs/tests/report-test798-server-unit-ci.txt b/docs/tests/report-test798-server-unit-ci.txt new file mode 100644 index 000000000..5b4b1c9f0 --- /dev/null +++ b/docs/tests/report-test798-server-unit-ci.txt @@ -0,0 +1,26 @@ +# test798 —— server 聚合单测门 +source_commit=2617987e75a6d5f3c0af3abc41709fad20176960 +base(current main)=034f00647d42d38d5086d7fc057eb7824a441791 +本文件是该源码提交的 report-only 子提交;锚定源码提交,与 PR 的 virtual-merge SHA 不同。 + +## 三个 mutation 维度(全部双向验过) +① 削弱被测代码:auth.ts 密码下限 < 8 → < 1 ⇒ MUTATION_RED,且断言锚在 (fail) 行 +② 断言宽容度:把命名断言指向一条该 mutation 下不会红的用例 + 松版 grep -Fq '<名字>' → rc=0 RESULT: PASS(收下不合规);严版 ^\(fail\).* → rc=1 +③ 分母缩水:删 59/69 个测试文件(保留 mutation 靶点所在文件) + 加下限前 → test_files=10 executed=10 failed=0 MUTATION_RED RESULT: PASS(rc=0) + 加下限后 → rc=1 FAIL: only 10 server test file(s) under src/, floor is 60 + +## 本次输出(在真源码提交上重跑) +``` +# test798 — complete server unit domain +source_commit=2617987e75a6d5f3c0af3abc41709fad20176960 +bun=1.3.14 node=v22.23.2 uid=1000 +commhub_db=/tmp/test798-server-unit.db +test_files=69 +[L0] every server/src unit file, one DB each, as non-root (cwd=repo root) +executed_files=69 discovered_files=69 failed_files=0 +[L1] witnessed-red: weaken the registration password floor +MUTATION_RED registration-password-floor-weakened rc=1 +RESULT: PASS +``` diff --git a/tests/test798-server-unit-ci/Dockerfile b/tests/test798-server-unit-ci/Dockerfile new file mode 100644 index 000000000..4e91a2a04 --- /dev/null +++ b/tests/test798-server-unit-ci/Dockerfile @@ -0,0 +1,44 @@ +ARG SOURCE_COMMIT +FROM node:22-bookworm-slim +ARG BUN_VERSION=1.3.14 +ARG BUN_LINUX_X64_SHA256=951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash build-essential ca-certificates curl git python3 unzip util-linux \ + && rm -rf /var/lib/apt/lists/* + +RUN curl --fail --silent --show-error --location \ + --retry 3 --retry-delay 2 --retry-all-errors \ + "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64.zip" \ + --output /tmp/bun-linux-x64.zip \ + && echo "${BUN_LINUX_X64_SHA256} /tmp/bun-linux-x64.zip" | sha256sum --check --strict \ + && unzip -j /tmp/bun-linux-x64.zip 'bun-linux-x64/bun' -d /usr/local/bin \ + && chmod 0755 /usr/local/bin/bun \ + && test "$(bun --version)" = "$BUN_VERSION" \ + && rm -f /tmp/bun-linux-x64.zip + +WORKDIR /workspace +COPY server/package.json ./server/ +RUN cd server && npm install + +COPY server ./server +# RFC-026 G9 / RFC-028 P1 的漂移门比对 hub 与 daemon 的同名共享源码, +# 要读同级 agent-node/src/shared。只带这个目录,不带整个包。 +# 若干 server 测试跨包 import agent-node 的源码(hub↔daemon 漂移门比对 +# shared/*.ts;peer-reply-atomic 用 reply-reliability;dashboard-slash-routing +# 用 inbox-dispatch)。带整个 src,不带它的依赖 —— 这些是纯源码 import。 +COPY agent-node/package.json ./agent-node/package.json +COPY agent-node/src ./agent-node/src +# scheduled-tasks-http.test.ts 起两个真 Hub 进程抢同一个 occurrence, +# worker 脚本在 tests/test601-hub-scheduled-tasks 下,按仓根相对路径 import。 +COPY tests/test601-hub-scheduled-tasks ./tests/test601-hub-scheduled-tasks +COPY tests/test798-server-unit-ci/run.sh ./tests/test798-server-unit-ci/run.sh + +ARG SOURCE_COMMIT +ENV TEST798_SOURCE_COMMIT=$SOURCE_COMMIT + +RUN chmod 0755 ./tests/test798-server-unit-ci/run.sh \ + && install -d -o node -g node -m 0700 "/run/user/$(id -u node)" \ + && chown -R node:node /workspace + +ENTRYPOINT ["bash", "tests/test798-server-unit-ci/run.sh"] diff --git a/tests/test798-server-unit-ci/run.sh b/tests/test798-server-unit-ci/run.sh new file mode 100755 index 000000000..14f5fc334 --- /dev/null +++ b/tests/test798-server-unit-ci/run.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +set -euo pipefail + +# test798 — server 的聚合单测门 +# +# server/src 下有 69 个 *.test.ts,而在这之前 CI 只点名跑其中 6 个 +# (scripts/qa.sh 的 L0_TESTS 5 个 + test686 引用 1 个),另外 63 个没有任何 +# CI job 会碰。server 是 hub 本体 —— 认证、token、网络隔离都在这里。 +# +# 形状抄 tests/test745-agent-network-unit-ci。 + +ROOT=/workspace +SOURCE_COMMIT=${TEST798_SOURCE_COMMIT:-} +[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: SOURCE_COMMIT must be one full lowercase Git SHA" >&2 + exit 1 +} + +# 🔴 红线:COMMHUB_DB 不设的话默认指向生产库。容器里够不到宿主的库, +# 但不能靠"够不到"来保证 —— 显式钉到容器内临时路径,并断言它真的被钉住了。 +# 31/69 个 server 测试引用了 sqlite/COMMHUB_DB,这条不是形式主义。 +export COMMHUB_DB=/tmp/test798-server-unit.db +[[ "$COMMHUB_DB" == /tmp/* ]] || { + echo "FAIL: COMMHUB_DB must point inside the container tmpdir, got '$COMMHUB_DB'" >&2 + exit 1 +} + +echo "# test798 — complete server unit domain" +echo "source_commit=$SOURCE_COMMIT" +echo "bun=$(bun --version) node=$(node --version) uid=$(id -u node)" +echo "commhub_db=$COMMHUB_DB" + +test_files=$(find "$ROOT/server/src" -type f -name '*.test.ts' | wc -l | tr -d ' ') +echo "test_files=$test_files" +# 🔴 绝对下限,不是 > 0。`executed >= discovered` 只能抓「runner 跳过了文件」, +# 抓不到「文件消失了」—— 分母会跟着现实自动缩水。 +# 实测:删掉 69 个里的 59 个(保留 mutation 靶点所在的 auth-validate), +# 这道门报 test_files=10 / executed=10 / failed=0 / MUTATION_RED / RESULT: PASS, +# rc=0 —— 也就是放行了一个删掉 85% server 单测的改动。 +# +# 下限要**故意**改:真删了测试就在这里调,并在 PR 里说明为什么。 +# 合并 main 时重算:本 PR 写下时 server/src 有 69 个,现在是 72(#798 之后又进了 +# rest-write-network-resolution 等)。floor 60 对 72 意味着**可以静默删掉 12 个**—— +# 而删测试正是这道门唯一挡得住的事。floor 抬到 70:留 2 个的合并余量,再多就必须 +# 在 PR 里显式改这一行。 +SERVER_TEST_FLOOR=70 +[[ "$test_files" -ge "$SERVER_TEST_FLOOR" ]] || { + echo "FAIL: only $test_files server test file(s) under src/, floor is $SERVER_TEST_FLOOR" >&2 + echo " 若确实删除/迁移了测试,请连同本 floor 一起改,并在 PR 里说明。" >&2 + exit 1 +} + +# 逐文件跑,每个文件一个独立 DB —— 这是 server 测试的既有契约: +# scripts/qa.sh 的 L0 就是 `COMMHUB_DB=/tmp/qa-l0-$name.db bun test `。 +# 用一个共享 DB 聚合跑会红 4 条(admin-networks 的 global-admin 可见性、 +# scheduled-tasks 的三条),而这 4 条单跑全绿 —— 是跨文件状态污染,不是产品坏。 +# 所以这道门按契约逐文件跑,而不是把"聚合能不能跑"这个它从没承诺过的性质当门。 +# +# cwd 必须是仓根:task-lifecycle-watcher 用 process.cwd() 拼 ./server/src/db.js, +# scheduled-tasks-http 按仓根相对路径 import tests/test601-.../race-worker.ts。 +echo "[L0] every server/src unit file, one DB each, as non-root (cwd=repo root)" +ran=0; failed=0; failed_names="" +while IFS= read -r f; do + rel=${f#"$ROOT"/} + name=$(basename "$f" .test.ts) + db="/tmp/test798-$name.db" + rm -f "$db" + if runuser -u node -- env HOME=/home/node COMMHUB_DB="$db" \ + bash -lc "cd $ROOT && bun test '$rel'" >"/tmp/test798-$name.log" 2>&1; then + ran=$((ran+1)) + else + ran=$((ran+1)); failed=$((failed+1)); failed_names="$failed_names $name" + echo "--- FAILED: $rel ---" + tail -25 "/tmp/test798-$name.log" + fi +done < <(find "$ROOT/server/src" -type f -name '*.test.ts' | sort) + +echo "executed_files=$ran discovered_files=$test_files failed_files=$failed" + +# 分母承重:跑过的文件数必须等于磁盘上的数。少一个都说明 find 的范围塌了, +# 而"跑了 2 个全绿"和"跑了 69 个全绿"打印出来是同一片绿色。 +[[ "$ran" -eq "$test_files" ]] || { + echo "FAIL: executed $ran file(s) but $test_files exist under server/src" >&2 + exit 1 +} +[[ "$failed" -eq 0 ]] || { + echo "FAIL: $failed file(s) failed:$failed_names" >&2 + exit 1 +} + +# --------------------------------------------------------------------------- +# witnessed-red:证明这道门真的在跑 server 的测试,而不是空转。 +# 靶点是注册时的密码下限 —— 把 `< 8` 改成 `< 1`,7 位密码就会被接受。 +# 这是一条真的安全回退,不是随手改个字符串。 +# --------------------------------------------------------------------------- +echo "[L1] witnessed-red: weaken the registration password floor" +TARGET='if (!password || password.length < 8) return `${label} must be at least 8 characters`;' +MUTATED='if (!password || password.length < 1) return `${label} must be at least 8 characters`;' +SRC="$ROOT/server/src/auth.ts" +before=$(sha256sum "$SRC" | cut -d' ' -f1) +python3 - "$SRC" "$TARGET" "$MUTATED" <<'__MUT__' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +source = path.read_text() +target, replacement = sys.argv[2], sys.argv[3] +if source.count(target) != 1: + raise SystemExit("mutation target count changed") +path.write_text(source.replace(target, replacement, 1)) +__MUT__ +after=$(sha256sum "$SRC" | cut -d' ' -f1) +[ "$before" != "$after" ] || { echo "FAIL: mutation was a byte no-op" >&2; exit 1; } + +rm -f /tmp/test798-mut.db +set +e +runuser -u node -- env HOME=/home/node COMMHUB_DB=/tmp/test798-mut.db \ + bash -lc "cd $ROOT && bun test server/src/auth-validate.test.ts" \ + >/tmp/test798-mutation.log 2>&1 +mutation_rc=$? +set -e +[ "$mutation_rc" -ne 0 ] || { + cat /tmp/test798-mutation.log + echo "FAIL: password-floor mutation survived" >&2 + exit 1 +} +# 红必须落在指名的那条行为上,而不是红在导入失败之类的别处。 +# +# 🔴 必须锚在 (fail) 行上。bun test 对每个用例都打 `(pass) <名字>` 或 +# `(fail) <名字>` —— 只 grep 名字的话,那条用例**通过**时也会命中, +# 断言就只证明了「这条用例存在」,而不是「红落在它身上」。 +# 这是宽容断言:配上 mutation_rc != 0 看起来很像样,但如果 mutation 实际 +# 打红的是别的用例,这一对断言照样全过。 +grep -Eq '^\(fail\).*rejects 7-char password' /tmp/test798-mutation.log || { + cat /tmp/test798-mutation.log + echo "FAIL: mutation red did not reach the named password-floor assertion" >&2 + exit 1 +} + +echo "MUTATION_RED registration-password-floor-weakened rc=$mutation_rc" +echo "RESULT: PASS" From cc325f405c2858d382b301aef76c42b542859cd9 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 06:35:11 +0800 Subject: [PATCH 46/59] =?UTF-8?q?ci:=20=E6=B3=A8=E5=86=8C=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=E4=BB=8E=E6=B2=A1=E8=BF=9B=20CI=20=E7=9A=84=20Docker?= =?UTF-8?q?=20=E9=97=A8,build-arg=20=E6=94=B9=E6=8E=A8=E5=AF=BC(=E7=AC=AC?= =?UTF-8?q?=E5=9B=9B=E4=B8=AA=E5=B7=B2=E8=BF=87=E6=97=B6,=E5=8F=A6?= =?UTF-8?q?=E5=BC=80=20issue)=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: 注册三个从没进 CI 的 Docker 门,并把 build-arg 从硬编码链改成推导 tests/ 下有四个形状完整的 Docker 门(Dockerfile + run.sh + 自己的 mutation) 从没被注册进 L1_TESTS,所以一直没人跑。逐个跑过之后: test224-grok-preview-security PASS 39s test597-dashboard-slash-namespace PASS 15s test679-task-trace PASS 36s test682-uncovered-task-trace FAIL ← 不注册,另开 issue,见下 三个通过的注册进 L1_TESTS(L1 并行跑,最差加 ~39s 墙钟)。 顺带把 build_args 从硬编码 if/elif 链改成从套件自己的 Dockerfile 推导。 那条链的失效方式是静默的:把套件加进 L1_TESTS 却忘了加分支,它会在没有 SHA 绑定的情况下跑,输出看起来一切正常。而新加的 test224/test597 用的正是 不带前缀的 `ARG SOURCE_COMMIT`,是原链无法表达、只能再加分支的形状。 替换前核过等价性:对原链覆盖的 test686/765/766/746 四个套件,推导结果与 硬编码逐字相同。 推导是否承重,分三种(不传 build-arg 时): test224 → rc=1 FAIL: SOURCE_COMMIT must bind… fail-closed,推导承重 test597 → rc=0 PASS 声明了却不强制 test679 → rc=0 PASS 声明了却不强制 后两个是那两道门自己的弱点,本 PR 不修,写进 NOT COVERED。 test682-uncovered-task-trace 不注册:它断言 cli.ts 里 sendPeerReplyTaskWithTrace( 恰好出现 1 次,实际 0 次。查下来不是烂了,是**过时了** —— #698 有意把 peer reply 改成协商 send_peer_reply 原子工具,那条 send_task 老路被删掉,并由 agent-node/src/reply-routing-source.test.ts 断言它**不得出现** (expect(source).not.toContain("sendPeerReplyTaskWithTrace({"))。 两道门方向相反,而后者在 CI 里跑着且是绿的。另外 agent-node/src/peer-reply-task-trace.ts 现在零生产调用方,只被 test682 自己引用。 单独开 issue,不在本 PR 里删任何东西。 * fix(ci): build-arg 推导要 || true —— pipefail 让它打死了整个 L1 runner 第一版在 CI 上挂了,而且挂得很有欺骗性:失败停在 `· build qa-cli-01-hub-start`,一个套件都没跑成,看起来像「L1 挂了」, 实际是参数推导那一行把 runner 打死了。 根因:scripts/qa.sh 是 set -euo pipefail,而多数套件的 Dockerfile 根本没有 ARG SOURCE_COMMIT —— grep 无命中退 1,pipefail 把 1 传给整个命令替换, set -e 于是在第一个这样的套件上退出。 我上一版只验了「推导算出来的参数名对不对」(对 7 个套件逐个核过), 没验它在 qa.sh 里跑不跑得通 —— 验了零件没验装配。 修法:命令替换末尾加 || true,并把原因写进注释。 witnessed-red(在真脚本上,不是最小复现): 去掉 || true → rc=1,日志停在 `· build qa-cli-01-hub-start`,与 CI 症状逐字一致 加回 || true → 三种 Dockerfile 形状各取一个跑真 qa.sh --l1: qa-cli-01-hub-start 无 ARG ✓ PASS test765-batch-runtime-gate TEST765_ 前缀 ARG ✓ PASS test597-dashboard-slash-namespace 裸 ARG ✓ PASS ✓ ALL PASS in 84s * ci: 三个孤儿门改放独立 job,不塞进 L1 上一版把 test224/test597/test679 加进了 L1_TESTS。选错家了。 CI 上 L0+L1 job 的真实耗时(main 近四次):141s / 135s / 148s,预算 300s, 余量约 150s。而 qa.sh 的 build 是**串行**的(只有 docker run 并行),这三个 套件要各加一次 build,其中 test679 带 javascript-obfuscator;单跑 run 已是 39s / 15s / 36s。L1 自称「~16s parallel」,是快层 —— 塞进去是拿余量赌。 改成 qa.yml 里的独立 job `recovered-suites`,预算 12 分钟,形状同单测门。 撤出 L1_TESTS 的原因写进了那里的注释,免得有人再塞一次。 build_args 推导保留在 qa.sh —— 它独立成立:原硬编码 if/elif 链的失效方式是 静默的(套件加进 L1_TESTS 却忘了加分支,会在没有 SHA 绑定的情况下跑)。 等价性核过:对 test686/765/766/746 四个套件,推导与硬编码逐字相同。 三个套件按 job 里逐字相同的命令验证(只传 --build-arg,run 不带 -e): test224 SOURCE_COMMIT rc=0 Summary: PASS test597 SOURCE_COMMIT rc=0 RESULT: PASS test679 TEST679_SOURCE_COMMIT rc=0 RESULT: PASS NOT COVERED:不传 build-arg 时只有 test224 是 fail-closed(rc=1), test597/test679 照样 PASS —— 它们声明了 SOURCE_COMMIT 却不强制。 那是那两道门自己的弱点,本 PR 不修。 * ci: test224 必须带 --network none;tests/lib/** 补进触发路径 两条都是独立审(codex)在本 PR 上提的 P1,核过属实。 1) test224 是安全套件。它的 Dockerfile 第 13 行明写 「the actual gate is run with --network none」,run.sh 第 160 行会打印 「runtime executed with network disabled」。而我的 job 是裸 docker run --rm —— **那句话在网络实际可用时照样打印**。 实测对照:带与不带 --network none,两次都 rc=0、都打印同一句 Summary, 差异只有时间戳和 tarball sha256。也就是说**套件自己不会拦住这个错误**, 只能由调用方保证。这是我引入的缺陷:把一道安全门接进 CI 时没照它自己的契约调用。 2) test224 的镜像 COPY 了 tests/lib/safe-rm.sh 并 source 它,但 qa.yml 的两处 path 过滤都没有 tests/lib/** —— 只改那个 helper 的 PR 不会触发这道门。 有一条我**不在本 PR 里改**:套件用一行硬编码 log "network: disabled by runner" **声明**前提,而不是探测它。要让它自己红,得加 fail-closed 探测(比如真去 resolve/connect 一次,通了就 fail)。那是改别人的门、会影响所有调用方, 交给 owner 决定,我只报不动。 codex 另外三条我的处置: - 「pin oven/bun digest」:成立,但属于 test224/test597 自身的 Dockerfile, 与 #799/#802 的 pin 工作同族,不夹进本 PR; - 「report 写在容器里被 --rm 丢掉」:成立,是观测缺口,同样属套件自身; - 「把安全套件排在低层套件之后」:是取舍不是缺陷,独立 job 里三个都会跑完, 排序不影响是否产出证据。 * docs(tests): report-only —— 锚点 aeec4b9c,含 --network none 的对照与四条 NOT COVERED * ci(qa): 落实三条已接受未实施的意见 —— 顺序、产物、Bun 输入 这三条我在窄审后都写过"成立",然后挂在"待收口后落"。收口从没到来, 而这条 PR 的意见已经躺了一天。不再等。 (e) 安全套件排在最前,与 CLAUDE.md 的分层规则相反 「分层测试:环境→认证→单点通信→完整流程→多用户→安全」 「前一层不过就不跑后面的」 改成 test597 → test679 → test224(安全最后)。后果不是"跑了会错", 而是底层套件红时安全证据已经先产出 —— 而那份证据的前提没成立。 (f) --rm 把套件报告删掉。test224 把 report-test224.txt 写在容器内 /artifacts 下,--rm 随即删掉那个文件系统;test597/test679 只有 stdout。 结果是三个套件每次 CI 都真跑,跑完什么都不留。 改法:test224 挂出 /artifacts;三个都 tee 到 $RUNNER_TEMP/suite-artifacts; 加 upload-artifact 且 if: always()(红了才最需要看输出)。 🔴 三处都显式 set -o pipefail —— GitHub Actions 默认 shell 是 bash -e, 不带 pipefail,不加这句 tee 的 0 会盖掉套件的非零退出。这正是本仓 #805 上被判 MAJOR 的同一个形态,不能在修别的问题时又引进来。 (d) Bun 输入未钉死。test224 的 oven/bun:1.3.1 与 test597 的 oven/bun:1.3.14 都是可变 tag —— 同一个 commit 在不同时间构建会跑在不同字节上。 已钉成 digest(manifest inspect 取得)。 ⚠️ test679 仍未钉:它是 node:22-bookworm-slim + curl bun.sh/install | bash, 构建时装到什么算什么。改成仓里既有的"下载指定版本 zip + 校验 SHA256" (test745 的做法)属于改动该套件的构建方式,我没有实跑过它,不敢 盲改。这一条如实留作 NOT COVERED,不假装已修。 * ci(test679): 钉死 Bun 输入 —— 上一版我标了"需先实跑"就留着没做 上一版我把这条列为 NOT COVERED,理由是"改构建方式需先实跑,盲改可能让 本来能跑的套件跑不起来"。那个理由成立,但消除它的办法就是先跑一次 —— 而我没跑。 这次跑了,而且不必重建整套:风险只在装 bun 那一层,所以隔离验证那一层。 两个最小镜像(原样 curl|bash vs 钉死下载+校验和)都构建成功,结果完全相同: bun 版本 1.3.14 == 1.3.14 路径 /root/.bun/bin/bun == /root/.bun/bin/bun 所以转换今天是等价的,而且从此确定。 顺带这也证明了风险是真的:不钉版本时"今天恰好是 1.3.14",bun 一发 1.3.15, 同一个 commit 就会构建在不同字节上 —— 而套件本身不会察觉。 验证边界:我验的是 bun 那一层,不是整套 test679 通过。后面三个 bun install 与 run.sh 未动,但它们没有被重跑过 —— 首次 CI 运行才是完整证据。 * ci(recovered-suites): 上传前修正产物属主 —— 门全绿却因 EACCES 判红 exact-head CI 上 `recovered suites (Docker)` 稳定红,但红的**不是任何一道门**: RESULT: PASS (×2) PASS: targeted Docker context contains no host auth/config state PASS: real child env equals the reviewed set; … PASS: candidate tarballs contain runnable entrypoints … Summary: PASS (Docker-only; runtime executed with network disabled; …) 红在最后一步 `Upload recovered-suite artifacts`: With the provided path, there will be 4 files uploaded ##[error]An error has occurred while creating the zip file for upload Error: EACCES: permission denied, open '.../suite-artifacts/report-test224.txt' 三个 suite 都是 root 容器写进 bind mount(`-v "$RUNNER_TEMP/suite-artifacts:/artifacts"`), 产物属主 root、mode 0600;upload-artifact 以 runner 用户打包,打开即 EACCES。 注意 `if-no-files-found: warn` 且日志明说「4 files uploaded」—— 不是「没找到文件」,是找到了读不了。 后果不是 cosmetic:这个 PR 的目的正是把三道长期失联的信号恢复成 CI 里的常驻门, 而现在 job 必红、证据也归档不了,等于恢复了个红灯。 修法:上传前把产物目录的属主/权限归一化。用 `if: always()`,因为前面步骤红时 更需要把证据传出来。 --------- Co-authored-by: vansin Co-authored-by: vansin Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/workflows/qa.yml | 93 +++++++++++++++++++ docs/tests/report-register-orphan-suites.txt | 57 ++++++++++++ scripts/qa.sh | 29 ++++-- .../test224-grok-preview-security/Dockerfile | 2 +- .../Dockerfile | 2 +- tests/test679-task-trace/Dockerfile | 15 ++- 6 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 docs/tests/report-register-orphan-suites.txt diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 972ee3be4..ab413caca 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -36,6 +36,10 @@ on: # 且 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」—— # 只改那个 worker 的 PR 不该跳过这道门(codex P2,核过属实) - 'tests/test601-hub-scheduled-tasks/**' + - 'tests/test224-grok-preview-security/**' + - 'tests/test597-dashboard-slash-namespace/**' + - 'tests/test679-task-trace/**' + - 'tests/lib/**' push: branches: [main] paths: @@ -62,6 +66,10 @@ on: # 且 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」—— # 只改那个 worker 的 PR 不该跳过这道门(codex P2,核过属实) - 'tests/test601-hub-scheduled-tasks/**' + - 'tests/test224-grok-preview-security/**' + - 'tests/test597-dashboard-slash-namespace/**' + - 'tests/test679-task-trace/**' + - 'tests/lib/**' # Older runs on the same ref get cancelled — saves minutes when a PR is # updated rapidly. main pushes run independently. @@ -121,6 +129,91 @@ jobs: - name: Run complete agent-node unit domain run: docker run --rm anet-test725-agent-node-unit + recovered-suites: + name: recovered suites (Docker) + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + + # 🔴 顺序不是随意的。CLAUDE.md 的测试规则写明: + # 「分层测试,从简单到复杂:环境→认证→单点通信→完整流程→多用户→安全」 + # 「前一层不过就不跑后面的」 + # 所以安全套件(test224)放在最后 —— 底层套件先红时,不该已经产出 + # 一份安全证据,因为那份证据的前提根本没成立。 + # 第一版我把 test224 放在最前,正好把这条规则倒过来了。 + + - name: Build test597-dashboard-slash-namespace + run: | + docker build \ + --build-arg SOURCE_COMMIT="$GITHUB_SHA" \ + -t anet-test597-dashboard-slash-namespace \ + -f tests/test597-dashboard-slash-namespace/Dockerfile . + + - name: Run test597-dashboard-slash-namespace + run: | + set -o pipefail # 🔴 不加它,tee 的 0 会盖掉套件的非零退出 + mkdir -p "$RUNNER_TEMP/suite-artifacts" + docker run --rm anet-test597-dashboard-slash-namespace \ + 2>&1 | tee "$RUNNER_TEMP/suite-artifacts/test597.log" + + - name: Build test679-task-trace + run: | + docker build \ + --build-arg TEST679_SOURCE_COMMIT="$GITHUB_SHA" \ + -t anet-test679-task-trace \ + -f tests/test679-task-trace/Dockerfile . + + - name: Run test679-task-trace + run: | + set -o pipefail + mkdir -p "$RUNNER_TEMP/suite-artifacts" + docker run --rm anet-test679-task-trace \ + 2>&1 | tee "$RUNNER_TEMP/suite-artifacts/test679.log" + + - name: Build test224-grok-preview-security + run: | + docker build \ + --build-arg SOURCE_COMMIT="$GITHUB_SHA" \ + -t anet-test224-grok-preview-security \ + -f tests/test224-grok-preview-security/Dockerfile . + + - name: Run test224-grok-preview-security + # 🔴 --network none 不是可选项:tests/test224-.../Dockerfile 第 13 行明写 + # 「the actual gate is run with --network none」,而 run.sh 会打印 + # 「runtime executed with network disabled」。不带这个 flag,那句话就是假的 —— + # 实测两种跑法都 PASS 且都打印同一句,套件自己不会拦住这个错误。 + # + # /artifacts 挂出来:该套件把 report-test224.txt 写在容器内 /artifacts 下, + # 而 --rm 会把那个文件系统删掉 —— 跑完什么都不留,门每次都真跑却无证据可查。 + run: | + set -o pipefail + mkdir -p "$RUNNER_TEMP/suite-artifacts" + docker run --rm --network none \ + -v "$RUNNER_TEMP/suite-artifacts:/artifacts" \ + anet-test224-grok-preview-security \ + 2>&1 | tee "$RUNNER_TEMP/suite-artifacts/test224.log" + + # 三个 suite 都是 root 容器写进 bind mount 的,产物属主 root、mode 0600。 + # upload-artifact 以 runner 用户打包 → EACCES: permission denied, + # 于是「门全绿但 job 判红」,而且证据也归档不了。实测报错: + # Error: EACCES: permission denied, open '.../suite-artifacts/report-test224.txt' + # if: always() —— 前面步骤红时更需要把证据传出来。 + - name: Normalize recovered-suite artifact permissions + if: always() + run: | + sudo chown -R "$(id -u):$(id -g)" "$RUNNER_TEMP/suite-artifacts" + chmod -R u+rw "$RUNNER_TEMP/suite-artifacts" + + - name: Upload recovered-suite artifacts + # if: always() —— 套件红了才最需要看它的输出 + if: always() + uses: actions/upload-artifact@v4 + with: + name: recovered-suite-artifacts + path: ${{ runner.temp }}/suite-artifacts + if-no-files-found: warn + qa: name: L0 + L1 (report-only) runs-on: ubuntu-latest diff --git a/docs/tests/report-register-orphan-suites.txt b/docs/tests/report-register-orphan-suites.txt new file mode 100644 index 000000000..3e58d4922 --- /dev/null +++ b/docs/tests/report-register-orphan-suites.txt @@ -0,0 +1,57 @@ +# 三个孤儿门:独立 job recovered-suites +source_commit=aeec4b9c130e6439feb622b1d2213f9f8f61d1fb +base(current main)=034f00647d42d38d5086d7fc057eb7824a441791 + +锚点即被测代码那一版;本文件是它的 report-only 子提交。 + +## 本次按 CI job 里逐字相同的命令跑(test224 带 --network none) +### test224-grok-preview-security +``` +PASS: targeted Docker context contains no host auth/config state +[L1] exact child environment + durable text boundaries +PASS: real child env equals the reviewed set; text boundaries redact; config/session dirs are 0700 and files are 0600 +[L2] build candidate package payloads without network +PASS: candidate tarballs contain runnable entrypoints and force publishConfig.tag=preview +[L3] synthetic credential leakage scan +PASS: tarballs, extracted payloads, build output, test output, and report contain zero synthetic marker bytes +candidate_tarball_sha256=0577f72aa6f629039770491af4996006d4b3a26a1a57ab7e3674899b834cb3af file=sleep2agi-agent-node-2.5.0-preview.31.tgz +candidate_tarball_sha256=1d07a3b3831a4ababeb028e34a503e310f431e5202650a43f1ea393bc9c9c30c file=sleep2agi-agent-network-2.3.0-preview.39.tgz +Summary: PASS (Docker-only; runtime executed with network disabled; no real credential was read) +``` +### test597-dashboard-slash-namespace +``` +(pass) Dashboard native slash migration notice > failed native replies still surface the migration notice and the failure [0.09ms] +(pass) reply filtering uses authenticated message provenance > a short presence reply to an authenticated Dashboard human task is delivered [0.12ms] +(pass) reply filtering uses authenticated message provenance > the same low-value class remains filtered for agent-to-agent tasks [0.07ms] +(pass) reply filtering uses authenticated message provenance > a provenance flag cannot bypass filtering for a non-task message type [0.05ms] + + 18 pass + 0 fail + 117 expect() calls +Ran 18 tests across 3 files. [1.88s] +RESULT: PASS +``` +### test679-task-trace +``` + + worker.js 2.29 MB (entry point) + + +[javascript-obfuscator-cli] Obfuscating file: dist/bin/cli.js... + +[javascript-obfuscator-cli] Obfuscating file: dist/src/client.js... + +[javascript-obfuscator-cli] Obfuscating file: dist/src/node-server.js... +RESULT: PASS +``` +## --network none 的对照(为什么这个 flag 必须有) +同一镜像,带与不带 --network none 两次都 rc=0,且都打印 +「Summary: PASS (Docker-only; runtime executed with network disabled; ...)」, +差异只有时间戳和 tarball sha256。套件用一行硬编码 log "network: disabled by runner" +**声明**前提而不探测它 —— 所以「网络确实被禁用」这件事只能由调用方保证。 + +## NOT COVERED +1. 上面那条「声明而不验证」我没改,那是改别人的门,交 owner; +2. test224/test597 用可变 oven/bun tag 而非 pinned digest(codex P1,成立,属套件自身); +3. 套件写在容器内的 report 被 --rm 丢掉,CI 未挂载/上传(codex P1,成立,属套件自身); +4. 不传 build-arg 时只有 test224 fail-closed,test597/test679 声明了 SOURCE_COMMIT 却不强制。 diff --git a/scripts/qa.sh b/scripts/qa.sh index 5d8a4accd..082fd62fc 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -75,6 +75,12 @@ L1_TESTS=( "test765-batch-runtime-gate" "test766-bunx-preflight" "test746-setup-bun-pin" + # 2026-08-13 扫出三个从没进 CI 的完整 Docker 门(test224 / test597 / test679), + # 一度想加在这里,但 L1 是「~16s 并行」的快层、job 预算 5 分钟,实测在 CI 上 + # 已经用掉 141–148s;而 qa.sh 的 build 是**串行**的(只有 docker run 并行), + # 那三个套件单跑就要 39s / 15s / 36s,还要各加一次 build(test679 带 + # javascript-obfuscator)。塞进来是拿余量赌。 + # 它们改放在 qa.yml 的独立 job(预算 12 分钟),同单测门的形状。 ) if [[ "${1:-}" == "--list" ]]; then @@ -144,15 +150,22 @@ if [[ $RUN_L1 -eq 1 ]]; then for t in "${L1_TESTS[@]}"; do # Build (cached if recent) note "build $t" + # 从套件自己的 Dockerfile 推导 SOURCE_COMMIT 参数名,而不是维护一条硬编码 + # 的 if/elif 链 —— 链的失效方式是静默的:把套件加进 L1_TESTS 却忘了加分支, + # 它会在**没有 SHA 绑定**的情况下跑,而输出看起来一切正常。 + # 等价性已核:对原链覆盖的 test686/765/766/746 四个套件,推导结果与硬编码 + # 逐字相同;新加的 test224/test597 用的是不带前缀的 ARG SOURCE_COMMIT, + # 正是原链无法表达、只能再加分支的那种形状。 build_args="" - if [[ "$t" == "test686-rest-shape-golden" ]]; then - build_args="--build-arg TEST686_SOURCE_COMMIT=$(git rev-parse HEAD)" - elif [[ "$t" == "test765-batch-runtime-gate" ]]; then - build_args="--build-arg TEST765_SOURCE_COMMIT=$(git rev-parse HEAD)" - elif [[ "$t" == "test766-bunx-preflight" ]]; then - build_args="--build-arg TEST766_SOURCE_COMMIT=$(git rev-parse HEAD)" - elif [[ "$t" == "test746-setup-bun-pin" ]]; then - build_args="--build-arg TEST746_SOURCE_COMMIT=$(git rev-parse HEAD)" + # `|| true` 不是装饰:本脚本是 set -euo pipefail,而多数套件的 Dockerfile + # 根本没有 ARG SOURCE_COMMIT —— grep 无命中退 1,pipefail 把它传给整个 + # 命令替换,set -e 于是在第一个这样的套件上把 runner 打死。 + # 第一版就是这么挂的:CI 在 `build qa-cli-01-hub-start` 处 exit 1, + # 一个套件都没跑成,而失败看起来像「L1 挂了」而不是「参数推导写错了」。 + arg_name=$(grep -oE '^ARG (SOURCE_COMMIT|TEST[0-9]+_SOURCE_COMMIT)' \ + "tests/$t/Dockerfile" 2>/dev/null | head -1 | awk '{print $2}' || true) + if [[ -n "$arg_name" ]]; then + build_args="--build-arg $arg_name=$(git rev-parse HEAD)" fi if ! dockerrun "docker build -q $build_args -t anet-$t -f tests/$t/Dockerfile ." >/tmp/qa-l1-$t-build.log 2>&1; then fail "L1 $t — build failed, see /tmp/qa-l1-$t-build.log" diff --git a/tests/test224-grok-preview-security/Dockerfile b/tests/test224-grok-preview-security/Dockerfile index e289952f1..4c81ce880 100644 --- a/tests/test224-grok-preview-security/Dockerfile +++ b/tests/test224-grok-preview-security/Dockerfile @@ -1,5 +1,5 @@ ARG SOURCE_COMMIT -FROM oven/bun:1.3.1 +FROM oven/bun:1.3.1@sha256:9c5d3c92b234b4708198577d2f39aab7397a242a40da7c2f059e51b9dc62b408 ARG SOURCE_COMMIT ENV TEST224_SOURCE_COMMIT=$SOURCE_COMMIT diff --git a/tests/test597-dashboard-slash-namespace/Dockerfile b/tests/test597-dashboard-slash-namespace/Dockerfile index f9fc28984..b26e9e1b8 100644 --- a/tests/test597-dashboard-slash-namespace/Dockerfile +++ b/tests/test597-dashboard-slash-namespace/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.14 +FROM oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 WORKDIR /workspace diff --git a/tests/test679-task-trace/Dockerfile b/tests/test679-task-trace/Dockerfile index c1a1af1eb..723fcb28b 100644 --- a/tests/test679-task-trace/Dockerfile +++ b/tests/test679-task-trace/Dockerfile @@ -1,6 +1,19 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends bash curl ca-certificates unzip python3 && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://bun.sh/install | bash +# 🔴 钉死 Bun 输入。原来是 `curl -fsSL https://bun.sh/install | bash` —— +# 构建时装到什么算什么,同一个 commit 在不同时间会跑在不同字节上。 +# 版本与校验和沿用本仓既有做法(见 tests/test745-agent-network-unit-ci/Dockerfile)。 +# 隔离验证过:改前改后都得到 bun 1.3.14、同在 /root/.bun/bin/bun,产出等价。 +ARG BUN_VERSION=1.3.14 +ARG BUN_LINUX_X64_SHA256=951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f +RUN curl --fail --silent --show-error --location --retry 3 --retry-delay 2 --retry-all-errors \ + "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64.zip" -o /tmp/bun.zip \ + && echo "${BUN_LINUX_X64_SHA256} /tmp/bun.zip" | sha256sum -c - \ + && unzip -q /tmp/bun.zip -d /tmp/bunx \ + && mkdir -p /root/.bun/bin \ + && mv /tmp/bunx/bun-linux-x64/bun /root/.bun/bin/bun \ + && chmod 0755 /root/.bun/bin/bun \ + && rm -rf /tmp/bun.zip /tmp/bunx ENV PATH="/root/.bun/bin:${PATH}" WORKDIR /app COPY agent-node /app/agent-node From a0cb1e005dce3d62ba3ef357ad6ae13cae418ea5 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 06:37:46 +0800 Subject: [PATCH 47/59] =?UTF-8?q?test(#167):=20=E5=AE=9A=E9=95=BF=20sleep?= =?UTF-8?q?=20=E6=8D=A2=E6=88=90=E8=BD=AE=E8=AF=A2=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?4.0s=20=E7=9A=84=20sleep=20=E8=A3=85=E5=9C=A8=20bun=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=205.0s=20=E9=A2=84=E7=AE=97=E9=87=8C=20(#931)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startHub owns a live watcher timer` 这一条在 CI 上红了一次: (fail) startHub owns a live watcher timer ... [5000.57ms] ^ this test timed out after 5000ms. 4 pass 1 fail Ran 5 tests across 1 file. 它不是偶发慢,是**结构上就没有余量**:两处定长 `Bun.sleep(800)` + `Bun.sleep(3_200)` 合计 4.0s,而 bun 每条测试默认预算 5.0s —— 剩 1.0s 要装下两次 bun 进程启动 (`bun -e import db.js` 初始化 + `bun run server/src/index.ts` 起一个真 hub)。 本机够,CI 里(冷 bun、Docker、72 个文件排队)不够。 改动: 1. **等事件那处改成轮询**。巡检周期是 `COMMHUB_DELIVERED_STALE_PATROL_MS=25`, 事件在插入后几十毫秒就该出现,3_200ms 纯粹是余量。轮询后常态快 ~60 倍, 慢的时候等得起(上限 20s)。 2. **等进程那处不假装在等就绪**。 🔴 我第一版写的是「等到 db 文件存在」—— 而那个文件在上一步 init 里就已经 建好了,条件恒真,等于没等。**一个不是目标状态独有的等待条件,和没有等待 是一回事,但读起来像有。** 现在这里只保留原断言的原意(子进程没有立刻崩): 在 800ms 窗口内轮询「它是否退出了」,一退出就立刻停下,不睡满。 3. **给这条加显式 30s 超时**。它要起两个真进程,默认 5s 对它本来就不成立。 前两处改完常态用不到这个上限;它只保证「慢」不会被报成「坏」。 `waitUntil` 到期时把**在等什么**写进异常消息 —— 定长 sleep 超时最坏的地方不是慢, 是红落在「事件没写」这条断言上,读的人会去查 watcher,而真实原因可能是 hub 还没起来。 为什么现在才暴露:这个文件**从来没进过 CI**,直到 #798 把 server/src 下 72 个 测试全接进去。 ⚠️ 本地没跑:这条会 `bun run server/src/index.ts` 起一个真 hub,不在宿主机上跑。 仅做了转译检查(`bun build --external '*'` → Bundled 1 module,rc=0)。 **判据是 CI 里的 `server unit (Docker, non-root)`。** Co-authored-by: t Co-authored-by: Claude Opus 5 --- server/src/task-lifecycle-watcher.test.ts | 46 ++++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/server/src/task-lifecycle-watcher.test.ts b/server/src/task-lifecycle-watcher.test.ts index a0365747d..ac4ae201e 100644 --- a/server/src/task-lifecycle-watcher.test.ts +++ b/server/src/task-lifecycle-watcher.test.ts @@ -45,6 +45,26 @@ afterAll(() => { try { server?.stop(true); } catch {} }); +/** + * 轮询到 `ready()` 为真,或到期抛错。 + * + * 定长 sleep 的问题不是「慢」,是**报错报在错的层**:超时之后测试红在 + * 「事件没写」这条断言上,读的人会去查 watcher,而真实原因可能是子进程 + * 还没起来。这里到期时把那句话直接说出来。 + */ +async function waitUntil(ready: () => boolean, timeoutMs: number, what: string | null): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (ready()) return; + if (Date.now() >= deadline) { + // what === null:到期本身就是期望结果(调用方随后自己断言),不抛。 + if (what === null) return; + throw new Error(`timed out after ${timeoutMs}ms: ${what}`); + } + await Bun.sleep(25); + } +} + describe("#167 Hub delivered-stale lifecycle watcher", () => { test("30s/60s thresholds are exact and non-delivered tasks stay silent", () => { const first = recordDeliveredStaleEvents(NOW); @@ -132,7 +152,14 @@ describe("#167 Hub delivered-stale lifecycle watcher", () => { stdout: "pipe", stderr: "pipe", }); - await Bun.sleep(800); + // 这里**不**等「hub 就绪」——因为没有一个只有 hub 起来了才成立的廉价判据: + // db 文件在上面那步 init 里就已经存在了,拿它当条件的话这个等待恒真,等于没等。 + // 真正需要「hub 起来了」的是下面那条断言,而它已经改成轮询到目标状态, + // hub 起得慢只是让它多等几轮。 + // + // 这一步保留的是原来那条断言的原意:**子进程没有立刻崩**。所以只给它一个 + // 短窗口,并且如果它在窗口内退出就立刻停下来报错,不用把 800ms 睡满。 + await waitUntil(() => child.exitCode !== null, 800, null); expect(child.exitCode).toBeNull(); const taskId = "stale-live-wiring"; @@ -147,14 +174,23 @@ describe("#167 Hub delivered-stale lifecycle watcher", () => { expect(childDb.query<{ count: number }, [string]>( "SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1", ).get(taskId)!.count).toBe(0); - await Bun.sleep(3_200); - expect(childDb.query<{ count: number }, [string]>( + // 巡检周期是 COMMHUB_DELIVERED_STALE_PATROL_MS=25ms —— 事件在插入后 + // 几十毫秒内就该出现。原来这里是定长 sleep(3_200),纯粹是余量: + // 4.0s 的 sleep 装在 bun 默认的 5.0s 单测预算里,只剩 1s 给两次进程启动。 + // 实测在 CI 上被这一条打红过(#798 让这个文件第一次进 CI 才暴露)。 + // 改成「轮询到目标状态,或到期报错」:常态下快 ~60 倍,慢的时候等得起。 + const countStale = () => childDb.query<{ count: number }, [string]>( "SELECT COUNT(*) AS count FROM task_events WHERE task_id = ?1 AND event_type = 'task.warning.delivered_stale_30s'", - ).get(taskId)!.count).toBe(1); + ).get(taskId)!.count; + await waitUntil(() => countStale() === 1, 20_000, + "watcher did not write the delivered_stale_30s event"); + expect(countStale()).toBe(1); } finally { childDb.close(); try { child.kill("SIGTERM"); } catch {} await child.exited; } - }); + // 🔴 显式超时:这一条要起两个真 bun 进程,bun 默认的 5s 对它不成立。 + // 上面两处已改成轮询,常态用不到这个上限;它只保证「慢」不会被报成「坏」。 + }, 30_000); }); From 9bd8ef0c1a20ff8b651648ea5a5a5c9d6dec5660 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 06:39:34 +0800 Subject: [PATCH 48/59] =?UTF-8?q?chore:=20=E5=88=A0=E6=8E=89=20test682=20?= =?UTF-8?q?=E8=BF=99=E9=81=93=E8=BF=87=E6=97=B6=E7=9A=84=E9=97=A8,?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=20#698=20=E5=BA=9F=E5=BC=83=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=95=99=E4=B8=8B=E7=9A=84=E4=B8=A4=E4=B8=AA=E6=AD=BB?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=20(#804)=20(#855)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test682 断言 cli.ts 里 sendPeerReplyTaskWithTrace( 恰好出现 1 次。#698 有意把 peer reply 改成协商 send_peer_reply 原子工具,那条老路被删,所以 main 上它出现 0 次 —— 这道门跑起来是红的。它从没注册进 L1_TESTS,所以一直没人发现。 而且方向和现役的门相反:agent-node/src/reply-routing-source.test.ts 断言 expect(source).not.toContain("sendPeerReplyTaskWithTrace({"); 一次都不许有。那道门在 test725 覆盖下、在 CI 里跑着、是绿的。两道门不可能同时满足。 顺带删掉同源的两个死模块(都只被测试引用,零生产调用方): agent-node/src/peer-reply-task-trace.ts 只被 test682 的两个文件 import agent-node/src/runtime/reply-routing.ts 只被自己的 reply-routing.test.ts import 导出的 ReplyRoute = "send_reply" | "send_task" 正是 #698 废掉的那个概念 核过的边界: - task-trace.ts 不受影响 —— 它另有 4 个生产引用者(channel-task-trace / client-task-trace / commhub-mcp / explicit-task-trace)。 - reply-routing-source.test.ts 保留:它那句 not.toContain 是反向断言, 锁住老形状不许回来,和被删的模块无关。 - 删后全仓不再有对这三个文件的引用(负向断言里的字符串字面量除外)。 - agent-node/src 测试文件数 91 → 90。 Co-authored-by: vansin --- agent-node/src/peer-reply-task-trace.ts | 33 ----- agent-node/src/runtime/reply-routing.test.ts | 113 ------------------ agent-node/src/runtime/reply-routing.ts | 67 ----------- tests/test682-uncovered-task-trace/Dockerfile | 18 --- tests/test682-uncovered-task-trace/run.sh | 55 --------- .../semantics.test.ts | 80 ------------- .../test682-uncovered-task-trace/true-hub.ts | 95 --------------- .../wiring.test.ts | 34 ------ 8 files changed, 495 deletions(-) delete mode 100644 agent-node/src/peer-reply-task-trace.ts delete mode 100644 agent-node/src/runtime/reply-routing.test.ts delete mode 100644 agent-node/src/runtime/reply-routing.ts delete mode 100644 tests/test682-uncovered-task-trace/Dockerfile delete mode 100755 tests/test682-uncovered-task-trace/run.sh delete mode 100644 tests/test682-uncovered-task-trace/semantics.test.ts delete mode 100644 tests/test682-uncovered-task-trace/true-hub.ts delete mode 100644 tests/test682-uncovered-task-trace/wiring.test.ts diff --git a/agent-node/src/peer-reply-task-trace.ts b/agent-node/src/peer-reply-task-trace.ts deleted file mode 100644 index 490e154ee..000000000 --- a/agent-node/src/peer-reply-task-trace.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { sendTaskWithTrace } from "./task-trace"; - -export async function sendPeerReplyTaskWithTrace(input: { - alias: string; - task: string; - priority: string; - fromAlias: string; - parentTaskId: string | null; - networkId: string | null; - meta?: Record; -}, dependencies: { - send: (args: Record) => Promise; - log: (line: string) => void; -}): Promise { - return sendTaskWithTrace({ - fromAlias: input.fromAlias, - toAlias: input.alias, - parentTaskId: input.parentTaskId, - networkId: input.networkId, - transport: "mcp_http", - lifecycleTracking: "not_tracked", - }, { - log: dependencies.log, - send: () => dependencies.send({ - alias: input.alias, - task: input.task, - priority: input.priority, - from_session: input.fromAlias, - parent_task_id: input.parentTaskId || undefined, - ...(input.meta ? { meta: input.meta } : {}), - }), - }); -} diff --git a/agent-node/src/runtime/reply-routing.test.ts b/agent-node/src/runtime/reply-routing.test.ts deleted file mode 100644 index 5f80a7271..000000000 --- a/agent-node/src/runtime/reply-routing.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - buildCodexAppServerReplyTask, - createReplyRouteCache, - resolveReplyRoute, -} from "./reply-routing"; - -const sessions = (...aliases: string[]) => aliases.map((alias) => ({ alias })); - -describe("codex-app-server reply routing", () => { - test("dashboard/user sender that is not a session falls back to send_reply", async () => { - const route = await resolveReplyRoute({ - target: "admin", - taskId: "task-dashboard", - replyViaSendTask: true, - cache: createReplyRouteCache(), - loadSessions: async () => sessions("codex-node", "peer-agent"), - }); - expect(route).toBe("send_reply"); - }); - - test("agent sender with a real session keeps send_task wake path", async () => { - const route = await resolveReplyRoute({ - target: "peer-agent", - taskId: "task-peer", - replyViaSendTask: true, - cache: createReplyRouteCache(), - loadSessions: async () => sessions("codex-node", "peer-agent"), - }); - expect(route).toBe("send_task"); - }); - - test("missing task id does not create an unparented reply task", async () => { - const route = await resolveReplyRoute({ - target: "peer-agent", - replyViaSendTask: true, - cache: createReplyRouteCache(), - loadSessions: async () => sessions("peer-agent"), - }); - expect(route).toBe("send_reply"); - }); - - test("roster load failure fails closed to send_reply", async () => { - const route = await resolveReplyRoute({ - target: "peer-agent", - taskId: "task-peer", - replyViaSendTask: true, - cache: createReplyRouteCache(), - loadSessions: async () => { - throw new Error("hub unavailable"); - }, - }); - expect(route).toBe("send_reply"); - }); - - test("short ttl cache avoids repeated roster fetches and refreshes after expiry", async () => { - let now = 1000; - let calls = 0; - let currentSessions = sessions("peer-agent"); - const cache = createReplyRouteCache(); - const loadSessions = async () => { - calls++; - return currentSessions; - }; - - await expect(resolveReplyRoute({ - target: "peer-agent", - taskId: "task-peer", - replyViaSendTask: true, - cache, - cacheTtlMs: 3000, - nowMs: () => now, - loadSessions, - })).resolves.toBe("send_task"); - expect(calls).toBe(1); - - currentSessions = sessions("other-agent"); - now = 2000; - await expect(resolveReplyRoute({ - target: "peer-agent", - taskId: "task-peer", - replyViaSendTask: true, - cache, - cacheTtlMs: 3000, - nowMs: () => now, - loadSessions, - })).resolves.toBe("send_task"); - expect(calls).toBe(1); - - now = 5001; - await expect(resolveReplyRoute({ - target: "peer-agent", - taskId: "task-peer", - replyViaSendTask: true, - cache, - cacheTtlMs: 3000, - nowMs: () => now, - loadSessions, - })).resolves.toBe("send_reply"); - expect(calls).toBe(2); - }); - - test("failed send_task replies keep the peer-visible failure marker and high priority", () => { - expect(buildCodexAppServerReplyTask("boom", true)).toEqual({ - task: "⚠️ boom", - priority: "high", - }); - expect(buildCodexAppServerReplyTask("done", false)).toEqual({ - task: "done", - priority: "normal", - }); - }); -}); diff --git a/agent-node/src/runtime/reply-routing.ts b/agent-node/src/runtime/reply-routing.ts deleted file mode 100644 index 1b5341f97..000000000 --- a/agent-node/src/runtime/reply-routing.ts +++ /dev/null @@ -1,67 +0,0 @@ -export type ReplyRoute = "send_reply" | "send_task"; - -export interface CommHubSessionLike { - alias?: unknown; -} - -export interface ReplyRouteCache { - expiresAt: number; - aliases: Set; -} - -export interface ResolveReplyRouteOptions { - target: string; - taskId?: string; - replyViaSendTask: boolean; - loadSessions: () => Promise; - cache: ReplyRouteCache; - nowMs?: () => number; - cacheTtlMs?: number; -} - -export function createReplyRouteCache(): ReplyRouteCache { - return { expiresAt: 0, aliases: new Set() }; -} - -export function buildCodexAppServerReplyTask(message: string, failed: boolean) { - return { - task: failed ? `⚠️ ${message}` : message, - priority: failed ? "high" : "normal", - }; -} - -export async function resolveReplyRoute(options: ResolveReplyRouteOptions): Promise { - if (!options.replyViaSendTask || !options.taskId) return "send_reply"; - return await isRoutableCommHubSession(options) ? "send_task" : "send_reply"; -} - -export async function isRoutableCommHubSession(options: Omit): Promise { - const now = options.nowMs?.() ?? Date.now(); - const ttl = options.cacheTtlMs ?? 3000; - const target = options.target.trim(); - if (!target) return false; - - if (now < options.cache.expiresAt) { - return options.cache.aliases.has(target); - } - - try { - const sessions = await options.loadSessions(); - if (!Array.isArray(sessions)) { - options.cache.aliases = new Set(); - options.cache.expiresAt = now + ttl; - return false; - } - options.cache.aliases = new Set( - sessions - .map((session) => session?.alias) - .filter((alias): alias is string => typeof alias === "string" && alias.length > 0), - ); - options.cache.expiresAt = now + ttl; - return options.cache.aliases.has(target); - } catch { - options.cache.aliases = new Set(); - options.cache.expiresAt = now + ttl; - return false; - } -} diff --git a/tests/test682-uncovered-task-trace/Dockerfile b/tests/test682-uncovered-task-trace/Dockerfile deleted file mode 100644 index 4a98c1eb2..000000000 --- a/tests/test682-uncovered-task-trace/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends bash curl ca-certificates unzip python3 && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://bun.sh/install | bash -ENV PATH="/root/.bun/bin:${PATH}" -WORKDIR /app -COPY agent-node/package.json /app/agent-node/package.json -COPY agent-network/package.json /app/agent-network/package.json -COPY server/package.json /app/server/package.json -RUN cd /app/agent-node && bun install --silent -RUN cd /app/agent-network && bun install --silent --ignore-scripts -RUN cd /app/server && bun install --silent -ARG TEST682_SOURCE_COMMIT=red -ENV TEST682_SOURCE_COMMIT=${TEST682_SOURCE_COMMIT} -COPY agent-node /app/agent-node -COPY agent-network /app/agent-network -COPY server /app/server -COPY tests/test682-uncovered-task-trace /app/tests/test682-uncovered-task-trace -CMD ["/app/tests/test682-uncovered-task-trace/run.sh"] diff --git a/tests/test682-uncovered-task-trace/run.sh b/tests/test682-uncovered-task-trace/run.sh deleted file mode 100755 index 05c2fc906..000000000 --- a/tests/test682-uncovered-task-trace/run.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -echo "source_commit=${TEST682_SOURCE_COMMIT}" -WORK=/tmp/test682 -HUB_BASE=http://127.0.0.1:9682 -mkdir -p "$WORK" -(cd /app/server && env PORT=9682 HOST=127.0.0.1 NODE_ENV=test COMMHUB_DB="$WORK/hub.db" bun run src/index.ts >"$WORK/hub.log" 2>&1) & -HUB_PID=$! -trap 'kill "$HUB_PID" 2>/dev/null || true' EXIT -for _ in $(seq 1 60); do curl -fsS "$HUB_BASE/health" >/dev/null 2>&1 && break; sleep .25; done -curl -fsS "$HUB_BASE/health" >/dev/null - -cd /app -bun test tests/test682-uncovered-task-trace/wiring.test.ts tests/test682-uncovered-task-trace/semantics.test.ts -HUB_BASE="$HUB_BASE" bun tests/test682-uncovered-task-trace/true-hub.ts - -mutate_expect_red() { - local file="$1" from="$2" to="$3" label="$4" - local backup="$WORK/$label.orig" - cp "$file" "$backup" - python3 - "$file" "$from" "$to" <<'PY' -import pathlib, sys -p=pathlib.Path(sys.argv[1]); old=sys.argv[2]; new=sys.argv[3]; data=p.read_text() -if data.count(old) != 1: raise SystemExit(f"anchor count={data.count(old)} for {old!r}") -p.write_text(data.replace(old,new,1)) -PY - cmp -s "$file" "$backup" && { echo "mutation no-op: $label" >&2; exit 1; } - set +e - bun test tests/test682-uncovered-task-trace/wiring.test.ts tests/test682-uncovered-task-trace/semantics.test.ts >"$WORK/$label.log" 2>&1 - local rc=$? - set -e - cp "$backup" "$file" - [[ $rc -ne 0 ]] || { echo "mutation stayed green: $label" >&2; exit 1; } - echo "WITNESSED_RED $label rc=$rc" -} - -mutate_expect_red agent-node/src/cli.ts 'sendPeerReplyTaskWithTrace({' 'sendPeerReplyTaskWithoutTrace({' peer-wiring -mutate_expect_red agent-network/src/client.ts 'sendClientTaskWithTrace({ alias: targetAlias' 'sendClientTaskWithoutTrace({ alias: targetAlias' client-wiring -mutate_expect_red agent-node/src/peer-reply-task-trace.ts 'transport: "mcp_http"' 'transport: "sdk_mcp_proxy"' peer-transport -mutate_expect_red agent-network/src/client-task-trace.ts 'transport: "mcp_http"' 'transport: "sdk_mcp_proxy"' client-transport -mutate_expect_red agent-node/src/peer-reply-task-trace.ts 'lifecycleTracking: "not_tracked"' 'lifecycleTracking: "tracked"' peer-lifecycle -mutate_expect_red agent-network/src/client-task-trace.ts 'lifecycleTracking: "not_tracked"' 'lifecycleTracking: "tracked"' client-lifecycle -mutate_expect_red agent-network/src/task-trace.ts ' throw error;' ' return { swallowed: true };' preserve-throw -mutate_expect_red agent-network/src/task-trace.ts '"missing_task_id"' '"send_failed"' missing-id -mutate_expect_red agent-network/src/task-trace.ts ' if (taskId) {' ' if (taskId && result?.ok !== false) {' queued-is-delivered -mutate_expect_red agent-node/src/peer-reply-task-trace.ts ' return sendTaskWithTrace({' ' return (Promise.resolve({ changed: true }) as any) || sendTaskWithTrace({' peer-return-shape -mutate_expect_red agent-network/src/client-task-trace.ts ' return sendTaskWithTrace({' ' return (Promise.resolve({ changed: true }) as any) || sendTaskWithTrace({' client-return-shape - -cd /app/agent-node -bun run build -cd /app/agent-network -bun run typecheck -bun run build -cmp -s /app/agent-node/src/task-trace.ts /app/agent-network/src/task-trace.ts -echo "RESULT: PASS" diff --git a/tests/test682-uncovered-task-trace/semantics.test.ts b/tests/test682-uncovered-task-trace/semantics.test.ts deleted file mode 100644 index ee2a2ed2f..000000000 --- a/tests/test682-uncovered-task-trace/semantics.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { sendTaskWithTrace } from "/app/agent-network/src/task-trace"; -import { sendClientTaskWithTrace } from "/app/agent-network/src/client-task-trace"; -import { sendPeerReplyTaskWithTrace } from "/app/agent-node/src/peer-reply-task-trace"; - -const input = { - fromAlias: "sender", - toAlias: "target", - parentTaskId: null, - networkId: null, - transport: "mcp_http" as const, - lifecycleTracking: "not_tracked" as const, -}; - -describe("one-shot task trace semantics", () => { - it("preserves the public client response object and exact send invocation", async () => { - const result = { ok: true, message_id: "client_shape" }; - let calls = 0; - expect(await sendClientTaskWithTrace({ alias: "target", fromAlias: "sender" }, { - log: () => {}, - send: async () => { calls += 1; return result; }, - })).toBe(result); - expect(calls).toBe(1); - }); - - it("preserves the peer response object and exact RFC-030 send arguments", async () => { - const result = { ok: true, message_id: "peer_shape" }; - let args: Record | null = null; - expect(await sendPeerReplyTaskWithTrace({ - alias: "target", task: "reply body", priority: "high", fromAlias: "sender", - parentTaskId: "parent_exact", networkId: "network_exact", - }, { - log: () => {}, - send: async (value) => { args = value; return result; }, - })).toBe(result); - expect(args).toEqual({ - alias: "target", task: "reply body", priority: "high", - from_session: "sender", parent_task_id: "parent_exact", - }); - }); - - it("returns a successful MCP envelope unchanged and records its canonical task id", async () => { - const result = { content: [{ type: "text", text: JSON.stringify({ ok: true, task_id: "task_envelope" }) }] }; - const lines: string[] = []; - expect(await sendTaskWithTrace(input, { send: async () => result, log: (line) => lines.push(line) })).toBe(result); - expect(lines.join("\n")).toContain("delivered"); - expect(lines.join("\n")).toContain("task_id=task_envelope"); - expect(lines.join("\n")).toContain("lifecycle=not_tracked"); - }); - - it("returns an app-level rejection unchanged while logging a redacted failure", async () => { - const result = { ok: false, error: "denied Bearer ntok_secret-value" }; - const lines: string[] = []; - expect(await sendTaskWithTrace(input, { send: async () => result, log: (line) => lines.push(line) })).toBe(result); - expect(lines.join("\n")).toContain("failed"); - expect(lines.join("\n")).toContain("send_rejected"); - expect(lines.join("\n")).not.toContain("ntok_secret-value"); - }); - - it("treats an offline queued task id as a durable delivery receipt", async () => { - const result = { ok: false, error: "alias_offline", queued: true, task_id: "task_queued" }; - const lines: string[] = []; - expect(await sendTaskWithTrace(input, { send: async () => result, log: (line) => lines.push(line) })).toBe(result); - expect(lines.join("\n")).toContain("delivered"); - expect(lines.join("\n")).toContain("task_id=task_queued"); - expect(lines.join("\n")).not.toContain("send_rejected"); - }); - - it("preserves transport exceptions and records missing task ids without changing responses", async () => { - const error = new Error("network down"); - const thrownLines: string[] = []; - await expect(sendTaskWithTrace(input, { send: async () => { throw error; }, log: (line) => thrownLines.push(line) })).rejects.toBe(error); - expect(thrownLines.join("\n")).toContain("send_failed"); - - const missing = { ok: true, value: "unchanged" }; - const missingLines: string[] = []; - expect(await sendTaskWithTrace(input, { send: async () => missing, log: (line) => missingLines.push(line) })).toBe(missing); - expect(missingLines.join("\n")).toContain("missing_task_id"); - }); -}); diff --git a/tests/test682-uncovered-task-trace/true-hub.ts b/tests/test682-uncovered-task-trace/true-hub.ts deleted file mode 100644 index eb103f1f8..000000000 --- a/tests/test682-uncovered-task-trace/true-hub.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { CommHub } from "/app/agent-network/src/client"; -import { sendPeerReplyTaskWithTrace } from "/app/agent-node/src/peer-reply-task-trace"; - -process.env.ANET_TASK_TRACE_FORMAT = "json"; - -const hub = process.env.HUB_BASE || "http://127.0.0.1:9682"; - -async function json(path: string, init: RequestInit = {}) { - const response = await fetch(`${hub}${path}`, init); - const body = await response.json() as any; - if (!response.ok) throw new Error(`${path}: ${response.status} ${JSON.stringify(body)}`); - return body; -} - -async function mcp(token: string, name: string, args: Record) { - const headers = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", Authorization: `Bearer ${token}` }; - await fetch(`${hub}/mcp`, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test682", version: "1" } } }) }); - const response = await fetch(`${hub}/mcp`, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name, arguments: args } }) }); - const raw = await response.text(); - const frame = raw.split(/\r?\n/).find((line) => line.startsWith("data: "))?.slice(6) || raw; - const envelope = JSON.parse(frame); - const text = envelope?.result?.content?.[0]?.text; - const value = typeof text === "string" ? JSON.parse(text) : envelope?.result; - if (envelope?.error || value?.ok === false) throw new Error(JSON.stringify(envelope?.error || value)); - return value; -} - -const reg = await json("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "trace-owner-682", password: "Trace_test_682!", email: "trace682@test.local" }) }); -const utok = reg.token as string; -const me = await json("/api/auth/me", { headers: { Authorization: `Bearer ${utok}` } }); -const networkId = me.networks[0].network_id as string; - -async function node(alias: string) { - const minted = await json("/api/auth/node-token", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${utok}` }, body: JSON.stringify({ network_id: networkId, node_name: alias }) }); - await mcp(minted.token, "report_status", { resume_id: `test682-${alias}`, alias, status: "idle", network_id: networkId }); - return minted.token as string; -} - -const senderToken = await node("trace-sender-682"); -await node("trace-client-682"); -await node("trace-peer-682"); - -const clientLines: string[] = []; -const originalLog = console.log; -console.log = (...args: unknown[]) => { clientLines.push(args.map(String).join(" ")); }; -let clientResult: any; -try { - const client = new CommHub({ url: hub, alias: "trace-sender-682", token: senderToken, autoConnect: false }); - clientResult = await client.send("trace-client-682", "client true hub"); -} finally { - console.log = originalLog; -} - -const parent = await mcp(senderToken, "send_task", { alias: "trace-peer-682", task: "parent seed", from_session: "trace-sender-682" }); -const parentTaskId = parent.task_id || parent.message_id; -if (!parentTaskId) throw new Error(`parent send lost canonical id: ${JSON.stringify(parent)}`); -const peerLines: string[] = []; -const peerResult = await sendPeerReplyTaskWithTrace({ - alias: "trace-peer-682", - task: "peer true hub", - priority: "high", - fromAlias: "trace-sender-682", - parentTaskId, - networkId, -}, { send: (args) => mcp(senderToken, "send_task", args), log: (line) => peerLines.push(line) }); - -for (const [name, result, lines] of [ - ["client", clientResult, clientLines], - ["peer", peerResult, peerLines], -] as const) { - if (!(result?.task_id || result?.message_id)) throw new Error(`${name} result lost canonical id: ${JSON.stringify(result)}`); - const events = lines.filter((line) => line.startsWith("{")).map((line) => JSON.parse(line)); - if (events.length !== 2) throw new Error(`${name} expected exactly start+delivery: ${lines.join("\n")}`); - if (events.some((event) => event.transport !== "mcp_http")) throw new Error(`${name} transport missing: ${JSON.stringify(events)}`); - if (events.some((event) => event.lifecycle_tracking !== "not_tracked")) throw new Error(`${name} lifecycle scope missing: ${JSON.stringify(events)}`); - if (events.map((event) => event.status).join(",") !== "sending,delivered") throw new Error(`${name} send trace incomplete: ${JSON.stringify(events)}`); - if (events.some((event) => ["acked", "started", "replied", "expired"].includes(event.status) || String(event.event).includes("stale"))) { - throw new Error(`${name} fabricated lifecycle: ${JSON.stringify(events)}`); - } -} -const clientEvents = clientLines.filter((line) => line.startsWith("{")).map((line) => JSON.parse(line)); -const peerEvents = peerLines.map((line) => JSON.parse(line)); -if (clientEvents.some((event) => event.parent_task_id !== null || event.network_id !== null)) throw new Error("client missing scope was hidden or fabricated"); -if (peerEvents.some((event) => event.parent_task_id !== parentTaskId || event.network_id !== networkId)) throw new Error("peer parent/network scope was lost"); -const allTrace = [...clientLines, ...peerLines].join("\n"); -if (/ntok_|utok_|Bearer\s/.test(allTrace)) throw new Error("trace leaked credentials"); -if (allTrace.includes("client true hub") || allTrace.includes("peer true hub")) throw new Error("trace leaked task content"); - -const tasks = await json(`/api/tasks?network_id=${encodeURIComponent(networkId)}`, { headers: { Authorization: `Bearer ${utok}` } }); -const rows = tasks.tasks || tasks || []; -const byContent = new Map(rows.map((row: any) => [row.content, row])); -if (!byContent.has("client true hub") || !byContent.has("peer true hub")) throw new Error("true Hub denominator missing a task"); -if (byContent.get("peer true hub")?.parent_task_id !== parentTaskId) throw new Error("peer true Hub parent mismatch"); -console.log("TRUE_HUB_UNCOVERED_ENTRY_COUNT=2"); -console.log("TRUE_HUB_TRACE_ASSERTIONS=16"); diff --git a/tests/test682-uncovered-task-trace/wiring.test.ts b/tests/test682-uncovered-task-trace/wiring.test.ts deleted file mode 100644 index ee0a485ad..000000000 --- a/tests/test682-uncovered-task-trace/wiring.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { readFileSync } from "node:fs"; - -const read = (path: string) => readFileSync(`/app/${path}`, "utf8"); - -describe("#167 known-uncovered send_task sites", () => { - it("routes the RFC-030 peer-reply task through its trace wrapper", () => { - const cli = read("agent-node/src/cli.ts"); - expect(cli.match(/sendPeerReplyTaskWithTrace\(/g)?.length).toBe(1); - expect(cli).toContain([ - "const taskResult = await sendPeerReplyTaskWithTrace({", - " alias: target,", - " task: replyTask.task,", - " priority: replyTask.priority,", - " fromAlias,", - " parentTaskId: taskId || null,", - " networkId: NETWORK_ID || null,", - ].join("\n")); - }); - - it("routes the public AgentClient send path through its trace wrapper", () => { - const client = read("agent-network/src/client.ts"); - expect(client.match(/sendClientTaskWithTrace\(/g)?.length).toBe(1); - expect(client).toContain("return sendClientTaskWithTrace({ alias: targetAlias, fromAlias: this.alias }, {"); - }); - - it("marks both one-shot senders as MCP HTTP without fabricated lifecycle tracking", () => { - for (const file of ["agent-node/src/peer-reply-task-trace.ts", "agent-network/src/client-task-trace.ts"]) { - const source = read(file); - expect(source).toContain('transport: "mcp_http"'); - expect(source).toContain('lifecycleTracking: "not_tracked"'); - } - }); -}); From 944d5c0c9ca2c0658627d4ccdb3ef5f9f70ba36e Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 06:43:20 +0800 Subject: [PATCH 49/59] =?UTF-8?q?chore(deps):=20agent-network=20lockfile?= =?UTF-8?q?=20=E6=8A=8A=20hono=20=E6=8E=A8=E8=BF=87=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=BA=BF(4.12.25=20=E2=86=92=204.13.1,=E6=B8=85=206=20?= =?UTF-8?q?=E6=9D=A1=E5=91=8A=E8=AD=A6)=20(#842)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): agent-network lockfile 把 hono 推过修复线(4.12.25 → 4.13.1) 关 #840。 agent-network/package-lock.json 把 hono 钉在 4.12.25,Dependabot 有 6 条 open 告警指向它,全部挂在这个 manifest 上: #106 medium 修复于 4.12.34 memo() 跨请求残留 SSR 输出 #105 low 修复于 4.12.34 Proxy Helper 不清 Connection 列出的响应头 #104 medium 修复于 4.12.34 Language 中间件算法复杂度 DoS # 60 medium 修复于 4.12.27 cx() 的 JSX 转义绕过导致服务端 XSS # 59 medium 修复于 4.12.27 API Gateway v1 adapter 丢重复响应头 # 58 medium 修复于 4.12.27 hono/jsx 不按请求隔离 context (我开 #840 时只列了前三条,漏了 #58/#59/#60 —— 它们创建于 2026-07-24, 修复线更低。已在 issue 里更正。)4.13.1 高于两条修复线,6 条全覆盖。 🔴 这不是安全修复,别在 release notes 里写成安全修复。两条理由: 1. 实际暴露面为零。这些告警分别需要 hono/jsx 的 memo() / cx() / per-request context、hono/proxy、hono/language、API Gateway adapter,而仓里 576 个 tracked .ts/.tsx 对 hono 零引用(大小写不敏感)——它是经 @modelcontextprotocol/sdk → hono ^4.11.4 传递进来的,没有任何一行代码用它。 2. lockfile 不随 npm 包发布。消费者 npm i 时重新解析,所以这个改动不改变 已发布包的用户拿到的依赖,只影响本仓与 CI 的构建。 改动刻意做成最小:npm update hono --package-lock-only,不整体刷新 lockfile。 实测波及范围: 版本变化 = 1 新增 = 0 移除 = 0 hono 4.12.25 → 4.13.1 package-lock.json | 6 +++--- (3 insertions, 3 deletions) 308 个包里只有它一个动了。 验证:tests/test745-agent-network-unit-ci(它用 npm ci 且 COPY lockfile, 所以改动会真正生效) 438 pass 0 fail executed_files=46 discovered_files=46 MUTATION_RED stale-config-help rc=1 RESULT: PASS 退出码 0 * docs(tests): 刷新 test745 报告 —— 记录新锁的 hono 跑绿的那次 审查(#842)指出:这个 PR 改了 agent-network/package-lock.json,而 test745 用 npm ci 装依赖 —— 改动改变了这道门实际跑的依赖图,而报告仍记着 b4e13f45 那版 镜像。仓里因此没有新锁制品的留存证据。指控成立。 新增一节,记录 source 507bae6f 那次: image id sha256:ac8b956a… 镜像内读回 TEST745_SOURCE_COMMIT=507bae6f… 镜像内实装 hono = 4.13.1 ← 这是本次改动的主张本身 438 pass / 0 fail / executed_files=46 discovered_files=46 MUTATION_RED stale-config-help rc=1 RESULT: PASS 退出码 0 hono 版本那一步不是凑数:套件全绿不证明 lockfile 生效 —— 构建缓存没失效、或 Dockerfile 没 COPY lockfile,都会给出一模一样的 438 绿。 建门那次的记录整段保留为附录。 自评:同一条审查意见我一小时前刚在 #841 上收到并修复,却没把同一个检查用到 同一次会话里创建的这个 PR 上 —— 修了实例,没修类。已对我全部 open PR 做了一遍 审计:改了套件输入且零报告更新的,只有这一个。 --------- Co-authored-by: vansin --- agent-network/package-lock.json | 6 +-- .../report-test745-agent-network-unit-ci.txt | 46 ++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/agent-network/package-lock.json b/agent-network/package-lock.json index 954ae1b75..e6de94de5 100644 --- a/agent-network/package-lock.json +++ b/agent-network/package-lock.json @@ -2140,9 +2140,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "dev": true, "license": "MIT", "engines": { diff --git a/docs/tests/report-test745-agent-network-unit-ci.txt b/docs/tests/report-test745-agent-network-unit-ci.txt index 4dea1f079..5721460bc 100644 --- a/docs/tests/report-test745-agent-network-unit-ci.txt +++ b/docs/tests/report-test745-agent-network-unit-ci.txt @@ -2,8 +2,50 @@ Date: 2026-08-13 (Asia/Shanghai) Issue: https://github.com/sleep2agi/agent-network/issues/745 -Base: 1f4cbf49cf1b03ba3dd9d7ea81c2778b318d0cce -Source commit: b4e13f45f032dbcf12ffc0b1d0c736571385702b +Source commit: 507bae6f9045aca6dab07009140e060066cb6936 + +本文件记录两次跑。上面这个是当前的那次(#842,hono 推过修复线之后); +建门那次(source b4e13f45 / Base 1f4cbf49)完整保留在文末附录里。 + +## hono 推过修复线之后的重跑(#842) + +审查指出:#842 改了 agent-network/package-lock.json,而 test745 用 npm ci 装 +依赖 —— 也就是说这次改动**改变了这道门实际跑的依赖图**,而这份报告当时仍记着 +b4e13f45 那版镜像。仓里因此没有「新锁的 hono 制品跑绿了」的留存证据。指控成立。 + +- image: `anet-test745:507bae6f-exact` +- image id: `sha256:ac8b956ab01a21e1fbc6fd801e99e2ff64e9655508497887f07314e9fa468ba8` +- 镜像内读回 `TEST745_SOURCE_COMMIT=507bae6f9045aca6dab07009140e060066cb6936` +- 🔴 **镜像内实际装到的 hono = 4.13.1**(不是从 lockfile 推的,是进容器读 + `node_modules/hono/package.json`)。这一步是这次改动的**主张本身** —— + 「lockfile 把 hono 推过了 4.12.34」只有在容器里看到 4.13.1 才算证到。 + **套件全绿不证明它**:lockfile 改了而构建缓存没失效、或 Dockerfile 没 COPY + lockfile,都会给出一模一样的 438 绿。 + +```text +source_commit=507bae6f9045aca6dab07009140e060066cb6936 + 438 pass + 0 fail + 1333 expect() calls +Ran 438 tests across 46 files. [4.34s] +executed_files=46 discovered_files=46 +MUTATION_RED stale-config-help rc=1 +RESULT: PASS +退出码 0 +``` + +本次运行日志摘要:`5db43c0ff9299d8cc1983ef700dfc2545b68d28881d6e6462e6f97aa02ffff1e`。它标识这一次运行,不作为来源凭据。 + +分母承重仍成立:`executed_files=46 discovered_files=46` —— 跑过的文件数等于 +磁盘上的数,没有因为依赖变动而少跑。 + +> ⚠️ 这份报告的 source commit 指向 `507bae6f`,而提交这份报告本身会产生一个 +> 更新的 commit。二者之间只差这一个文件。 + +--- + +## 附录:建门那次(source b4e13f45 / Base 1f4cbf49) + Image: sha256:484e6b482816e36daf0a05385b7d09944786661eba17da973ff2c329fe7ce415 Image env: TEST745_SOURCE_COMMIT=b4e13f45f032dbcf12ffc0b1d0c736571385702b From 10c71dd4820893e401229536538db4925e12b55e Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 06:51:21 +0800 Subject: [PATCH 50/59] =?UTF-8?q?test(ci):=20=E8=AE=A9=20test725/test745?= =?UTF-8?q?=20=E8=A6=86=E7=9B=96=20tests/=20=E7=9B=AE=E5=BD=95(=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E9=97=A8=E8=87=AA=E7=A7=B0=20complete=20=E5=8D=B4?= =?UTF-8?q?=E6=BC=8F=E4=BA=86=2025=20=E4=B8=AA=E6=96=87=E4=BB=B6)=20(#800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(ci): 让 test725/test745 覆盖 tests/ 目录,兑现"complete unit domain" 两个门的抬头都写着 "complete agent-node/agent-network unit domain", 但只跑 src/,把 tests/ 下 25 个文件排除在外 —— 其中不乏安全相关的: feishu-markdown-image-ssrf、secret-mask ×3、vendor-error-sanitize、feishu-tool-deny。 这些正是静默失效代价最高的那类。 这个目录里混着两种测试,任何单一命令都跑不全: - 脚本式(16+6 个):自己打 "N/N passed",失败 process.exit(1),必须 bun ; 用 bun test 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件 (实测:bun test tests/ 只跑完第一个就结束)。 - bun:test 式(3 个):describe/it,必须 bun test ;用 bun 跑会报 "Cannot use describe outside of the test runner"。 所以按文件内容分派,并把两条判据都写进注释。 退出码可用已先验:这些脚本失败时确实 process.exit(1),不是 fail-open。 落地前实测: agent-node/tests 6/6 直接过 agent-network/tests 单命令 14/19 → 按内容分派 17/19 → 补两处环境契约 19/19 两处契约都在 Dockerfile 内解决,并写明原因: - feishu-envelope-compat 跨包 import agent-node/src/runtime/feishu-envelope - feishu-bridge-ipc 硬编码绝对路径 /work/feishu-attachments,容器里 node 建不了 分母承重:tests_dir_executed 必须等于 find 出来的数,且 >0。 实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS; test745 tests_dir 19/19/0 + MUTATION_RED + PASS。 * test(ci): test725 的 mutation 命名断言也锚在 (fail) 行 与 #798 同一类:原来 grep 的 'the inbox choke point feeds the augmented text into processTask' 是**测试名**,而 bun test 对每个用例都打 `(pass) <名字>` / `(fail) <名字>` —— 那条用例通过时也会命中,断言只证明了「它存在」,不证明「红落在它身上」。 A/B 在 #798 上做过(把断言指向一条该 mutation 下不会红的用例): 松版 → rc=0 RESULT: PASS(收下了不合规);严版 → rc=1 点名失败。 这道门不是我写的,我在本 PR 里本来就在改它的 run.sh(加 tests/ 分派), 所以顺手收紧;改动只让门更严,并在下面重跑验证仍绿。 若 owner 认为不该由本 PR 动它,我可以拆出去。 * test(ci): tests/ 分派也要绝对下限,同 #798 那个洞 #798 实测:只比「executed == discovered」的门,在删掉 85% 测试文件后 照样 RESULT: PASS —— 分母跟着现实自动缩水。 我在本 PR 里加的 tests/ 分派用的是同一形状,所以有同一个洞。 补 AGENT_NETWORK_TESTS_FLOOR=15(现 19 个)、AGENT_NODE_TESTS_FLOOR=5(现 6 个), 并写明「真删了测试就故意改这个数」。 范围说明:只给**我在本 PR 新加的 tests/ 分派**加下限,没有动这两个门原有的 src/ 分母判定(那是 #791/#725 的既有代码,同类下限缺失我另报,不夹进本 PR)。 * docs(tests): report-only —— 锚点 1e9e75da,含下限的双向验证 --------- Co-authored-by: vansin Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs/tests/report-pkg-tests-dir-gate.txt | 2080 +++++++++++++++++ tests/test725-agent-node-unit-ci/run.sh | 49 +- .../test745-agent-network-unit-ci/Dockerfile | 7 +- tests/test745-agent-network-unit-ci/run.sh | 45 + 4 files changed, 2179 insertions(+), 2 deletions(-) create mode 100644 docs/tests/report-pkg-tests-dir-gate.txt diff --git a/docs/tests/report-pkg-tests-dir-gate.txt b/docs/tests/report-pkg-tests-dir-gate.txt new file mode 100644 index 000000000..c5c54a0a4 --- /dev/null +++ b/docs/tests/report-pkg-tests-dir-gate.txt @@ -0,0 +1,2080 @@ +# test725/test745 扩到 tests/ 目录 +source_commit=1e9e75dab635dc03d12636232ebc2ac117c2dee6 +base(current main)=034f00647d42d38d5086d7fc057eb7824a441791 +本文件是该源码提交的 report-only 子提交。 + +## 本轮:tests/ 分派加绝对下限(同 #798 那个洞) +双向验过:19 个 → RESULT: PASS;删到 4 个 → rc=1 FAIL: only 4 file(s) under agent-network/tests, floor is 15 + +## test725 +``` +# test725 — complete agent-node unit domain +source_commit=1e9e75dab635dc03d12636232ebc2ac117c2dee6 +bun=1.3.14 node=v22.23.2 uid=1000 +[L0] full agent-node/src unit suite as non-root +bun test v1.3.14 (0d9b296a) + +src/inbox-message-policy.test.ts: +(pass) atomic peer reply inbox policy > ordinary work still expects a response [1.21ms] +(pass) atomic peer reply inbox policy > a peer reply is actionable but cannot start reply ping-pong [0.09ms] +(pass) atomic peer reply inbox policy > plain informational messages retain ack-only behavior [0.05ms] + +src/external-schedules.test.ts: +(pass) external schedule manifest > reports an exact bounded shape and strips host paths to basename [1.27ms] +(pass) external schedule manifest > missing manifest is an explicit empty observation; config-less legacy stays omitted [2.14ms] +(pass) external schedule manifest > unknown keys, duplicate ids, invalid timestamps, and oversized lists fail closed [1.14ms] +(pass) external schedule manifest > symlink manifest never follows the target [0.65ms] +(pass) external schedule manifest > editable/revision are derived only from a verified managed crontab under the process gate [3.25ms] + +src/inbox-skip-log.test.ts: +(pass) formatInboxSkipLog > self-message diagnostics identify the routing layer and full task [0.09ms] +(pass) formatInboxSkipLog > all inbound filter reasons produce an actionable INFO-safe line [0.16ms] +(pass) formatInboxSkipLog > the formatter has no message-content input [0.06ms] + +src/owner-schedule-consumer.test.ts: +(pass) process-gated owner schedule consumer > disabled process registers no poll and makes zero network/host calls [1.69ms] +(pass) process-gated owner schedule consumer > exact node intent applies once, ACKs, and deletes journal only after ACK [10.94ms] +(pass) process-gated owner schedule consumer > foreign-node intent and invalid authority shape never reach crontab [1.03ms] +(pass) process-gated owner schedule consumer > lost ACK keeps journal; same delivered intent recovers without a second install [9.97ms] + +src/codex-model-default.test.ts: +(pass) agent-node Codex model resolution > missing model uses the verified supported default [0.09ms] +(pass) agent-node Codex model resolution > explicit model remains authoritative [0.03ms] + +src/claude-tool-aliases.test.ts: +(pass) Claude CommHub tool aliases > pins the exact registered in-process CommHub tool set [0.06ms] +(pass) Claude CommHub tool aliases > does not advertise aliases when the in-process server failed [0.09ms] + +src/reply-reliability.test.ts: +(pass) classifyCommHubResponse > returns ok with parsed application payload (the happy path) [0.43ms] +(pass) classifyCommHubResponse > JSON-RPC error envelope → retryable CommHubError [0.25ms] +(pass) classifyCommHubResponse > MCP result.isError → retryable CommHubError [0.20ms] +(pass) classifyCommHubResponse > real legacy Hub unknown-tool result preserves the MCP code [0.10ms] +(pass) classifyCommHubResponse > application-level ok:false → appLevel CommHubError (NON-retryable) [0.13ms] +(pass) classifyCommHubResponse > non-JSON tool text is passed through verbatim [0.34ms] +(pass) classifyCommHubResponse > data with neither error nor result returns ok with the raw data [0.07ms] +(pass) CommHubError > instances are distinguishable from generic Error via instanceof [0.15ms] +(pass) CommHubError > appLevel flag survives the throw/catch round trip [0.13ms] +(pass) PendingReplyQueue > load() returns empty array when file does not exist [1.04ms] +(pass) PendingReplyQueue > persist + load round-trips an entry with attempts=0 [4.55ms] +(pass) PendingReplyQueue > final persistence boundary scrubs known, shaped, assignment and error credentials [4.86ms] +(pass) PendingReplyQueue > direct save cannot bypass scrub and leaves no sibling temp artifact [3.03ms] +(pass) PendingReplyQueue > load migrates an old broad-mode queue without leaving raw credential bytes [2.80ms] +(pass) PendingReplyQueue > load repairs a broad mode even when content needs no rewrite [1.32ms] +(pass) PendingReplyQueue > accepts the same process-wide redactor used by ordinary log call sites [3.15ms] +(pass) PendingReplyQueue > invalid legacy content is securely replaced with an empty 0600 queue [3.56ms] +(pass) PendingReplyQueue > persist is idempotent on (to, taskId) — attempts counter preserved [6.94ms] +(pass) PendingReplyQueue > clear removes only the matching (to, taskId) [11.80ms] +(pass) PendingReplyQueue.drain > delivers every entry on success and persists an empty queue [9.85ms] +(pass) PendingReplyQueue.drain > transient failure requeues with attempts++ and lastError [7.19ms] +(pass) PendingReplyQueue.drain > transient error text is scrubbed before it reaches disk [5.25ms] +(pass) PendingReplyQueue.drain > app-level CommHubError is dropped loud — not retried, not requeued [9.46ms] +(pass) PendingReplyQueue.drain > drain on empty queue is a no-op and does not write the file [0.47ms] +(pass) PendingReplyQueue.drain > file format is stable JSON — readable by an operator after a crash [4.46ms] +(pass) quickHash > is deterministic [0.26ms] +(pass) quickHash > differs across inputs [0.06ms] +(pass) quickHash > returns 32-char hex [0.10ms] + +src/controlled-upload.test.ts: +(pass) normalizeUploadName > strips directories and control chars [1.34ms] +(pass) resolveControlledUploadPath — NUL live guard > rejects embedded NUL before any fs access [0.92ms] +(pass) resolveControlledUploadPath — NUL live guard > rejects NUL-only / leading NUL [0.61ms] +(pass) resolveControlledUploadPath > accepts regular file under root [0.96ms] +(pass) resolveControlledUploadPath > rejects path outside roots [0.64ms] +(pass) resolveControlledUploadPath > rejects absolute foreign path /etc/passwd [0.42ms] +(pass) resolveControlledUploadPath > rejects traversal that escapes root [0.44ms] +(pass) resolveControlledUploadPath > rejects missing path [0.69ms] +(pass) openFstatBoundedReadControlledFile — same fd + bound > reads small PNG via same-fd path [1.65ms] +(pass) openFstatBoundedReadControlledFile — same fd + bound > rejects oversize without allocating full max+1 into a single slurp beyond cap [20.88ms] +(pass) openFstatBoundedReadControlledFile — same fd + bound > rejects symlink leaf at open (O_NOFOLLOW) [1.32ms] +(pass) openFstatBoundedReadControlledFile — same fd + bound > fstat is on the same opened fd (structural pin) [0.53ms] +(pass) uploadControlledLocalFile > uploads PNG fixture via mock fetch and returns file_id [2.48ms] +(pass) uploadControlledLocalFile > refuses oversize before network [18.23ms] +(pass) uploadControlledLocalFile > never falls back to path when file_id missing [1.88ms] +(pass) uploadControlledLocalFile > rejects untrusted path without calling hub [0.94ms] +(pass) uploadControlledLocalFile > rejects NUL path without calling hub [0.65ms] +(pass) defaultControlledUploadRoots > includes grok sessions and attachment cache [0.96ms] +(pass) source contracts (adversarial pins) > same-fd pin: fstatSync(fd) + openSync; no path re-stat/readFileSync in reader [0.51ms] +(pass) source contracts (adversarial pins) > NUL guard pin: rawPath.includes NUL marker present [0.51ms] +(pass) source contracts (adversarial pins) > bounded-read pin: extra-byte probe after maxBytes [0.33ms] + +src/commhub-mcp.test.ts: +(pass) injectAgentFromSession > adds current alias to outbound task calls [0.13ms] +(pass) injectAgentFromSession > adds current alias to outbound message calls [0.07ms] +(pass) injectAgentFromSession > overrides stale or model-supplied from_session on ntok outbound calls [0.07ms] +(pass) injectAgentFromSession > does not add from_session to read-only calls [0.03ms] + +src/inbox-dispatch.test.ts: +(pass) isInteractiveDashboardTask > accepts a Hub-authenticated dashboard chat task [0.55ms] +(pass) isInteractiveDashboardTask > pre-stamp admin rows stay FIFO because aliases are not auth facts [0.15ms] +(pass) isInteractiveDashboardTask > rejects node-authenticated spoofing, malformed ids, and plain messages [0.09ms] +(pass) dispatchInboxBatch > awaited batches preserve legacy runtime serialization [1.92ms] +(pass) dispatchInboxBatch > a later SSE snapshot enters while the first detached turn is still running [1.95ms] +(pass) dispatchInboxBatch > the real serialized drain lane can fetch a later SSE snapshot before the active turn ends [1.29ms] +(pass) dispatchInboxBatch > detached completion failures remain observable [1.46ms] +(pass) dispatchInboxBatch > settling detached work emits a wake for the next Hub inbox window [1.47ms] +(pass) dispatchInboxBatch > a throwing settle callback cannot strand queued N+1 work [1.58ms] +(pass) dispatchInboxBatch > same-tick duplicate kicks claim one row exactly once [0.48ms] +(pass) dispatchInboxBatch > bounded admission waits N+1 and starts it after a slot settles [6.29ms] +(pass) dispatchInboxBatch > durable reply drain waits until detached Codex rows finish [0.22ms] +(pass) dispatchInboxBatch > active Codex direct delivery and durable drain send one reply, not two [5.34ms] + +src/reply-routing-source.test.ts: +(pass) #698 peer reply runtime wiring > peer replies negotiate the atomic tool and retain only a terminal legacy fallback [1.30ms] +(pass) #698 peer reply runtime wiring > every actionable inbox turn crosses the behavior-tested reply-policy seam [0.65ms] +(pass) #698 peer reply runtime wiring > new_reply SSE events wake the actionable work inbox [0.31ms] + +src/task-runtime-evidence.test.ts: +(pass) logicalTaskIdFromInbox > retry/reassign task rows use stable task_id, not fresh inbox.id [0.13ms] +(pass) logicalTaskIdFromInbox > legacy task rows and non-task rows retain transport identity [0.07ms] +(pass) createTaskRuntimeEvidenceReporter > construction and process admission report no evidence [0.27ms] +(pass) createTaskRuntimeEvidenceReporter > submission and many runtime events produce one exact report per level [0.37ms] +(pass) createTaskRuntimeEvidenceReporter > a consumed-only runtime remains honest and lets the Hub imply submission [0.20ms] +(pass) createTaskRuntimeEvidenceReporter > missing logical task identity is a fail-closed no-op [0.14ms] +(pass) createTaskRuntimeEvidenceReporter > an old-Hub failure is visible but never breaks the model turn [0.44ms] +(pass) agent-node inbox wiring > keeps transport ACK separate from stable task evidence and replies [4.25ms] +(pass) agent-node inbox wiring > all runtime dispatch families receive the same task-lifetime reporter [1.20ms] +(pass) agent-node inbox wiring > SDK and direct-stdio boundaries preserve their distinct evidence semantics [1.75ms] + +src/grok-isolated-cwd.test.ts: +(pass) prepareGrokIsolatedCwd (#204 preview.7) > creates per-node grok-cwd directory under home/.anet/nodes//grok-cwd [2.33ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > falls back to alias when nodeId is absent [2.06ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > sanitises nodeKey to avoid path traversal / weird chars [2.06ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > skips .mcp.json (does NOT symlink it into isolated cwd) [1.42ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > symlinks top-level files (README.md) and directories (docs/, src/) [1.51ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > is idempotent — second run sees existing symlinks and counts 0 new [1.38ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > picks up new entries on re-run (snapshot freshness) [1.60ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > falls back to userCwd (isolated=false) when mkdir fails [1.54ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > falls back to userCwd when userCwd does not exist (readdir fails) [1.35ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > does NOT throw on per-entry symlink failure — warns and continues [1.87ms] +(pass) prepareGrokIsolatedCwd (#204 preview.7) > two different nodes get fully isolated dirs (concurrency safe by construction) [2.62ms] + +src/inbox-dispatch-wiring.test.ts: +(pass) Codex app-server live inbox kick wiring > a Codex snapshot releases the serialized fetch lane after submission [0.27ms] +(pass) Codex app-server live inbox kick wiring > Codex detached admission is explicitly bounded and completion wakes the Hub window [0.56ms] +(pass) Codex app-server live inbox kick wiring > pending reply drain is fenced while detached Codex rows are active [0.21ms] + +src/owner-schedule-control.test.ts: +(pass) owner schedule managed-cron control > parses only exact managed markers and publishes bounded inventory [1.20ms] +(pass) owner schedule managed-cron control > changes timing/enabled while preserving command and unmanaged bytes [7.71ms] +(pass) owner schedule managed-cron control > command replacement, wrong node, wrong revision, and unknown patch fail before install [3.21ms] +(pass) owner schedule managed-cron control > install/readback failure restores and verifies the exact old crontab [3.10ms] +(pass) owner schedule managed-cron control > unsafe node directory and symlink journal fail closed with zero host write [1.10ms] +(pass) owner schedule managed-cron control > local audit is minimal, private and idempotent [2.70ms] + +src/owner-schedule-wiring.test.ts: +(pass) owner schedule process wiring > capability is pinned from config once and never exposed as a model tool [1.23ms] +(pass) owner schedule process wiring > SSE is only a doorbell and snapshots are editable only under the same gate [0.93ms] +(pass) owner schedule process wiring > new token mint paths bind the immutable node id and opt-in is explicit [3.18ms] + +src/runtime-effective-label.test.ts: +(pass) #491 startup banner reports the EFFECTIVE runtime > alias input 'codex-tui' → banner names the effective runtime (codex-app-server), not just the raw input [7260.16ms] +(pass) #491 startup banner reports the EFFECTIVE runtime > canonical input stays readable (no regression for the common case) [7190.17ms] +(pass) #491 regression lock — unknown runtime fails closed > unknown runtime → non-zero exit, error names the value AND the supported list [135.14ms] +(pass) #553 Grok startup banner reports model ownership truthfully > unset model on Grok ACP names the Grok CLI as owner, not the runtime alias as a model id [7140.92ms] +(pass) #553 Grok startup banner reports model ownership truthfully > unset model on Grok CLI uses the same non-versioned ownership statement [7160.36ms] +(pass) #553 Grok startup banner reports model ownership truthfully > an explicit Grok model is still reported exactly [7164.00ms] + +src/peer-reply-send.test.ts: +(pass) peer reply capability fallback > capable Hub uses only the atomic terminal route [0.49ms] +(pass) peer reply capability fallback > old Hub wire error terminalizes through send_reply, never send_task [0.62ms] +(pass) peer reply capability fallback > every explicit capability downgrade preserves terminal reply semantics [0.61ms] +(pass) peer reply capability fallback > transport ambiguity and unrelated hard errors never choose a second route [0.42ms] +(pass) peer reply capability fallback > negative capability is rechecked instead of cached [0.48ms] +(pass) peer reply capability fallback > legacy terminalization failure stays visible to the pending queue [0.31ms] +(pass) peer reply capability fallback > classifier accepts only explicit capability signals [0.14ms] + +src/private-log.test.ts: +(pass) Grok preview private ordinary logs > scrubs and repairs legacy logs before appending through a 0600 file [4.39ms] +(pass) Grok preview private ordinary logs > rejects a symlinked directory or final log file [1.39ms] +(pass) Grok preview private ordinary logs > rejects a multiply-linked log instead of rewriting another pathname [0.62ms] +(pass) Grok preview private ordinary logs > does not follow a log-directory symlink introduced after preparation [0.73ms] + +src/owner-schedule-system-crontab.test.ts: +(pass) owner schedule real crontab adapter > round-trips an exact managed marker through the container crontab [26.10ms] + +src/credential-redaction.test.ts: +(pass) credential persistence redactor > removes exact caller-known values regardless of punctuation or context [0.27ms] +(pass) credential persistence redactor > redacts network, GitHub, AWS and provider token shapes in free text [0.22ms] +(pass) credential persistence redactor > redacts credential assignments while preserving keys and valid JSON [0.31ms] +(pass) credential persistence redactor > redacts shell/error assignment forms including quoted values [0.11ms] +(pass) credential persistence redactor > redacts an unlabelled connection URI with embedded userinfo [0.04ms] +(pass) credential persistence redactor > does not over-delete normal prose and non-credential settings [0.06ms] +(pass) credential persistence redactor > deep-redacts JSON-like values without mutating the input [0.36ms] +(pass) credential value collection > collects exact sensitive values and shaped values under unknown keys [1.25ms] +(pass) credential value collection > key classifier is exact enough not to treat ordinary AWS settings as credentials [0.13ms] + +src/inbox-skip-log-wiring.test.ts: +(pass) processInbox logs skipped messages at INFO before acknowledging [1.61ms] + +src/peer-reply-inbox.test.ts: +(pass) inbox turn reply-policy enforcement > delivers once, ACKs once, and exposes no outbound reply dependency [0.55ms] +(pass) inbox turn reply-policy enforcement > ordinary request returns its outcome without ACKing in this seam [0.38ms] +(pass) inbox turn reply-policy enforcement > runtime failure does not ACK a result that was never consumed [0.33ms] +(pass) peer reply SSE routing > new_reply schedules exactly one drain [0.13ms] +(pass) peer reply SSE routing > unrelated events do not schedule a drain [0.06ms] + +src/grok-artifact-extractor.test.ts: +(pass) listGrokVideoArtifacts (#205 Step 2 simplified) > returns empty when grokSessionDir is undefined [0.81ms] +(pass) listGrokVideoArtifacts (#205 Step 2 simplified) > returns empty when videos/ subdir is missing [0.31ms] +(pass) listGrokVideoArtifacts (#205 Step 2 simplified) > enumerates .mp4 files in videos/ as absolute paths [1.00ms] +(pass) listGrokVideoArtifacts (#205 Step 2 simplified) > matches mp4 case-insensitively [0.83ms] +(pass) listGrokVideoArtifacts (#205 Step 2 simplified) > does not throw on permission errors — returns [] [0.58ms] +(pass) formatVideoTrailer (#205 Step 2 simplified) > returns empty string for empty list [0.19ms] +(pass) formatVideoTrailer (#205 Step 2 simplified) > formats one path [0.13ms] +(pass) formatVideoTrailer (#205 Step 2 simplified) > formats multiple paths [0.07ms] +(pass) formatVideoTrailer (#205 Step 2 simplified) > skips paths already mentioned in existingReply (no duplication) [0.04ms] +(pass) formatVideoTrailer (#205 Step 2 simplified) > only appends paths NOT already mentioned, even when some are [0.05ms] + +src/explicit-task-lifecycle.test.ts: +(pass) explicit delegation lifecycle trace > keeps the production delegation loop wired through the tested state machine [1.27ms] +(pass) explicit delegation lifecycle trace > emits ack, start, and reply from the production polling state machine [1.29ms] +(pass) explicit delegation lifecycle trace > emits both bounded stale warnings and expiry when delivery never advances [0.39ms] +(pass) explicit delegation lifecycle trace > pins the production poll, stale-warning, and timeout defaults [0.67ms] +(pass) explicit delegation lifecycle trace > maps failed and cancelled terminal states to a failed trace without retrying [0.41ms] + +src/task-trace.test.ts: +(pass) task trace contract > renders missing parent and lifecycle scope honestly [0.39ms] +(pass) task trace contract > redacts credentials from errors [0.15ms] +(pass) task trace contract > emits parseable JSON and neutralizes human log injection [0.14ms] +(pass) task trace contract > recognizes the real MCP content envelope before cli parsing [0.80ms] +(pass) task trace contract > uses stable event names for send and observed lifecycle phases [0.14ms] + +src/sse-recovery-guidance.test.ts: +(pass) sseAbandonGuidance > states that abandon leaves the current process alive [0.13ms] +(pass) sseAbandonGuidance > requires stop-and-replace instead of starting a duplicate [0.05ms] +(pass) sseAbandonGuidance > preserves the co-presence launch shape in recovery guidance [0.06ms] +(pass) sseAbandonGuidance > the production SSE abandon hook uses the honest guidance [0.92ms] + +src/cli-explicit-delegation.test.ts: +(pass) extractExplicitDelegation > matches send_task alias/task call [0.93ms] +(pass) extractExplicitDelegation > matches mcp send_task positional call [0.14ms] +(pass) extractExplicitDelegation > matches 给 X 发任务 [0.15ms] +(pass) extractExplicitDelegation > matches 和 X 沟通一下 [0.23ms] +(pass) extractExplicitDelegation > matches bare 和 X 沟通一下 [0.12ms] +(pass) extractExplicitDelegation > matches 和 X send_task 一下 [0.08ms] +(pass) extractExplicitDelegation > matches 和 X send_task 一下 with no punctuation before body [0.07ms] +(pass) extractExplicitDelegation > matches bare 和 X send_task 一下 [0.06ms] +(pass) extractExplicitDelegation > matches 让 X 做 [0.09ms] +(pass) extractExplicitDelegation > matches 交给 X [0.03ms] +(pass) extractExplicitDelegation > does not match no alias [0.02ms] +(pass) extractExplicitDelegation > does not match normal Q&A [0.04ms] +(pass) extractExplicitDelegation > matches bare send_task (MCP-like) [0.04ms] +(pass) extractExplicitDelegation > matches bare send_task with multi-word task body [0.04ms] +(pass) extractExplicitDelegation > matches 你去给 X 打个招呼 [0.04ms] +(pass) extractExplicitDelegation > matches 你去给 X with longer body [0.09ms] +(pass) extractExplicitDelegation > matches 给 X 发个消息 BODY (verb-suffix stripped) [0.07ms] +(pass) extractExplicitDelegation > matches 给 X 发 BODY (bare verb) [0.05ms] +(pass) extractExplicitDelegation > matches 给 X 沟通一下 BODY [0.04ms] +(pass) extractExplicitDelegation > matches 给 X 说 BODY [0.04ms] +(pass) extractExplicitDelegation > matches 给 X 发任务 (regression — specific pattern still wins) [0.04ms] + +src/util/timeout.test.ts: +(pass) withTimeout — happy path (factory wins) > resolves with factory value when fn settles before deadline [0.56ms] +(pass) withTimeout — happy path (factory wins) > passes a non-aborted signal when fn finishes promptly [0.13ms] +(pass) withTimeout — happy path (factory wins) > returns objects, not just strings [0.15ms] +(pass) withTimeout — happy path (factory wins) > propagates fn's rejection unchanged (not wrapped) [0.25ms] +(pass) withTimeout — timeout path (timer wins) > rejects with TimeoutError when fn outlasts deadline [32.21ms] +(pass) withTimeout — timeout path (timer wins) > TimeoutError message includes label + ms [0.11ms] +(pass) withTimeout — timeout path (timer wins) > TimeoutError without label still works [0.07ms] +(pass) withTimeout — timeout path (timer wins) > fires AbortSignal on timeout so factory can cancel in-flight work [43.50ms] +(pass) withTimeout — zero / negative deadline sentinel > timeoutMs=0 disables the timer (CLAUDE_TIMEOUT_MS=0 sentinel) [52.02ms] +(pass) withTimeout — zero / negative deadline sentinel > timeoutMs<0 also disables (defensive) [0.51ms] +(pass) withTimeout — zero / negative deadline sentinel > untimed call still receives a non-aborted signal [0.17ms] +(pass) withTimeout — externalSignal propagation > forwards external abort into factory signal [212.11ms] +(pass) withTimeout — externalSignal propagation > already-aborted external signal aborts immediately [0.53ms] +(pass) withTimeout — cleanup > clears timer on successful return (no dangling handles) [22.67ms] +(pass) resolveTimeoutMs — precedence > env wins over flag and default [0.31ms] +(pass) resolveTimeoutMs — precedence > flag wins when env is missing [0.05ms] +(pass) resolveTimeoutMs — precedence > default wins when env and flag both missing [0.05ms] +(pass) resolveTimeoutMs — precedence > flag wins when env is empty string (treated as unset) [0.05ms] +(pass) resolveTimeoutMs — precedence > flag wins when env is non-numeric garbage [0.12ms] +(pass) resolveTimeoutMs — precedence > flag wins when env is negative [0.04ms] +(pass) resolveTimeoutMs — precedence > default wins when flag is NaN [0.04ms] +(pass) resolveTimeoutMs — precedence > zero is honoured (not treated as unset) — env=0 disables timeout [0.09ms] +(pass) resolveTimeoutMs — precedence > zero is honoured at flag level too [0.05ms] +(pass) resolveTimeoutMs — clamping > clamps below minMs and reports clamped=true [0.07ms] +(pass) resolveTimeoutMs — clamping > clamps above maxMs and reports clamped=true [0.06ms] +(pass) resolveTimeoutMs — clamping > in-bounds value is not clamped [0.05ms] +(pass) resolveTimeoutMs — clamping > default value also gets clamped (configuration sanity) [0.06ms] +(pass) resolveTimeoutMs — defensive null handling > null envValue is treated as unset [0.04ms] +(pass) resolveTimeoutMs — defensive null handling > null flagValue is treated as unset [0.04ms] + +src/util/single-flight.test.ts: +(pass) single-flight resource initialization > concurrent callers share exactly one initializer [0.59ms] +(pass) single-flight resource initialization > a rejected initializer is cleared and can be retried [0.51ms] + +src/util/supervise-child.test.ts: +(pass) superviseChild — shutdown gate stops the loop > shutdownGate=true from the start → runOnce never called [0.49ms] +(pass) superviseChild — shutdown gate stops the loop > shutdownGate flips true after first iteration → exactly one runOnce [0.27ms] +(pass) superviseChild — backoff growth + cap > waits double the delay each iteration, capping at maxDelayMs [2.57ms] +(pass) superviseChild — runOnce that returns WITHOUT markStable is treated as failed (regression pin) > runOnce that returns cleanly without markStable → backoff doubles [0.58ms] +(pass) superviseChild — markStable resets backoff > after iteration that calls markStable, next wait is baseDelayMs again [0.64ms] +(pass) superviseChild — markStable resets backoff > markStable called multiple times in one iteration is idempotent [0.50ms] +(pass) superviseChild — abandonAfterMs > calls onAbandon and returns after cumulative downtime exceeds threshold [0.60ms] +(pass) superviseChild — abandonAfterMs > markStable in any iteration resets downtime — abandon never fires [0.52ms] +(pass) superviseChild — runOnce error handling > runOnce throws → onError fires, loop continues [0.77ms] +(pass) superviseChild — runOnce error handling > runOnce throws AND shutdownGate goes true → loop exits, no further iteration [0.30ms] +(pass) superviseChild — jitter range > jitterRatio=0.25 + random=0 → -25% of delay (lower bound) [0.59ms] +(pass) superviseChild — jitter range > jitterRatio=0.25 + random=1 → +25% of delay (upper bound) [0.33ms] +(pass) superviseChild — jitter range > jitterRatio=0 → deterministic waits at exact delay [0.42ms] +(pass) superviseChild — jitter range > waitMs floor 100 enforces minimum wait even with tiny base + negative jitter [0.45ms] +(pass) superviseChild — defensive contract > returns (does not throw) when runOnce never resolves and shutdown flips [1.46ms] + +src/util/access-resolve.test.ts: +(pass) normalizeAllowFrom — input shapes > real string[] passes through deduped (filter empty strings) [0.21ms] +(pass) normalizeAllowFrom — input shapes > undefined → empty + not malformed [0.05ms] +(pass) normalizeAllowFrom — input shapes > null → empty + not malformed [0.05ms] +(pass) normalizeAllowFrom — input shapes > non-array object → empty + malformed (corrupted access.json shape) [0.04ms] +(pass) normalizeAllowFrom — input shapes > string instead of array → malformed [0.03ms] +(pass) normalizeAllowFrom — input shapes > array with non-string elements drops them [0.06ms] +(pass) resolveTelegramAccess — fail-closed empty allowFrom (v0.11 security change) > empty array → deny with empty-fail-closed kind [0.23ms] +(pass) resolveTelegramAccess — fail-closed empty allowFrom (v0.11 security change) > undefined → deny [0.06ms] +(pass) resolveTelegramAccess — fail-closed empty allowFrom (v0.11 security change) > malformed → deny + reason mentions malformed [0.13ms] +(pass) resolveTelegramAccess — wildcard '*' opens the channel > ['*'] alone allows any sender [0.07ms] +(pass) resolveTelegramAccess — wildcard '*' opens the channel > ['*', 'specific_id'] still wildcard-allows (wins precedence) [0.05ms] +(pass) resolveTelegramAccess — explicit id / username matching > senderId in list → allow [0.08ms] +(pass) resolveTelegramAccess — explicit id / username matching > senderUsername match (no id match) → allow [0.05ms] +(pass) resolveTelegramAccess — explicit id / username matching > neither id nor username in list → deny [0.07ms] +(pass) resolveTelegramAccess — explicit id / username matching > empty senderUsername doesn't accidentally match empty list entry [0.05ms] +(pass) resolveTelegramAccess — explicit id / username matching > blank-string id with username match still allows [0.04ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > empty allowFrom → deny [0.35ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > wildcard allows [0.08ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > specific id allows [0.08ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > sender not in list → deny [0.06ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > empty allowChats → fail-closed [0.12ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > chat in allowChats + groupPolicy=all → allow [0.08ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > chat in allowChats + groupPolicy=observe → deny [0.08ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > chat NOT in allowChats → deny (even with policy=all) [0.10ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > wildcard chats opens any chat (with groupPolicy=all) [0.04ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > groupPolicy=mention allows (caller decides at message inspect time) [0.04ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns warn string for empty allowFrom [0.14ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns warn string for malformed allowFrom + mentions malformed [0.22ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns null when allowFrom has at least one entry [0.09ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns null for wildcard-allow (channel intentionally open) [0.04ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader stores raw allowFrom verbatim — no normalization at load time [0.10ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader emits boot-warn when allowFrom is missing [0.04ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader emits boot-warn when allowFrom is malformed (non-array) [0.06ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader is silent when allowFrom has at least one entry (even if numeric) [0.12ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [123] alone (numeric sender id from a misformatted access.json) → loader+resolver fail-closed [0.08ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [null] (corrupted access.json) → loader+resolver fail-closed [0.10ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [{}] (object instead of id string) → loader+resolver fail-closed [0.07ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [123, '@vansin'] (mixed) → '@vansin' still allowed, numeric '123' rejected [0.21ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [null, '*'] (mixed wildcard) → wildcard wins despite garbage entries [0.13ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > missing access.json entirely (loader gets null) → fail-closed [0.07ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > empty array NEVER allows [0.05ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > undefined NEVER allows [0.03ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > null NEVER allows [0.03ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > object-shape (corrupted) NEVER allows [0.03ms] + +src/runtime/fetch-attachment.test.ts: +(pass) FILE_ID_REGEX matches server contract > accepts the same shapes the hub accepts [0.17ms] +(pass) FILE_ID_REGEX matches server contract > rejects path-traversal + length-out-of-range [0.09ms] +(pass) resolveAttachmentToLocalPath — file_id path > hub 200 OK → bytes written to cache + chmod 600 + Bearer auth attached [8.72ms] +(pass) resolveAttachmentToLocalPath — file_id path > file_id_invalid before any HTTP call (path traversal attempt) [0.53ms] +(pass) resolveAttachmentToLocalPath — file_id path > hub 404 → not_found code [0.48ms] +(pass) resolveAttachmentToLocalPath — file_id path > hub 401 → auth_failed code [0.45ms] +(pass) resolveAttachmentToLocalPath — size cap (🔴 通信龙 nit: BYTE unit + mid-stream abort) > Content-Length > cap → size_exceeded with declared-and-cap surfaced + no cache file written [0.70ms] +(pass) resolveAttachmentToLocalPath — size cap (🔴 通信龙 nit: BYTE unit + mid-stream abort) > Content-Length lies (says small, sends big) → size_exceeded MID-STREAM with cleanup [1.42ms] +(pass) resolveAttachmentToLocalPath — size cap (🔴 通信龙 nit: BYTE unit + mid-stream abort) > DEFAULT_MAX_BYTES is 50 MiB unless COMMHUB_ATTACHMENT_MAX_BYTES is set (current process is unset → 50 MiB) [0.08ms] +(pass) resolveAttachmentToLocalPath — trusted local path fallback (single-host / feishu compat) > no file_id + path inside cache root → returns canonical path, no HTTP call [0.80ms] +(pass) resolveAttachmentToLocalPath — trusted local path fallback (single-host / feishu compat) > configured Feishu root remains a compatible trusted drop-zone [0.65ms] +(pass) resolveAttachmentToLocalPath — trusted local path fallback (single-host / feishu compat) > existing file outside trusted roots is rejected [0.59ms] +(pass) resolveAttachmentToLocalPath — trusted local path fallback (single-host / feishu compat) > symlink inside a trusted root cannot escape to another host file [0.55ms] +(pass) resolveAttachmentToLocalPath — trusted local path fallback (single-host / feishu compat) > no file_id + path does NOT exist → not_found error [0.52ms] +(pass) resolveAttachmentToLocalPath — trusted local path fallback (single-host / feishu compat) > no file_id AND no path → no_file_id_no_path error [0.31ms] +(pass) resolveAttachmentToLocalPath — cache hit > same file_id + same size → no HTTP call, returns cached:true [0.49ms] +(pass) resolveAttachmentToLocalPath — cache hit > same file_id + different size → cache miss, re-fetches [4.90ms] +(pass) sweepAttachmentCacheOnce > purges files older than TTL, keeps fresh [0.99ms] +(pass) sweepAttachmentCacheOnce > no-op when cache dir doesn't exist [0.29ms] + +src/runtime/readable-attachment-prompt.test.ts: +(pass) readable attachment prompt > pins the exact runtime set without changing structured-image SDK lanes [0.13ms] +(pass) readable attachment prompt > pins the readable extension allowlist as an exact value set [0.34ms] +(pass) readable attachment prompt > injects absolute deduplicated paths and escapes control characters [0.37ms] +(pass) readable attachment prompt > leaves text byte-identical when no attachment resolved [0.03ms] +(pass) readable attachment prompt > path-prompt runtimes reject sender-local paths while structured lanes retain legacy behavior [0.23ms] +(pass) readable attachment prompt > the inbox choke point feeds the augmented text into processTask [1.60ms] + +src/runtime/create-node-daemon.test.ts: +(pass) #633 daemon private state > global config repair and replacement converge to private state [3.55ms] +(pass) #633 daemon private state > global config read refuses a symlink without touching its target [0.96ms] +(pass) §4.2.2 daemon-side flag VALUE validator (BLOCKER #2 — defense in depth) > permissionMode enum [0.35ms] +(pass) §4.2.2 daemon-side flag VALUE validator (BLOCKER #2 — defense in depth) > dangerouslySkipPermissions boolean (string 'true' must be rejected) [0.11ms] +(pass) §4.2.2 daemon-side flag VALUE validator (BLOCKER #2 — defense in depth) > maxTurns integer range — 'DROP TABLE' / float / out-of-range rejected [0.21ms] +(pass) §4.2.2 daemon-side flag VALUE validator (BLOCKER #2 — defense in depth) > budget number with decimals allowed; out-of-range rejected [0.27ms] +(pass) §4.2.2 daemon-side flag VALUE validator (BLOCKER #2 — defense in depth) > timeout integer range [0.19ms] +(pass) §4.2.2 daemon-side flag VALUE validator (BLOCKER #2 — defense in depth) > unknown key rejected [0.10ms] +(pass) buildAnetArgsDaemon now reaches flag value validation > happy path with mixed flags [0.45ms] +(pass) buildAnetArgsDaemon now reaches flag value validation > smuggled string maxTurns rejected by daemon even if hub missed [0.23ms] +(pass) buildAnetArgsDaemon now reaches flag value validation > smuggled string dangerouslySkipPermissions rejected [0.13ms] +(pass) buildAnetArgsDaemon now reaches flag value validation > name shell-metachar still rejected (existing validateName, F2) [0.10ms] +(pass) buildAnetArgsDaemon now reaches flag value validation > runtime enum still enforced [0.08ms] +(pass) buildAnetArgsDaemon now reaches flag value validation > channels non-empty rejected (P1 fail-closed) [0.09ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > happy path with hash witness [0.96ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: no ANET_BIN_ABS at all [0.14ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: relative path [0.12ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: symlink (contains symlink component) [0.57ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: world-writable (mode 0o777) [0.41ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: group-writable (mode 0o775) [0.37ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: not executable (mode 0o644) [0.42ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: owner not root (no opt-out) [0.43ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > ACCEPT: owner not root WHEN ANET_DAEMON_ALLOW_NON_ROOT_BIN=1 (explicit opt-out) [0.37ms] +(pass) §4.2.6 B2 loadAndVerifyAnetBin — install-time pin 5-check (BLOCKER #3 hardened) > REJECT: sha256 mismatch with install witness [0.48ms] +(pass) minimalEnv defensive compose (BLOCKER #1+#2 lineage — kept stable) > happy path: no extra → PATH includes daemon's own node bin dir + SAFE_PATH (issue #301 nvm fix) [0.39ms] +(pass) minimalEnv defensive compose (BLOCKER #1+#2 lineage — kept stable) > legitimate extra key passes + fixed PATH keeps execPath prepend (issue #301) [0.17ms] +(pass) minimalEnv defensive compose (BLOCKER #1+#2 lineage — kept stable) > THROWS on reserved key in extra (LD_PRELOAD smuggled by attacker) [0.19ms] +(pass) minimalEnv defensive compose (BLOCKER #1+#2 lineage — kept stable) > THROWS on fixed key in extra (PATH smuggled — caller cannot override the trust-root execPath prepend) [0.09ms] +(pass) minimalEnv defensive compose (BLOCKER #1+#2 lineage — kept stable) > C1 invariant — issue #301 fix does NOT widen attacker surface: PATH source is process.execPath (daemon's already-resolved node), NOT env.PATH (attacker C1 surface) [0.31ms] +(pass) FAIL_FAST_MS primitive — real subprocess kill-0 lifecycle > child that exits within window → process.kill(pid, 0) raises ESRCH after wait [501.78ms] +(pass) FAIL_FAST_MS primitive — real subprocess kill-0 lifecycle > child that survives window → process.kill(pid, 0) succeeds [202.99ms] +(pass) RFC-027 BLOCKER-1 — childrenMap key shape matches hub canonical node_id > derive key from request_id, not alias [0.20ms] +(pass) RFC-027 BLOCKER-1 — childrenMap key shape matches hub canonical node_id > recordSpawnedChild end-to-end with the canonical key — stop-daemon can find it [8.35ms] + +src/runtime/claude-native-binary.test.ts: +(pass) Claude native binary version pin > uses a directly exported package manifest when available [0.55ms] +(pass) Claude native binary version pin > walks from the resolved entrypoint when package exports hide package.json [0.38ms] +(pass) Claude native binary version pin > fails closed instead of installing latest when the SDK cannot be attested [0.17ms] +(pass) Claude native binary version pin > missing-binary fallback invokes npm with the installed SDK exact version [0.27ms] + +src/runtime/stop-daemon.test.ts: +(pass) recordSpawnedChild + map shape > records + snapshot returns entry [0.45ms] +(pass) recordSpawnedChild + map shape > re-record overwrites pid [0.17ms] +(pass) handleStopDoorbell — noop_not_my_child > unknown child_node_id → degraded ack (not error) [1.44ms] +/bin/sh: 1: pgrep: not found +(pass) handleStopDoorbell — happy stop (SIGTERM-reaped quickly) > child reaped after SIGTERM → ack stopped + SIGTERM signal recorded [4.99ms] +/bin/sh: 1: pgrep: not found +(pass) handleStopDoorbell — SIGKILL escalation > child ignores SIGTERM → grace exceeded → SIGKILL → ack stopped w/ SIGKILL [35.29ms] +/bin/sh: 1: pgrep: not found +(pass) handleStopDoorbell — delete action with delete_config > mv child workdir to ~/.anet/deleted/-/ + chmod 700 + ack backup_path [3.02ms] +/bin/sh: 1: pgrep: not found +(pass) handleStopDoorbell — delete action with delete_config > delete_config=false → no backup dir, no source move [2.28ms] +(pass) handleStopDoorbell — real subprocess primitive (no mocks) > real subprocess: SIGTERM kills + kill-0 ESRCH after [304.82ms] +(pass) rebuildChildrenMapOnBoot (RFC-027 PR1.1) > happy: hub returns 2 children + each has unique matching pid → both recovered [1.94ms] +(pass) rebuildChildrenMapOnBoot (RFC-027 PR1.1) > alias substring collision: pgrep finds 'bot2' for alias 'bot' but cmdline argv exact-match rejects [0.74ms] +(pass) rebuildChildrenMapOnBoot (RFC-027 PR1.1) > zombie pid skipped (state=Z) [0.55ms] +(pass) rebuildChildrenMapOnBoot (RFC-027 PR1.1) > ambiguous: multiple verified pids → skipped (operator intervention) [0.60ms] +(pass) rebuildChildrenMapOnBoot (RFC-027 PR1.1) > hub-active but pgrep finds nothing → missing (warn, don't auto-nudge) [0.49ms] +(pass) rebuildChildrenMapOnBoot (RFC-027 PR1.1) > daemon's own pid is excluded from candidates [0.45ms] +(pass) rebuildChildrenMapOnBoot (RFC-027 PR1.1) > list_my_children failure → safe empty result (no throw, no map mutation) [0.42ms] +(pass) rebuildChildrenMapOnBoot — real subprocess primitive (no pgrep mocks, no proc mocks) > matcher accepts a real subprocess whose argv contains --alias [203.09ms] + +src/runtime/claude-error-classify.test.ts: +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > HTTP 429 standalone [0.31ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > HTTP 529 overloaded (Anthropic spec) [0.04ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > rate_limit_exceeded (Anthropic / OpenAI shape) [0.02ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > rate-limit hyphen variant [0.02ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > rate limit space variant [0.02ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > quota exceeded phrase [0.02ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > quota exhausted phrase [0.02ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > Anthropic spec overloaded_error [0.04ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > plain overloaded mention [0.04ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > too_many_requests OpenAI-compat [0.03ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > too many requests space form [0.04ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > insufficient_quota OpenAI shape [0.03ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > usage_limit hit [0.03ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > MiniMax Chinese Token Plan 上限 [0.15ms] +(pass) isRateLimitOrQuotaError — POSITIVE (must classify as quota/rate-limit) > capacity exceeded vendor message [0.03ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > 401 unauthorized (auth, not quota) [0.03ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > 403 forbidden (auth, not quota) [0.02ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > plain timeout (not quota) [0.02ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > 400 bad request (not quota) [0.02ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > 499 client closed (not quota) [0.04ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > ETIMEDOUT network error (not quota) [0.04ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > HTTP 4290 not a real status (avoid false positive on substring) [0.03ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > empty string [0.03ms] +(pass) isRateLimitOrQuotaError — NEGATIVE (regression gate, must NOT match) > null / undefined [0.04ms] +(pass) isEmptyResultSoftFailure — POSITIVE (must flag as empty-vendor-reply) > result null + output_tokens 0 [0.10ms] +(pass) isEmptyResultSoftFailure — POSITIVE (must flag as empty-vendor-reply) > result undefined (M3 incident shape) [0.04ms] +(pass) isEmptyResultSoftFailure — POSITIVE (must flag as empty-vendor-reply) > result empty string but usage non-zero [0.04ms] +(pass) isEmptyResultSoftFailure — POSITIVE (must flag as empty-vendor-reply) > result has text but output_tokens 0 (suspicious) [0.04ms] +(pass) isEmptyResultSoftFailure — POSITIVE (must flag as empty-vendor-reply) > usage missing entirely (defaulting to 1 = non-zero) but result empty [0.04ms] +(pass) isEmptyResultSoftFailure — NEGATIVE (regression gate, normal success) > normal success — result + non-zero tokens [0.10ms] +(pass) isEmptyResultSoftFailure — NEGATIVE (regression gate, normal success) > short single-char reply still counts as success [0.04ms] +(pass) isEmptyResultSoftFailure — NEGATIVE (regression gate, normal success) > usage entirely missing but result non-empty [0.04ms] +(pass) quotaRemediationHint — vendor URL routing > intern-ai routing [0.16ms] +(pass) quotaRemediationHint — vendor URL routing > minimax routing [0.05ms] +(pass) quotaRemediationHint — vendor URL routing > deepseek routing [0.06ms] +(pass) quotaRemediationHint — vendor URL routing > anthropic-native routing [0.05ms] +(pass) quotaRemediationHint — vendor URL routing > unknown vendor falls back to generic hint [0.06ms] +(pass) quotaRemediationHint — vendor URL routing > empty / undefined → generic [0.04ms] + +src/runtime/grok-build-cli.test.ts: +(pass) buildGrokCliArgs > rejects an older Grok CLI before it can ignore required safety flags [0.47ms] +(pass) buildGrokCliArgs > uses streaming headless mode and resumes an existing session [0.31ms] +(pass) buildGrokCliArgs > fails closed instead of auto-approving when permission bypass is disabled [0.15ms] +(pass) buildGrokCliArgs > maps an explicit node tool allowlist and keeps MCP unavailable [0.20ms] +(pass) buildGrokCliArgs > intersects explicit tools with the read-only set when auto-approval is off [0.12ms] +(pass) buildGrokCliArgs > rejects unknown node tool names instead of silently widening access [0.10ms] +(pass) buildGrokCliArgs > rejects an explicit empty tool allowlist instead of widening to all tools [0.10ms] +(pass) buildGrokCliArgs > denies model reads of runtime credential and node-state paths [0.08ms] +(pass) runGrokCliTurn > reports spawn submission before first exact JSONL event consumption [73.29ms] +(pass) runGrokCliTurn > reduces streaming JSON text and persists the end-event session [46.29ms] +(pass) runGrokCliTurn > spawns with exactly the projected environment and no ambient credentials [49.68ms] +(pass) runGrokCliTurn > keeps the production-shaped setpriv/sh launcher on the exact PWD-bound env [58.22ms] +(pass) runGrokCliTurn > refuses a shell launcher when PWD is missing from the reviewed env [1.00ms] +(pass) runGrokCliTurn > removes the prompt when spawn rejects a malformed allowed env value [1.68ms] +(pass) runGrokCliTurn > surfaces non-zero exits and stderr [45.38ms] +(pass) runGrokCliTurn > fails fast when headless Grok asks for an interactive login [44.83ms] +(pass) runGrokCliTurn > rejects cancelled turns [45.24ms] +(pass) runGrokCliTurn > rejects a formal error event even if the process exits zero [49.91ms] +(pass) runGrokCliTurn > rejects max-turn truncation instead of reporting a partial reply as success [47.61ms] +(pass) runGrokCliTurn > terminates the process group when the caller aborts [37.46ms] +(pass) runGrokCliTurn > kills a silent child after the idle timeout [38.18ms] +(pass) assertUnprivilegedUserNsUsable (#grok userns preflight) > passes when the probe succeeds [0.38ms] +(pass) assertUnprivilegedUserNsUsable (#grok userns preflight) > throws with the real stderr and an actionable next step when uid_map is refused [0.17ms] +(pass) assertUnprivilegedUserNsUsable (#grok userns preflight) > still throws when the probe fails with no stderr at all [0.29ms] +(pass) assertUnprivilegedUserNsUsable (#grok userns preflight) > honours a custom unshare binary path [0.10ms] + +src/runtime/grok-child-env.test.ts: +(pass) Grok child environment boundary > builds the exact reviewed key set and drops every unreviewed credential [1.76ms] +(pass) Grok child environment boundary > re-projects a beforeSpawn result instead of trusting arbitrary keys [0.18ms] +(pass) Grok child environment boundary > rejects a beforeSpawn callback that changes a controlled value [1.41ms] +(pass) Grok child environment boundary > keeps the inherited list exact and reviewable [0.07ms] +(pass) Grok child environment boundary > keeps PTY PWD equal and adds only reviewed terminal/sandbox controls [0.61ms] +(pass) Grok child environment boundary > builds the narrower helper environment from an empty object [0.24ms] + +src/runtime/node-id-source.test.ts: +(pass) resolveNodeIdSource > configured identity wins over a polluted supervisor env [0.45ms] +(pass) resolveNodeIdSource > matching launcher env is accepted without a warning [0.11ms] +(pass) resolveNodeIdSource > legacy config without node_id keeps the env fallback [0.05ms] +(pass) resolveNodeIdSource > missing identity remains empty [0.04ms] +(pass) resolveNodeIdSource > warning escapes control characters from inherited env [0.11ms] + +src/runtime/inbox-drain-lane.test.ts: +(pass) inbox drain lanes > an informational lane drains while the work lane is busy [0.45ms] +(pass) inbox drain lanes > each lane remains serial [0.32ms] +(pass) inbox drain lanes > repeated wakeups for the same drain coalesce into one dirty rerun [0.39ms] +(pass) inbox drain lanes > a failed drain is reported and does not poison later retries [0.39ms] +(pass) inbox drain lanes > retry mode backs off and eventually completes the same drain [3.42ms] +(pass) inbox drain lanes > one failed inbox item does not starve later items in the same snapshot [3.03ms] +(pass) inbox drain lanes > ack-only retry does not duplicate the first notification or delay the second [1.51ms] + +src/runtime/codex-app-server-client.test.ts: +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > reverse request (method + id) routes to `reverse_request`, NOT orphan_response [16.69ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > reverse request also fires `reverse:` targeted event [10.62ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > notification (method + no id) routes to method-keyed event [8.60ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > response (id + result) resolves the matching pending request [11.02ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > response (id + error) rejects with codex-formatted Error [9.19ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > orphan response (id present, no matching pending) fires `orphan_response` [10.62ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > malformed messages fire `malformed` [8.57ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > parse errors on non-JSON payload fire `parse_error` [10.29ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > request timeout rejects the pending promise and cleans up the entry [45.06ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > close rejects any in-flight request cleanly (no unhandled rejection) [3.73ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > respondToReverseRequest emits a well-formed response envelope [12.65ms] +(pass) CodexAppServerClient — dispatch correctness (RFC-030 §7 + bug fix) > errorReverseRequest emits a JSON-RPC error envelope [10.43ms] +(pass) CodexAppServerClient — dead shared endpoint diagnostics (#455) > wraps an empty TypeError with endpoint and remediation [0.70ms] +(pass) CodexAppServerClient — dead shared endpoint diagnostics (#455) > scrubs nested causes and bearer credentials independently of runtime shape [0.26ms] +(pass) CodexAppServerClient — dead shared endpoint diagnostics (#455) > synchronous WebSocket constructor failure uses the same safe boundary [0.45ms] +(pass) CodexAppServerClient — dead shared endpoint diagnostics (#455) > real dead loopback with query credential rejects/emits without leaking it [1.10ms] + +src/runtime/delegation-precheck.test.ts: +(pass) delegationTargetExists > imperative happy path — real other session is found [0.22ms] +(pass) delegationTargetExists > #230 — descriptive-text false positive no longer self-reflects [0.10ms] +(pass) delegationTargetExists > self-only match — only the calling node has this alias [0.07ms] +(pass) delegationTargetExists > typo alias — caller meant a real agent but mistyped [0.07ms] +(pass) delegationTargetExists > empty sessions array → empty_sessions [0.05ms] +(pass) delegationTargetExists > missing sessions field (caller did not destructure correctly) → no_sessions_field [0.06ms] +(pass) delegationTargetExists > empty target alias is defensively reported as not_in_sessions [0.04ms] +(pass) delegationTargetExists > whitespace padding is trimmed before comparison [0.05ms] +(pass) delegationTargetExists > sessions with missing / non-string alias fields are skipped without throwing [0.05ms] + +src/runtime/classify-result.test.ts: +(pass) classifyRuntimeResult — error precedence > quota error msg → soft-fail-quota (highest precedence) [1.36ms] +(pass) classifyRuntimeResult — error precedence > non-quota error → hard error [0.06ms] +(pass) classifyRuntimeResult — error precedence > auth error msg (401) → hard error (NOT quota — auth has its own path) [0.04ms] +(pass) classifyRuntimeResult — error precedence > error msg outranks empty result (don't double-classify) [0.04ms] +(pass) classifyRuntimeResult — in=0 & out=0 & cost=0 silent reject > all three zero → soft-fail-empty (even when result text present) [0.06ms] +(pass) classifyRuntimeResult — in=0 & out=0 & cost=0 silent reject > in=0 & out=0 but cost field MISSING + non-empty result → success (codex usage unreliable) [0.05ms] +(pass) classifyRuntimeResult — in=0 & out=0 & cost=0 silent reject > in=0 & cost=0 but out>0 → NOT silent reject (vendor returned something) [0.06ms] +(pass) classifyRuntimeResult — in=0 & out=0 & cost=0 silent reject > normal turn (all signals positive) → success [0.04ms] +(pass) classifyRuntimeResult — in=0 & out=0 & cost=0 silent reject > non-empty result + output_tokens=0 + cost missing → success (codex false-positive guard) [0.05ms] +(pass) classifyRuntimeResult — empty-result rule (strict) > empty string result + non-zero tokens → soft-fail-empty [0.04ms] +(pass) classifyRuntimeResult — empty-result rule (strict) > null result + non-zero tokens → soft-fail-empty [0.04ms] +(pass) classifyRuntimeResult — empty-result rule (strict) > undefined result, missing usage → soft-fail-empty (empty result alone is enough) [0.03ms] +(pass) classifyRuntimeResult — empty-result rule (strict) > single-char '0' result + tokens → success (not empty) [0.03ms] +(pass) classifyRuntimeResult — empty-result rule (strict) > result text present + missing usage → success (don't penalise unreported usage) [0.03ms] +(pass) classifyRuntimeResult — empty-result rule (strict) > empty string result + cost present + tokens → soft-fail-empty (text emptiness is the signal) [0.05ms] +(pass) classifyRuntimeResult — vendor hint routing via baseUrl > quota error with deepseek baseUrl → deepseek dashboard hint [0.05ms] +(pass) classifyRuntimeResult — vendor hint routing via baseUrl > quota error with intern baseUrl → intern hint [0.07ms] +(pass) classifyRuntimeResult — vendor hint routing via baseUrl > empty result with anthropic baseUrl → anthropic hint [0.08ms] +(pass) classifyRuntimeResult — vendor hint routing via baseUrl > missing baseUrl → generic hint [0.05ms] +(pass) formatClassificationError — message shape (parsed by IM bridge) > soft-fail-quota → 执行出错: [额度用尽][] : — [0.52ms] +(pass) formatClassificationError — message shape (parsed by IM bridge) > soft-fail-empty → 执行出错: 返回空响应 with in/out [0.10ms] +(pass) formatClassificationError — message shape (parsed by IM bridge) > error kind → 执行出错: [0.05ms] +(pass) formatClassificationError — message shape (parsed by IM bridge) > success kind → empty string (caller should not call this; defensive) [0.04ms] +(pass) formatClassificationError — message shape (parsed by IM bridge) > missing usage in context → in=0 out=0 fallback [0.04ms] +(pass) formatClassificationError — message shape (parsed by IM bridge) > missing hint on quota → no trailing dash artifact [0.11ms] +(pass) formatClassificationError — message shape (parsed by IM bridge) > reason longer than 80 chars is truncated on quota path [0.08ms] + +src/runtime/codex-app-server-bridge.test.ts: +(pass) CodexAppServerBridge — bootstrap + task mapping > bootstrap sends initialize + initialized + thread/resume in order [6.06ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > empty threadId → bootstrap creates a thread (thread/start) and adopts its id [6.92ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > stale threadId with no rollout → resume fails, bootstrap falls back to thread/start [7.70ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > startTaskTurn returns the server-assigned turnId and marks bridge working [3.44ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > turn/completed for OUR turn fires task_reply mapped back to the task_id [15.83ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > only exact owned-turn item events emit task_activity [15.95ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > authenticated Dashboard native /goal text reaches the shared thread unchanged and replies [16.98ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > clientUserMessageId rebinds a task when a goal successor replaces the turn/start response id [53.67ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > client-id ownership observed before the RPC response wins without reversing task event order [28.71ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > real bridge + runtime bounds a deferred terminal when exact client identity never arrives [37.60ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > real bridge + runtime bounds an unresolved turn/start through the left-FIFO fallback [64.08ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > agentMessage/delta accumulates when server omits finalText [14.62ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > turn/completed for a HUMAN-TUI-initiated turn is dropped (§7.5) [17.50ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > events for a DIFFERENT thread are dropped (defense in depth) [16.45ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > startTaskTurn refuses a second task while one is active [4.58ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > turn/completed with an error field fires task_error, NOT task_reply [14.10ms] +(pass) CodexAppServerBridge — bootstrap + task mapping > turn/completed with interrupted status cannot become a successful reply [16.59ms] +(pass) CodexAppServerBridge — approvals (waiting_human) §7.6 > reverse-request approval records waiting_human and sends NO response [16.85ms] +(pass) CodexAppServerBridge — approvals (waiting_human) §7.6 > serverRequest/resolved clears waiting_human and status recovers [28.50ms] +(pass) CodexAppServerBridge — approvals (waiting_human) §7.6 > multiple concurrent approvals: bridge stays waiting_human until all resolve [38.39ms] +(pass) CodexAppServerBridge — two-client race for idle > only one bridge wins turn/start; the other observes and does not reply [23.35ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > reconnect recovers an active human turn and keeps it steerable [5.25ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > reconnect provenance keeps an orphaned network turn FIFO-only [25.24ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > reconnect provenance ignores leading whitespace before the network prefix [7.33ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > reconnect stays FIFO-only when real-wire active history omits userMessage [3.33ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > uses exact turn/steer contract and maps the human turn final answer [28.49ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > multiple Dashboard rows steer one human turn while ordinary agent work stays queued [39.04ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > steer mismatch fails closed and preserves the task in the normal FIFO [38.88ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > turn completion cannot attribute a task before turn/steer acceptance [40.30ms] +(pass) CodexAppServerBridge — authenticated Dashboard steering > reconciliation recovers a missed human turn completion and exact steered reply [17.98ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > concurrent startTaskTurn: exactly ONE turn/start reaches the server even with a slow response [57.99ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > submitTask queues the second task and drains it after turn/completed (order preserved) [118.09ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > cancelQueuedTask removes only the named FIFO row before it can execute [59.53ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > thread/read recovers a completed owned turn while a successor keeps the thread active [107.94ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > thread/read uses clientUserMessageId to recover a replacement turn when all live item events were lost [5.63ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > slow full-history fallback recovers when both terminal and successor notifications are lost [4.47ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > full history never attributes a different completed turn to the owned task [4.79ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > thread/read never recovers an interrupted turn as success [4.90ms] +(pass) CodexAppServerBridge — sync claim + FIFO queue (通信龙) > drain losing the idle race requeues at the FRONT and retries on next idle [168.64ms] + +src/runtime/probe-daemon.test.ts: +(pass) createPinnedLookup — Node/Bun lookup callback contract > single-address callback honors requested family [0.72ms] +(pass) createPinnedLookup — Node/Bun lookup callback contract > all-address callback returns only pinned copies [0.21ms] +(pass) createPinnedLookup — Node/Bun lookup callback contract > wrong hostname and unavailable family fail closed without fallback [0.44ms] +(pass) assertSecureTlsEnv (boot guard) > clean env passes [0.11ms] +(pass) assertSecureTlsEnv (boot guard) > NODE_TLS_REJECT_UNAUTHORIZED=0 throws [0.14ms] +(pass) classifyProbeResponse — status enum mapping > 200 → ok [0.14ms] +(pass) classifyProbeResponse — status enum mapping > 401 → auth_fail [0.04ms] +(pass) classifyProbeResponse — status enum mapping > 403 → auth_fail [0.03ms] +(pass) classifyProbeResponse — status enum mapping > 429 → quota [0.02ms] +(pass) classifyProbeResponse — status enum mapping > 500 → vendor_5xx [0.03ms] +(pass) classifyProbeResponse — status enum mapping > 404 → other_4xx [0.03ms] +(pass) classifyProbeResponse — status enum mapping > errorKind=redirect_forbidden surfaces directly [0.03ms] +(pass) classifyProbeResponse — status enum mapping > errorKind=timeout surfaces [0.03ms] +(pass) classifyProbeResponse — status enum mapping > errorKind=probe_resolve_unsafe_ip → returned status string passes through [0.03ms] +(pass) classifyProbeResponse — status enum mapping > ack has NO error_message / response_body / url fields (zod whitelist on hub side will reject; we just don't include) [0.07ms] +(pass) safelyFetchProbe — SSRF guards (per 通信龙 spot-check c) > base_url with private IP literal (169.254.169.254) → probe_resolve_unsafe_ip [1.88ms] +(pass) safelyFetchProbe — SSRF guards (per 通信龙 spot-check c) > base_url with private IP literal (10.0.0.1) → probe_resolve_unsafe_ip [0.20ms] +(pass) safelyFetchProbe — SSRF guards (per 通信龙 spot-check c) > base_url with localhost without ALLOW_LOOPBACK env → probe_resolve_unsafe_ip [0.18ms] +(pass) safelyFetchProbe — SSRF guards (per 通信龙 spot-check c) > base_url with localhost WITH ALLOW_LOOPBACK env → permitted to proceed (will fail on real network but not on IP guard) [5.22ms] +(pass) safelyFetchProbe — SSRF guards (per 通信龙 spot-check c) > NODE_TLS_REJECT_UNAUTHORIZED=0 → tls_error before any fetch [0.13ms] +(pass) handleProbeDoorbell — daemon validateBaseUrl re-check (compromised-hub defense) > non-allowlist host for anthropic → daemon-level reject + ack probe_target_forbidden, no fetch [1.11ms] +(pass) handleProbeDoorbell — daemon validateBaseUrl re-check (compromised-hub defense) > unknown vendor → daemon rejects, ack probe_target_forbidden [0.23ms] +(pass) handleProbeDoorbell — daemon validateBaseUrl re-check (compromised-hub defense) > bad URL (not parseable) → daemon rejects, ack probe_target_forbidden [0.25ms] +(pass) handleProbeDoorbell — daemon validateBaseUrl re-check (compromised-hub defense) > plain HTTP scheme on non-loopback host → daemon rejects, ack probe_target_forbidden [0.18ms] +(pass) handleProbeDoorbell — daemon validateBaseUrl re-check (compromised-hub defense) > get_probe_request returns ok:false → no ack pushed (hub sweeper handles) [0.23ms] + +src/runtime/current-alias.test.ts: +(pass) CurrentAliasResolver — startup snapshot > current() returns the initial alias before any refresh() [0.19ms] +(pass) CurrentAliasResolver — startup snapshot > ageMs() reports Infinity before first fetch (cache is cold) [0.17ms] +(pass) CurrentAliasResolver — refresh() cache behaviour > warm cache short-circuits — no fetch fired within TTL [0.60ms] +(pass) CurrentAliasResolver — refresh() cache behaviour > expired cache hits the server and updates the alias + fires onDrift [0.37ms] +(pass) CurrentAliasResolver — refresh() cache behaviour > concurrent refresh() calls dedupe onto one fetch [10.52ms] +(pass) CurrentAliasResolver — graceful fetch failure > fetch throwing keeps the cached value and emits a warn [0.51ms] +(pass) CurrentAliasResolver — graceful fetch failure > fetch returning null is treated as 'server does not know yet' [0.18ms] +(pass) CurrentAliasResolver — graceful fetch failure > fetch returning empty string is also treated as 'server does not know' [0.25ms] +(pass) CurrentAliasResolver — graceful fetch failure > after a failed fetch the cache timestamp still bumps — no hammering [0.28ms] +(pass) CurrentAliasResolver — set() force install > set() updates the alias and fires onDrift with source 'snapshot' [0.24ms] +(pass) CurrentAliasResolver — set() force install > set() with the same value is a no-op (no drift event, but cache timestamp bumps) [0.07ms] +(pass) CurrentAliasResolver — set() force install > set('') is ignored (defends against caller forgetting to validate) [0.05ms] +(pass) CurrentAliasResolver — edge cases > nodeId = null short-circuits refresh() and never calls the fetch hook [0.18ms] +(pass) CurrentAliasResolver — edge cases > cacheTtlMs = 0 disables caching — every refresh() fetches [0.20ms] +(pass) CurrentAliasResolver — edge cases > ageMs() reflects elapsed time after a refresh [0.19ms] + +src/runtime/feishu-outbound-dir.test.ts: +(pass) Feishu legacy outbound directory > prefers the canonical worker value verbatim [0.17ms] +(pass) Feishu legacy outbound directory > reconstructs a legacy envelope from the explicit channel binding [0.12ms] +(pass) Feishu legacy outbound directory > does not consult a stale ambient node alias [0.14ms] +(pass) Feishu legacy outbound directory > passes the same explicit binding name to the worker [0.11ms] + +src/runtime/deleted-sweeper.test.ts: +(pass) RFC-027 §5.2 K — sweeper purges 30d+ backups (physical delete, no soft state) > backup older than RETENTION_MS → physically removed [1.80ms] +(pass) RFC-027 §5.2 K — sweeper purges 30d+ backups (physical delete, no soft state) > backup younger than 30d → KEPT [0.65ms] +(pass) RFC-027 §5.2 K — sweeper purges 30d+ backups (physical delete, no soft state) > mixed: 2 old + 1 recent → only the 2 olds purged [1.14ms] +(pass) sweeper safety invariants (D7 nit) > skips dir names that don't match - pattern (no accidental purge) [0.61ms] +(pass) sweeper safety invariants (D7 nit) > log function receives ONLY the dir name — never any inner file path [0.63ms] +(pass) sweeper safety invariants (D7 nit) > dir-listing error (deletedRoot missing) → returns clean empty result, no throw [0.52ms] +[deleted-sweeper] failed to purge 1783998481024-bad: simulated EACCES +(pass) sweeper safety invariants (D7 nit) > rmDir throw → counted as error, sweep continues for siblings [0.91ms] + +src/runtime/config-apply.test.ts: +(pass) RESTART_SENTINEL — exact value pin > equals 75 (BSD EX_TEMPFAIL semantics, parent supervisor checks this exact code) [0.32ms] +(pass) #633 private text writer > replaces a leaf symlink without following it [2.48ms] +(pass) validateLocalPatch — defense-in-depth > undefined model + empty flags passes (no-op patch) [0.43ms] +(pass) validateLocalPatch — defense-in-depth > valid full patch passes [0.20ms] +(pass) validateLocalPatch — defense-in-depth > unknown flag rejected (even if hub validator drifts loose) [0.15ms] +(pass) validateLocalPatch — defense-in-depth > permissionMode invalid enum rejected [0.14ms] +(pass) validateLocalPatch — defense-in-depth > dangerouslySkipPermissions non-boolean rejected [0.16ms] +(pass) validateLocalPatch — defense-in-depth > maxTurns out of range rejected [0.19ms] +(pass) validateLocalPatch — defense-in-depth > timeout invalid rejected [0.14ms] +(pass) validateLocalPatch — defense-in-depth > empty-string model rejected [0.16ms] +(pass) computeApplyMode — tier classifier > empty patch → restart_only (restart_node) [0.21ms] +(pass) computeApplyMode — tier classifier > model only → restart [0.13ms] +(pass) computeApplyMode — tier classifier > permissionMode → restart [0.12ms] +(pass) computeApplyMode — tier classifier > dangerouslySkipPermissions → restart [0.15ms] +(pass) computeApplyMode — tier classifier > teammateMode no longer in allowlist → ignored by classifier (returns hot since no restart-required flag matches) [0.18ms] +(pass) computeApplyMode — tier classifier > timeout → restart [0.21ms] +(pass) computeApplyMode — tier classifier > maxTurns only → hot [0.18ms] +(pass) computeApplyMode — tier classifier > budget only → hot [0.18ms] +(pass) computeApplyMode — tier classifier > mixed (model + maxTurns) → restart (strictest wins) [0.17ms] +(pass) atomicWriteJson — temp + rename > creates file with JSON content + trailing newline [2.37ms] +(pass) atomicWriteJson — temp + rename > overwrites existing file atomically (no .tmp left behind) [2.25ms] +(pass) #472 private config permissions > atomic write is 0600 under umask 0 [3.40ms] +(pass) #472 private config permissions > atomic write is 0600 under umask 2 [2.27ms] +(pass) #472 private config permissions > atomic write is 0600 under umask 22 [2.20ms] +(pass) #472 private config permissions > atomic write is 0600 under umask 77 [2.30ms] +(pass) #472 private config permissions > repairs existing primary, backup, and parent before token read [0.95ms] +(pass) #472 private config permissions > custom --config parent is never chmodded [0.54ms] +(pass) #472 private config permissions > atomic custom --config write preserves parent mode [2.09ms] +(pass) #472 private config permissions > backup atomically replaces a legacy broad .prev [2.20ms] +(pass) backupConfigPrev — pre-write snapshot > copies existing config to .prev [2.24ms] +(pass) backupConfigPrev — pre-write snapshot > returns backedUp=false when no config exists yet (first-write case) [0.30ms] +(pass) backupConfigPrev — pre-write snapshot > overwrites previous .prev (single-generation rotation) [4.01ms] +(pass) loadConfigWithSelfHeal — boot recovery > primary parses → returns primary [0.53ms] +(pass) loadConfigWithSelfHeal — boot recovery > primary corrupted + .prev valid → restores .prev + reports source=prev [2.31ms] +(pass) loadConfigWithSelfHeal — boot recovery > primary corrupted + no .prev → throws (truly bricked, caller surfaces) [0.64ms] +(pass) loadConfigWithSelfHeal — boot recovery > primary AND .prev corrupted → throws with both errors [0.49ms] +(pass) loadConfigWithSelfHeal — boot recovery > primary missing entirely → throws (caller will skip / first-boot path) [0.27ms] +(pass) mergePatch — patch + existing → new config (no mutation) > model replace [0.41ms] +(pass) mergePatch — patch + existing → new config (no mutation) > flags merge (does not replace whole flags obj) [0.23ms] +(pass) mergePatch — patch + existing → new config (no mutation) > empty existing + patch → patch only [0.21ms] +(pass) mergePatch — patch + existing → new config (no mutation) > empty patch → existing unchanged (deep clone) [0.23ms] +(pass) buildConfigSnapshot — pure helper contract (#290 final, drain-omit guard) > buildConfigSnapshot returns a valid snapshot regardless of caller drain state (pure) [0.53ms] +(pass) validateLocalPatch — teammateMode dropped (#290 review) > teammateMode rejected (was: allowed boolean; now: not-in-allowlist) [0.25ms] +(pass) computeApplyMode — teammateMode is no longer restart-required (#290 review) > teammateMode-only patch → hot (no longer in RESTART_REQUIRED_FLAGS) [0.18ms] +(pass) buildConfigSnapshot — masked report (no secrets) > includes model + ALLOWED_FLAGS only [0.32ms] +(pass) buildConfigSnapshot — masked report (no secrets) > missing model → null (not undefined, dashboard renders explicitly) [0.20ms] +(pass) buildConfigSnapshot — masked report (no secrets) > config_update_capable=false signals bare node (no supervisor wrapper) [0.18ms] +(pass) buildConfigSnapshot — role (PR1 #338) > role: host_supervisor passes through (string) [0.16ms] +(pass) buildConfigSnapshot — role (PR1 #338) > role: member passes through [0.16ms] +(pass) buildConfigSnapshot — role (PR1 #338) > role: missing → null (not undefined; dashboard distinguishes) [0.14ms] +(pass) buildConfigSnapshot — role (PR1 #338) > role: non-string narrowed to null (typeof guard) [0.20ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > nests runtimes_supported + allowed_secret_keys + max_concurrent_children [0.26ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > matches hub canonical path snap.daemon_capabilities.* — NOT at top level [0.24ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > partial declare: only runtimes_supported emits, others omitted [0.18ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > missing → daemon_capabilities undefined (regular non-daemon node) [0.16ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > typeof narrow: non-array runtimes_supported dropped silently [0.22ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > typeof narrow: array with non-string element dropped silently [0.19ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > typeof narrow: max_concurrent_children non-finite or non-positive dropped [0.27ms] +(pass) buildConfigSnapshot — daemon_capabilities (PR3 #338 nit ①) > partial valid + partial invalid: only valid fields included [0.23ms] +(pass) channels — validateLocalPatch > valid keys pass [0.30ms] +(pass) channels — validateLocalPatch > commhub rejected — not a fork target (cli.ts:673 UNSUPPORTED_CHANNEL guard) [0.21ms] +(pass) channels — validateLocalPatch > unknown channel key rejected (defense-in-depth vs hub drift) [0.21ms] +(pass) channels — validateLocalPatch > non-array rejected [0.27ms] +(pass) channels — validateLocalPatch > non-string element rejected [0.20ms] +(pass) channels — validateLocalPatch > more than 16 entries rejected [0.26ms] +(pass) channels — computeApplyMode > channels-present patch is restart-tier [0.21ms] +(pass) channels — computeApplyMode > channels: [] still a state change → restart [0.14ms] +(pass) channels — computeApplyMode > channels + hot flag upgrades to restart [0.15ms] +(pass) channels — computeApplyMode > model + channels → restart [0.17ms] +(pass) channels — computeApplyMode > empty patch → restart_only [0.15ms] +(pass) channels — mergePatch replaces, does not merge > channels absent in patch: existing.channels preserved [0.27ms] +(pass) channels — mergePatch replaces, does not merge > channels present: existing.channels REPLACED wholesale [0.33ms] +(pass) channels — mergePatch replaces, does not merge > channels: [] disables all editable channels [0.31ms] +(pass) channels — mergePatch replaces, does not merge > first-write case (existing has no channels key) [0.21ms] +(pass) channels — mergePatch replaces, does not merge > defensive clone — patch mutation does not leak into merged [0.20ms] +(pass) mergePatch — path-qualified specs preserved > bare-type patch preserves existing telegram:/abs/path [0.30ms] +(pass) mergePatch — path-qualified specs preserved > bare-type patch keeps both when both were path-qualified [0.19ms] +(pass) mergePatch — path-qualified specs preserved > bare-type patch adds new bare key when existing had no matching spec [0.18ms] +(pass) mergePatch — path-qualified specs preserved > disable-all still works — empty patch wipes even path-qualified specs [0.19ms] +(pass) mergePatch — path-qualified specs preserved > first-write no existing channels: bare types stay bare [0.16ms] +(pass) buildConfigSnapshot — always emits channels for content-match finalize > empty config emits channels=[] [0.26ms] +(pass) buildConfigSnapshot — always emits channels for content-match finalize > bare-type list emitted verbatim + sorted [0.22ms] +(pass) buildConfigSnapshot — always emits channels for content-match finalize > path-qualified specs collapse to bare type [0.19ms] +(pass) buildConfigSnapshot — always emits channels for content-match finalize > dupes deduped, unparseable dropped [0.17ms] +(pass) buildConfigSnapshot — always emits channels for content-match finalize > non-array channels field yields [] [0.18ms] + +src/runtime/codex-dep-loader.test.ts: +(pass) loadCodexSdk > returns the imported module without installing when already present [1.26ms] +(pass) loadCodexSdk > auto-installs and retries when the first import fails [0.66ms] +(pass) loadCodexSdk > throws a friendly multi-line error when install fails — includes pasteable npm command + module path + both root causes [0.86ms] +(pass) loadCodexSdk > install succeeds but post-install import still fails → terminal error names the install-then-resolve mismatch [0.51ms] +(pass) loadCodexSdk > module dir with shell metacharacters is single-quoted in the recovery hint [0.47ms] + +src/runtime/create-node-daemon-private-wiring.test.ts: +(pass) #633 daemon secret writers all use the private atomic choke point [0.31ms] + +src/runtime/reply-routing.test.ts: +(pass) codex-app-server reply routing > dashboard/user sender that is not a session falls back to send_reply [0.65ms] +(pass) codex-app-server reply routing > agent sender with a real session keeps send_task wake path [0.21ms] +(pass) codex-app-server reply routing > missing task id does not create an unparented reply task [0.15ms] +(pass) codex-app-server reply routing > roster load failure fails closed to send_reply [0.28ms] +(pass) codex-app-server reply routing > short ttl cache avoids repeated roster fetches and refreshes after expiry [0.49ms] +(pass) codex-app-server reply routing > failed send_task replies keep the peer-visible failure marker and high priority [0.11ms] + +src/runtime/grok-build-cli-home.test.ts: +(pass) prepareGrokCliHome > derives an opaque path segment and rejects dot identities [0.54ms] +(pass) prepareGrokCliHome > accepts only the pinned Grok regular-file copy of source agent_id [6.34ms] +(pass) prepareGrokCliHome > isolates config/trust, preserves a shared auth path, and creates stable sandbox profiles [2.35ms] +(pass) prepareGrokCliHome > refuses broad-mode or symlinked source auth without repairing it [1.44ms] +(pass) prepareGrokCliHome > repairs an existing Grok session store to owner-only modes [2.11ms] +(pass) prepareGrokCliHome > does not follow a symlink while repairing an existing session store [1.15ms] +(pass) prepareGrokCliHome > keeps the post-stop cleanup policy exact and reviewable [0.16ms] +(pass) prepareGrokCliHome > removes exact empty read-only project placeholders before resume without admitting executable sources [4.36ms] +(pass) prepareGrokCliHome > validates every exact project placeholder before unlinking any sibling [1.93ms] +(pass) prepareGrokCliHome > does not let a fatal project counterexample starve independent state containment [2.06ms] +(pass) prepareGrokCliHome > preserves nonempty, linked, wrong-mode, and wrong-type project counterexamples [4.13ms] +(pass) prepareGrokCliHome > preserves real project extension directories and still rejects executable contents on resume [1.89ms] +(pass) prepareGrokCliHome > removes only exact transient state and hardens retained post-stop state [5.85ms] +(pass) prepareGrokCliHome > hardens only the native lock derived from the exact leader socket [1.22ms] +(pass) prepareGrokCliHome > retains a non-empty leader log and rejects post-stop link attacks [2.28ms] +(pass) prepareGrokCliHome > refuses a non-empty exact sandbox placeholder [1.90ms] +(pass) prepareGrokCliHome > reclaims an empty mode-000 sandbox marker under a foreign pid without aborting [1.28ms] +(pass) prepareGrokCliHome > keeps a non-empty foreign sandbox marker unreadable so it fails closed [1.49ms] +(pass) prepareGrokCliHome > validates exact TUI process ids before mutation and refuses a placeholder symlink [1.51ms] +(pass) prepareGrokCliHome > enables the single TUI leader only for explicit copresence mode [14.69ms] +(pass) prepareGrokCliHome > admits only canonical owner-held commhub MCP artifacts [3.56ms] +(pass) prepareGrokCliHome > rejects a shared auth path covered by a required sandbox deny before state mutation [0.66ms] +(pass) prepareGrokCliHome > refuses to claim sandbox isolation when no deny target exists [0.88ms] +(pass) prepareGrokCliHome > rejects a source GROK_HOME reached through an ancestor symlink before state mutation [0.85ms] +(pass) prepareGrokCliHome > removes runtime-owned native hooks before every turn [1.37ms] +(pass) prepareGrokCliHome > unlinks a runtime-owned hook symlink without touching its external target [1.39ms] +(pass) prepareGrokCliHome > fails closed when a project native hook path exists [0.68ms] +(pass) prepareGrokCliHome > trusts only the exact canonical nested cwd and atomically replaces stale grants [2.86ms] +(pass) prepareGrokCliHome > rejects broad or symlinked folder-trust targets before writing trust state [1.13ms] +(pass) prepareGrokCliHome > refuses a planted trust-store symlink and leaves its target untouched [1.42ms] +(pass) prepareGrokCliHome > rejects every project executable source before granting folder trust [10.52ms] +(pass) prepareGrokCliHome > does not impose the shared-folder strict policy on legacy headless mode [2.45ms] +(pass) prepareGrokCliHome > rejects repo-root hooks from a nested cwd and dangling hook links [1.20ms] +(pass) prepareGrokCliHome > rejects a symlinked project .grok directory [0.70ms] +(pass) prepareGrokCliHome > rejects symlinked isolated homes and generated state without changing targets [1.69ms] +(pass) prepareGrokCliHome > rejects a state-home path escape before chmod, removal, or writes [1.15ms] +(pass) prepareGrokCliHome > requires a valid zero-hook inspect response [0.57ms] +(pass) prepareGrokCliHome > flocks the canonical project inode across symlink aliases and releases cleanly [119.70ms] +(pass) prepareGrokCliHome > gives the real flock holder only the exact helper environment [70.12ms] + +src/goals/format.test.ts: +(pass) formatSelfLoopsBlock — empty / omit semantics > no goals + omitWhenEmpty=true (default) → empty string [0.21ms] +(pass) formatSelfLoopsBlock — empty / omit semantics > no goals + omitWhenEmpty=false → explicit '无活跃循环' block [0.13ms] +(pass) formatSelfLoopsBlock — empty / omit semantics > only terminal goals (cancelled/complete/failed) → empty (same as no goals) [0.24ms] +(pass) formatSelfLoopsBlock — content shape > single active goal: header + id8 + cadence + text [0.44ms] +(pass) formatSelfLoopsBlock — content shape > paused goals shown with status='paused' [0.13ms] +(pass) formatSelfLoopsBlock — content shape > mix active + paused + terminal → only active+paused appear [0.16ms] +(pass) formatSelfLoopsBlock — cron-lite cadence rendering > time_of_day cadence: '每天 09:00' [0.10ms] +(pass) formatSelfLoopsBlock — cron-lite cadence rendering > weekday cadence: 'mon/wed/fri 18:30' [0.11ms] +(pass) formatSelfLoopsBlock — cron-lite cadence rendering > new-format interval cadence renders same as legacy interval_ms [0.08ms] +(pass) formatSelfLoopsBlock — cap + truncation > more than maxGoals → truncates with '...' summary [0.35ms] +(pass) formatSelfLoopsBlock — cap + truncation > text is one-line truncated at 100 chars [0.10ms] +(pass) formatSelfLoopsBlock — cap + truncation > multi-line text is rendered as single line [0.34ms] +(pass) formatSelfLoopsBlock — relative time rendering > next_wake_at far in the future → ISO-shortened [0.14ms] +(pass) formatSelfLoopsBlock — relative time rendering > next_wake_at in past → '已到期' [0.13ms] +(pass) formatSelfLoopsBlock — relative time rendering > malformed ISO doesn't crash, falls back to raw [0.12ms] + +src/goals/routing.test.ts: +(pass) shouldCreateScheduledGoal — Dashboard native slash pass-through > authenticated Dashboard /goal and /loop pass through for every agent-node runtime [0.21ms] +(pass) shouldCreateScheduledGoal — Dashboard native slash pass-through > authenticated Dashboard /agoal and /aloop always select the ANet scheduler [0.12ms] +(pass) shouldCreateScheduledGoal — Dashboard native slash pass-through > non-Dashboard traffic retains /goal and /loop during the compatibility window [0.11ms] +(pass) shouldCreateScheduledGoal — Dashboard native slash pass-through > near matches and slash text away from the start never select the scheduler [0.14ms] +(pass) appendLegacyScheduledGoalNotice > non-Dashboard /goal and /loop replies carry a deterministic migration notice [0.12ms] +(pass) appendLegacyScheduledGoalNotice > new namespaced commands, Dashboard pass-through, and near matches are not warned [0.05ms] +(pass) appendLegacyScheduledGoalNotice > the migration notice is first so the outer reply cap cannot truncate it [0.10ms] +(pass) Dashboard native slash migration notice > interval-shaped /goal and /loop replies explain that ANet scheduling moved to /aloop [0.85ms] +(pass) Dashboard native slash migration notice > ordinary native commands, namespaced commands, and non-Dashboard paths are untouched [0.11ms] +(pass) Dashboard native slash migration notice > the notice survives low-value filtering and the outer reply cap [0.20ms] +(pass) Dashboard native slash migration notice > failed native replies still surface the migration notice and the failure [0.08ms] +(pass) reply filtering uses authenticated message provenance > a short presence reply to an authenticated Dashboard human task is delivered [0.24ms] +(pass) reply filtering uses authenticated message provenance > the same low-value class remains filtered for agent-to-agent tasks [0.08ms] +(pass) reply filtering uses authenticated message provenance > a provenance flag cannot bypass filtering for a non-task message type [0.05ms] + +src/goals/loops-http-server.test.ts: +(pass) localhost binding (通信龙 hard constraint #1+#2) > server bound to 127.0.0.1, not 0.0.0.0 [9.14ms] +(pass) localhost binding (通信龙 hard constraint #1+#2) > port is reachable [10.16ms] +(pass) localhost binding (通信龙 hard constraint #1+#2) > random port (different runs get different ports) [6.04ms] +(pass) bearer auth no-bypass (通信龙 hard constraint #4) > missing Authorization header → 401 [7.16ms] +(pass) bearer auth no-bypass (通信龙 hard constraint #4) > wrong token → 401 [7.32ms] +(pass) bearer auth no-bypass (通信龙 hard constraint #4) > non-Bearer scheme → 401 [5.14ms] +(pass) bearer auth no-bypass (通信龙 hard constraint #4) > correct Bearer → 200 [8.46ms] +(pass) bearer auth no-bypass (通信龙 hard constraint #4) > path other than /mcp → 404 [7.04ms] +(pass) MCP protocol — initialize / tools/list / tools/call > initialize returns serverInfo + tools capability [7.25ms] +(pass) MCP protocol — initialize / tools/list / tools/call > tools/list returns all 6 self-loop tools [7.32ms] +(pass) MCP protocol — initialize / tools/list / tools/call > tools/list each tool has description + inputSchema [7.12ms] +(pass) MCP protocol — initialize / tools/list / tools/call > unknown method → JSON-RPC -32601 [7.27ms] +(pass) MCP protocol — initialize / tools/list / tools/call > malformed JSON → -32700 [5.51ms] +(pass) tools/call — handler dispatch into parent ctx > list_my_loops on empty store [7.26ms] +(pass) tools/call — handler dispatch into parent ctx > create_my_loop with interval string writes to parent goalStore [9.10ms] +(pass) tools/call — handler dispatch into parent ctx > unknown tool name → JSON-RPC -32601 [5.51ms] +(pass) safety防线 cross-HTTP boundary (M2 verification line) > batch-cancel via HTTP triggers confirm-back on 4th call [10.47ms] +(pass) safety防线 cross-HTTP boundary (M2 verification line) > cooldown via HTTP — edit within 30s of upsert rejected [8.05ms] +(pass) safety防线 cross-HTTP boundary (M2 verification line) > max-active-goals cap honored across HTTP [13.04ms] +(pass) safety防线 cross-HTTP boundary (M2 verification line) > preflight invalid timezone rejected via HTTP (M1 #302 round-2 still works) [9.44ms] +(pass) custom token override (for tests) > explicit token honored [14.13ms] +(pass) path routing — exact pathname (通信牛 hardening nit) > /mcp (exact) accepted → 200 [5.28ms] +(pass) path routing — exact pathname (通信牛 hardening nit) > /mcp?foo=bar (with query string) accepted → 200 [10.11ms] +(pass) path routing — exact pathname (通信牛 hardening nit) > /mcpXYZ (suffix) rejected → 404 (not auth-checked) [6.09ms] +(pass) path routing — exact pathname (通信牛 hardening nit) > /mcp/ (trailing slash) rejected → 404 [6.25ms] +(pass) path routing — exact pathname (通信牛 hardening nit) > /mcp-leak (dash suffix) rejected → 404 [5.99ms] +(pass) path routing — exact pathname (通信牛 hardening nit) > / (root) rejected → 404 [5.45ms] + +src/goals/failure-counter.test.ts: +(pass) resolveMaxConsecutiveFailures > default 5 when env unset [0.11ms] +(pass) resolveMaxConsecutiveFailures > env override honored [0.04ms] +(pass) resolveMaxConsecutiveFailures > invalid env falls back to default [0.04ms] +(pass) getFailureCount > legacy undefined → 0 [0.15ms] +(pass) getFailureCount > explicit 0 → 0 [0.06ms] +(pass) getFailureCount > explicit N → N [0.04ms] +(pass) bumpFailure > first failure: undefined → 1, shouldPause=false at default threshold [0.11ms] +(pass) bumpFailure > 4 → 5 at default threshold: shouldPause=true [0.06ms] +(pass) bumpFailure > 3 → 4 at threshold 5: shouldPause=false (below threshold) [0.05ms] +(pass) bumpFailure > custom threshold — 2 → 3 at threshold 3: shouldPause=true [0.05ms] +(pass) bumpFailure > beyond threshold: count continues to increment but shouldPause stays true [0.05ms] +(pass) resetFailure > legacy undefined stays undefined (no unnecessary write) [0.16ms] +(pass) resetFailure > 0 stays 0 (no unnecessary write) [0.06ms] +(pass) resetFailure > N > 0 → 0 [0.03ms] +(pass) resetFailure > threshold value → 0 [0.03ms] +(pass) applyAutoPause > status flipped to paused + counter preserved for observability [0.12ms] +(pass) applyAutoPause > progress_log entry recorded with count + reason [0.13ms] +(pass) applyAutoPause > long reason truncated to 300 chars in summary [0.08ms] +(pass) integration: full cycle > 5 bumps → pause → unpause reset → 5 more bumps → pause again [0.15ms] + +src/goals/parser.test.ts: +(pass) parseGoalCommand — English intervals > `5 min` form [0.13ms] +(pass) parseGoalCommand — English intervals > `5min` joined form [0.06ms] +(pass) parseGoalCommand — English intervals > `5 minutes` long form (plural wins over `min`) [0.05ms] +(pass) parseGoalCommand — English intervals > `1 hour` [0.08ms] +(pass) parseGoalCommand — English intervals > `hourly` keyword [0.07ms] +(pass) parseGoalCommand — English intervals > `daily` [0.06ms] +(pass) parseGoalCommand — English intervals > `1 day` [0.17ms] +(pass) parseGoalCommand — English intervals > `/goal` prefix is optional [0.07ms] +(pass) parseGoalCommand — English intervals > `/loop` alias [0.07ms] +(pass) parseGoalCommand — English intervals > `/aloop` strips the namespaced canonical prefix [0.09ms] +(pass) parseGoalCommand — English intervals > `/agoal` strips the namespaced compatibility prefix [0.13ms] +(pass) parseGoalCommand — Chinese intervals > `每5分钟` [0.24ms] +(pass) parseGoalCommand — Chinese intervals > `每 5 分钟` with spaces [0.07ms] +(pass) parseGoalCommand — Chinese intervals > `5分钟` bare (no 每) [0.14ms] +(pass) parseGoalCommand — Chinese intervals > `每小时` [0.05ms] +(pass) parseGoalCommand — Chinese intervals > `每天` [0.06ms] +(pass) parseGoalCommand — Chinese intervals > `每2小时` [0.08ms] +(pass) parseGoalCommand — rejection paths > no interval — reject [0.11ms] +(pass) parseGoalCommand — rejection paths > empty input — reject [0.04ms] +(pass) parseGoalCommand — rejection paths > seconds rejected with informative error [0.09ms] +(pass) parseGoalCommand — rejection paths > Chinese 秒 rejected [0.07ms] +(pass) parseGoalCommand — rejection paths > text becomes empty after stripping interval — reject [0.08ms] +(pass) parseGoalCommand — rejection paths > `/goal hourly` alone — reject (no text) [0.06ms] +(pass) parseGoalCommand — rejection paths > MIN_INTERVAL_MS is 60s [0.03ms] +(pass) parseGoalCommand — defence-in-depth > `1 min` exact minimum is accepted [0.03ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > `5m` parses to 5 × 60_000 ms (the canonical CLI emission) [0.06ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > `30m` / `90m` arbitrary minutes parse correctly [0.08ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > `2h` parses to 2 hours [0.09ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > `1d` parses to 24 hours [0.08ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > single-letter and word-form yield the same interval (no semantic drift) [0.07ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > `5min` still wins over `5m` (longest-prefix declaration order) [0.06ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > single-letter inside a larger word is NOT swallowed (lookahead guard) [0.05ms] +(pass) parseGoalCommand — #144 round-6 single-letter units (CLI parity) > `30s` is rejected with sub-minute error (parser + CLI aligned) [0.08ms] + +src/goals/loops-grok-wire.test.ts: +(pass) grok ACP MCP injection — RFC-025 M3 wire > when LOOPS env unset, only commhub server (back-compat) [0.22ms] +(pass) grok ACP MCP injection — RFC-025 M3 wire > when LOOPS env set, commhub + loops servers both present [0.05ms] +(pass) grok ACP MCP injection — RFC-025 M3 wire > loops server entry: ACP http schema (type+url+headers array) [0.09ms] +(pass) grok ACP MCP injection — RFC-025 M3 wire > loops headers: Authorization Bearer + transport tag + alias hint [0.14ms] +(pass) grok ACP MCP injection — RFC-025 M3 wire > loops entry localhost URL only (per security constraint) [0.13ms] +(pass) grok ACP MCP injection — RFC-025 M3 wire > loops + commhub independent: commhub headers don't leak token, loops headers don't leak ntok [0.13ms] +(pass) grok ACP MCP injection — #693 upload stdio > adds stdio commhub_upload when uploadMcpCommand provided [0.16ms] + +src/goals/completion-detect.test.ts: +(pass) isGoalCompleteSentinel — POSITIVE (must detect) > Chinese sentinel on its own line [0.10ms] +(pass) isGoalCompleteSentinel — POSITIVE (must detect) > Chinese sentinel at end of text without trailing newline [0.04ms] +(pass) isGoalCompleteSentinel — POSITIVE (must detect) > Chinese sentinel at start of text [0.04ms] +(pass) isGoalCompleteSentinel — POSITIVE (must detect) > English GOAL_COMPLETE underscore on its own line [0.09ms] +(pass) isGoalCompleteSentinel — POSITIVE (must detect) > English GOAL COMPLETE (space) on its own line [0.03ms] +(pass) isGoalCompleteSentinel — POSITIVE (must detect) > sentinel with leading/trailing whitespace on the line [0.03ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > bare 'completed' in progress report [0.03ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > 'X completed' phrase mid-sentence [0.03ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > Chinese '已完成' as section header (not the goal-complete sentinel) [0.02ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > Chinese '已完成 X 项' enumeration in body [0.03ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > 'goal completed' as a phrase inside prose (was caught by old regex) [0.02ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > '目标已完成' substring without standalone line (old regex would match) [0.03ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > lowercased 'goal_complete' (sentinel is case-sensitive on English) [0.04ms] +(pass) isGoalCompleteSentinel — NEGATIVE (regression gate, must NOT detect) > empty / null / undefined input [0.05ms] + +src/goals/schedule.test.ts: +(pass) computeNextWakeAt — interval mode > interval 5min from a baseline returns baseline + 5min [0.09ms] +(pass) computeNextWakeAt — interval mode > interval 24h returns +24h [0.07ms] +(pass) computeNextWakeAt — interval mode > interval is timezone-independent (UTC anchor same result regardless of node TZ) [0.07ms] +(pass) computeNextWakeAt — time_of_day mode (per-TZ wall clock) > 09:00 Asia/Shanghai, called at 10:00 Asia/Shanghai → tomorrow 09:00 (already past today) [5.13ms] +(pass) computeNextWakeAt — time_of_day mode (per-TZ wall clock) > 09:00 Asia/Shanghai, called at 08:00 Asia/Shanghai → today 09:00 (still upcoming) [0.46ms] +(pass) computeNextWakeAt — time_of_day mode (per-TZ wall clock) > 09:00 Asia/Shanghai, called AT 09:00 exactly → today (boundary include) [0.77ms] +(pass) computeNextWakeAt — time_of_day mode (per-TZ wall clock) > falls back to node default TZ if schedule has no timezone [0.72ms] +(pass) computeNextWakeAt — weekday mode > Monday 09:00 Asia/Shanghai, called Sun 10:00 → tomorrow (Mon) 09:00 [0.78ms] +(pass) computeNextWakeAt — weekday mode > Mon/Wed/Fri 18:30 Asia/Shanghai, called Sun 10:00 → Monday 18:30 (next eligible) [0.48ms] +(pass) computeNextWakeAt — weekday mode > Mon/Wed/Fri 18:30, called Mon 18:00 → today 18:30 (today eligible AND time still upcoming) [0.39ms] +(pass) computeNextWakeAt — weekday mode > Mon/Wed/Fri 18:30, called Mon 19:00 → today is Mon but past 18:30 → Wed 18:30 [0.78ms] +(pass) computeNextWakeAt — weekday mode > Friday 09:00, called Saturday → next Friday (full week wrap-around) [1.01ms] +(pass) computeNextWakeAt — weekday mode > workdays ['mon','tue','wed','thu','fri'] for daily standup is supported [0.51ms] +(pass) computeNextWakeAt — DST edge cases (US Eastern) > 09:00 America/New_York in summer (EDT) → 13:00 UTC [0.78ms] +(pass) computeNextWakeAt — DST edge cases (US Eastern) > 09:00 America/New_York in winter (EST) → 14:00 UTC [0.65ms] +(pass) computeNextWakeAt — DST edge cases (US Eastern) > daily 02:30 wake DOES NOT skip on DST spring-forward day (just shifts that day) [0.78ms] +(pass) computeNextWakeAt — DST edge cases (US Eastern) > daily 03:30 exists on spring-forward day (post-jump, unambiguous EDT) [0.88ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > daily 01:30, called Sat noon → fires at FIRST 01:30 EDT (before fall-back) [0.78ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > daily 01:30, called AT first 01:30 EDT boundary → NEXT DAY (not second 01:30 EST same day) [0.73ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > daily 01:30, called between the two occurrences (05:45 UTC) → next day [0.54ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > daily 01:30, called AT fall-back moment (06:00 UTC) → next day (skip 2nd occurrence) [0.68ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > daily 01:30, called AFTER second occurrence (06:30 UTC) → next day [0.56ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > daily 02:30 (post-fallback UNAMBIGUOUS) still fires on fall-back day — was buggy before P1.3 [0.60ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > daily 03:00 (fully post-fallback) on fall-back day — regression for iterated offset [0.55ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > weekday Sun 01:30 on fall-back Sunday → first occurrence EDT [0.45ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > weekday Sun 02:30 on fall-back Sunday → same day (was CRASH before P1.3) [0.44ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > weekday Sun 01:30 called AT first fire → NEXT Sunday (not same-day 2nd occurrence) [0.98ms] +(pass) computeNextWakeAt — DST fall-back (autumn) — RFC-025 P1.3 > time_of_day 09:00 on fall-back day (outside ambiguous window) unchanged [0.40ms] +(pass) computeNextWakeAt — legacy interval-only (back-compat regression) > undefined schedule → uses interval_ms from goal context, returns now + interval [0.07ms] +(pass) computeNextWakeAt — legacy interval-only (back-compat regression) > undefined schedule + zero fallback interval → still returns now (no negative offset) [0.08ms] +(pass) computeNextWakeAt — legacy interval-only (back-compat regression) > undefined schedule + missing fallback interval throws (programmer error) [0.12ms] +(pass) computeNextWakeAt — parser-rejected edge cases (defensive) > invalid time format '25:99' throws [0.16ms] +(pass) computeNextWakeAt — parser-rejected edge cases (defensive) > empty weekday list throws (caught by parser too, defense in depth) [0.11ms] +(pass) computeNextWakeAt — parser-rejected edge cases (defensive) > unknown weekday name throws [0.17ms] + +src/goals/self-loop-tools.test.ts: +(pass) list_my_loops > empty store → {goals: [], total: 0} [2.23ms] +(pass) list_my_loops > includes goal_id_short + cadence schedule shape [0.85ms] +(pass) create_my_loop > interval string '5m' creates goal [0.63ms] +(pass) create_my_loop > cron-lite time_of_day creates goal with schedule field [1.78ms] +(pass) create_my_loop > missing task → invalid_args [0.32ms] +(pass) create_my_loop > missing both schedule and interval → invalid_schedule [0.32ms] +(pass) create_my_loop > sub-minute interval rejected (parser 60s floor) [0.36ms] +(pass) create_my_loop > max active goals cap (3 cap → 4th rejected) [1.46ms] +(pass) edit_my_loop > change interval + report new value [1.52ms] +(pass) edit_my_loop > paused=true → status=paused [1.12ms] +(pass) edit_my_loop > cooldown — edit within 30s of last update rejected [0.51ms] +(pass) edit_my_loop > unknown goal_id → goal_not_found [0.30ms] +(pass) edit_my_loop > P0.3 unpause resets consecutive_failures (fresh 5-strike window) [1.13ms] +(pass) edit_my_loop > P0.3 paused=false when already active does NOT wipe mid-failure counter [1.14ms] +(pass) edit_my_loop > P0.3 paused=true does NOT reset consecutive_failures [1.15ms] +(pass) reschedule_my_loop (★ ScheduleWakeup 范式) > pushes next_wake_at forward, interval_ms unchanged [1.73ms] +(pass) reschedule_my_loop (★ ScheduleWakeup 范式) > invalid next_wake_in → invalid_interval [0.65ms] +(pass) reschedule_my_loop (★ ScheduleWakeup 范式) > cooldown applies [0.49ms] +(pass) complete_my_loop (★ 达标归档) > status → 'complete' [1.25ms] +(pass) complete_my_loop (★ 达标归档) > unknown goal_id → goal_not_found [0.32ms] +(pass) cancel_my_loop > status → 'cancelled' [1.45ms] +(pass) cancel_my_loop > batch cancel (3 in 30s) triggers confirm-back on 4th [3.52ms] +(pass) #302 round-2 — preflight computeNextWakeAt (self-lock prevention) > create_my_loop: bad timezone in schedule → invalid_schedule, NOT written [0.56ms] +(pass) #302 round-2 — preflight computeNextWakeAt (self-lock prevention) > create_my_loop: bad weekday → invalid_schedule, NOT written [0.46ms] +(pass) #302 round-2 — preflight computeNextWakeAt (self-lock prevention) > create_my_loop: bad time format → invalid_schedule, NOT written [0.37ms] +(pass) #302 round-2 — preflight computeNextWakeAt (self-lock prevention) > edit_my_loop: bad timezone on edit → invalid_schedule, EXISTING goal untouched [0.94ms] +(pass) #302 round-2 — preflight computeNextWakeAt (self-lock prevention) > create_my_loop: VALID structured schedule still works (regression) [1.52ms] +(pass) SELF_LOOP_TOOL_SPECS — registration table > exports 6 tools with stable names [0.27ms] +(pass) SELF_LOOP_TOOL_SPECS — registration table > every spec has non-empty description (LLM-discoverable) [0.22ms] +(pass) SELF_LOOP_TOOL_SPECS — registration table > description guides per RFC-025 §3.2 (intent-parse + report-back + safety) [0.43ms] + +src/goals/codex-wake.test.ts: +(pass) runCodexWakeForGoal — first wake (no codex_thread_id) > startThread path → captures threadId, returns text + failed=false [1.60ms] +(pass) runCodexWakeForGoal — first wake (no codex_thread_id) > startThread with thread.id still null → threadId undefined (SDK didn't expose id yet) [0.24ms] +(pass) runCodexWakeForGoal — first wake (no codex_thread_id) > empty agent_message stream → returns '(无回复)' fallback [0.18ms] +(pass) runCodexWakeForGoal — subsequent wake (has codex_thread_id) > resumeThread succeeds → captures (possibly updated) threadId [0.22ms] +(pass) runCodexWakeForGoal — subsequent wake (has codex_thread_id) > resume returns thread whose .id was updated by SDK → reflects new id [0.15ms] +(pass) runCodexWakeForGoal — resume-fail fallback (the critical path) > resumeThread throws → startThread fallback, threadRebuilt=true, rebuildReason populated [0.48ms] +(pass) runCodexWakeForGoal — resume-fail fallback (the critical path) > startThread fallback also throws → failed=true with both errors surfaced [0.23ms] +(pass) runCodexWakeForGoal — resume-fail fallback (the critical path) > first wake + startThread throws → failed=true, threadRebuilt=false [0.14ms] +(pass) runCodexWakeForGoal — run-time error after thread obtained > runStreamed throws on first wake → failed=true, threadId still captured if SDK set it [0.23ms] +(pass) runCodexWakeForGoal — run-time error after thread obtained > runStreamed throws on resume → failed=true, threadRebuilt=false (resume itself worked) [0.22ms] +(pass) runCodexWakeForGoal — DI plumbing > newCodex called per wake (not cached across wakes — fresh client each time) [0.28ms] +(pass) runCodexWakeForGoal — DI plumbing > buildOpts passed verbatim to start/resume Thread [0.42ms] +(pass) runCodexWakeForGoal — DI plumbing > warn callback fires on resume-fail; log callback fires on success [0.35ms] +(pass) runCodexWakeForGoal — DI plumbing > missing log/warn deps → no throw (defaults are noops) [0.17ms] + +src/goals/scheduler.test.ts: +(pass) decideTickWork — basic selection > empty list → empty buckets [0.25ms] +(pass) decideTickWork — basic selection > single active goal due now → due [0.27ms] +(pass) decideTickWork — basic selection > single active goal due 1ms ago → due [0.09ms] +(pass) decideTickWork — basic selection > single active goal due 1ms in future → pending, not due [0.12ms] +(pass) decideTickWork — basic selection > multiple active goals: only the overdue ones wake; pending stay [0.16ms] +(pass) decideTickWork — status filtering > each non-active status is skipped (never appears in due) [0.16ms] +(pass) decideTickWork — status filtering > mixed batch: only active+due appear in due bucket [0.21ms] +(pass) decideTickWork — status filtering > wake order preserves input order — deterministic, no shuffling [0.11ms] +(pass) decideTickWork — invalid timestamp recovery > missing next_wake_at → treated as overdue (surface to wake handler) [0.07ms] +(pass) decideTickWork — invalid timestamp recovery > empty string next_wake_at → treated as overdue [0.06ms] +(pass) decideTickWork — invalid timestamp recovery > garbage next_wake_at (Date.parse → NaN) → treated as overdue [0.05ms] +(pass) decideTickWork — invalid timestamp recovery > non-string next_wake_at (number 0 from corrupt JSON) → treated as overdue [0.05ms] +(pass) decideTickWork — invalid timestamp recovery > inactive + invalid timestamp → still skipped (status wins over wake check) [0.06ms] +(pass) decideTickWork — counter sanity > active + skipped sums to total goals; pending + due sums to active [0.12ms] + +src/goals/store.test.ts: +(pass) GoalStore — basic lifecycle > fresh store: load with no file → ok, empty list [0.63ms] +(pass) GoalStore — basic lifecycle > upsert → get → list roundtrip [0.69ms] +(pass) GoalStore — basic lifecycle > delete → flushes to disk [1.30ms] +(pass) GoalStore — basic lifecycle > setStatus → in-memory + persisted [1.18ms] +(pass) GoalStore — basic lifecycle > setStatus on unknown id → undefined, no throw [0.33ms] +(pass) GoalStore — basic lifecycle > mutate applies in-place + bumps updated_at [6.45ms] +(pass) GoalStore — basic lifecycle > mutate on unknown id → undefined, mutator NOT invoked [0.35ms] +(pass) GoalStore — restart persistence > two instances see the same goals (= restart simulation) [0.85ms] +(pass) GoalStore — restart persistence > status change survives reload [1.11ms] +(pass) GoalStore — corruption recovery (#2) > invalid JSON → ok=false, .corrupt backup, empty store [1.81ms] +(pass) GoalStore — corruption recovery (#2) > unknown schema version → recovery [0.60ms] +(pass) GoalStore — corruption recovery (#2) > malformed shape (goals not array) → recovery [0.50ms] +(pass) GoalStore — Grok preview persistence boundary > recursively migrates task/progress/error, final writes, and archives at 0600 [3.26ms] +(pass) GoalStore — Grok preview persistence boundary > scrubs a broad-mode corrupt backup and replaces the live file with an empty safe store [1.68ms] +(pass) GoalStore — Grok preview persistence boundary > recursively scrubs a parseable unsupported-schema backup [1.57ms] +(pass) P0 runtime gate — name resolution > isClaudeRuntime accepts every claude alias [0.17ms] +(pass) P0 runtime gate — name resolution > isClaudeRuntime rejects codex / grok / unknown / empty [0.07ms] +(pass) P0 runtime gate — name resolution > runtimeBucket maps to canonical buckets [0.11ms] +(pass) #144 round-6 — claude runtime gate REMOVED, scheduler is universal > newGoal({runtime: 'claude-agent-sdk'}) succeeds (was the load-bearing bug) [0.09ms] +(pass) #144 round-6 — claude runtime gate REMOVED, scheduler is universal > newGoal succeeds for every recognized runtime alias (no per-bucket carve-out) [0.17ms] +(pass) #144 round-6 — claude runtime gate REMOVED, scheduler is universal > GoalStore.upsert accepts a claude-runtime goal end-to-end [0.74ms] +(pass) #144 round-6 — claude runtime gate REMOVED, scheduler is universal > isClaudeRuntime still classifies (kept for cross-bucket detection, not gating) [0.05ms] +(pass) P0 runtime gate — archiveAndClear > with live goals: backup file created, store emptied, reload sees empty [1.91ms] +(pass) P0 runtime gate — archiveAndClear > with no live file: returns undefined, no throw, store still flushes empty [0.45ms] +(pass) P0 runtime gate — archiveAndClear > backup filenames are unique across rapid calls [15.05ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > claude + empty → ok (scheduler runs; was 'skip' pre-#144) [0.27ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > claude + only claude-active goals → ok (scheduler runs) [0.23ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > codex + empty → ok [0.03ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > codex + only codex goals → ok [0.06ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > grok + only grok goals → ok [0.08ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > claude + active codex/grok goals → archive + runScheduler=true (recover after archive) [0.32ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > codex + grok-active leftover → archive (NOT fatal exit anymore) [0.09ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > grok + codex-active leftover → archive [0.05ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > inactive foreign-bucket goals do NOT trigger archive (only `active` counts) [0.09ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > claude with only inactive foreign leftover → ok (just cleanup pending) [0.05ms] +(pass) #144 round-6 — decideStartupAction (refined-B matrix) > unknown bucket → skip (no scheduler, no auto-archive) [0.06ms] +(pass) GoalStore — mutex serialisation (#1+#3) > 50 concurrent upserts → all 50 persist (no torn writes) [20.63ms] +(pass) GoalStore — mutex serialisation (#1+#3) > interleaved upsert + setStatus + delete stays consistent [13.18ms] + +src/runtime/grok-copresence/profile-process.test.ts: +(pass) Grok co-presence profile is pinned for the whole process > same input yields two exact, non-overlapping process capabilities [127.34ms] + +src/runtime/grok-copresence/jsonl.test.ts: +(pass) Grok copresence envelope and user parsing > parses only an exact, query-anchored Agent Network envelope [0.50ms] +(pass) Grok copresence envelope and user parsing > extracts the first authoritative user_query from string or Grok text-array content [0.48ms] +(pass) Grok copresence envelope and user parsing > does not trust a syntactically valid prefix unless the bridge registered it [1.50ms] +(pass) Grok copresence envelope and user parsing > nested user_query text cannot turn an owned network task into human delegation [0.37ms] +(pass) Grok copresence turn reducer > waits for completion and replies with the last non-empty assistant record [0.55ms] +(pass) Grok copresence turn reducer > keeps the last no-tool assistant when later tool-bearing chatter exists [0.28ms] +(pass) Grok copresence turn reducer > handles completion/chat-history polling order without returning an empty reply [0.33ms] +(pass) Grok copresence turn reducer > does not finalize an intermediate assistant visible before the completion event [0.24ms] +(pass) Grok copresence turn reducer > retains a completion observed before even the network user line [0.36ms] +(pass) Grok copresence turn reducer > retains an event-first human completion only for a trusted PTY submission [0.26ms] +(pass) Grok copresence turn reducer > never carries an unowned idle completion into a later network task [0.28ms] +(pass) Grok copresence turn reducer > binds an event-first completion to the exact registered network task [0.39ms] +(pass) Grok copresence turn reducer > consumes sanitized sample A block content and turn_number boundary [0.30ms] +(pass) Grok copresence turn reducer > consumes sanitized sample B and selects only the 14th no-tool assistant [0.58ms] +(pass) Grok copresence turn reducer > ignores standalone system-reminder user records without abandoning a network turn [0.17ms] +(pass) Grok copresence turn reducer > fails a terminal record without turn_started and never binds it to the next user [0.27ms] +(pass) Grok copresence turn reducer > never maps a human turn or failed network turn to a network reply [0.69ms] +(pass) Grok copresence turn reducer > abandons an unfinished network turn rather than attaching its answer to a human turn [0.53ms] +(pass) Grok copresence turn reducer > pairs events correctly when chat_history leads by two unnumbered turns [0.68ms] +(pass) Grok copresence turn reducer > does not let a new start overtake an abandoned numbered terminal [0.38ms] +(pass) Grok completion compatibility and defensive parsing > recognizes only top-level turn_ended with an exact successful outcome [0.31ms] +(pass) Grok completion compatibility and defensive parsing > binds turn_started turn_number while permission lifecycle remains inert [0.25ms] +(pass) Grok completion compatibility and defensive parsing > fails a started turn when turn_ended has no outcome [0.25ms] +(pass) Grok completion compatibility and defensive parsing > fails closed on an overlapping turn_started epoch [0.25ms] +(pass) Grok completion compatibility and defensive parsing > retains only a bounded tail of raw completion candidates [0.23ms] +(pass) Grok completion compatibility and defensive parsing > contains malformed and overlong lines instead of parsing or retaining them [1.23ms] +(pass) Grok completion compatibility and defensive parsing > incrementally joins split lines and drops a fragmented oversized line once [1.56ms] +(pass) persistent JSONL tail cursor > starts fresh at end by default, with an explicit start override [0.38ms] +(pass) persistent JSONL tail cursor > continues and fails closed on truncate or inode rotation [0.24ms] +(pass) persistent JSONL tail cursor > treats corrupt persisted state as non-replayable and advances JSON-safely [0.24ms] + +src/runtime/grok-copresence/attach.test.ts: +(pass) Grok co-presence local attach server > serves one owner-only client and cleans its socket on close [15.97ms] +(pass) Grok co-presence local attach server > rejects a second client without disturbing the attached human [10.51ms] +(pass) Grok co-presence local attach server > routes input and resize frames only through serialized arbiter callbacks [6.63ms] +(pass) Grok co-presence local attach server > fails closed when an inbound frame exceeds the configured bound [20.40ms] +(pass) Grok co-presence local attach server > refuses symlinks and regular files at the socket path [1.06ms] + +src/runtime/grok-copresence/state.test.ts: +(pass) Grok co-presence arbitration > lets the first human byte win a simultaneous human/network race [1.38ms] +(pass) Grok co-presence arbitration > gives a newly active human composer priority over an existing FIFO [0.35ms] +(pass) Grok co-presence arbitration > dequeues network tasks FIFO and never preempts an active turn [0.62ms] +(pass) Grok co-presence arbitration > cancels only queued timeouts and rejects duplicate task ids [0.35ms] +(pass) Grok co-presence arbitration > retains the active network task and FIFO across disconnect/reconnect [1.13ms] +(pass) Grok co-presence arbitration > marks approvals waiting for the human without emitting a response [0.29ms] +(pass) Grok co-presence arbitration > clears an already-waiting preview todo resolution in either active turn without completing it [0.32ms] + +src/runtime/grok-copresence/profile-wiring.test.ts: +(pass) Grok co-presence profile wiring > pins validated config before dynamically loading the runtime [3.22ms] +(pass) Grok co-presence profile wiring > cannot mutate the capability according to a logical turn owner [0.15ms] + +src/runtime/grok-copresence/leader-lifecycle.test.ts: +(pass) Grok auto-Leader lifecycle identity > rejects a different kernel executable hidden behind a pinned argv0 [0.94ms] +(pass) Grok auto-Leader lifecycle identity > rejects a live native listener whose argv0 forges the pinned executable [363.91ms] +(pass) Grok auto-Leader lifecycle identity > terminates one exact generation and removes only its stale socket [94.35ms] +(pass) Grok auto-Leader lifecycle identity > does not adopt a listener whose generation marker differs [157.07ms] +(pass) Grok auto-Leader lifecycle identity > does not signal or unlink after the socket pathname is replaced [55.12ms] +(pass) Grok auto-Leader lifecycle identity > revalidates the exact identity before escalating a TERM-resistant Leader [585.01ms] +(pass) Grok auto-Leader lifecycle identity > does not escalate when a TERM-resistant Leader replaces its listener [391.50ms] +(pass) Grok auto-Leader lifecycle identity > does not signal after the configured binary inode is replaced [72.03ms] +(pass) Grok auto-Leader lifecycle identity > retains the stale socket when another process from the generation remains [296.91ms] + +src/runtime/grok-copresence/allowlist-near-miss.test.ts: +(pass) grok copresence preview tool profile is an exact value set > a profile tool with an otherwise valid tuple is accepted [0.64ms] +(pass) grok copresence preview tool profile is an exact value set > refuses "todo_write2" [0.05ms] +(pass) grok copresence preview tool profile is an exact value set > refuses "search_tool2" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool2" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool_v2" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool-admin" +(pass) grok copresence preview tool profile is an exact value set > refuses "Use_Tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "USE_TOOL" +(pass) grok copresence preview tool profile is an exact value set > refuses "Todo_Write" +(pass) grok copresence preview tool profile is an exact value set > refuses " use_tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool " +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool\n" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_too" +(pass) grok copresence preview tool profile is an exact value set > refuses "tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "not_use_tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "xuse_toolx" [0.02ms] +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "use​tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_to​ol" +(pass) grok copresence preview tool profile is an exact value set > refuses "" +(pass) grok copresence preview tool profile is an exact value set > refuses " " +(pass) grok copresence preview tool profile is an exact value set > refuses "Search_Tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "TODO_WRITE" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool\r" +(pass) grok copresence preview tool profile is an exact value set > refuses "use_tool\u0000" +(pass) grok copresence preview tool profile is an exact value set > refuses "x_todo_write" +(pass) grok copresence preview tool profile is an exact value set > refuses "use-tool" +(pass) grok copresence preview tool profile is an exact value set > refuses "todo-write" +(pass) grok copresence preview tool profile is an exact value set > the profile is exactly the three pinned tools [0.12ms] + +src/runtime/grok-copresence/profile-selection.test.ts: +(pass) Grok co-presence process capability profile > accepts only the two exact startup profiles [0.20ms] +(pass) Grok co-presence process capability profile > defaults closed and rejects an invalid process profile [0.13ms] + +src/runtime/grok-copresence/runtime.test.ts: +(pass) Grok copresence launch and injection policy > keeps the fixed-tool auto-resolution exception exact and limited to active turns [0.44ms] +(pass) Grok copresence launch and injection policy > admits exact automatic lifecycles only for the fixed preview tool boundary [0.37ms] +(pass) Grok copresence launch and injection policy > exposes only reviewed value-free task failure codes and exact JSONL subcodes [0.52ms] +(pass) Grok copresence launch and injection policy > keeps the JSONL subcode allowlist direct, frozen, and actual-path-only [0.27ms] +(pass) Grok copresence launch and injection policy > locks the probed Grok TUI build exactly [0.21ms] +(pass) Grok copresence launch and injection policy > pins one TUI-effective commhub-only agent profile and hard-denies fallback routes [0.90ms] +(pass) Grok copresence launch and injection policy > rejects terminal escape injection and reserved origin markup [0.57ms] +(pass) Grok copresence launch and injection policy > recognizes the pinned TUI composer footer across ANSI fragments [0.31ms] +(pass) Grok copresence launch and injection policy > rejects external permission sources and noninteractive modes [1.41ms] +(pass) Grok copresence runtime integration > terminates the independently persistent auto-Leader and its unchanged stale socket [559.81ms] +(pass) Grok copresence runtime integration > cleans and hardens the exact pinned footprint only after confirmed close [538.75ms] +(pass) Grok copresence runtime integration > cleans each exact sandbox placeholder at its confirmed recovery boundary [911.85ms] +(pass) Grok copresence runtime integration > removes an old placeholder before a recovery generation reuses its PID [1205.20ms] +(pass) Grok copresence runtime integration > queues network input until the pinned TUI composer is ready [1213.87ms] +(pass) Grok copresence runtime integration > maps keyless fake-writer file mutations to exact value-free tail subcodes [3921.91ms] +(pass) Grok copresence runtime integration > continues exactly once across prefix-preserving atomic chat rewrites [2266.62ms] +(pass) Grok copresence runtime integration > rejects an atomic replacement that preserves only the consumed prefix [667.36ms] +(pass) Grok copresence runtime integration > rejects a same-inode shrink below the highest observed size even when offset remains valid [583.46ms] +(pass) Grok copresence runtime integration > does not expose an intermediate atomic generation before its successor preserves it [1082.15ms] +(pass) Grok copresence runtime integration > does not expose a pinned generation unlinked between path check and read [1107.69ms] +(pass) Grok copresence runtime integration > maps chat and events reset callback failures and stops polling after fatal [1534.92ms] +(pass) Grok copresence runtime integration > maps keyless reducer, lifecycle, and combined flush invariants at their boundaries [2532.40ms] +(pass) Grok copresence runtime integration > close waits for and tears down a Leader spawned by in-flight recovery [937.73ms] +(pass) Grok copresence runtime integration > retains containment and lifetime locks when a closing recovery PTY will not stop [2925.58ms] +(pass) Grok copresence runtime integration > excludes a different runtime from the same canonical project for the full TUI lifetime [1108.26ms] +(pass) Grok copresence runtime integration > contains an exited recovery generation before reusing its PID [1799.07ms] +(pass) Grok copresence runtime integration > retains final-cleanup ownership after every failed recovery PID is consumed [935.21ms] +(pass) Grok copresence runtime integration > reports exact submission and trusted consumption, never queued admission [1795.97ms] +(pass) Grok copresence runtime integration > arbitrates a live PTY, settles final JSONL, attaches once, and resumes [4070.62ms] +(pass) Grok copresence runtime integration > fails closed on automatic permission resolution without a human action [619.65ms] +(pass) Grok copresence runtime integration > accepts only the pinned preview todo_write automatic resolution tuple [1915.08ms] +(pass) Grok copresence runtime integration > keeps the shared TUI alive when the pinned preview auto-resolves todo_write in a human turn [1810.85ms] +(pass) Grok copresence runtime integration > keeps the shared TUI alive across exact search_tool then use_tool in a human turn [1659.41ms] +(pass) Grok copresence runtime integration > rejects every mutated preview todo_write automatic resolution tuple [4236.29ms] +(pass) Grok copresence runtime integration > preserves exact permission lifecycle order across coalesced and split event reads [2342.57ms] +(pass) Grok copresence runtime integration > fails closed on malformed or oversized permission lifecycle JSONL [1198.51ms] +(pass) Grok copresence runtime integration > rejects terminal reordering around automatic permission lifecycles [1802.88ms] +(pass) Grok copresence runtime integration > allows repeated fixed-tool automatic permission lifecycles in one network turn [1103.59ms] +(pass) Grok copresence runtime integration > never replies with a tool-bearing assistant when the final log is delayed past settling [1888.32ms] +(pass) Grok copresence runtime integration > rejects a completed turn that never resolved its approval [595.16ms] +(pass) Grok copresence runtime integration > does not resume a TUI that crashed at an approval prompt [608.79ms] +(pass) Grok copresence runtime integration > rejects a permission record that landed just before the crash poll [891.42ms] +(pass) Grok copresence runtime integration > refuses process-level resume with a persisted unresolved approval [226.93ms] +(pass) Grok copresence runtime integration > permits process-level resume after a persisted approval was resolved [545.57ms] +(pass) Grok copresence runtime integration > arms both resume tails before spawn-time permission records can be skipped [305.07ms] +(pass) Grok copresence runtime integration > discards spawn-time orphan completions before accepting the first new network task [1104.33ms] +(pass) Grok copresence runtime integration > drains more than one tail chunk before attach and fully cleans a startup rejection [901.15ms] +(pass) Grok copresence runtime integration > accepts the pinned startup auto-approval transition [597.71ms] +(pass) Grok copresence runtime integration > reruns the spawn audit and refuses recovery when it fails [824.84ms] +(pass) Grok copresence runtime integration > keeps auto-approval across recovery before scheduling [1726.69ms] +(pass) Grok copresence runtime integration > jointly drains chat and events until both recovery cursors are stable [1884.42ms] +(pass) Grok copresence runtime integration > rejects a beforeSpawn callback that widens a controlled child setting [206.40ms] +(pass) Grok copresence runtime integration > gives every real lifetime-lock holder only the exact helper environment [548.53ms] + +src/runtime/opencode-acp/events.test.ts: +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk with text content → replyText += content.text [2.17ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_thought_chunk with text → thoughtText, NOT replyText (grok discipline) [0.17ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > tool_call and tool_call_update both bump toolCalls [0.07ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > usage_update snaps totalTokens into state.usage [0.10ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > available_commands_update consumed silently (session-init only) [0.07ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > agent_message_chunk without text content adds a warning [0.10ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown method returns ignored without mutating state [0.07ms] +(pass) reduceOpencodeAcpNotification — session/update dispatch > unknown sessionUpdate subtype returns ignored (forward-compat) [0.05ms] +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > captures stopReason + usage from result [0.23ms] +(pass) reduceOpencodeAcpResponse — session/prompt terminal response > missing stopReason still marks turn complete [0.04ms] +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > full one-word turn: 10 thought chunks + 1 message chunk + usage + response [0.38ms] +(pass) reduceOpencodeAcpFrames — replay the Phase 0b captured turn > thinking-only terminal turn (no agent_message_chunk) — replyText stays empty [0.16ms] + +src/runtime/opencode-acp/child-env.test.ts: +(pass) buildOpencodeChildEnv — deny-by-default boundary > locks the exact hardened ancestor candidate set [0.12ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects sticky world-writable /tmp instead of silently degrading [4.07ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > passes only runtime/network allowlist and controls all state roots [18.47ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe inline policy disables every local tool without replacing provider/model [12.10ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > unsafe opt-in explicitly overrides the wizard's persisted safe policy [9.90ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > detects exact managed config sources across Linux, Windows, and macOS [1.33ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > safe runtime renders ordinary same-uid config through a strict allowlist [17.08ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > copies only blessed API auth fields into fresh data and keeps persistent state outside the child [16.90ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > never exposes planted persistent DB/log/cache/state/tmp descendants in safe or unsafe mode [24.39ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > removes a partially built launch tree when env construction fails [12.67ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > pre-spawn revalidation hard-fails when an ancestor discovery candidate appears [17.11ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > keeps active roots but reclaims a dead-owner crash root without following symlinks [44.43ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > reclaims dead-owner roots after the node workDir is deleted or recreated [53.93ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a transient cleanup pathname swap is retried after child exit [27.13ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > a dead owner marker is retained while an orphan child still references the root [51.35ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > an exact exited-process identity exemption never hides a live descendant or PID mismatch [50.46ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects symlinks at workDir and every security-sensitive state layer [14.58ms] +(pass) buildOpencodeChildEnv — deny-by-default boundary > rejects permissive modes and foreign ownership without repairing them [1.31ms] + +src/runtime/opencode-acp/profile-state.test.ts: +(pass) OpenCode private profile state > loads, atomically updates, backs up, and writes a session [12.43ms] +(pass) OpenCode private profile state > a post-load config symlink cannot redirect session writeback [1.04ms] +(pass) OpenCode private profile state > boot refuses a config symlink before self-heal can write its target [0.99ms] +(pass) OpenCode private profile state > backup refuses a pre-planted .prev symlink [0.72ms] +(pass) OpenCode private profile state > runtime hint rejects suspicious config leaves for every runtime [0.82ms] + +src/runtime/opencode-acp/client.test.ts: +(pass) OpencodeAcpClient — request/response correlation > request() resolves with the matching response's result [71.63ms] +(pass) OpencodeAcpClient — request/response correlation > error response rejects the promise with a shaped message [66.81ms] +(pass) OpencodeAcpClient — streaming notifications > emits 'notification' for every session/update frame [70.16ms] +(pass) OpencodeAcpClient — streaming notifications > id-carrying reverse requests get an explicit method-not-found response [69.10ms] +(pass) OpencodeAcpClient — process lifecycle > child exit rejects all pending requests [64.57ms] +(pass) OpencodeAcpClient — process lifecycle > isRunning flips false after stop() [2.51ms] +(pass) OpencodeAcpClient — process lifecycle > explicit child env is not merged with the client's process.env [55.31ms] + +src/runtime/opencode-acp/runtime.test.ts: +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > safe default keeps spawn + ACP session in one external launch workspace [168.23ms] +[opencode-acp] session/new — ses_probe_au... +(pass) openOpencodeRuntime — cwd and tool policy > version probe root is credential-free and gone before runtime auth is materialized [180.57ms] +[opencode-acp] session/load ok — resumed ses_existing... +(pass) openOpencodeRuntime — cwd and tool policy > safe session/load reuses the exact spawn PWD as its ACP cwd [142.37ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — cwd and tool policy > explicit unsafe flag restores project cwd and emits a trusted-task warning [121.00ms] +[opencode-acp] session/new — ses_evidence... +(pass) openOpencodeRuntime — cwd and tool policy > reports submission before exact prompt-response consumption [141.34ms] +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > normal stop removes the launch root and copied vendor auth [131.47ms] +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +[opencode-acp] session/new — ses_test... +(pass) openOpencodeRuntime — opening lifecycle > repeated open/stop cycles do not accumulate launch roots [3262.64ms] +(pass) openOpencodeRuntime — opening lifecycle > an ancestor candidate planted by the version probe hard-fails before ACP spawn [64.37ms] +(pass) openOpencodeRuntime — opening lifecycle > package replacement after credential-free probe is rejected and runtime auth root is discarded [66.26ms] +(pass) openOpencodeRuntime — opening lifecycle > in-place binary self-modification after probe is rejected before credential spawn [68.04ms] +(pass) openOpencodeRuntime — opening lifecycle > production rejects canonical same-version packages below project cwd or node workDir [29.21ms] +(pass) openOpencodeRuntime — opening lifecycle > initialize failure force-kills the child before rejecting [134.02ms] +(pass) openOpencodeRuntime — opening lifecycle > onClient exposes a stalled-handshake child synchronously for shutdown [53.02ms] +[opencode-acp] session/new — ses_idle... +(pass) opencodeThink — failed-turn lifecycle > prompt idle timeout force-kills the child before rejecting [212.23ms] +[opencode-acp] session/new — ses_rescue_i... +[opencode-acp] #383 thinking-only terminal turn (chunks=0 thoughtChunks=1) — re-prompting for plain-text final +(pass) opencodeThink — failed-turn lifecycle > a failed thinking-only rescue discards the child before returning [163.96ms] + +src/runtime/opencode-acp/binary.test.ts: +(pass) resolvePinnedOpencodeBinary > locks the non-root uid=gid umask-0002 compatibility policy [0.16ms] +(pass) resolvePinnedOpencodeBinary > accepts the canonical package entrypoint and probes it from the external cwd [43.50ms] +(pass) resolvePinnedOpencodeBinary > accepts an npm-style PATH shim but returns the canonical package binary [29.04ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version fake package inside the project before executing it [1.34ms] +(pass) resolvePinnedOpencodeBinary > rejects forged package metadata and noncanonical entrypoints [3.99ms] +(pass) resolvePinnedOpencodeBinary > rejects unsafe file, package-directory, ancestor, and owner modes [3.34ms] +(pass) resolvePinnedOpencodeBinary > still enforces exact --version output after package identity succeeds [24.39ms] +(pass) resolvePinnedOpencodeBinary > refuses a caller-selected version other than the vetted release pin [0.88ms] +(pass) resolvePinnedOpencodeBinary > rejects a same-version package in a monorepo ancestor before probing it [1.76ms] +(pass) resolvePinnedOpencodeBinary > discovers a workspace ancestor when the configured project leaf is absent [0.94ms] +(pass) resolvePinnedOpencodeBinary > launcher absolute path wins over a hostile search PATH [27.12ms] +(pass) resolvePinnedOpencodeBinary > rejects non-absolute overrides [0.23ms] + +src/runtime/grok-build-acp/events.test.ts: +(pass) Grok ACP event reducer — fixture replay > T6 prompt fixture accumulates final reply chunks [4.02ms] +(pass) Grok ACP event reducer — fixture replay > T8 session/load skips replay chunks from the previous turn [1.18ms] +(pass) Grok ACP event reducer — fixture replay > T9 abort + resume accumulates only the resumed turn reply [0.93ms] + +src/runtime/grok-build-acp/resume-hint.test.ts: +(pass) fetchUnresolvedOutbound > returns empty array when the hub has no outbound rows for this sender [0.51ms] +(pass) fetchUnresolvedOutbound > filters to only delivered/started status [0.32ms] +(pass) fetchUnresolvedOutbound > caps results at topN (preserves server-side recency order) [0.38ms] +(pass) fetchUnresolvedOutbound > forwards the sender alias and a sane limit to the listTasks hook (no node_id fallback path) [0.27ms] +(pass) fetchUnresolvedOutbound > #146 PR-4 二审 — sends from_node_id ONLY when probe confirmed server supports it [0.24ms] +(pass) fetchUnresolvedOutbound > #146 PR-4 二审 — without probe confirmation, never sends from_node_id (old-server safety) [0.18ms] +(pass) fetchUnresolvedOutbound > #146 PR-4 二审 — when probe explicitly returned false, falls back even with node_id available [0.16ms] +(pass) fetchUnresolvedOutbound > #146 PR-4 — empty / null nodeId falls back to from_name path [0.28ms] +(pass) fetchUnresolvedOutbound > graceful fallback when list_tasks throws — returns empty, does not propagate [0.29ms] +(pass) fetchUnresolvedOutbound > graceful fallback for malformed payloads — non-array tasks [0.24ms] +(pass) fetchUnresolvedOutbound > clamps absurd opts: topN > 50 is capped, limit > 100 is capped [0.17ms] +(pass) fetchUnresolvedOutbound > 二审 — drops rows whose from_node_id does not match ours (server bug defence) [0.25ms] +(pass) fetchUnresolvedOutbound > 二审 — when row has no from_node_id, falls back to from_name match [0.30ms] +(pass) fetchUnresolvedOutbound > 二审 — drops rows with NEITHER from_node_id nor from_name (conservative) [0.36ms] +(pass) fetchUnresolvedOutbound > 二审 — prefers from_node_id over from_name when both present (handles rename correctly) [0.27ms] +(pass) fetchUnresolvedOutbound > 二审 — when WE have no nodeId, identity check uses from_name only [0.24ms] +(pass) buildResumeHint > returns null for an empty list — caller skips the prepend with no noise [0.09ms] +(pass) buildResumeHint > single task is listed with target alias + task id (8-char) + content preview [0.36ms] +(pass) buildResumeHint > hint wording: explicit do-NOT-redispatch instruction in both Chinese phrasing and English keyword [0.17ms] +(pass) buildResumeHint > hint promotes send_message as the legitimate alternative for status check-ins [0.09ms] +(pass) buildResumeHint > hint mentions server-side dedup as a safety net but tells the LLM not to rely on it [0.10ms] +(pass) buildResumeHint > hint avoids to-do framing — would push the LLM into reprocessing [0.10ms] +(pass) buildResumeHint > long content is truncated to 120 chars including ellipsis [0.18ms] +(pass) buildResumeHint > content with triple-backticks is defanged (prevents code-fence injection from resumed task body) [0.07ms] +(pass) buildResumeHint > missing fields fall back gracefully without throwing [0.06ms] +(pass) buildResumeHint > multi-task list preserves order from the input (server-side recency) [0.11ms] + +src/runtime/grok-build-acp/client.test.ts: +(pass) GrokAcpClient > starts the ACP server as `grok agent stdio` without inventing a model flag [102.18ms] +(pass) GrokAcpClient > handles ACP server-to-client fs and permission requests [71.10ms] +(pass) GrokAcpClient > coerces non-integer fs error codes to numeric JSON-RPC codes [65.89ms] +(pass) GrokAcpClient > requestWithIdleTimeout does not fire while agent is streaming notifications [798.73ms] +(pass) GrokAcpClient > requestWithIdleTimeout fires when agent goes silent past threshold [1209.87ms] +(pass) GrokAcpClient > preserves valid integer error codes [73.74ms] + +src/runtime/grok-build-acp/timeout-resolve.test.ts: +(pass) resolveGrokAcpTimeout > env wins over flags and default (mirrors cli.ts precedence) [2.29ms] +(pass) resolveGrokAcpTimeout > flag wins over default when env is unset [0.08ms] +(pass) resolveGrokAcpTimeout > flag string is parsed (config.json values arrive as strings or numbers) [0.06ms] +(pass) resolveGrokAcpTimeout > default fires when neither env nor flag is set [0.10ms] +(pass) resolveGrokAcpTimeout > empty string env is ignored (operator unset the var) [0.06ms] +(pass) resolveGrokAcpTimeout > null and empty flag are ignored — falls through to default [0.10ms] +(pass) resolveGrokAcpTimeout > non-numeric / negative / NaN inputs fall through (the silent-default trap) [0.10ms] + +src/runtime/grok-build-acp/runtime.test.ts: +(pass) runGrokAcpTurn runtime evidence > separates prompt submission from exact prompt-response consumption [92.73ms] + +src/runtime/opencode-copresence/inbox-wiring.test.ts: +(pass) OpenCode copresence CommHub message wiring > work and informational drains are independent lanes [0.56ms] +(pass) OpenCode copresence CommHub message wiring > new_message SSE uses a non-blocking informational lane [0.18ms] +(pass) OpenCode copresence CommHub message wiring > message is displayed as a non-replying TUI notification in the fast drain [0.19ms] +(pass) OpenCode copresence CommHub message wiring > the task drain does not claim OpenCode copresence messages [0.20ms] +(pass) OpenCode copresence CommHub message wiring > network tasks pass their authenticated sender into the shared TUI turn [0.14ms] +(pass) OpenCode copresence CommHub message wiring > startup and SSE reconnect both recover pending informational messages [0.31ms] +(pass) OpenCode copresence CommHub message wiring > runtime startup is single-flight and shutdown waits for an in-flight open [0.15ms] +(pass) OpenCode copresence CommHub message wiring > tmux SIGHUP enters the same cleanup path as SIGTERM [0.45ms] + +src/runtime/opencode-copresence/runtime.test.ts: +(pass) OpenCode native serve+attach copresence > requires an explicit provider/model for production copresence [0.37ms] +(pass) OpenCode native serve+attach copresence > requires an explicit provider/model at the vetted launch seam too [1.60ms] +(pass) OpenCode native serve+attach copresence > wires one token-bound CommHub MCP without reopening local tools [1.93ms] +(pass) OpenCode native serve+attach copresence > uses one authenticated loopback session for FIFO network turns and emits an owner-only attach launcher [250.10ms] +(pass) OpenCode native serve+attach copresence > shows the network sender in both the toast title and message body [174.03ms] +(pass) OpenCode native serve+attach copresence > shows the normalized network-task sender in the shared TUI turn [203.30ms] +(pass) OpenCode native serve+attach copresence > waits for an already-busy human session before injecting a network turn [618.29ms] +(pass) OpenCode native serve+attach copresence > refuses a reply owned by a human turn that won the idle-to-submit race [147.93ms] +(pass) OpenCode native serve+attach copresence > uses OpenCode's ascending message ID shape across sequential network turns [229.37ms] +(pass) OpenCode native serve+attach copresence > does not treat a missing session status and missing session record as idle [481.28ms] +(pass) OpenCode native serve+attach copresence > binds teardown authority to a detached pid, pgrp, and process start ticks [2.14ms] + +src/runtime/codex-app-server/session-manager.test.ts: +(pass) createCodexSessionManager > the production Codex inbox path is wired through the shared holder [1.43ms] +(pass) createCodexSessionManager > concurrent Dashboard handlers share one complete open attempt [1.04ms] +(pass) createCodexSessionManager > a rejected open is cleared and the next row can retry [0.42ms] +(pass) createCodexSessionManager > stopped and explicitly invalidated sessions are never reused [0.28ms] +(pass) createCodexSessionManager > a session that dies during bootstrap is not published [0.19ms] + +src/runtime/codex-app-server/runtime.test.ts: +(pass) buildOwnedAppServerArgs > no opts → bare app-server (codex defaults apply) [0.12ms] +(pass) buildOwnedAppServerArgs > approval_policy only → single -c override before --listen [0.06ms] +(pass) buildOwnedAppServerArgs > sandbox_mode only → single -c override [0.03ms] +(pass) buildOwnedAppServerArgs > auto-approve posture (never + danger-full-access) → both overrides, policy first [0.04ms] +(pass) buildOwnedAppServerArgs > commhubMcpUrl → adds url + bearer-token-env-var -c overrides [0.08ms] +(pass) buildOwnedAppServerArgs > the CommHub bearer TOKEN never appears in argv (only the env-var NAME) [0.15ms] +(pass) buildOwnedAppServerArgs > full production posture (yolo + commhub MCP) → stable order, --listen last [0.13ms] +(pass) recoverSharedTurnOnAttach > invokes persisted active-turn recovery before shared runtime is returned [0.51ms] +(pass) recoverSharedTurnOnAttach > history read failure is visible and never reported as steerable [0.36ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > FIFO admission reports neither submission nor consumption [22.79ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > exact runtime submission and task_started report each level once [0.76ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > exact task activity resets the response idle deadline for a long-running turn [73.26ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > activity from another task cannot keep a silent owned task alive [57.59ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > a started task whose client identity never confirms has a bounded, distinct response timeout [26.43ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > a never-started FIFO task has its own finite, distinct queue deadline [80.77ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > lost task_started after FIFO removal remains finite [80.83ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > a failed start or steer requeued after the queue deadline cannot leave a ghost row [112.58ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > queued wait does not consume the model-response timeout budget [92.62ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > another task starting cannot arm this task's timeout [114.66ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > resolves from authoritative reconciliation when turn/completed is missed [7.53ms] +(pass) codexAppServerThink — terminal-event reconciliation watchdog > forwards the authenticated Dashboard steering decision to the bridge [5.49ms] +(pass) codexAppServerReplyOrThrow > failed bridge outcomes enter processTask's thrown failure path [0.47ms] +(pass) codexAppServerReplyOrThrow > successful empty replies preserve the existing fallback [0.08ms] + + 1281 pass + 0 fail + 4365 expect() calls +Ran 1281 tests across 91 files. [116.25s] +[L0b] every agent-node/tests file, dispatched by kind +tests_dir_executed=6 tests_dir_discovered=6 tests_dir_failed=0 +[L1] witnessed-red: disconnect readable attachment content from runtime +MUTATION_RED readable-attachment-runtime-disconnected rc=1 +RESULT: PASS +``` +## test745 +``` +# test745 — complete agent-network unit domain +source_commit=1e9e75dab635dc03d12636232ebc2ac117c2dee6 +bun=1.3.14 node=v22.23.2 git=git version 2.39.5 uid=1000 +test_files=46 +[L0] full agent-network/src unit suite as non-root +bun test v1.3.14 (0d9b296a) + +src/cli-args.test.ts: +(pass) CLI argument parsing > pins the complete presence-only flag set [0.18ms] +(pass) CLI argument parsing > --accept-dev-channels does not swallow a following positional operand [0.46ms] +(pass) CLI argument parsing > --accept-dev-channels works after a positional operand [0.11ms] +(pass) CLI argument parsing > --dev-open does not swallow a following positional operand [0.02ms] +(pass) CLI argument parsing > --dev-open works after a positional operand [0.02ms] +(pass) CLI argument parsing > --dry-run does not swallow a following positional operand [0.01ms] +(pass) CLI argument parsing > --dry-run works after a positional operand +(pass) CLI argument parsing > --follow does not swallow a following positional operand +(pass) CLI argument parsing > --follow works after a positional operand +(pass) CLI argument parsing > --no-auto-self does not swallow a following positional operand +(pass) CLI argument parsing > --no-auto-self works after a positional operand [0.01ms] +(pass) CLI argument parsing > --no-yolo does not swallow a following positional operand [0.01ms] +(pass) CLI argument parsing > --no-yolo works after a positional operand +(pass) CLI argument parsing > --resume-latest does not swallow a following positional operand +(pass) CLI argument parsing > --resume-latest works after a positional operand +(pass) CLI argument parsing > --self does not swallow a following positional operand [0.02ms] +(pass) CLI argument parsing > --self works after a positional operand +(pass) CLI argument parsing > --f does not swallow a following positional operand [0.02ms] +(pass) CLI argument parsing > --f works after a positional operand +(pass) CLI argument parsing > presence-only flags do not accept an explicit true or false value [0.07ms] +(pass) CLI argument parsing > value flags, repeatable flags, and multiple positionals retain their behavior [0.12ms] +(pass) CLI argument parsing > key=value remains unsupported and is treated as the complete key [0.11ms] + +src/normalize-runtime.test.ts: +(pass) normalizeRuntime — fallback default is claude-agent-sdk (Vincent no-Max) > legacy normalization: unknown string → claude-agent-sdk [0.22ms] +(pass) normalizeRuntime — fallback default is claude-agent-sdk (Vincent no-Max) > empty string → claude-agent-sdk [0.04ms] +(pass) normalizeRuntime — fallback default is claude-agent-sdk (Vincent no-Max) > undefined (no arg) → claude-agent-sdk [0.03ms] +(pass) normalizeRuntime — fallback default is claude-agent-sdk (Vincent no-Max) > undefined profile arg → claude-agent-sdk [0.05ms] +(pass) normalizeRuntime — fallback default is claude-agent-sdk (Vincent no-Max) > profile with missing runtime field → claude-agent-sdk [0.05ms] +(pass) normalizeRuntime — fallback default is claude-agent-sdk (Vincent no-Max) > profile with empty-string runtime field → claude-agent-sdk [0.04ms] +(pass) normalizeRuntimeStrict — execution boundaries fail closed > missing and empty runtime still select the documented default [0.14ms] +(pass) normalizeRuntimeStrict — execution boundaries fail closed > canonical names and supported aliases are accepted [0.06ms] +(pass) normalizeRuntimeStrict — execution boundaries fail closed > a non-empty unknown runtime is rejected [0.25ms] +(pass) normalizeRuntime — explicit choices are preserved > explicit 'claude-code-cli' → claude-code-cli (operator opt-in still works) [0.04ms] +(pass) normalizeRuntime — explicit choices are preserved > explicit 'claude-agent-sdk' → claude-agent-sdk [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > alias 'claude' → claude-agent-sdk (existing canonicalization) [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > alias 'claude-sdk' → claude-agent-sdk [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > alias 'agent-sdk' (string form) → claude-agent-sdk [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > 'codex' / 'codex-sdk' → codex-sdk [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > 'grok' / 'grok-build' / 'grok-build-acp' → grok-build-acp [0.05ms] +(pass) normalizeRuntime — explicit choices are preserved > explicit Grok co-presence names → grok-build-cli [0.06ms] +(pass) normalizeRuntime — explicit choices are preserved > explicit 'opencode-cli' → opencode-cli (canonical launcher name) [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > alias 'opencode' → opencode-cli (short form) [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > profile with runtime='opencode-cli' → opencode-cli [0.05ms] +(pass) normalizeRuntime — explicit choices are preserved > profile with runtime='opencode' → opencode-cli [0.04ms] +(pass) normalizeRuntime — explicit choices are preserved > explicit 'codex-app-server' → codex-app-server [0.02ms] +(pass) normalizeRuntime — explicit choices are preserved > alias 'codex-tui' → codex-app-server [0.25ms] +(pass) normalizeRuntime — explicit choices are preserved > alias 'codex-appserver' → codex-app-server [0.04ms] +(pass) normalizeRuntime — explicit choices are preserved > 'codex-sdk' still → codex-sdk (not shadowed by the app-server branch) [0.05ms] +(pass) normalizeRuntime — explicit choices are preserved > 'codex' still → codex-sdk (legacy short alias unchanged) [0.03ms] +(pass) normalizeRuntime — explicit choices are preserved > profile with runtime='codex-app-server' → codex-app-server [0.04ms] +(pass) normalizeRuntime — profile object paths > profile with runtime='claude-code-cli' → claude-code-cli (explicit, preserved) [0.03ms] +(pass) normalizeRuntime — profile object paths > profile with runtime='agent-sdk' + codexRuntime='codex' → codex-sdk (legacy hybrid) [0.04ms] +(pass) normalizeRuntime — profile object paths > profile with runtime='agent-sdk' + no codexRuntime → claude-agent-sdk [0.03ms] +(pass) normalizeRuntime — profile object paths > legacy profile normalization keeps unknown → default for display/migration [4.15ms] + +src/batch-workdir-wiring.test.ts: +(pass) batch workdir wiring > normalizes create workdir before mkdir or chdir [0.86ms] +(pass) batch workdir wiring > normalizes cleanup workdir before filesystem mutation [0.36ms] + +src/top-level-help-contract.test.ts: +(pass) top-level help matches the implemented command parsers > advertises only the implemented config and batch shapes [289.50ms] +(pass) top-level help matches the implemented command parsers > includes the provider required by opencode auth-login [219.84ms] + +src/opencode-pin.test.ts: +(pass) opencode-pin — built-in fallback > release builtin pin is the revalidated opencode-ai@1.18.1 [0.29ms] +(pass) opencode-pin — built-in fallback > returns the built-in constant when no override file exists [0.57ms] +(pass) opencode-pin — built-in fallback > missing/untrusted package hint preserves detail and exact install command [0.27ms] +(pass) opencode-pin — override file write + read round-trip > a smoke marker for the exact release pin is recognized [1.36ms] +(pass) opencode-pin — override file write + read round-trip > a locally-smoked different version cannot override the release pin [0.43ms] +(pass) opencode-pin — validation refuses malformed / unvalidated overrides > hand-edited file with version but NO smokePassedAt → falls back to built-in [0.36ms] +(pass) opencode-pin — validation refuses malformed / unvalidated overrides > version string doesn't match semver → falls back to built-in [0.34ms] +(pass) opencode-pin — validation refuses malformed / unvalidated overrides > smokePassedAt not an ISO timestamp → falls back to built-in [0.34ms] +(pass) opencode-pin — validation refuses malformed / unvalidated overrides > malformed JSON → falls back to built-in without throwing [0.95ms] + +src/tmux-attach.test.ts: +(pass) tmux attach resolution > parses opaque IDs and Unicode names [0.81ms] +(pass) tmux attach resolution > selects the exact TUI instead of prefix siblings [0.21ms] +(pass) tmux attach resolution > does not fall back to a bridge or node session [0.06ms] + +src/owner-env-file.test.ts: +(pass) loadOwnerOnlyEnvFile > loads the isolated commhub credential without overriding explicit identity [1.16ms] +(pass) loadOwnerOnlyEnvFile > rejects relative, broad-mode, and symlinked credential files [0.77ms] + +src/opencode-owner-mode.test.ts: +(pass) OpenCode owner/mode policy > accepts umask-0002 modes only for a non-root uid=gid layout [0.12ms] +(pass) OpenCode owner/mode policy > always rejects world write and keeps root/foreign ownership strict [0.08ms] + +src/channel-attachments.test.ts: +(pass) Claude channel attachments > pins the readable extension allowlist as an exact value set [0.57ms] +(pass) Claude channel attachments > cache roots are alias-isolated even for path-shaped aliases [0.41ms] +(pass) Claude channel attachments > downloads an authenticated Dashboard PNG and surfaces an owner-local Read path [4.77ms] +(pass) Claude channel attachments > downloads an authenticated non-image file for the Read-capable channel [2.53ms] +(pass) Claude channel attachments > does not fetch or inject a non-allowlisted file type [0.26ms] +(pass) Claude channel attachments > download failure preserves the original text and exposes no token [0.72ms] +(pass) Claude channel attachments > rejects traversal-shaped file ids before any fetch [0.33ms] +(pass) Claude channel attachments > does not trust a sender-provided local path [0.66ms] + +src/codex-model-default.test.ts: +(pass) Codex model defaults > all Codex creation runtime spellings use the supported default [0.13ms] +(pass) Codex model defaults > shared Codex choice catalog has one supported default [0.12ms] + +src/copresence-identity.test.ts: +(pass) Test 1: UUID round-trip > writeMarker persists exactly the provided uuid (single source of truth) [4.48ms] +(pass) Test 1: UUID round-trip > writeMarker refuses empty uuid (guard against silent regeneration) [0.42ms] +(pass) Test 1: UUID round-trip > writeMarker refuses non-string uuid [0.32ms] +(pass) Test 2: enumeration failure is loud (fail-closed) > verifyGroupHomogeneity fails-closed when listAllPids throws [1.10ms] +(pass) Test 2: enumeration failure is loud (fail-closed) > verifyGroupHomogeneity fails-closed when a member's environ read throws [0.65ms] +(pass) Test 2: enumeration failure is loud (fail-closed) > verifyGroupHomogeneity fails-closed when a stat read throws [0.31ms] +(pass) Test 3: foreign member in PGID → SKIP > group with unmarked co-resident refuses homogeneity [0.27ms] +(pass) Test 3: foreign member in PGID → SKIP > group where every member carries the marker is ok [0.24ms] +(pass) Test 4: main-dead-child-alive (environ scan is authority) > scan finds workers even when marker's stored pids are gone [0.80ms] +(pass) Test 5: child setsid → new PGID > detached child grouped under its current pgid, not marker's stored pgid [0.37ms] +(pass) Test 6: PID-reuse defense is the boot_id + environ-scan invariant > environ scan only returns pids whose current environ carries the uuid [0.27ms] +(pass) Test 7: partial-start rollback (marker gate) > MISSING marker after partial start prevents any process action [0.38ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > null body → SCHEMA_INVALID (no TypeError from `in` operator) [0.53ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > bare number → SCHEMA_INVALID [0.42ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > empty array → SCHEMA_INVALID [0.60ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > empty object → SCHEMA_INVALID (missing required fields) [0.60ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > wrong types in schema → SCHEMA_INVALID [0.59ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > syntactically invalid JSON → PARSE_ERROR [0.50ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > wrong mode → WRONG_MODE (even with valid JSON) [0.52ms] +(pass) Test 8: malformed marker → structured refuse (never throws) > symlink → SYMLINK (refuses to follow) [0.57ms] +(pass) Test 8b: filesystem/environment refuse guards (mutation-sensitive) > NOT_REGULAR: directory at marker path with mode 0600 (skips SYMLINK+WRONG_MODE) [0.49ms] +(pass) Test 8b: filesystem/environment refuse guards (mutation-sensitive) > OWNER_MISMATCH: valid marker file whose lstat.uid differs from process.getuid() (SECURITY CRITICAL) [3.00ms] +(pass) Test 8b: filesystem/environment refuse guards (mutation-sensitive) > STALE_BOOT_ID: valid schema but boot_id differs from current /proc boot_id [0.95ms] +(pass) Test 9: self-context refuses stop from within the tree > caller's own environ carrying the marker is detected [0.38ms] +(pass) Test 9: self-context refuses stop from within the tree > ancestor carrying the marker is detected via PPID walk [0.31ms] +(pass) Test 9: self-context refuses stop from within the tree > clean caller (no marker in ancestry) returns self=false [0.26ms] +(pass) Test 10: non-copresence codex-app-server → legacy path (zero diff) > readMarker returns MISSING for an ordinary codex-app-server node dir [0.64ms] +(pass) Test 11: 二次 stop is idempotent (MISSING = already stopped) > 2nd read after successful removeMarker returns MISSING (no side effects) [2.68ms] +(pass) Test 11: 二次 stop is idempotent (MISSING = already stopped) > removeMarker on already-missing marker does not throw [0.36ms] +(pass) reapMarkerGroups: end-to-end (mocked /proc + kill) > verified groups get SIGTERM, still-alive groups then get SIGKILL [6.58ms] +(pass) reapMarkerGroups: end-to-end (mocked /proc + kill) > groups with foreign members are SKIPPED, never signaled [2.38ms] +(pass) reapMarkerGroups: end-to-end (mocked /proc + kill) > no marker-carrying pids anywhere → immediate success (idempotent) [0.47ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > other-user EACCES on environ → skip that pid (expected, not fail) [0.65ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > Defect A defense: own-uid EACCES pid IN SCOPE (anchored) → reap refuses to delete marker [0.97ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > Blocker 1: own-uid EACCES pid OUT OF SCOPE → informational only, teardown still succeeds [1.25ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > Blocker 1: unreadable pid sharing a marker carrier's PGROUP is in scope (no anchors needed) [0.42ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > Blocker 8/invariant 5: an anchor whose starttime no longer matches is REJECTED (pid reuse) [0.39ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > Blocker 7: post-kill RESCAN unreadable half also preserves the marker [0.76ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > zombie process environ EACCES → skip (mm freed, expected) [0.45ms] +(pass) Blocker 1: scanEnvironForMarker EACCES discrimination > EACCES-carrying process that vanishes during discrimination → skip [0.40ms] +(pass) Blocker 2: verifyGroupHomogeneity zombie discrimination + EMPTY_GROUP > group containing a zombie same-uid member still verifies OK for the live marker members [0.41ms] +(pass) Blocker 2: verifyGroupHomogeneity zombie discrimination + EMPTY_GROUP > group containing an other-user EACCES member still verifies OK for our members [0.34ms] +(pass) Blocker 2: verifyGroupHomogeneity zombie discrimination + EMPTY_GROUP > empty group (no live marker members) → EMPTY_GROUP refuse (never ok:true) [0.25ms] +(pass) Blocker 2: verifyGroupHomogeneity zombie discrimination + EMPTY_GROUP > own-uid non-zombie unreadable → ENUM_ERROR (fail-closed) [0.23ms] +(pass) Finding #2: killPgroup pgid<=0 guard > realKiller().killPgroup(0, TERM) throws — kill(-0) would target caller's own pgroup [0.39ms] +(pass) Finding #2: killPgroup pgid<=0 guard > realKiller().pgroupAlive(0) throws [0.34ms] +(pass) Finding #3: reapMarkerGroups uses async sleep (not busy-wait) > grace period is truly asynchronous — event loop ticks during it [103.67ms] +(pass) Finding #3: reapMarkerGroups uses async sleep (not busy-wait) > injected sleep function is used (tests can override with fast/deterministic version) [1.32ms] +(pass) Finding #7: readMarker PLATFORM_UNSUPPORTED on non-Linux > on non-Linux, readMarker refuses cleanly regardless of on-disk state [3.27ms] +(pass) Finding #4: writeMarker accepts partial sessions object > writeMarker with only appsrv session succeeds and readMarker returns ok [3.95ms] +(pass) Finding #4: writeMarker accepts partial sessions object > writeMarker with empty sessions object still succeeds (uuid is what matters) [3.80ms] +(pass) Blocker 3: verifyGroupHomogeneity stat-unreadable pids are bounded by ownership > an unrelated OTHER-uid pid whose stat is unreadable does NOT poison the group [0.47ms] +(pass) Blocker 3: verifyGroupHomogeneity stat-unreadable pids are bounded by ownership > a pid hidden so thoroughly that even its uid is unknown does NOT poison the group [0.43ms] +(pass) Blocker 3: verifyGroupHomogeneity stat-unreadable pids are bounded by ownership > an OWN-uid pid whose stat is unreadable still fails closed (we cannot rule out membership) [0.31ms] +(pass) Blocker 4: readMarker checks MISSING before PLATFORM_UNSUPPORTED > non-Linux + NO marker file → MISSING (silent legacy fall-through, no scary warning) [0.44ms] +(pass) Blocker 4: readMarker checks MISSING before PLATFORM_UNSUPPORTED > non-Linux + marker file present → PLATFORM_UNSUPPORTED (we genuinely cannot act on it) [2.61ms] +(pass) Blockers 5+6: prepareIdentityForStart > no marker on disk → writes the new marker, reaps nothing [1.35ms] +(pass) Blockers 5+6: prepareIdentityForStart > Blocker 6: a PRESERVED marker is reaped by its OWN uuid before the new one is written [0.88ms] +(pass) Blockers 5+6: prepareIdentityForStart > Blocker 6: if the old generation cannot be reaped, start is BLOCKED and nothing is overwritten [0.54ms] +(pass) Blockers 5+6: prepareIdentityForStart > a marker from a previous BOOT is discarded without a reap (its pids cannot exist) [0.51ms] +(pass) Blockers 5+6: prepareIdentityForStart > an unreadable/suspicious marker BLOCKS start rather than overwriting it [0.68ms] +(pass) Blockers 5+6: prepareIdentityForStart > Blocker 5: the marker is written with an EMPTY sessions object (before any session exists) [0.38ms] +(pass) Blockers 5+6: prepareIdentityForStart > refuses an empty uuid (guards against a silently regenerated identity) [0.51ms] + +src/claude-vendor-env-wiring.test.ts: +(pass) node create captures vendor shell env before profile construction [0.25ms] +(pass) every dotenv-writing create preflights before any node-state side effect [0.24ms] +(pass) the dotenv writer itself reuses the side-effect-free planner [0.17ms] + +src/copresence-cli-wiring.test.ts: +(pass) cli.ts copresence start ordering (structural gate) > the copresence start path really does create tmux sessions with -e (anchor for the tests below) [0.06ms] +(pass) cli.ts copresence start ordering (structural gate) > Blocker 5: prepareIdentityForStart runs BEFORE the first tmux new-session [0.05ms] +(pass) cli.ts copresence start ordering (structural gate) > Blocker 5: no marker write happens before the identity preparation call [0.07ms] +(pass) cli.ts copresence start ordering (structural gate) > Blocker 6: a blocked preparation aborts the start (never falls through to session creation) [0.07ms] +(pass) cli.ts copresence start ordering (structural gate) > Blocker 12: the tmux capability preflight runs BEFORE the first tmux new-session [0.04ms] +(pass) cli.ts copresence stop wiring (structural gate) > Blockers 1+2: the stop-time reap is given the marker's recorded pids as scope anchors [0.21ms] +(pass) cli.ts copresence stop wiring (structural gate) > marker removal happens only on a successful reap [0.24ms] + +src/grok-copresence-disclosure.test.ts: +(pass) grok co-presence disclosure > default profile reports the exact three tools and no web [0.18ms] +(pass) grok co-presence disclosure > WebSearch profile reports general web_search without widening other tools [0.10ms] +(pass) grok co-presence disclosure > near-match tools are disclosed as invalid rather than a reviewed profile [0.14ms] +(pass) grok co-presence disclosure > resume warns that a changed config cannot mutate the existing session [0.08ms] + +src/opencode-agent-node-pair.test.ts: +(pass) OpenCode agent-node release pairing > pins the exact versions being released together [0.09ms] +(pass) OpenCode agent-node release pairing > rejects latest 2.4.x-style help and accepts the RFC-029 capability [0.08ms] +(pass) OpenCode agent-node release pairing > admits only the exact preview package identity with safe file modes [9.44ms] +(pass) OpenCode agent-node release pairing > skips an exact project-local impersonator and selects the later global package [6.25ms] + +src/batch-workdir.test.ts: +(pass) normalizeBatchWorkdir > expands current-user tilde before a batch changes cwd [0.25ms] +(pass) normalizeBatchWorkdir > anchors a relative workdir once to the caller cwd [0.07ms] +(pass) normalizeBatchWorkdir > keeps an absolute workdir absolute [0.04ms] +(pass) normalizeBatchWorkdir > rejects empty and unsupported named-user shorthands [0.24ms] + +src/copresence-identity.real.test.ts: +(pass) REAL /proc integration (Linux only) > A: scan on real /proc with a nonce uuid does not throw and finds nothing [1.35ms] +(pass) REAL /proc integration (Linux only) > B: live marker member + REAL zombie sibling in the same pgroup → homogeneity ok:true (escalation stays possible) [118.74ms] +(pass) REAL /proc integration (Linux only) > C: readEnviron(1) EACCESes and readOwnerUid(1) is root (non-root only) [0.28ms] +(pass) REAL /proc integration (Linux only) > D: POSITIVE — spawned marker carrier is found by the scan [28.34ms] +(pass) REAL /proc integration (Linux only) > E: END-TO-END — scan → group → homogeneity all succeed on real /proc [30.82ms] +(pass) REAL /proc integration (Linux only) > F: REAL REAP — reapMarkerGroups(realEnumerator, realKiller) kills a real carrier and returns success [378.32ms] +(pass) REAL /proc integration (Linux only) > G: CLEAN-HOST REAP — nothing carries the uuid → success on THIS host (blocker 1 regression) [1.21ms] +(pass) REAL /proc integration (Linux only) > H: NON-DUMPABLE — marker-carrying non-dumpable child of a carrier is accounted for, not dropped (blocker 2) [97.31ms] +(pass) REAL /proc integration (Linux only) > I: readOwnerUid reports the REAL uid of a non-dumpable process (environ inode owner lies) [52.27ms] +(pass) REAL /proc integration (Linux only) > J: REAL START SEAM — prepareIdentityForStart reclaims a live previous generation and installs the new marker [342.30ms] +(pass) REAL /proc integration (Linux only) > K: REAL START SEAM — a previous generation that cannot be reaped BLOCKS the start and its marker survives [92.22ms] +(pass) REAL /proc integration (Linux only) > L: anchorsFromMarker feeds real recorded pane pids into the scope test [28.44ms] + +src/claude-code-cli-tty-preflight.test.ts: +(pass) claude-code-cli spawn preflight (#486 P0 regression gate) > body contains the claude-code-cli spawn (anchor for the assertions below) [0.10ms] +(pass) claude-code-cli spawn preflight (#486 P0 regression gate) > Refuse: non-TTY stdin preflight fires BEFORE the claude spawn [0.11ms] +(pass) claude-code-cli spawn preflight (#486 P0 regression gate) > Refuse: non-TTY branch exits non-zero [0.23ms] +(pass) claude-code-cli spawn preflight (#486 P0 regression gate) > Refuse: message names claude-code-cli and recommends --accept-dev-channels first (--tmux listed with its precondition) [0.25ms] +(pass) claude-code-cli spawn preflight (#486 P0 regression gate) > Success gate: 'session pinned' / 'session saved' only fires on exit code 0 [0.22ms] +(pass) claude-code-cli spawn preflight (#486 P0 regression gate) > Exit-code propagation: non-zero child exit calls process.exit(code) [0.16ms] +(pass) claude-code-cli spawn preflight (#486 P0 regression gate) > Spawn-error path: child.on('error') exits non-zero (was silent → false success) [0.10ms] +(pass) --tmux escape-hatch headless (#486 CR regression gate) > body contains the --tmux branch (anchor) [0.06ms] +(pass) --tmux escape-hatch headless (#486 CR regression gate) > --tmux branch has a headless (no-TTY) codepath (`new-session -d`) [0.11ms] +(pass) --tmux escape-hatch headless (#486 CR regression gate) > --tmux headless: does NOT inherit stdin on detached spawn (was `stdio:"inherit"`) [0.13ms] +(pass) --tmux escape-hatch headless (#486 CR regression gate) > --tmux headless: verifies session liveness after detached spawn [0.16ms] +(pass) --tmux escape-hatch headless (#486 CR regression gate) > --tmux headless: propagates non-zero exit on failure paths [0.10ms] +(pass) --tmux escape-hatch headless (#486 CR regression gate) > --tmux headless: prints attach hint after successful startup [0.08ms] + +src/dashboard-managed-process.test.ts: +(pass) managed Dashboard listener decisions > empty port starts; same healthy managed release remains untouched [0.48ms] +(pass) managed Dashboard listener decisions > only an exact managed stale npx listener may be terminated [0.11ms] +(pass) managed Dashboard listener decisions > unmanaged, ambiguous, reused, foreign, and global listeners fail closed [0.25ms] +(pass) record parser and command identity reject malformed state [0.19ms] + +src/token-cli.test.ts: +(pass) parseTokenCreateName > keeps the legacy positional form [0.19ms] +(pass) parseTokenCreateName > accepts separated and equals --name forms [0.08ms] +(pass) parseTokenCreateName > fails closed for missing, empty, unknown, mixed, or extra operands [0.17ms] + +src/cli-args-wiring.test.ts: +(pass) CLI option and positional parsing share cli-args.ts [3.19ms] + +src/private-state.test.ts: +(pass) #472 private state writer > publishes 0600 files and 0700 parent under umask 0 [4.29ms] +(pass) #472 private state writer > publishes 0600 files and 0700 parent under umask 2 [3.41ms] +(pass) #472 private state writer > publishes 0600 files and 0700 parent under umask 22 [3.21ms] +(pass) #472 private state writer > publishes 0600 files and 0700 parent under umask 77 [3.13ms] +(pass) #472 private state writer > atomically replaces a legacy 0664 target with a 0600 inode [4.18ms] +(pass) #472 private state writer > replaces a leaf symlink instead of writing through it [4.29ms] +(pass) #472 private state writer > repairs a legacy file and parent before reading [0.68ms] +(pass) #472 private state writer > read repair refuses a symlink instead of chmod-following it [0.82ms] + +src/tmux-capability.test.ts: +(pass) parseTmuxVersion > parses the shapes real tmux builds print [0.49ms] +(pass) parseTmuxVersion > returns null when there is no version to find [0.08ms] +(pass) tmuxSupportsSessionEnv > 3.2 is the floor; the letter suffix is a patch marker and never lifts a version over it [0.14ms] +(pass) tmuxSupportsSessionEnv > major version dominates the minor comparison [0.07ms] +(pass) checkTmuxCapability > too old → actionable verdict naming the required version [0.30ms] +(pass) checkTmuxCapability > tmux absent → missing verdict, not a crash [0.22ms] +(pass) checkTmuxCapability > unparseable version → unknown (permissive: never refuse a tmux that may be fine) [0.13ms] +(pass) checkTmuxCapability > modern tmux → ok [0.08ms] +(pass) assertTmuxSupportsSessionEnv (cli wrapper) > old tmux aborts the start with an explanation [0.40ms] +(pass) assertTmuxSupportsSessionEnv (cli wrapper) > missing tmux aborts the start [0.14ms] +(pass) assertTmuxSupportsSessionEnv (cli wrapper) > modern tmux is silent and does not abort [0.07ms] +(pass) assertTmuxSupportsSessionEnv (cli wrapper) > unknown version warns but does NOT abort [0.12ms] + +src/opencode-launch-env.test.ts: +(pass) hardenOpencodeAgentNodeEnv > restores launcher PATH and strips every pre-entrypoint loader hook [0.56ms] +(pass) hardenOpencodeAgentNodeEnv > does not mutate the caller's env object [0.18ms] +(pass) hardenOpencodeAgentNodeEnv > strips case-variant loader and PATH keys for Windows semantics [0.13ms] + +src/secret-shell-guidance.test.ts: +(pass) #379 secret shell guidance > keeps the existing POSIX export form [0.23ms] +(pass) #379 secret shell guidance > uses PowerShell syntax and quote escaping on Windows [0.07ms] + +src/opencode-auth-login.test.ts: +(pass) OpenCode manual auth-login sandbox > builds deterministic provider-specific API-key login argv [0.52ms] +(pass) OpenCode manual auth-login sandbox > uses a fresh all-XDG tree and strips ambient credentials/config hooks [30.14ms] +(pass) OpenCode manual auth-login sandbox > strictly consumes only the selected provider API record through a private leaf [20.79ms] +(pass) OpenCode manual auth-login sandbox > refuses OAuth, mixed-provider and symlink auth shapes without disclosing secrets [23.01ms] +(pass) OpenCode manual auth-login sandbox > persistent planted DB/log links are never exposed and cleanup never follows descendant links [23.81ms] +(pass) OpenCode manual auth-login sandbox > cleanup unlinks a swapped root symlink but never removes its outside target [18.38ms] +(pass) OpenCode manual auth-login sandbox > cleanup quarantines the tracked inode but leaves a regular root-name replacement untouched [18.67ms] +(pass) OpenCode manual auth-login sandbox > a live tracked root whose literal name ends in deleted is still removed [15.68ms] +(pass) OpenCode manual auth-login sandbox > Linux reports nlink zero for a removed directory retained by fd [9.12ms] +(pass) OpenCode manual auth-login sandbox > cleanup retains inode ownership after bounded failure and succeeds on retry [22.50ms] +(pass) OpenCode manual auth-login sandbox > refuses a concurrent live owner marker [18.22ms] +(pass) OpenCode manual auth-login sandbox > refuses a provider that does not match the node's unique configured preset [12.61ms] +(pass) OpenCode manual auth-login sandbox > prunes a dead owner's stale root without following its planted links [35.52ms] +(pass) OpenCode manual auth-login sandbox > PID reuse does not retain a stale credential root [29.44ms] +(pass) OpenCode manual auth-login sandbox > stale sweep resumes a crash-left quarantine while its owner marker remains [28.32ms] +(pass) OpenCode manual auth-login sandbox > stale sweep removes an empty quarantine left after marker-last deletion [15.26ms] +(pass) OpenCode manual auth-login sandbox > spawn-time revalidation rejects a hostile ancestor discovery candidate [15.70ms] +(pass) OpenCode manual auth-login sandbox > with helper always cleans the fresh root when the action throws [15.04ms] + +src/node-start-help.test.ts: +(pass) #518 node start help exposes the recommended headless flag > real `anet node start --help` names --accept-dev-channels and its operating boundary [169.49ms] +(pass) #518 node start help exposes the recommended headless flag > asking for help performs no node-start work [162.59ms] + +src/claude-code-cli-dependency-preflight.test.ts: +(pass) #485 claude-code-cli dependency preflight > create remains a warning while start fails closed [0.15ms] +(pass) #485 claude-code-cli dependency preflight > dependency refusal runs before launch side effects [0.07ms] + +src/bootstrap-password-db.test.ts: +(pass) bootstrap password database binding > turns the local default into an explicit absolute path [0.64ms] +(pass) bootstrap password database binding > anchors a relative COMMHUB_DB to the hub launch cwd [0.19ms] +(pass) bootstrap password database binding > rejects an unusable default before opening a database [0.26ms] +(pass) bootstrap password database binding > does not invent a SQLite target for a PostgreSQL Hub [0.30ms] +(pass) bootstrap password database binding > updates only the explicitly resolved database, never ambient HOME [70.26ms] +(pass) bootstrap password database binding > child refuses a missing explicit path without falling back to HOME [44.90ms] + +src/gitignore-writeback.test.ts: +(pass) ensureGitignoreRule — file does not exist > creates file with the rule + trailing newline [2.81ms] +(pass) ensureGitignoreRule — file does not exist > trims surrounding whitespace from the rule before writing [0.58ms] +(pass) ensureGitignoreRule — file exists, rule absent > appends rule and reports 'appended' [0.77ms] +(pass) ensureGitignoreRule — file exists, rule absent > adds missing trailing newline before appending [0.87ms] +(pass) ensureGitignoreRule — file exists, rule absent > empty file → appended, not created [0.59ms] +(pass) ensureGitignoreRule — rule already present (idempotent) > exact match returns already-present + does not modify file [0.40ms] +(pass) ensureGitignoreRule — rule already present (idempotent) > trimmed match (rule with surrounding whitespace) treats as present [0.35ms] +(pass) ensureGitignoreRule — rule already present (idempotent) > commented-out rule does NOT count as present [0.33ms] +(pass) ensureGitignoreRule — rule already present (idempotent) > multiple invocations are idempotent (call 3 times) [0.40ms] +(pass) ensureGitignoreRule — multiple distinct rules don't collide > two different rules go to two different lines [0.36ms] +(pass) ensureGitignoreRule — multiple distinct rules don't collide > similar-but-different rules don't false-match (`.anet/` vs `.anet/foo`) [0.39ms] +(pass) ensureGitignoreRules — batch > empty rules list is a no-op [0.27ms] +(pass) ensureGitignoreRules — batch > creates file with all rules on first call [0.42ms] +(pass) ensureGitignoreRules — batch > second batch call is fully idempotent [0.40ms] +(pass) ensureGitignoreRules — batch > partial overlap — only new rules appended [0.45ms] +(pass) ensureGitignoreRule — defensive > empty rule throws [0.32ms] +(pass) ensureGitignoreRule — defensive > whitespace-only rule throws [0.22ms] + +src/secret-shell-guidance-wiring.test.ts: +(pass) #379 create and migrate both use platform-aware secret guidance [5.30ms] + +src/opencode-runtime-binding.test.ts: +(pass) external OpenCode runtime binding > survives regular config runtime downgrade and proves the original exact runtime [11.48ms] +(pass) external OpenCode runtime binding > read returns undefined only for absent state and deterministic keys separate nodes [9.32ms] +(pass) external OpenCode runtime binding > an absent exact leaf does not impose POSIX modes on ordinary runtime state [1.67ms] +(pass) external OpenCode runtime binding > unbound legacy symlink or junction-style node paths remain invisible [7.21ms] +(pass) external OpenCode runtime binding > Windows synthetic permission bits do not disable structural security checks [0.85ms] +(pass) external OpenCode runtime binding > secure removal is idempotent and removes the exact binding [6.77ms] +(pass) external OpenCode runtime binding > secure removal refuses tampered content without unlinking it [5.87ms] +(pass) external OpenCode runtime binding > rejects binding-directory and leaf symlinks [6.03ms] +(pass) external OpenCode runtime binding > rejects dangling binding-root and exact-leaf symlinks [3.54ms] +(pass) external OpenCode runtime binding > rejects permissive modes, hard links, and foreign ownership [6.12ms] +(pass) external OpenCode runtime binding > rejects private but tampered runtime, identity, and extra fields [6.68ms] +(pass) external OpenCode runtime binding > rejects binding roots that overlap the canonical project in either direction [4.03ms] +(pass) external OpenCode runtime binding > a symlinked node workDir cannot remove another project's binding [6.46ms] +(pass) assertOpencodeNodeStateUntracked > allows ordinary non-Git projects [1.42ms] +(pass) assertOpencodeNodeStateUntracked > allows ordinary untracked projects inside a Git worktree checkout [54.44ms] +(pass) assertOpencodeNodeStateUntracked > rejects forged Git worktree file markers [1.42ms] +(pass) assertOpencodeNodeStateUntracked > allows ignored/untracked state but rejects git add -f tracked state [17.46ms] +(pass) assertOpencodeNodeStateUntracked > rejects a force-added dotenv or any tracked file below the node directory [24.25ms] + +src/client.test.ts: +(pass) CommHub.reply calls send_reply MCP tool [4.03ms] + +src/supervise-child.test.ts: +(pass) superviseChild — shutdown gate stops the loop > shutdownGate=true from the start → runOnce never called [0.81ms] +(pass) superviseChild — shutdown gate stops the loop > shutdownGate flips true after first iteration → exactly one runOnce [0.26ms] +(pass) superviseChild — backoff growth + cap > waits double the delay each iteration, capping at maxDelayMs [13.43ms] +(pass) superviseChild — runOnce that returns WITHOUT markStable is treated as failed (regression pin) > runOnce that returns cleanly without markStable → backoff doubles [16.04ms] +(pass) superviseChild — markStable resets backoff > after iteration that calls markStable, next wait is baseDelayMs again [16.01ms] +(pass) superviseChild — markStable resets backoff > markStable called multiple times in one iteration is idempotent [15.99ms] +(pass) superviseChild — abandonAfterMs > calls onAbandon and returns after cumulative downtime exceeds threshold [16.08ms] +(pass) superviseChild — abandonAfterMs > markStable in any iteration resets downtime — abandon never fires [16.02ms] +(pass) superviseChild — runOnce error handling > runOnce throws → onError fires, loop continues [16.02ms] +(pass) superviseChild — runOnce error handling > runOnce throws AND shutdownGate goes true → loop exits, no further iteration [2.62ms] +(pass) superviseChild — jitter range > jitterRatio=0.25 + random=0 → -25% of delay (lower bound) [13.37ms] +(pass) superviseChild — jitter range > jitterRatio=0.25 + random=1 → +25% of delay (upper bound) [16.02ms] +(pass) superviseChild — jitter range > jitterRatio=0 → deterministic waits at exact delay [16.03ms] +(pass) superviseChild — jitter range > waitMs floor 100 enforces minimum wait even with tiny base + negative jitter [16.01ms] +(pass) superviseChild — defensive contract > returns (does not throw) when runOnce never resolves and shutdown flips [2.82ms] + +src/claude-vendor-env.test.ts: +(pass) collectClaudeVendorEnvForCreate > captures known vendor endpoint and credential for claude-agent-sdk [0.43ms] +(pass) collectClaudeVendorEnvForCreate > explicit --env value wins without duplicate capture [0.18ms] +(pass) collectClaudeVendorEnvForCreate > does not capture vendor variables for another runtime [0.05ms] +(pass) collectClaudeVendorEnvForCreate > rejects line-oriented dotenv injection [0.20ms] +(pass) collectClaudeVendorEnvForCreate > rejects line breaks in explicit --env for every runtime [0.16ms] +(pass) planPlainSecretEnvRewrites > plans the exact dotenv assignment without mutating the profile [0.32ms] +(pass) planPlainSecretEnvRewrites > rejects a secret dotenv value with CRLF before any caller mutation [0.20ms] + +src/locale-diagnostic-wiring.test.ts: +(pass) #68 doctor reports the pure locale diagnostic as a warning [3.84ms] + +src/primary-network.test.ts: +(pass) resolvePrimaryNetwork > uses current_network even when the network list is reversed and renamed [0.64ms] +(pass) resolvePrimaryNetwork > fails explicitly when current_network is missing instead of guessing networks[0] [0.43ms] +(pass) resolvePrimaryNetwork > turns transport and HTTP failures into explicit resolution errors [0.36ms] +(pass) debate, demo-social, and pr-review all use the shared resolver [2.29ms] + +src/opencode-preset.test.ts: +(pass) OPENCODE_PRESETS registry > exports the two blessed presets (anthropic + openai) [0.13ms] +(pass) OPENCODE_PRESETS registry > findOpencodePreset('anthropic') returns the record; unknown returns null [0.06ms] +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns the trimmed key when the env var is set [0.15ms] +(pass) readPresetKeyFromEnv — env-only, no interactive prompt > returns null when the env var is missing / empty [0.08ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > body shape matches opencode auth.json convention [0.55ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes to /.local/share/opencode/auth.json with mode 0o600 [6.47ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writeOpencodeConfigJson lands under .config/opencode with 0o600 [5.70ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > keyless create atomically clears a private pre-planted auth file [7.24ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > default tool policy disables filesystem, shell, task, and skill tools [0.43ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > writes only blessed provider identity and strips all pre-planted routing/executable config [5.80ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > atomically replaces a private but invalid pre-planted config without parsing it [7.78ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects symlink escapes in workDir, config/data ancestors, and final targets [6.92ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > validates the full tree before mutation so a bad auth side cannot partially rewrite config [1.08ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > rejects permissive modes and foreign owners without chmod-follow repair [2.85ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > prepares .anet/nodes/node before profile secrets and provides atomic private leaf I/O [28.92ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > accepts an ordinary 0775 project root for a non-root uid=gid private group [3.24ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects .anet/nodes/node and config/.env symlink chains before secret writes [7.16ms] +(pass) buildAuthJsonBody + writeOpencodeAuthJson > profile preflight rejects writable ancestors and non-private node roots [1.78ms] + +src/opencode-smoke-env.test.ts: +(pass) buildOpencodeSmokeEnv > locks the exact hardened ancestor candidate set [2.59ms] +(pass) buildOpencodeSmokeEnv > rejects sticky world-writable /tmp instead of silently degrading [0.51ms] +(pass) buildOpencodeSmokeEnv > inherits only transport/locale trust settings and controls all OpenCode roots [0.75ms] +(pass) buildOpencodeSmokeEnv > every writable root can be precreated private, including XDG_RUNTIME_DIR [1.04ms] + +src/grok-attach-client.test.ts: +(pass) validateGrokAttachSocket rejects symlinks, non-sockets, and foreign owners [2.76ms] +(pass) connectGrokAttach bridges base64 terminal I/O, status, resize, and detach [8.40ms] +(pass) connectGrokAttach splits large input so every NDJSON frame stays bounded [1.16ms] +(pass) connectGrokAttach fails closed on an invalid handshake and oversized frame [1.22ms] +(pass) a single-client rejection before hello preserves the server error [0.61ms] +(pass) hello followed by a fatal frame in the same chunk cannot return a dead session [0.66ms] +(pass) detach force-closes a peer that never completes its half-close [12.81ms] +(pass) callback failure and invalid limits fail before returning an attached client [1.07ms] +(pass) remote detach is surfaced and closes without echoing a detach frame [0.71ms] + +src/grok-copresence-profile.test.ts: +(pass) Grok copresence profile defaults > builds the Grok agent-node parent environment from an exact empty allowlist [1.99ms] +(pass) Grok copresence profile defaults > does not mistake an old headless-only agent-node for co-presence support [0.11ms] +(pass) Grok copresence profile defaults > builds the npm resolver environment from an exact empty allowlist [0.62ms] +(pass) Grok copresence profile defaults > prepares two distinct empty owner-only npm config files without following symlinks [1.59ms] +(pass) Grok copresence profile defaults > enables copresence only for non-headless grok-build-cli [0.46ms] +(pass) Grok copresence profile defaults > uses the owner-bound state home even when XDG is owner-only [0.40ms] +(pass) Grok copresence profile defaults > falls back to a bounded owner tmp path when the state home is too long [0.12ms] + +src/opencode-copresence-cli.test.ts: +(pass) OpenCode co-presence CLI wiring > persists copresence mode before launching the bridge [0.04ms] +(pass) OpenCode co-presence CLI wiring > starts only exact alias and alias-bridge tmux sessions [0.07ms] +(pass) OpenCode co-presence CLI wiring > does not depend on a long-lived tmux server's stale launcher environment [0.06ms] +(pass) OpenCode co-presence CLI wiring > waits for the owner-only runtime launcher before starting the official TUI [0.07ms] +(pass) OpenCode co-presence CLI wiring > the generic --copresence dispatcher selects OpenCode by stored runtime [0.16ms] +(pass) OpenCode co-presence CLI wiring > operator help names the create, attach, and stop commands [0.85ms] +(pass) OpenCode co-presence CLI wiring > prints an exact tmux target so an exited TUI cannot prefix-match the bridge [0.07ms] + +src/locale-diagnostic.test.ts: +(pass) #68 locale diagnostic > LC_ALL overrides an otherwise UTF-8 LANG [1.50ms] +(pass) #68 locale diagnostic > LC_CTYPE overrides LANG when LC_ALL is empty [0.08ms] +(pass) #68 locale diagnostic > accepts common UTF-8 spellings [0.10ms] +(pass) #68 locale diagnostic > warns for POSIX, C, non-UTF-8, and unset locale [0.10ms] +(pass) #68 locale diagnostic > does not prescribe POSIX locale variables on Windows [0.04ms] +(pass) #68 locale diagnostic > renders locale values without terminal control or unbounded output [0.23ms] + +src/opencode-package-binary.test.ts: +(pass) validateOpencodePackageBinary > accepts only the canonical exact npm package entrypoint [1.94ms] +(pass) validateOpencodePackageBinary > rejects a same-version package impersonator inside the project [1.17ms] +(pass) validateOpencodePackageBinary > skips a same-version project shim and selects a later trusted package [2.00ms] +(pass) validateOpencodePackageBinary > rejects a monorepo-root package when invoked from a nested app [2.36ms] +(pass) validateOpencodePackageBinary > ordinary 0664 checkout package.json does not abort boundary discovery [1.75ms] +(pass) validateOpencodePackageBinary > accepts both exact registry spellings of bin.opencode [1.98ms] +(pass) validateOpencodePackageBinary > rejects forged name, version, and bin metadata [2.96ms] +(pass) validateOpencodePackageBinary > rejects world-writable files and package ancestors [2.96ms] +(pass) validateOpencodePackageBinary > rejects a symlinked package.json even when its contents are exact [1.16ms] +(pass) #739 cwd 参与信任判定 > 缺陷现状:cwd 为文件系统根时,禁止根含 / —— 与任何包路径都重叠 [0.21ms] +(pass) #739 cwd 参与信任判定 > 缺陷现状:cwd=/ 时,一个各方面都合法的包也会被拒 [1.37ms] +(pass) #739 cwd 参与信任判定 > 缺陷现状:cwd 是全局安装前缀的祖先时,全局安装的包被判成项目本地 [1.40ms] +(pass) #739 cwd 参与信任判定 > 这条守卫要防的东西必须继续被防住(修 #739 时不许放宽它) [1.02ms] + +src/im/access-resolve.test.ts: +(pass) normalizeAllowFrom — input shapes > real string[] passes through deduped (filter empty strings) [1.64ms] +(pass) normalizeAllowFrom — input shapes > undefined → empty + not malformed [0.05ms] +(pass) normalizeAllowFrom — input shapes > null → empty + not malformed [0.05ms] +(pass) normalizeAllowFrom — input shapes > non-array object → empty + malformed (corrupted access.json shape) [0.04ms] +(pass) normalizeAllowFrom — input shapes > string instead of array → malformed [0.02ms] +(pass) normalizeAllowFrom — input shapes > array with non-string elements drops them [0.05ms] +(pass) resolveTelegramAccess — fail-closed empty allowFrom (v0.11 security change) > empty array → deny with empty-fail-closed kind [0.18ms] +(pass) resolveTelegramAccess — fail-closed empty allowFrom (v0.11 security change) > undefined → deny [0.06ms] +(pass) resolveTelegramAccess — fail-closed empty allowFrom (v0.11 security change) > malformed → deny + reason mentions malformed [0.10ms] +(pass) resolveTelegramAccess — wildcard '*' opens the channel > ['*'] alone allows any sender [0.05ms] +(pass) resolveTelegramAccess — wildcard '*' opens the channel > ['*', 'specific_id'] still wildcard-allows (wins precedence) [0.07ms] +(pass) resolveTelegramAccess — explicit id / username matching > senderId in list → allow [0.08ms] +(pass) resolveTelegramAccess — explicit id / username matching > senderUsername match (no id match) → allow [0.07ms] +(pass) resolveTelegramAccess — explicit id / username matching > neither id nor username in list → deny [0.06ms] +(pass) resolveTelegramAccess — explicit id / username matching > empty senderUsername doesn't accidentally match empty list entry [0.06ms] +(pass) resolveTelegramAccess — explicit id / username matching > blank-string id with username match still allows [0.04ms] +(pass) resolveTelegramAccess — explicit id / username matching > production-shape: bare username (no @) in allowFrom matches bare msg.from.username [0.04ms] +(pass) resolveTelegramAccess — explicit id / username matching > production-shape mismatch: @vansin in allowFrom does NOT match bare vansin payload [0.05ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > empty allowFrom → deny [0.20ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > wildcard allows [0.05ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > specific id allows [0.04ms] +(pass) resolveFeishuAccess — DM path mirrors telegram fail-closed > sender not in list → deny [0.08ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > empty allowChats → fail-closed [0.11ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > chat in allowChats + groupPolicy=all → allow [0.08ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > chat in allowChats + groupPolicy=observe → deny [0.06ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > chat NOT in allowChats → deny (even with policy=all) [0.09ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > wildcard chats opens any chat (with groupPolicy=all) [0.07ms] +(pass) resolveFeishuAccess — group path (allowChats + groupPolicy) > groupPolicy=mention allows (caller decides at message inspect time) [0.06ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns warn string for empty allowFrom [0.16ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns warn string for malformed allowFrom + mentions malformed [0.05ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns null when allowFrom has at least one entry [0.04ms] +(pass) buildEmptyAllowlistWarn — boot-time visibility > returns null for wildcard-allow (channel intentionally open) [0.03ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader stores raw allowFrom verbatim — no normalization at load time [0.13ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader emits boot-warn when allowFrom is missing [0.08ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader emits boot-warn when allowFrom is malformed (non-array) [0.07ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > loader is silent when allowFrom has at least one entry (even if numeric) [0.04ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [123] alone (numeric sender id from a misformatted access.json) → loader+resolver fail-closed [0.10ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [null] (corrupted access.json) → loader+resolver fail-closed [0.05ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [{}] (object instead of id string) → loader+resolver fail-closed [0.04ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [123, '@vansin'] (mixed) → '@vansin' still allowed, numeric '123' rejected [0.06ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > [null, '*'] (mixed wildcard) → wildcard wins despite garbage entries [0.04ms] +(pass) loadTelegramAccess + resolver — wiring regression (CHANGE_REQ on #276) > missing access.json entirely (loader gets null) → fail-closed [0.10ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > empty array NEVER allows [0.06ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > undefined NEVER allows [0.04ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > null NEVER allows [0.05ms] +(pass) regression — pre-v0.11 fail-open MUST NOT come back > object-shape (corrupted) NEVER allows [0.05ms] + +src/im/feishu/adapter-lifecycle.test.ts: +(pass) FeishuAdapter WS lifecycle > SDK start resolution is not readiness; missing onReady times out fail-closed [21.73ms] +(pass) FeishuAdapter WS lifecycle > onReady is the only initial online authority [1.53ms] +(pass) FeishuAdapter WS lifecycle > initial onError rejects and scrubs credentials [1.51ms] +(pass) FeishuAdapter WS lifecycle > initial onError scrubs arbitrary Lark access-token shapes [1.59ms] +(pass) FeishuAdapter WS lifecycle > spurious reconnect before first ready cannot mark health connected [17.47ms] +[2026-08-13T03:13:45.223Z] [feishu:audit] error from=? conv=? — inbound [redacted] Bearer [redacted] +(pass) FeishuAdapter WS lifecycle > inbound handler errors use the same token scrub before health [3.79ms] +(pass) FeishuAdapter WS lifecycle > reconnecting lowers health and reconnected restores it [3.09ms] +(pass) FeishuAdapter WS lifecycle > terminal error after ready lowers health and notifies worker owner once [1.58ms] +(pass) FeishuAdapter WS lifecycle > stop closes the public SDK client and invalidates late callbacks [1.61ms] +(pass) worker terminal owner logs safely and exits non-zero [0.15ms] + + 438 pass + 0 fail + 1333 expect() calls +Ran 438 tests across 46 files. [4.37s] +executed_files=46 discovered_files=46 +[L0b] every agent-network/tests file, dispatched by kind +tests_dir_executed=19 tests_dir_discovered=19 tests_dir_failed=0 +[L1] witnessed-red: top-level config help must match the implemented parser +Expected to contain: "anet config [path|json]" +MUTATION_RED stale-config-help rc=1 +RESULT: PASS +``` diff --git a/tests/test725-agent-node-unit-ci/run.sh b/tests/test725-agent-node-unit-ci/run.sh index 117bca4a6..cf2ee8462 100644 --- a/tests/test725-agent-node-unit-ci/run.sh +++ b/tests/test725-agent-node-unit-ci/run.sh @@ -50,6 +50,50 @@ echo "executed_files=${executed:-unknown} discovered_files=$test_files" exit 1 } +# tests/ 下还有 6 个文件,直到现在没有任何 CI 会跑 —— 而这道门的抬头写着 +# "complete agent-node unit domain"。补上,让那句话变成真的。 +# +# 这个目录里混着两种测试,任何单一命令都跑不全: +# - 脚本式:自己打 "N/N passed",失败时 process.exit(1),必须 `bun `; +# 用 `bun test` 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件。 +# - bun:test 式:describe/it,必须 `bun test `;用 `bun ` 跑会报 +# "Cannot use describe outside of the test runner"。 +# 所以按文件内容分派。 +echo "[L0b] every agent-node/tests file, dispatched by kind" +tdir_total=$(find "$ROOT/agent-node/tests" -maxdepth 1 -type f -name '*.test.ts' | wc -l | tr -d ' ') +tdir_ran=0; tdir_failed=0; tdir_names="" +while IFS= read -r f; do + rel=${f#"$ROOT"/agent-node/} + if grep -q 'bun:test' "$f"; then cmd="bun test $rel"; else cmd="bun $rel"; fi + if runuser -u node -- env HOME=/home/node \ + bash -lc "cd $ROOT/agent-node && $cmd" >"/tmp/test725-tests-$(basename "$f" .test.ts).log" 2>&1; then + tdir_ran=$((tdir_ran+1)) + else + tdir_ran=$((tdir_ran+1)); tdir_failed=$((tdir_failed+1)) + tdir_names="$tdir_names $(basename "$f" .test.ts)" + echo "--- FAILED: agent-node/$rel ---" + tail -20 "/tmp/test725-tests-$(basename "$f" .test.ts).log" + fi +done < <(find "$ROOT/agent-node/tests" -maxdepth 1 -type f -name '*.test.ts' | sort) + +echo "tests_dir_executed=$tdir_ran tests_dir_discovered=$tdir_total tests_dir_failed=$tdir_failed" +# 🔴 绝对下限:`executed == discovered` 只能抓「runner 跳过了文件」, +# 抓不到「文件消失了」—— 分母会跟着现实自动缩水。见 #798 的实测: +# 删掉 85% 的测试后,只比数量的门照样 PASS。真删了测试就故意改这个数。 +AGENT_NODE_TESTS_FLOOR=5 +[[ "$tdir_total" -ge "$AGENT_NODE_TESTS_FLOOR" ]] || { + echo "FAIL: only $tdir_total file(s) under agent-node/tests, floor is $AGENT_NODE_TESTS_FLOOR" >&2 + exit 1 +} +[[ "$tdir_ran" -eq "$tdir_total" && "$tdir_total" -gt 0 ]] || { + echo "FAIL: ran $tdir_ran of $tdir_total files under agent-node/tests" >&2 + exit 1 +} +[[ "$tdir_failed" -eq 0 ]] || { + echo "FAIL: $tdir_failed file(s) failed under agent-node/tests:$tdir_names" >&2 + exit 1 +} + echo "[L1] witnessed-red: disconnect readable attachment content from runtime" TARGET=$'deliverToRuntime: () => processTask(\n runtimeContent,' MUTATED=$'deliverToRuntime: () => processTask(\n content,' @@ -79,7 +123,10 @@ set -e echo "FAIL: attachment wiring mutation survived" >&2 exit 1 } -grep -Fq 'the inbox choke point feeds the augmented text into processTask' /tmp/test725-mutation.log || { +# 🔴 锚在 (fail) 行:bun test 对每个用例都打 `(pass) <名字>` / `(fail) <名字>`, +# 只 grep 名字的话那条用例**通过**时也会命中,断言就只证明了它存在。 +# A/B 见 #798:松版会收下一个根本没打中指名行为的 mutation。 +grep -Eq '^\(fail\).*the inbox choke point feeds the augmented text into processTask' /tmp/test725-mutation.log || { cat /tmp/test725-mutation.log echo "FAIL: mutation red did not reach the named wiring assertion" >&2 exit 1 diff --git a/tests/test745-agent-network-unit-ci/Dockerfile b/tests/test745-agent-network-unit-ci/Dockerfile index ca29fd9c0..067a6f39c 100644 --- a/tests/test745-agent-network-unit-ci/Dockerfile +++ b/tests/test745-agent-network-unit-ci/Dockerfile @@ -22,13 +22,18 @@ COPY agent-network/package.json agent-network/package-lock.json ./agent-network/ RUN cd agent-network && npm ci COPY agent-node/package.json ./agent-node/package.json +# tests/feishu-envelope-compat.test.ts 跨包 import agent-node 的 runtime 源码。 +COPY agent-node/src ./agent-node/src COPY agent-network ./agent-network COPY tests/test745-agent-network-unit-ci/run.sh ./tests/test745-agent-network-unit-ci/run.sh ARG SOURCE_COMMIT ENV TEST745_SOURCE_COMMIT=$SOURCE_COMMIT -RUN chmod 0755 ./tests/test745-agent-network-unit-ci/run.sh \ +# tests/feishu-bridge-ipc.test.ts 把附件落在硬编码的 /work/feishu-attachments 下, +# 不是 workspace 相对路径。给 node 建出来,否则它红在 EACCES 上、看着像产品坏。 +RUN install -d -o node -g node -m 0755 /work \ + && chmod 0755 ./tests/test745-agent-network-unit-ci/run.sh \ && install -d -o node -g node -m 0700 "/run/user/$(id -u node)" \ && chown -R node:node /workspace diff --git a/tests/test745-agent-network-unit-ci/run.sh b/tests/test745-agent-network-unit-ci/run.sh index 084838c1a..69d591dcf 100644 --- a/tests/test745-agent-network-unit-ci/run.sh +++ b/tests/test745-agent-network-unit-ci/run.sh @@ -58,6 +58,51 @@ echo "executed_files=${executed:-unknown} discovered_files=$test_files" exit 1 } + +# tests/ 下还有 19 个文件,直到现在没有任何 CI 会跑 —— 而这道门的抬头写着 +# "complete agent-network unit domain"。补上,让那句话变成真的。 +# +# 这个目录里混着两种测试,任何单一命令都跑不全: +# - 脚本式:自己打 "N/N passed",失败时 process.exit(1),必须 `bun `; +# 用 `bun test` 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件。 +# - bun:test 式:describe/it,必须 `bun test `;用 `bun ` 跑会报 +# "Cannot use describe outside of the test runner"。 +# 所以按文件内容分派。 +echo "[L0b] every agent-network/tests file, dispatched by kind" +tdir_total=$(find "$ROOT/agent-network/tests" -maxdepth 1 -type f -name '*.test.ts' | wc -l | tr -d ' ') +tdir_ran=0; tdir_failed=0; tdir_names="" +while IFS= read -r f; do + rel=${f#"$ROOT"/agent-network/} + if grep -q 'bun:test' "$f"; then cmd="bun test $rel"; else cmd="bun $rel"; fi + if runuser -u node -- env HOME=/home/node \ + bash -lc "cd $ROOT/agent-network && $cmd" >"/tmp/test745-tests-$(basename "$f" .test.ts).log" 2>&1; then + tdir_ran=$((tdir_ran+1)) + else + tdir_ran=$((tdir_ran+1)); tdir_failed=$((tdir_failed+1)) + tdir_names="$tdir_names $(basename "$f" .test.ts)" + echo "--- FAILED: agent-network/$rel ---" + tail -20 "/tmp/test745-tests-$(basename "$f" .test.ts).log" + fi +done < <(find "$ROOT/agent-network/tests" -maxdepth 1 -type f -name '*.test.ts' | sort) + +echo "tests_dir_executed=$tdir_ran tests_dir_discovered=$tdir_total tests_dir_failed=$tdir_failed" +# 🔴 绝对下限:`executed == discovered` 只能抓「runner 跳过了文件」, +# 抓不到「文件消失了」—— 分母会跟着现实自动缩水。见 #798 的实测: +# 删掉 85% 的测试后,只比数量的门照样 PASS。真删了测试就故意改这个数。 +AGENT_NETWORK_TESTS_FLOOR=15 +[[ "$tdir_total" -ge "$AGENT_NETWORK_TESTS_FLOOR" ]] || { + echo "FAIL: only $tdir_total file(s) under agent-network/tests, floor is $AGENT_NETWORK_TESTS_FLOOR" >&2 + exit 1 +} +[[ "$tdir_ran" -eq "$tdir_total" && "$tdir_total" -gt 0 ]] || { + echo "FAIL: ran $tdir_ran of $tdir_total files under agent-network/tests" >&2 + exit 1 +} +[[ "$tdir_failed" -eq 0 ]] || { + echo "FAIL: $tdir_failed file(s) failed under agent-network/tests:$tdir_names" >&2 + exit 1 +} + echo "[L1] witnessed-red: top-level config help must match the implemented parser" TARGET=' anet config [path|json] Show config summary, path, or raw JSON' MUTATED=' anet config get|set Inspect or edit config' From ba020730dab3e3fa9523505cb20491cff4b133d1 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 07:04:59 +0800 Subject: [PATCH 51/59] =?UTF-8?q?ci:=20=E5=85=83=E9=97=A8=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E6=96=B0=E5=A2=9E=E6=B5=8B=E8=AF=95=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8D=E8=83=BD=E8=90=BD=E5=9C=A8=E6=89=80=E6=9C=89?= =?UTF-8?q?=E8=81=9A=E5=90=88=E9=97=A8=E7=9A=84=E6=89=AB=E6=8F=8F=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E4=B9=8B=E5=A4=96(=E4=BE=9D=E8=B5=96=20#798=20#800)?= =?UTF-8?q?=20(#801)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个) server/src 下 69 个 *.test.ts,CI 可达的只有 6 个(scripts/qa.sh 的 L0_TESTS 点名 5 个 + test686 引用 1 个),另外 63 个没有任何 job 会碰。server 是 hub 本体 —— 认证、token、网络隔离都在这里,盲区比 agent-network 那 46 个严重。 形状抄 test745/test725,但按 server 自己的契约做了两处改动: 1) 逐文件跑,每个文件一个独立 DB。scripts/qa.sh 的 L0 本来就是 `COMMHUB_DB=/tmp/qa-l0-$name.db bun test ` —— 这是既有契约。 用一个共享 DB 聚合跑会红 4 条(admin-networks 的 global-admin 可见性、 scheduled-tasks 三条),而这 4 条单跑全绿,是跨文件状态污染。 把"聚合能不能跑"当门等于给它加了一条它从没承诺过的性质。 2) cwd 必须是仓根。task-lifecycle-watcher 用 process.cwd() 拼 ./server/src/db.js,scheduled-tasks-http 按仓根相对路径 import tests/test601-.../race-worker.ts。从 server/ 目录跑会让这两个红在路径上, 看起来像产品坏了。 红线:COMMHUB_DB 不设默认指向生产库。容器里够不到宿主的库,但不靠"够不到" 保证 —— run.sh 显式钉到 /tmp 并断言钉住了。31/69 个测试引用 sqlite/COMMHUB_DB。 分母承重:executed_files 必须等于 find 出来的 test_files,少一个就红。 witnessed-red:把 auth.ts 注册密码下限 `< 8` 改成 `< 1`(7 位密码会被接受, 一条真的安全回退),先校验字节非 no-op,再要求红落在指名的 "rejects 7-char password" 上。 实测:test_files=69 executed_files=69 failed_files=0, MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS,耗时 51s。 完整输出见 docs/tests/report-test798-server-unit-ci.txt。 * ci: server 单测门抽成独立 job,别挂在 agent-network 名下 上一版把 build/run 两步插进了 agent-network-unit job 里,所以它确实跑了 (CI 日志实测 test_files=69 executed_files=69 failed_files=0 MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS), 但会以 "agent-network unit (Docker, non-root)" 的名义显示 —— server 挂了会归错帐,而且两个重 Docker build 串在一个 job 里。 抽成 server-unit job,显示名 "server unit (Docker, non-root)"。 * test(ci): 让 test725/test745 覆盖 tests/ 目录,兑现"complete unit domain" 两个门的抬头都写着 "complete agent-node/agent-network unit domain", 但只跑 src/,把 tests/ 下 25 个文件排除在外 —— 其中不乏安全相关的: feishu-markdown-image-ssrf、secret-mask ×3、vendor-error-sanitize、feishu-tool-deny。 这些正是静默失效代价最高的那类。 这个目录里混着两种测试,任何单一命令都跑不全: - 脚本式(16+6 个):自己打 "N/N passed",失败 process.exit(1),必须 bun ; 用 bun test 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件 (实测:bun test tests/ 只跑完第一个就结束)。 - bun:test 式(3 个):describe/it,必须 bun test ;用 bun 跑会报 "Cannot use describe outside of the test runner"。 所以按文件内容分派,并把两条判据都写进注释。 退出码可用已先验:这些脚本失败时确实 process.exit(1),不是 fail-open。 落地前实测: agent-node/tests 6/6 直接过 agent-network/tests 单命令 14/19 → 按内容分派 17/19 → 补两处环境契约 19/19 两处契约都在 Dockerfile 内解决,并写明原因: - feishu-envelope-compat 跨包 import agent-node/src/runtime/feishu-envelope - feishu-bridge-ipc 硬编码绝对路径 /work/feishu-attachments,容器里 node 建不了 分母承重:tests_dir_executed 必须等于 find 出来的数,且 >0。 实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS; test745 tests_dir 19/19/0 + MUTATION_RED + PASS。 * docs(tests): report-only —— 锚点 46e752c3(含 current main 034f0064) 按独审要求重做 provenance:append current main → 在精确源码提交上重跑 → report-only 子提交。 main 的新增提交 #802 只动 tests/qa-180-rename-ghost/,与本 PR 四个文件零相交, rebase 无冲突;qa.yml 两处 path(test746 / test798)都保留; server 步骤已是独立 job server-unit(name="server unit (Docker, non-root)", timeout 12), 不再嵌在 agent-network-unit 里。 实测:executed_files=69 discovered_files=69 failed_files=0, MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS。 * docs(tests): report-only —— 锚点 a4fd375f(含 current main 034f0064) 按独审 SUPERSEDE 的要求重做 provenance。原 CLEAN 判定被撤回是对的: 上一份报告声称的锚点 92d96129 比本 PR base 还早两个提交,那上面的 run.sh 里 没有 tests_dir_executed 那段代码,报告内容 provably 不可能由它产出。 根因是我跑门时 --build-arg SOURCE_COMMIT 传的是当时的 origin/main。 实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS; test745 tests_dir 19/19/0 + MUTATION_RED + PASS。 * ci: 元门 —— 修掉独立审抓出的三条 P1(其中一条是元门自己的漏网) 独立审(codex)在本 PR 上提了三条 P1,逐条复现后全部成立: 1) **深度不感知 —— 元门自己放行了没人会跑的测试。** 两个 unit runner 扫 `/tests` 用的是 `find … -maxdepth 1`,而本脚本 原来只按前缀判覆盖。复现:把一个测试放到 `agent-network/tests/sub/` 下, 元门报「0 个漏网」rc=0,而 runner 的 find 对它命中 0。 **这正是这道门存在的意义所在,它却在自己身上漏了。** 修法:深度从门里推导(scan_depth),不假定递归;`bun test /` 形式按递归算。 双向验过:子目录文件 → rc=1 且点名;直属文件 → rc=0。 2) **套件豁免不校验套件是否真实存在。** 原来只要路径以 `tests/` 开头就放行,于是 `tests/test999-example/new.test.ts` 这种既没 Dockerfile 也没 run.sh 的目录也能过 —— 豁免变成「只要放对地方 就不用被任何东西跑」。改成要求套件目录里 Dockerfile 和 run.sh 都在。 双向验过:伪套件 → rc=1;补上两个文件 → rc=0。 3) **qa.yml 改动不触发本门。** qa.yml 决定那三个聚合门到底跑不跑,它一改本门的前提就可能塌, 但它不在触发路径里。已加进两处 path 过滤。 NOT COVERED(第 2 条修完仍存在的缺口):校验了「套件是一套门」, 但**没有**校验「该套件已注册进 CI」。test224/test597/test679 就长期 有完整 Dockerfile+run.sh 却没人跑 —— 那是 #803 在解决的问题,不是本门的判据。 * ci: 元门要验「这道门真的被 CI 跑」,不只是「它存在且声明了范围」 独立审(codex P1)指出的缺口,我上一版只在 NOT COVERED 里记了没修: qa.yml 一旦删掉/改名某个 job、或不再 build/run 它的 Dockerfile, 本脚本照样发绿 —— 因为它从没看过 qa.yml。 **这正是本门要防的那类问题(有门、没人跑),不能留在自己身上。** 判据要求 qa.yml 里同时出现两件事,单独一条不算: -f tests//Dockerfile 真的构建了它 docker run … <这次 build 打的 tag> 真的跑了那个产物 两条解耦 mutation,各自红在不同原因上(基线绿): F1 删掉 server-unit 的 docker run(build 保留) → rc=1「qa.yml 构建了 anet-test798-server-unit 但没有 docker run 它」 F2 把 test745 的 build -f 路径改名 → rc=1「qa.yml 里没有 build tests/test745-agent-network-unit-ci/Dockerfile」 一道门可能覆盖多个根(test745 覆盖 src 与 tests),接线问题去重后只报一次。 * docs(tests): report-only —— 锚点 9626c98e,七条 mutation * ci: 落实 ⑤⑥ 两条已接受未实施的意见;② 需所有者决定,如实标注 ⑥ SOURCE_COMMIT 只验格式不验字节 原来只验 ^[0-9a-f]{40}$。任何 SHA 都能过,而审查指出提交进来的 report 里那个 SHA 早于本套件自身 —— 那份证据无法从它自称的版本复现。 改成与 test823 相同的做法:构建时把 run.sh 在该 commit 下的 git blob 哈希作为 build-arg 传入,容器内就地重算比对(blob 哈希 = sha1("blob \0"+内容),不需要容器里装 git)。 已验脚本内算法与 git hash-object 结果一致;该机制的端到端红/绿在 #835 上证过两次(传错 blob、blob 对但文件被篡改,都 exit 1)。 ⑤ qa.yml 缺 test601 路径 test798 的镜像 COPY 了 test601 的 race-worker.ts,而 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」。只改那个 worker 的 PR 不该跳过这道门。已在两处 paths 补上。 (这 4 行原本只存在于 #798;若只合本 PR、把 #798 当冗余关掉,它们永远 不会落地 —— 此前已在本 PR 记录过这个坑。) ② server 的 npm install 无 lockfile —— 我没有改,需要所有者决定 实测:server/package.json 有 4 个依赖,4 个全用 caret 范围,且仓里没有 任何 lockfile/shrinkwrap。所以同一个 commit 在不同时间构建确实会解析出 不同依赖图,审查这条成立。 但修法只有一条:提交一份 lockfile。那是仓库级的依赖钉死决策 —— 它影响 每一次 server 构建,不只是这道门;而且生成出来的树我无法在这里验证是否 仍然全绿。这不该由我单方面决定,如实留作待决,不假装已修。 * ci(test798): server 依赖钉死 —— 提交 lockfile 并改用 npm ci 审查 ② 说的成立:server/package.json 4 个依赖全用 caret 且仓里没有 lockfile, 所以同一个 commit 在不同时间构建会解析出不同依赖图 —— 上游发一个兼容版本 就能让这道门变红或改变被测行为,而仓库一个字节都没动。 我上一版把这条标成"仓库级决策,不该由我单方面做"。那个定性是错的: agent-network/package-lock.json 已提交 docs-site/package-lock.json 已提交 prototype/anet-client-app/package-lock.json 已提交 5 个包里 3 个已经提交 lockfile,.gitignore 的 *.lock 也匹配不到 package-lock.json。提交它是本仓既有做法,server 与 agent-node 只是不一致。 真正卡住的是一次验证跑,不是授权 —— 我把成本问题说成了权限问题。 本次改动: - npm install --package-lock-only 生成 server/package-lock.json(1212 行, 未装 node_modules)。锁到的直接依赖: @modelcontextprotocol/sdk 1.30.0 / bun-types 1.3.14 / hono 4.13.1 / zod 4.4.3 - Dockerfile 改为 COPY package.json + package-lock.json,并把 npm install 换成 npm ci(ci 严格按 lockfile 装,install 会按 caret 取"当下最新兼容版")。 验证:带 lockfile 重建后跑完整套件 test_files=69 executed_files=69 failed_files=0 MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS 退出码 0 * ci(test798): 把 RUNSH_BLOB 真的传进去 —— 门在要求它,workflow 从没供给 CI 上 `server unit (Docker, non-root)` 稳定红,日志里唯一的失败行: FAIL: TEST798_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节 链条断在最后一环: run.sh:25-27 要求 TEST798_RUNSH_BLOB 且校验 ^[0-9a-f]{40}$,否则 fail-closed Dockerfile:41,45 ARG RUNSH_BLOB → ENV TEST798_RUNSH_BLOB qa.yml:79-84 docker build 只传 SOURCE_COMMIT,**没传 RUNSH_BLOB** 于是 ARG 取空、ENV 为空串、正则不过。门本身是对的 —— 它正确拒绝了一次 「说不清自己测了哪份字节」的运行,缺的只是供给那一行。 补法用 git 自己的 blob 哈希,和 run.sh:31 的算法是同一个东西: run.sh 算的是 sha1("blob \0" + 内容),那正是 git 的 blob object id。 本地实测两者一致(在本分支 head 上): git rev-parse HEAD:tests/test798-server-unit-ci/run.sh = 0e48c36d9ef516e60040ace54bc69c16c161e47f { printf 'blob %d\0' "$(wc -c < run.sh)"; cat run.sh; } | sha1sum = 0e48c36d9ef516e60040ace54bc69c16c161e47f 对照 #798:它的 run.sh 里 RUNSH_BLOB 命中 0 次 —— 所以这不是 #798 的回归, 是本 PR 新加的要求没接完线。 🔴 这一条只修 CI 红。独立审查另指出本 PR 仍夹带 #798 的旧版本、需在 #798 之后 rebase —— 那件事不在本提交范围内。 --------- Co-authored-by: vansin Co-authored-by: vansin Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-test-file-coverage.py | 241 ++++ .github/workflows/qa.yml | 1 + .github/workflows/test-file-coverage.yml | 49 + .../report-test-file-coverage-meta-gate.txt | 30 + server/package-lock.json | 1212 +++++++++++++++++ tests/test798-server-unit-ci/Dockerfile | 11 +- tests/test798-server-unit-ci/run.sh | 19 + 7 files changed, 1561 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/check-test-file-coverage.py create mode 100644 .github/workflows/test-file-coverage.yml create mode 100644 docs/tests/report-test-file-coverage-meta-gate.txt create mode 100644 server/package-lock.json diff --git a/.github/scripts/check-test-file-coverage.py b/.github/scripts/check-test-file-coverage.py new file mode 100755 index 000000000..6ee42252b --- /dev/null +++ b/.github/scripts/check-test-file-coverage.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""元门:每个 *.test.ts 都必须落在某个聚合门的扫描范围里。 + +## 为什么需要这个 + +2026-08-13 手工扫了一遍,发现三处「有测试、但没有任何 CI job 会跑它」: + server/src 69 个,CI 只点名跑 6 个 + agent-network/src 46 个,0 个被引用(#791 补掉) + agent-network/tests 19 个 + agent-node/tests 6 个,两个门自称 complete 却漏了 + +每一处都是同一个结构:测试在本地是绿的,PR 上看不出异常,改坏了不会有人知道。 +补完之后剩下的问题是 —— **下一个新增的测试文件会不会又静默漏掉?** +靠人再扫一遍不是答案。这个脚本就是答案。 + +## 判据 + +聚合门用 `find -name '*.test.ts'` 覆盖若干个根。任何测试文件: + - 落在某个根下 → 被覆盖 + - 落在 tests/<套件>/ 下 → 属于「套件自带」,单独计数并列出(它们由各自的 + Docker 套件跑,是否进 CI 由套件决定,不在本门的判据里) + - 两者都不是 → **失败**。这是唯一的漏网形态:新包、新目录、或者把测试 + 放在了聚合门扫不到的地方。 + +## 两条防空转 + +1. **根必须真的是门的扫描范围**。COVERED 是一份声明,声明会漂 —— 门被删、 + 改名、或者把范围缩掉,这里就要红,否则本门会对着一份早已不成立的清单发绿。 + 注意这条**不能**用子串检查:第一版写的是 `root not in text`,mutation 当场 + 证伪 —— 把 find 的路径改成 $ROOT/server/nonexistent 之后,'server/src' 仍然 + 出现在注释和 FAIL 文案里,门照样绿。见 declares_scope()。 +2. **分母必须非零**。扫出 0 个测试文件时退出 3,而不是「没有违规,通过」—— + 扫描器范围塌掉和真的没有违规,打印出来是同一片绿色。 +""" + +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +# root → 声称覆盖它的门(run.sh 路径)。该门必须把 root 真正声明为扫描范围,见 declares_scope()。 +COVERED = { + "server/src": "tests/test798-server-unit-ci/run.sh", + "agent-network/src": "tests/test745-agent-network-unit-ci/run.sh", + "agent-network/tests": "tests/test745-agent-network-unit-ci/run.sh", + "agent-node/src": "tests/test725-agent-node-unit-ci/run.sh", + "agent-node/tests": "tests/test725-agent-node-unit-ci/run.sh", +} + +# tests/<套件>/ 下的测试文件属于套件自带,单独计数 +SUITE_PREFIX = "tests/" + + + + +WORKFLOW = REPO / ".github" / "workflows" / "qa.yml" + + +def gate_is_wired(gate: str) -> tuple[bool, str]: + """这道门有没有真的被 CI 构建并运行。 + + 原来只验了两件事:门文件存在、门声明了扫描范围。**都不等于它会跑。** + 独立审(codex P1)指出:qa.yml 一旦删掉或改名某个 job、或不再 build/run + 它的 Dockerfile,本脚本照样发绿 —— 因为它从没看过 qa.yml。 + 这正是本门要防的那类问题(有门、没人跑),所以不能留在自己身上。 + + 判据是 qa.yml 里同时出现: + - `-f tests//Dockerfile`(真的构建了它) + - `docker run … <这次 build 打的 tag>`(真的跑了那个产物) + 只比 tag 字符串,不解析 YAML —— 但两条都要中,单独一条不算。 + """ + suite = Path(gate).parent.name + if not WORKFLOW.is_file(): + return False, "qa.yml 不存在" + wf = WORKFLOW.read_text(encoding="utf-8") + build = re.search(rf'-f\s+tests/{re.escape(suite)}/Dockerfile', wf) + if not build: + return False, f"qa.yml 里没有 build tests/{suite}/Dockerfile" + tags = re.findall(rf'-t\s+(\S+)\s+\\?\s*\n?\s*-f\s+tests/{re.escape(suite)}/Dockerfile', wf) + if not tags: + return False, f"qa.yml 里 build tests/{suite} 时没有 -t " + tag = tags[0] + if not re.search(rf'docker run[^\n]*\b{re.escape(tag)}\b', wf): + return False, f"qa.yml 构建了 {tag} 但没有 docker run 它" + return True, tag + + +def suite_is_real(path: str) -> bool: + """`tests//x.test.ts` 只有在那个套件真的是一套门时才豁免。 + + 独立审(codex P1):原来只要路径以 `tests/` 开头就放行,于是 + `tests/test999-example/new.test.ts` 这种既没有 Dockerfile 也没有 run.sh 的 + 目录也能过 —— 豁免变成了「只要放对地方就不用被任何东西跑」。 + 所以要求套件目录里 Dockerfile 和 run.sh 都在。 + + 注意这条**仍然不保证该套件进了 CI**(它可能像 test224/597/679 那样长期 + 没人注册)。那是另一回事,写在 NOT COVERED 里。 + """ + parts = path.split("/") + if len(parts) < 3: + return False + suite = REPO / parts[0] / parts[1] + return (suite / "Dockerfile").is_file() and (suite / "run.sh").is_file() + + +def scan_depth(gate_text: str, root: str) -> int | None: + """门扫这个根时的深度上限:1 = 只扫直属文件,None = 递归。 + + 这条是独立审(codex P1)抓出来的,而且当场复现:两个 unit runner 扫 + `/tests` 用的是 `find … -maxdepth 1`,而本脚本原来只按前缀判覆盖 —— + 于是 `agent-network/tests/sub/x.test.ts` 被判为「已覆盖」,可 runner 的 + find 对它命中 0。**这道门放行了一个没人会跑的测试**,正是它存在的意义所在。 + + 所以深度必须从门里推导,不能假定。 + """ + m = re.search( + rf'find\s+"\$ROOT/{re.escape(root)}"\s+(?P(?:-maxdepth\s+\d+\s+)?)', + gate_text, + ) + if m: + d = re.search(r'-maxdepth\s+(\d+)', m.group("flags") or "") + return int(d.group(1)) if d else None + return None # `bun test /` 形式:bun 会递归 + + +def declares_scope(gate_text: str, root: str) -> bool: + """门里必须真的把 root 当成扫描范围,而不是只在注释里提到它。 + + 第一版这里写的是 `root not in gate_text` —— 子串检查。mutation 当场证明 + 它是坏的:把 find 的路径从 $ROOT/server/src 改成 $ROOT/server/nonexistent + 之后,'server/src' 仍然出现在注释和 FAIL 文案里,门照样发绿。 + 宽容的断言会把不合规当合规收下。所以只认两种真实的范围声明形式。 + """ + pkg, _, sub = root.partition("/") + patterns = [ + # find "$ROOT/" … -name '*.test.ts' + rf'find\s+"\$ROOT/{re.escape(root)}"', + # cd /workspace/ && bun test / + # 结尾必须锚定:`bun test src/` 才算声明整个目录。不锚的话 + # `bun test src/cli.test.ts` 也会匹配上 —— 范围收窄到单个文件, + # 门却仍然宣称覆盖了整个 src/。第二轮 mutation 就是这么活下来的。 + rf'cd\s+/workspace/{re.escape(pkg)}\s+&&\s+bun test\s+{re.escape(sub)}/(?=[\'"\s]|$)', + ] + return any(re.search(p, gate_text) for p in patterns) + + +def tracked_test_files() -> list[str]: + out = subprocess.run( + ["git", "ls-files", "*.test.ts"], + cwd=REPO, capture_output=True, text=True, check=True, + ).stdout + return sorted(p for p in out.splitlines() if p) + + +def main() -> int: + failures: list[str] = [] + + # 防空转 1:每个声明的根都要在它声称的门里字面出现 + for root, gate in COVERED.items(): + gate_path = REPO / gate + if not gate_path.is_file(): + failures.append(f"门不存在:{gate}(声称覆盖 {root})") + continue + text = gate_path.read_text(encoding="utf-8") + if not declares_scope(text, root): + failures.append( + f"门 {gate} 没有把 '{root}' 声明为扫描范围 —— " + "覆盖声明与门的实际范围已经不一致" + ) + wired, why = gate_is_wired(gate) + # 一道门可能覆盖多个根(test745 覆盖 src 和 tests),接线问题只报一次 + msg = f"门 {gate} 没有接进 CI:{why}" + if not wired and msg not in failures: + failures.append(msg) + + files = tracked_test_files() + print(f"tracked_test_files={len(files)}") + + # 防空转 2:分母为零说明扫描范围塌了,不是「没有违规」 + if not files: + print("FAIL: 扫到 0 个 *.test.ts —— 扫描范围塌了,不是通过", file=sys.stderr) + return 3 + + by_root: dict[str, int] = {r: 0 for r in COVERED} + suite_files: list[str] = [] + orphans: list[str] = [] + + depths = { + root: scan_depth((REPO / gate).read_text(encoding="utf-8"), root) + if (REPO / gate).is_file() else None + for root, gate in COVERED.items() + } + for f in files: + for root in COVERED: + if not f.startswith(root + "/"): + continue + rest = f[len(root) + 1:] + d = depths[root] + if d is not None and rest.count("/") >= d: + # 落在门扫不到的深度里 —— 加了也不会有人跑,按漏网处理 + continue + by_root[root] += 1 + break + else: + if f.startswith(SUITE_PREFIX) and suite_is_real(f): + suite_files.append(f) + else: + orphans.append(f) + + for root, n in sorted(by_root.items()): + print(f" covered {root:<22} {n}") + print(f" suite-owned (tests//) {len(suite_files)}") + for f in suite_files: + print(f" {f}") + + total = sum(by_root.values()) + len(suite_files) + len(orphans) + if total != len(files): + failures.append(f"计数不闭合:分类合计 {total} != 文件数 {len(files)}") + + if orphans: + failures.append( + "以下测试文件不在任何聚合门的扫描范围里 —— 加了也不会有人跑:\n" + + "\n".join(f" {f}" for f in orphans) + + "\n 要么把它挪进已覆盖的根,要么给它所在的包补一个聚合门" + "(照 tests/test798-server-unit-ci 的形状)。" + ) + + if failures: + print() + for msg in failures: + print(f"FAIL: {msg}", file=sys.stderr) + return 1 + + print(f"\nOK: {len(files)} 个测试文件,{len(files) - len(suite_files)} 个在聚合门范围内," + f"{len(suite_files)} 个套件自带,0 个漏网") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index ab413caca..74ee47e95 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -106,6 +106,7 @@ jobs: run: | docker build \ --build-arg SOURCE_COMMIT="$GITHUB_SHA" \ + --build-arg RUNSH_BLOB="$(git rev-parse HEAD:tests/test798-server-unit-ci/run.sh)" \ -t anet-test798-server-unit \ -f tests/test798-server-unit-ci/Dockerfile . diff --git a/.github/workflows/test-file-coverage.yml b/.github/workflows/test-file-coverage.yml new file mode 100644 index 000000000..5d0358e50 --- /dev/null +++ b/.github/workflows/test-file-coverage.yml @@ -0,0 +1,49 @@ +# 元门:新增的 *.test.ts 不能落在所有聚合门的扫描范围之外。 +# +# 起因是 2026-08-13 手工扫出的三处盲区(server/src 69 个 CI 只跑 6 个、 +# agent-network/src 46 个 0 被引用、两个门自称 complete 却漏了 tests/ 下 25 个)。 +# 那三处都补掉了,但补完剩下的问题是:下一个新增的测试文件会不会又静默漏掉。 +# 靠人再扫一遍不是答案,所以有了这道门。 +# +# 判据和两条防空转见 .github/scripts/check-test-file-coverage.py 的文档串。 +# 用 Python 而不是 yml 里的 bash 循环,同 no-memory-slugs.yml 的理由。 + +name: lint (every test file is covered by a gate) + +on: + pull_request: + paths: + - '**/*.test.ts' + - 'tests/test725-agent-node-unit-ci/**' + - 'tests/test745-agent-network-unit-ci/**' + - 'tests/test798-server-unit-ci/**' + # qa.yml 决定这三个门到底跑不跑 —— 它一改,本门的前提就可能塌(codex P1) + - '.github/workflows/qa.yml' + - '.github/scripts/check-test-file-coverage.py' + - '.github/workflows/test-file-coverage.yml' + push: + branches: [main] + paths: + - '**/*.test.ts' + - 'tests/test725-agent-node-unit-ci/**' + - 'tests/test745-agent-network-unit-ci/**' + - 'tests/test798-server-unit-ci/**' + # qa.yml 决定这三个门到底跑不跑 —— 它一改,本门的前提就可能塌(codex P1) + - '.github/workflows/qa.yml' + - '.github/scripts/check-test-file-coverage.py' + - '.github/workflows/test-file-coverage.yml' + +concurrency: + group: lint-test-coverage-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + test-file-coverage: + name: every test file is covered by a gate + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + + - name: Run check-test-file-coverage.py + run: python3 .github/scripts/check-test-file-coverage.py diff --git a/docs/tests/report-test-file-coverage-meta-gate.txt b/docs/tests/report-test-file-coverage-meta-gate.txt new file mode 100644 index 000000000..963d4d0cb --- /dev/null +++ b/docs/tests/report-test-file-coverage-meta-gate.txt @@ -0,0 +1,30 @@ +# 元门 check-test-file-coverage.py +source_commit=9626c98e4d301d88822cbb3031753fd94a946ed8 +base(current main)=034f00647d42d38d5086d7fc057eb7824a441791 +stack: 含 #798 与 #800 的源码 + +## 基线 +tracked_test_files=236 + covered agent-network/src 46 + covered agent-network/tests 19 + covered agent-node/src 91 + covered agent-node/tests 6 + covered server/src 69 + suite-owned (tests//) 5 + tests/test224-grok-preview-security/security-gate.test.ts + tests/test597-dashboard-slash-namespace/cli-wire.test.ts + tests/test679-task-trace/wiring.test.ts + tests/test682-uncovered-task-trace/semantics.test.ts + tests/test682-uncovered-task-trace/wiring.test.ts + +OK: 236 个测试文件,231 个在聚合门范围内,5 个套件自带,0 个漏网 +rc=0 + +## mutation(七条,全部双向验过) +A 落在任何根之外的新文件 → rc=1 点名 +B find 范围改成 nonexistent → rc=1「没有把 server/src 声明为扫描范围」 +C bun test src/ 收窄成单文件 → rc=1(加结尾锚定后才红,第一版活下来过) +D 伪套件 tests/test999-example/ → rc=1;补 Dockerfile+run.sh 后 rc=0 +E 子目录 agent-network/tests/sub/ → rc=1;直属文件 rc=0(独立审抓出的,不是我自己发现的) +F1 删掉 server-unit 的 docker run → rc=1「构建了但没有 docker run 它」 +F2 build -f 路径改名 → rc=1「没有 build tests/…/Dockerfile」 diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 000000000..16ccebe3a --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1212 @@ +{ + "name": "@sleep2agi/commhub-server", + "version": "0.9.0-preview.29", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@sleep2agi/commhub-server", + "version": "0.9.0-preview.29", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "bun-types": "^1.3.13", + "hono": "^4.12.25", + "zod": "^4.4.3" + }, + "bin": { + "commhub-server": "bin/commhub.ts" + }, + "engines": { + "bun": ">=1.2.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bun-types": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz", + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/tests/test798-server-unit-ci/Dockerfile b/tests/test798-server-unit-ci/Dockerfile index 4e91a2a04..1c43ab75f 100644 --- a/tests/test798-server-unit-ci/Dockerfile +++ b/tests/test798-server-unit-ci/Dockerfile @@ -18,8 +18,11 @@ RUN curl --fail --silent --show-error --location \ && rm -f /tmp/bun-linux-x64.zip WORKDIR /workspace -COPY server/package.json ./server/ -RUN cd server && npm install +COPY server/package.json server/package-lock.json ./server/ +# 🔴 npm ci 而不是 npm install:ci 严格按 lockfile 装,install 会按 caret 解析 +# 「当下最新的兼容版本」。后者意味着同一个 commit 在不同时间构建出不同依赖图 —— +# 上游发一个兼容版本就能让这道门变红或改变被测行为,而仓库一个字节都没动。 +RUN cd server && npm ci COPY server ./server # RFC-026 G9 / RFC-028 P1 的漂移门比对 hub 与 daemon 的同名共享源码, @@ -35,7 +38,11 @@ COPY tests/test601-hub-scheduled-tasks ./tests/test601-hub-scheduled-tasks COPY tests/test798-server-unit-ci/run.sh ./tests/test798-server-unit-ci/run.sh ARG SOURCE_COMMIT +ARG RUNSH_BLOB ENV TEST798_SOURCE_COMMIT=$SOURCE_COMMIT +# run.sh 在 SOURCE_COMMIT 下的 git blob 哈希 —— 让容器内能验证「报告里的 SHA +# 确实对应镜像里被测的字节」,而不是只验 SHA 的格式。 +ENV TEST798_RUNSH_BLOB=$RUNSH_BLOB RUN chmod 0755 ./tests/test798-server-unit-ci/run.sh \ && install -d -o node -g node -m 0700 "/run/user/$(id -u node)" \ diff --git a/tests/test798-server-unit-ci/run.sh b/tests/test798-server-unit-ci/run.sh index 14f5fc334..d4a01d27d 100755 --- a/tests/test798-server-unit-ci/run.sh +++ b/tests/test798-server-unit-ci/run.sh @@ -16,6 +16,25 @@ SOURCE_COMMIT=${TEST798_SOURCE_COMMIT:-} exit 1 } +# 🔴 光验 SOURCE_COMMIT 的格式不够:任何 40 位十六进制都能通过,而那个 SHA 可能 +# 根本不含镜像里被测的文件 —— 提交进仓的 report 就出现过写着一个早于套件自身的 +# 修订号,那份证据无法从它自称的版本复现。 +# 做法(与 test823 同):构建时把 run.sh 在该 commit 下的 git blob 哈希作为 +# build-arg 传进来,这里就地重算镜像内文件的 blob 哈希并比对。 +# blob 哈希 = sha1("blob \0" + 内容),容器里不需要装 git。 +RUNSH_BLOB=${TEST798_RUNSH_BLOB:-} +[[ "$RUNSH_BLOB" =~ ^[0-9a-f]{40}$ ]] || { + echo "FAIL: TEST798_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节" >&2 + exit 1 +} +_self="$ROOT/tests/test798-server-unit-ci/run.sh" +_actual=$( { printf 'blob %d\0' "$(wc -c < "$_self")"; cat "$_self"; } | sha1sum | cut -d' ' -f1 ) +[[ "$_actual" == "$RUNSH_BLOB" ]] || { + echo "FAIL: 镜像里的 run.sh 与 SOURCE_COMMIT=$SOURCE_COMMIT 声称的不是同一份" >&2 + echo " 期望 blob $RUNSH_BLOB,实际 $_actual" >&2 + exit 1 +} + # 🔴 红线:COMMHUB_DB 不设的话默认指向生产库。容器里够不到宿主的库, # 但不能靠"够不到"来保证 —— 显式钉到容器内临时路径,并断言它真的被钉住了。 # 31/69 个 server 测试引用了 sqlite/COMMHUB_DB,这条不是形式主义。 From 46829d4be959b65c7362625de577c3ac3661e859 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 07:08:31 +0800 Subject: [PATCH 52/59] =?UTF-8?q?docs:=20docs/=20=E9=87=8C=2013=20?= =?UTF-8?q?=E6=9D=A1=20cli.ts=20=E8=A1=8C=E5=8F=B7=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E6=94=B9=E9=92=89=E7=AC=A6=E5=8F=B7=E9=94=9A(22=20=E2=86=92=20?= =?UTF-8?q?9,=E6=94=B9=E5=89=8D=2011/11=20=E5=85=A8=E9=94=99)=20(#857)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: docs/ 里 13 条 cli.ts 行号引用改钉符号锚(22 → 9) #852 量过:docs/ 下按 blob/main 钉 cli.ts 行号的引用,锚文本带符号名、可机器判定的 11 条里 **漂移 11、仍对 0**。这次把能确定唯一锚串的都改掉。 改法照 #845 已确立的形状: 改前 [`cli.ts:228 loadProfile`](…/blob/main/agent-network/bin/cli.ts#L228) 改后 [`cli.ts`](…/blob/main/agent-network/bin/cli.ts) —— 搜 `function loadProfile(` 13 条的真实位置(改之前它们全都指错了): adminUtokPath 文档说 28 实际 138 saveGlobal 文档说 77-81 实际 1000 saveServerConfig 文档说 89-95 实际 1047 saveAdminUtok 文档说 105-111 实际 1062 loadProfile 文档说 228 实际 1208 saveProfile 文档说 246-273 实际 1272 setupCommand 文档说 556 实际 1860 ensureMcpJson 文档说 1644 实际 4300 runCommand 文档说 2044 实际 5641 renameCommand 文档说 2583 / 2629 实际 6911 deleteCommand 文档说 2800-2840 实际另处 dashboardReleaseTag 文档说 347 实际另处 每一条新锚串都逐条核过在 cli.ts 里**唯一**(13 条,非唯一 0 条)。 `RuntimeName` 那条**没有改**:它在 cli.ts 里出现 13 次,做不出唯一锚 —— 与其钉一个 含糊的锚,不如留着行号,等有人给它一个能唯一定位的写法。 剩下 9 条锚文本里没有符号名(形如 `[cli.ts:1724](…#L1724)`),机械改不了, 需要人读源码判断它当初想指的是什么。留给 #852。 * docs: 再修 6 条需要人读源码的 cli.ts 引用(9 → 3) 接上一提交。这 6 条的锚文本里没有符号名,机械改不了,是逐条读上下文判出它当初 想指什么、再去源码里定位的: node-lifecycle.md:206 正文说 notifyServerOffline → 搜 `async function notifyServerOffline(` node-lifecycle.md:213 正文说「确认流程」 → 搜 `This will delete "${displayName}" (node_id:` node-lifecycle.md:383 正文直接写了 resolveNodeRef → 搜 `function resolveNodeRef(` architecture.md:316 正文说写 .mcp.json → 搜 `.mcp.json: commhub → .anet/node-server.js` architecture.md:326 正文引了 compare-by-content → 搜 `if (src !== dst)` architecture.md:328 RuntimeName type → 🔴 它已经不在 cli.ts 里了 最后一条值得单说:文档写「RuntimeName type cli.ts:145」,但 cli.ts 里 `type RuntimeName =` 出现 **0 次** —— 这个类型已经搬到 `agent-network/src/normalize-runtime.ts:16`。这不是行号漂移,是文件都换了。 所以这条改的是链接目标,不只是锚。 「确认流程」那条要小心:`Run again with --force to confirm.` 在 cli.ts 里出现 **2 次** (deleteCommand 8143 / networkCommand 10173),不能拿它当锚。读上下文确认文档说的是 节点删除,才选了 deleteCommand 里唯一的那句。 每条锚串都核过唯一。node-lifecycle.md 的行号 pin 已归零。 * docs: 收掉最后 3 条 cli.ts 行号引用 —— docs/ 归零(22 → 0) 这 3 条的锚文本里没有符号名,是逐条读正文判出来的: architecture.md:520 正文自己就写着「`sub === "dashboard"` 分支」 → 链接去掉行号即可,锚已经在正文里(唯一,cli.ts:6092) RFC-002:37 「参考实现」指的是 anet channel add/ls → 搜 `async function channelCommand(`(唯一,cli.ts:8159) pitfalls.md:80 「candidates 数组:源文件搜索顺序」 → cli.ts 里 `const candidates` 有 4 处,其中 1024 与 4333 都是 node-server.js 解析顺序。判据是 4333 那处的注释 `// dist/src/node-server.js(npm 包混淆后产物,优先)` 与 pitfalls 正文第 1 条逐字对应,1024 那处不是。 → 搜那条注释(唯一) 至此 docs/ 下 blob/main 形式的 cli.ts 行号引用为 0。每条锚串都核过在源码里唯一。 --------- Co-authored-by: t Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs/architecture.md | 24 ++++++++++++------------ docs/design-auth-network.md | 2 +- docs/node-lifecycle.md | 12 ++++++------ docs/pitfalls.md | 2 +- docs/rfcs/RFC-002-channel-bind-cli.md | 2 +- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9f5be95d3..1224eae8b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,7 @@ for await (const message of query({ prompt, options })) { /* ... */ } 默认值(hub=http://127.0.0.1:9200, runtime=claude-agent-sdk) ``` -verify [`cli.ts:228 loadProfile`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts): +verify [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function loadProfile(`: ```ts const p = join(nodesDir(), id, "config.json"); // .anet/nodes//config.json ``` @@ -125,7 +125,7 @@ const p = join(nodesDir(), id, "config.json"); // .anet/nodes//config.json > 上例是 `anet node create 开发马 --runtime claude-agent-sdk --model `(已登录)实际生成的最小集。条件字段:`teammateMode`(仅 `claude-code-cli`)、`session`(仅 `claude-code-cli` 或 `--session`)、`maxTurns`(仅 `--max-turns`)、`tools`(仅 `--tools`);`logLevel` 是 **top-level** 字段(不在 `flags` 里),且 `createCommand` 不写它(用户可选加)。 -verify [`cli.ts:246-273 saveProfile`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts): +verify [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function saveProfile(`: ```ts const toSave: Record = { anet_version, node_id, node_name, runtime, @@ -195,7 +195,7 @@ anet server [--port 9200] [--token xxx] [--db path] [--cors origins] ### `anet setup` -R511 校准:旧 doc 写「`anet setup --hub --alias --type`,配置新 Agent 加入网络」是 V2 早期签名 —— 当前 `anet setup`([`cli.ts:556 setupCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts))是**交互式 runtime 依赖安装器**,不带参数,也不写网络配置(入网走 `anet node create`)。 +R511 校准:旧 doc 写「`anet setup --hub --alias --type`,配置新 Agent 加入网络」是 V2 早期签名 —— 当前 `anet setup`([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function setupCommand(`)是**交互式 runtime 依赖安装器**,不带参数,也不写网络配置(入网走 `anet node create`)。 ```bash anet setup @@ -211,7 +211,7 @@ anet setup ### `anet run` -R511 校准:旧 doc 写的 `[--handler script.ts]` flag + 「handler 协议」是 V2 设计草稿,**当前不存在**。当前 `anet run`([`cli.ts:2044 runCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts))是用 Client SDK 起的**极简 standalone SSE agent**:连 hub、监听 task、自动 echo「收到」回复 —— **不跑 LLM**,区别于 `anet node start`(跑真实 AI runtime)。 +R511 校准:旧 doc 写的 `[--handler script.ts]` flag + 「handler 协议」是 V2 设计草稿,**当前不存在**。当前 `anet run`([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function runCommand(`)是用 Client SDK 起的**极简 standalone SSE agent**:连 hub、监听 task、自动 echo「收到」回复 —— **不跑 LLM**,区别于 `anet node start`(跑真实 AI runtime)。 ```bash anet run --alias [--hub ] @@ -313,11 +313,11 @@ await startServer({ ## 5. Channel 插件自动配置 — R221 校准 -`anet node start` 检测到 `runtime: "claude-code-cli"` 时,自动确保 Channel 插件可用([`cli.ts:1644 ensureMcpJson`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)): +`anet node start` 检测到 `runtime: "claude-code-cli"` 时,自动确保 Channel 插件可用([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function ensureMcpJson(`): 1. 从 npm 包 (`dist/src/node-server.js` 优先 / `src/node-server.ts` 兜底) 复制到 `{项目}/.anet/node-server.js`(**注意:是 `.js` 不是 `.ts`** —— [R216 chain](https://github.com/sleep2agi/agent-network/issues/10#issuecomment-4438192170)) 2. 安装依赖(`@modelcontextprotocol/sdk ^1.12.0` 通过 `bun install`) -3. 写入 `.mcp.json`:`commhub → .anet/node-server.js`([cli.ts:1724](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)) +3. 写入 `.mcp.json`:`commhub → .anet/node-server.js`([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `.mcp.json: commhub → .anet/node-server.js`) ``` {项目}/ @@ -327,9 +327,9 @@ await startServer({ └── package.json # @modelcontextprotocol/sdk ^1.12.0 ``` -已配置过且内容一致直接跳过(compare-by-content:`if (src !== dst) writeFileSync(...)`,[cli.ts:1679-1680](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts))。`anet init project` 也做同样的事(另外还写 CLAUDE.md)。 +已配置过且内容一致直接跳过(compare-by-content:`if (src !== dst) writeFileSync(...)`,[`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `if (src !== dst)`)。`anet init project` 也做同样的事(另外还写 CLAUDE.md)。 -R221 校准:原 doc 写「`runtime: "claude-code"`」+「`.anet/node-server.ts`」+「`.mcp.json args:[".anet/node-server.ts"]`」三处都是 V2 早期命名/文件名,当前 runtime name 是 `claude-code-cli`([RuntimeName type cli.ts:145](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)),落盘文件名是 `.js`。 +R221 校准:原 doc 写「`runtime: "claude-code"`」+「`.anet/node-server.ts`」+「`.mcp.json args:[".anet/node-server.ts"]`」三处都是 V2 早期命名/文件名,当前 runtime name 是 `claude-code-cli`(RuntimeName type —— 已移出 cli.ts,现在在 [`agent-network/src/normalize-runtime.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/src/normalize-runtime.ts),搜 `export type RuntimeName =`),落盘文件名是 `.js`。 --- @@ -447,9 +447,9 @@ R223 校准:旧 doc 只写 `bun build src/client.ts bin/cli.ts --outdir dist - - ⚠️ 旧 `COMMHUB_AUTH_TOKEN` 仅 `/api/*` 读类兼容(v1.0 移除) ### 配置安全 — R223 校准 -- `~/.anet/server/admin-utok.json` 自动 chmod 600([`cli.ts:105-111 saveAdminUtok`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) `writeFileSync(..., {mode: 0o600})` + `chmodSync(..., 0o600)`,v0.8 bootstrap 写入 admin token) -- `~/.anet/server/config.json` 自动 chmod 600([`cli.ts:89-95 saveServerConfig`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)) -- ⚠️ `~/.anet/config.json` **不是 600** —— [`cli.ts:77-81 saveGlobal`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) 用默认 `writeFileSync` 无 mode 选项,实际权限通常 `644` (`rw-r--r--`)。在多用户机器上其他本地用户可读你的 utok_。**单用户 host 影响有限,多用户共享 host 建议手动 `chmod 600 ~/.anet/config.json`**(v0.9 RFC 待修) +- `~/.anet/server/admin-utok.json` 自动 chmod 600([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function saveAdminUtok(` `writeFileSync(..., {mode: 0o600})` + `chmodSync(..., 0o600)`,v0.8 bootstrap 写入 admin token) +- `~/.anet/server/config.json` 自动 chmod 600([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function saveServerConfig(`) +- ⚠️ `~/.anet/config.json` **不是 600** —— [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function saveGlobal(` 用默认 `writeFileSync` 无 mode 选项,实际权限通常 `644` (`rw-r--r--`)。在多用户机器上其他本地用户可读你的 utok_。**单用户 host 影响有限,多用户共享 host 建议手动 `chmod 600 ~/.anet/config.json`**(v0.9 RFC 待修) - 项目 `.anet/nodes//config.json` 不应包含 token(放全局配置;R222 chain 说明项目 config 用 hub/token 字段覆盖全局是 advanced use case) - `.anet/` 应加入 `.gitignore` 防止提交 @@ -521,7 +521,7 @@ R256 校准:旧 doc 用 `send_task(hub, result)` 回复任务结果 —— 这 ## 10. Web Dashboard -> **R220 校准(2026-05-13)**:本节的「内置轻量 UI」+「`http://YOUR_IP:9200/dashboard`」是 V2 早期设计草稿,**v0.8 实际未实现** —— commhub-server `server/src/index.ts` 没有 `/dashboard` 路由([全 source grep `/dashboard` 0 hit](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts))。当前**唯一 Dashboard 是独立的 Next.js 包 `@sleep2agi/agent-network-dashboard`**,通过 `anet hub dashboard` 子命令拉起([`agent-network/bin/cli.ts:2386`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) `sub === "dashboard"` 分支,默认端口 3000;版本不再 hardcode pin —— [`dashboardReleaseTag()` cli.ts:347](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) 默认拉 `@preview` tag,可用 `ANET_DASHBOARD_VERSION` env 覆盖,跟 anet release channel 对齐 — 见 #61)。最新部署方式见 [anet.sh/guide/dashboard](https://anet.sh/guide/dashboard)。下面的「两种 Dashboard」/「内置 UI 设计原则」/「实现方案」/「HTML 结构」全是 V2 设计草稿,仅保留历史背景,**当前不适用**。 +> **R220 校准(2026-05-13)**:本节的「内置轻量 UI」+「`http://YOUR_IP:9200/dashboard`」是 V2 早期设计草稿,**v0.8 实际未实现** —— commhub-server `server/src/index.ts` 没有 `/dashboard` 路由([全 source grep `/dashboard` 0 hit](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts))。当前**唯一 Dashboard 是独立的 Next.js 包 `@sleep2agi/agent-network-dashboard`**,通过 `anet hub dashboard` 子命令拉起([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) `sub === "dashboard"` 分支,默认端口 3000;版本不再 hardcode pin —— [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function dashboardReleaseTag(` 默认拉 `@preview` tag,可用 `ANET_DASHBOARD_VERSION` env 覆盖,跟 anet release channel 对齐 — 见 #61)。最新部署方式见 [anet.sh/guide/dashboard](https://anet.sh/guide/dashboard)。下面的「两种 Dashboard」/「内置 UI 设计原则」/「实现方案」/「HTML 结构」全是 V2 设计草稿,仅保留历史背景,**当前不适用**。 ### 当前 Dashboard diff --git a/docs/design-auth-network.md b/docs/design-auth-network.md index 782f1525e..392bb99c8 100644 --- a/docs/design-auth-network.md +++ b/docs/design-auth-network.md @@ -13,7 +13,7 @@ > - 首个用户自动 admin > - users.plan 字段 + networks.visibility/max_members 字段 > - **RFC-001 Phase 1**:COMMHUB_AUTH_TOKEN 软废弃,仅 `/api/*` 只读 + deprecation warning -> - **RFC-001 Phase 2**:admin utok_ bootstrap(`~/.anet/server/admin-utok.json` chmod 600,R224 校准:实际路径是 `~/.anet/server/` 不是 `~/.commhub/`,verify [`cli.ts:28 adminUtokPath`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L28))、`anet passwd` / `anet hub admin reset-user`、密码强度 ≥ 8 + 弱密码字典、`anet doctor --fix` 探测并重发 ntok_ +> - **RFC-001 Phase 2**:admin utok_ bootstrap(`~/.anet/server/admin-utok.json` chmod 600,R224 校准:实际路径是 `~/.anet/server/` 不是 `~/.commhub/`,verify [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function adminUtokPath(`)、`anet passwd` / `anet hub admin reset-user`、密码强度 ≥ 8 + 弱密码字典、`anet doctor --fix` 探测并重发 ntok_ > > ❌ 未实现(目标态,排到 v0.9+): > - MCP 写操作的**细粒度**网络角色检查 —— `canWrite` (tools.ts:24 `role !== "viewer"`) 只挡 viewer,owner/admin/member 一视同仁;且**无 per-task ownership 检查**(member 能 cancel/reassign 网络里任何任务,不限自己派的)。注:viewer 已经**不能** send_task(canWrite 拦住),缺的是更细的角色/归属门控 diff --git a/docs/node-lifecycle.md b/docs/node-lifecycle.md index e3b0d958b..97f25cb7c 100644 --- a/docs/node-lifecycle.md +++ b/docs/node-lifecycle.md @@ -169,9 +169,9 @@ register() → callCommHub("report_status", { **触发**: `anet node rename ` [`--force`] -**前置条件**: rename 需要 hub + token + network_id(`anet login` 后才有,缺则 `process.exit(1)`)。运行中的 node **必须加 `--force`** —— [`cli.ts:2629-2631 renameCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2629) 检测到 `.pid` 进程存活且没 `--force` 时直接退出;运行中改名走 RFC-010 §4.4 active rename,**不杀进程**。 +**前置条件**: rename 需要 hub + token + network_id(`anet login` 后才有,缺则 `process.exit(1)`)。运行中的 node **必须加 `--force`** —— [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function renameCommand(` 检测到 `.pid` 进程存活且没 `--force` 时直接退出;运行中改名走 RFC-010 §4.4 active rename,**不杀进程**。 -**RFC-010 两阶段事务** —— R481 校准:旧 doc 的「P0 只改本地 `renameSync` + P1 CommHub rename API 未采纳」已过时,当前 [`cli.ts:2583-2721 renameCommand`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2583) 实现的是带 CommHub 协同的两阶段事务: +**RFC-010 两阶段事务** —— R481 校准:旧 doc 的「P0 只改本地 `renameSync` + P1 CommHub rename API 未采纳」已过时,当前 [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function renameCommand(` 实现的是带 CommHub 协同的两阶段事务: - **PHASE 1 — PREPARE(全程可回滚,old node 原封不动)**:写 `rename.lock` → `cpSync(oldDir → newDir)`(**copy 不是 move**)→ 更新 `newProfile.node_name` / `alias` + `saveProfile` → POST `/api/node-rename/prepare` 拿 `txn_id`。任一步失败 → 回滚(删 newDir + POST `/api/node-rename/abort` + 删 lock),`old` 完全不变。 - **PHASE 2 — COMMIT(顺序敏感)**: @@ -199,18 +199,18 @@ register() → callCommHub("report_status", { **触发**: `anet node delete ` (首次提示,再加 `--force` 才真删) -**前置条件**: 不强制 offline —— `anet node delete` 会先 `stopNode(nodeId)` 杀进程 + `await notifyServerOffline(...)` 通知 hub 后再删本地目录([cli.ts:2800-2840 deleteCommand](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2800))。 +**前置条件**: 不强制 offline —— `anet node delete` 会先 `stopNode(nodeId)` 杀进程 + `await notifyServerOffline(...)` 通知 hub 后再删本地目录([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function deleteCommand(`)。 **实际数据变更**: 1. **本地**: `rmSync(.anet/nodes//, { recursive: true, force: true })` —— 删整个目录(含 config.json、channels/、logs/;目录名是 alias / node_name,不是内部 node_id 字段;R209 chain 一致) -2. **CommHub session**: `notifyServerOffline` 调用 `report_status(offline)`([cli.ts:2725-2750](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2725))—— **只把 sessions row.status 改成 offline,不 DELETE**。这一行 session 会一直留在 db 里(10 分钟 stale cutoff 触发时也只是再次 mark offline)。 +2. **CommHub session**: `notifyServerOffline` 调用 `report_status(offline)`([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function notifyServerOffline(`)—— **只把 sessions row.status 改成 offline,不 DELETE**。这一行 session 会一直留在 db 里(10 分钟 stale cutoff 触发时也只是再次 mark offline)。 3. **CommHub inbox**: **不清理** —— 残留 inbox 消息会一直留着。如果之后用同 alias 再 `anet node start`,新进程会从 `getInbox` 拉到旧消息(注意:旧消息可能跟新进程 session 上下文无关)。 ::: warning 旧 doc P1 设计未采纳 原 doc 写「DELETE FROM sessions / DELETE FROM inbox」是设计草稿意图,**未实施**。实际只 mark offline + 删本地目录,不清服务端 row(v0.8.2 起验证,至当前 stable 未变)。 ::: -**确认流程**([cli.ts:2831-2835](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2831)): +**确认流程**([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `This will delete "${displayName}" (node_id:`): ``` $ anet node delete 指挥室 @@ -380,7 +380,7 @@ anet node rename 指挥室 总指挥 ### anet 识别 node 的逻辑 -实际函数名 `resolveNodeRef`([`cli.ts:198`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L198)): +实际函数名 `resolveNodeRef`([`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `function resolveNodeRef(`): ```typescript function resolveNodeRef(ref: string) { diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 78d34763d..b84baef0a 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -77,7 +77,7 @@ if (src !== dst) { } ``` -verify [`agent-network/bin/cli.ts:1658-1674`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L1658) `candidates` 数组:源文件搜索顺序为 +verify [`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `// dist/src/node-server.js(npm 包混淆后产物,优先)` `candidates` 数组:源文件搜索顺序为 1. `dist/src/node-server.js`(npm 包混淆后产物,优先) 2. `src/node-server.ts`(开发环境源码) 3. `npm root -g/@sleep2agi/agent-network/...` 全局安装路径兜底 diff --git a/docs/rfcs/RFC-002-channel-bind-cli.md b/docs/rfcs/RFC-002-channel-bind-cli.md index a5978e87e..0ba2b9837 100644 --- a/docs/rfcs/RFC-002-channel-bind-cli.md +++ b/docs/rfcs/RFC-002-channel-bind-cli.md @@ -34,7 +34,7 @@ anet channel add telegram anet channel ls [node-id] ``` -参考实现:[`agent-network/bin/cli.ts:2685-2788`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts#L2685)。 +参考实现:[`cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function channelCommand(`。 效果: - 在 `.anet/nodes//channels/telegram/` 落两份配置: From 31ea026373f1826e28a99cf86baf0aeffdf2909b Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 07:12:33 +0800 Subject: [PATCH 53/59] =?UTF-8?q?ci(docs):=20=E7=AC=A6=E5=8F=B7=E9=94=9A?= =?UTF-8?q?=E7=82=B9=E5=BF=85=E9=A1=BB=E7=9C=9F=E5=AE=9E=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E8=A1=A5=E4=B8=8A=20#857=20=E6=8D=A2?= =?UTF-8?q?=E8=BF=87=E5=8E=BB=E4=B9=8B=E5=90=8E=E6=B2=A1=E4=BA=BA=E7=9C=8B?= =?UTF-8?q?=E7=9A=84=E9=82=A3=E4=B8=80=E6=A0=BC=20(#932)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #857 把 docs 里的行号 pin 换成了符号锚点。换得对:那 13 条行号逐条对下来 **13 条全错** —— `loadProfile` 实际在 cli.ts:1274(doc 写 228),`runCommand` 在 5812(doc 写 2044),`ensureMcpJson` 指的那一行是**空行**。而它们全都长得像 有效引用:格式对、行号在文件范围内、点开能打开,所以读的人不会怀疑。 但换完之后留了一格没人看:**符号锚点不会因为「上面插了几行」失效,却会因为 改名而失效,而失效之后同样没有任何东西会喊。** #843 那道门在数行号 pin(守住 不再变多),符号锚点在变多,一直没有对应的门。 判据:每一条 `搜 \`X\`` 里的 X,必须在**它左边最近的那个源码链接**指向的文件里 逐字存在。两类失败都报 —— 找不到(改名/删除/写错),以及前面根本没有链接 (无法判定它指哪个文件,这本身就是缺陷)。 ## 起点(与手工核对一致) checked 21 symbol anchor(s) across 262 tracked doc(s); 21 resolved every symbol anchor exists in the file it names. 21 这个数是先手工数出来的,再让脚本跑 —— 两边对上才用。 ## 见红(三种坏法,三种输出,互不相同) A 改一个锚点为源码里不存在的名字 → rc=1 "not found in 'agent-network/bin/cli.ts'" B 去掉锚点前面的链接 → rc=1 "no source link precedes this anchor" C 把扫描范围改成一个不存在的目录 → rc=2 "0 tracked .md … 扫描范围塌了" 三份输出的 md5 两两不同。C 是分母承重:🔴 这道门最可能的坏法不是判据写错, 是**一条都没扫到然后打印一片绿** —— 那种假绿和真绿逐字相同。所以扫到 0 个 md 或 0 条锚点一律 exit 2,让「没问题」和「没有看」在输出上长得不一样。 变异做完全部还原:`cmp docs/architecture.md` 与 main 逐字节相同。 ## 🔴 刻意不加 paths 过滤 这道门的主要失效场景是**有人在 cli.ts 里把一个函数改名**,不是有人改了 doc。 按 `docs/**` 过滤的话,改源码的 PR 不会触发它 —— 门在、判据也对,但在最需要它 的那一类改动上永远不会被触发。整个脚本跑完不到一秒,省这点没有意义。 `--selftest` 8 条,含「无锚点文本 → 计数必须为 0」。job 名 `doc-symbol-anchors` 全仓唯一(25 个 job,25 个不同的名字)。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-doc-symbol-anchors.py | 238 ++++++++++++++++++++ .github/workflows/doc-symbol-anchors.yml | 45 ++++ 2 files changed, 283 insertions(+) create mode 100755 .github/scripts/check-doc-symbol-anchors.py create mode 100644 .github/workflows/doc-symbol-anchors.yml diff --git a/.github/scripts/check-doc-symbol-anchors.py b/.github/scripts/check-doc-symbol-anchors.py new file mode 100755 index 000000000..838a00222 --- /dev/null +++ b/.github/scripts/check-doc-symbol-anchors.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""docs 里的「符号锚点」必须在它自己点名的那个文件里真实存在。 + +背景 —— 为什么需要这道门 +======================== + +#857 把 docs 里 13 条 `cli.ts:228 loadProfile` 这样的**行号 pin** 换成了 +**符号锚点**: + + [`cli.ts`](…/agent-network/bin/cli.ts) —— 搜 `function loadProfile(` + +换的理由是行号会漂:那 13 条抽查下来 **13 条全错**,`loadProfile` 实际在 1274 行, +doc 写 228;`runCommand` 在 5812,doc 写 2044。而它们全都**长得像有效引用** —— +格式对、行号在文件范围内、点开能打开 —— 所以读的人不会怀疑。 + +符号锚点确实不会因为「上面插了几行」而失效。**但它会因为改名而失效,而失效之后 +同样没有任何东西会喊。** #843 那道门在数行号 pin(守住不再变多),而符号锚点 +在变多,却没有任何门在看。 + +这道门补的就是这一格:**每一条 `搜 `X`` 里的 X,必须在它前面那个链接指向的文件里 +真实存在。** + +判据 +==== + +对每一条 `搜 ```: + 1. 往左找**最近的**一个指向本仓源码的 markdown 链接,取出仓库相对路径; + 2. 断言 `` 是那个文件内容的子串(逐字,不做正则,不忽略空白)。 + +两类失败都报: + - anchor 在文件里找不到 → 锚点失效(改名/删除/写错) + - anchor 前面没有链接 → 无法判定它指哪个文件,这本身就是缺陷 + +分母承重 +======== + +🔴 这道门最可能的坏法不是「判据写错」,是**「一条都没扫到」然后打印一片绿**。 +所以:扫到 0 个 md 文件、或 0 条锚点,一律 exit 2(而不是 exit 0)。 +「没有问题」和「没有看」在输出上必须长得不一样。 + +用法 +==== + + python3 .github/scripts/check-doc-symbol-anchors.py + python3 .github/scripts/check-doc-symbol-anchors.py --selftest +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +# 锚点本体:`搜 ` 之后的第一个反引号串。 +# 🔴 只取第一个 —— docs/architecture.md:450 那种一行里 `搜 X` 后面还跟着两个 +# 描述性代码串(`writeFileSync(..., {mode: 0o600})` 之类),它们不是锚点。 +ANCHOR = re.compile(r"搜\s*`([^`]+)`") + +# 指向本仓源码的链接。两种写法都收: +# [`cli.ts`](https://github.com///blob//agent-network/bin/cli.ts) +# [`cli.ts`](../../agent-network/bin/cli.ts) +BLOB_LINK = re.compile( + r"\]\(\s*(?:https?://github\.com/[^/\s]+/[^/\s]+/blob/[^/\s]+/)?([^)\s#]+?)\s*(?:#[^)\s]*)?\)" +) + +# 只有这些后缀算「源码文件」——链接到别的 .md 不构成锚点目标。 +SOURCE_SUFFIXES = {".ts", ".tsx", ".js", ".mjs", ".cjs", ".py", ".sh", ".yml", ".yaml", ".json"} + +DOC_ROOTS = ("docs/", "docs-site/") + + +def tracked_markdown(repo: Path) -> list[str]: + out = subprocess.run( + ["git", "ls-files", "-z", "--", "docs", "docs-site"], + cwd=repo, capture_output=True, text=True, check=True, + ).stdout + return sorted(p for p in out.split("\0") if p.endswith(".md")) + + +def nearest_source_link(line: str, before: int) -> str | None: + """往左找最近的、指向源码文件的链接目标。""" + best = None + for m in BLOB_LINK.finditer(line): + if m.end() > before: + break + target = m.group(1) + if Path(target).suffix in SOURCE_SUFFIXES: + best = target + return best + + +def scan_text(rel: str, text: str) -> tuple[list[tuple], int]: + """返回 (问题列表, 本文件里的锚点数)。""" + problems: list[tuple] = [] + count = 0 + for lineno, line in enumerate(text.split("\n"), start=1): + for m in ANCHOR.finditer(line): + count += 1 + anchor = m.group(1) + target = nearest_source_link(line, m.start()) + if target is None: + problems.append((rel, lineno, anchor, None, "no source link precedes this anchor")) + continue + problems.append((rel, lineno, anchor, target, None)) + return problems, count + + +def resolve(repo: Path, doc_rel: str, target: str) -> Path: + """相对链接按 doc 所在目录解析;仓库绝对路径(如 agent-network/bin/cli.ts)按仓根解析。""" + if target.startswith("./") or target.startswith("../"): + return (repo / doc_rel).parent.joinpath(target).resolve() + return (repo / target).resolve() + + +def run(repo: Path) -> int: + docs = tracked_markdown(repo) + if not docs: + print("FAIL: 0 tracked .md under docs/ or docs-site/ — 扫描范围塌了", file=sys.stderr) + return 2 + + pending: list[tuple] = [] + total_anchors = 0 + for rel in docs: + try: + text = (repo / rel).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + print(f"::error file={rel}::cannot read: {exc}") + pending.append((rel, 0, "", None, f"unreadable: {exc}")) + continue + found, n = scan_text(rel, text) + pending.extend(found) + total_anchors += n + + if total_anchors == 0: + print("FAIL: 0 symbol anchors found across " + f"{len(docs)} doc(s) — 判据没变,是取集塌了", file=sys.stderr) + return 2 + + problems = 0 + checked = 0 + for rel, lineno, anchor, target, note in pending: + if note: + print(f"::error file={rel},line={lineno}::symbol anchor `{anchor}` — {note}") + problems += 1 + continue + path = resolve(repo, rel, target) + try: + body = path.read_text(encoding="utf-8") + except OSError: + print(f"::error file={rel},line={lineno}::symbol anchor `{anchor}` " + f"names '{target}', which does not exist") + problems += 1 + continue + checked += 1 + if anchor not in body: + print(f"::error file={rel},line={lineno}::symbol anchor `{anchor}` " + f"not found in '{target}' — 被改名/删掉了,或者一开始就写错了") + problems += 1 + + print(f"checked {total_anchors} symbol anchor(s) across {len(docs)} tracked doc(s); " + f"{checked} resolved to a readable source file") + if problems: + print(f"\n{problems} problem(s).") + return 1 + print("every symbol anchor exists in the file it names.") + return 0 + + +# --------------------------------------------------------------------------- +# selftest +# +# 🔴 夹具里的锚点用字符串拼接造,不写成字面量 —— 否则这个文件自己会被 +# 真实扫描当成 docs 命中(它不在 docs/ 下,但同类门吃过这个亏,留个明示)。 +# --------------------------------------------------------------------------- +def selftest() -> int: + SEARCH = "搜" + BT = "`" + + def anchor(text: str) -> str: + return SEARCH + " " + BT + text + BT + + def link(target: str) -> str: + return "[`x`](https://github.com/o/r/blob/main/" + target + ")" + + cases: list[tuple[str, bool, str]] = [] + + def check(name: str, line: str, src_map: dict[str, str], want_problem: bool) -> None: + probs, n = scan_text("docs/f.md", line) + got_problem = False + for _rel, _ln, a, target, note in probs: + if note: + got_problem = True + elif a not in src_map.get(target or "", ""): + got_problem = True + ok = (got_problem == want_problem) and n >= 1 + cases.append((name, ok, f"anchors={n} problem={got_problem} want={want_problem}")) + + src = {"a/b.ts": "function loadProfile() {}\nconst x = 1;\n"} + + check("锚点存在 → 过", link("a/b.ts") + " —— " + anchor("function loadProfile("), src, False) + check("锚点不存在 → 红", link("a/b.ts") + " —— " + anchor("function gone("), src, True) + check("锚点前没有链接 → 红", "见 " + anchor("function loadProfile("), src, True) + check("链接是 .md 不算源码 → 红", + "[`d`](https://github.com/o/r/blob/main/docs/x.md) " + anchor("function loadProfile("), + src, True) + check("一行两个链接,取最近的那个", + link("a/other.ts") + " 前文 " + link("a/b.ts") + " —— " + anchor("function loadProfile("), + src, False) + check("搜后面跟多个代码串,只有第一个是锚点", + link("a/b.ts") + " —— " + anchor("function loadProfile(") + " " + BT + "无关描述" + BT, + src, False) + check("逗号连接(不是破折号)也算", + link("a/b.ts") + "," + anchor("function loadProfile("), src, False) + + # 分母:一条锚点都没有的文本,scan 必须返回 0(上游据此 exit 2) + _p, n0 = scan_text("docs/f.md", "一段没有任何锚点的正文") + cases.append(("无锚点文本 → count=0(上游 exit 2)", n0 == 0, f"count={n0}")) + + for name, ok, detail in cases: + print(f" {'ok ' if ok else 'FAIL'} {name} [{detail}]") + bad = sum(1 for _n, ok, _d in cases if not ok) + print(f"selftest: {len(cases) - bad}/{len(cases)} ok") + return 1 if bad else 0 + + +def main() -> int: + if "--selftest" in sys.argv: + return selftest() + repo = Path(subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, check=True, + ).stdout.strip()) + return run(repo) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/doc-symbol-anchors.yml b/.github/workflows/doc-symbol-anchors.yml new file mode 100644 index 000000000..afe87046d --- /dev/null +++ b/.github/workflows/doc-symbol-anchors.yml @@ -0,0 +1,45 @@ +# docs 里的「符号锚点」必须在它自己点名的那个文件里真实存在。 +# +# 2026-08-18,#857 把 docs 里 13 条 `cli.ts:228 loadProfile` 这样的行号 pin 换成了 +# `[cli.ts](…) —— 搜 \`function loadProfile(\`` 这样的符号锚点。换的理由是行号会漂: +# 那 13 条逐条对下来 **13 条全错** —— `loadProfile` 实际在 1274 行(doc 写 228), +# `runCommand` 在 5812(doc 写 2044),`ensureMcpJson` 那一行是空行。而它们全都 +# 长得像有效引用:格式对、行号在文件范围内、点开能打开。 +# +# 符号锚点不会因为「上面插了几行」而失效,但**会因为改名而失效** —— 而失效之后, +# 在这道门之前,没有任何东西会喊。#843 那道门在数行号 pin(守住不再变多), +# 符号锚点在变多,却一直没人看。 +# +# 起点是绿的:main 上 21 条锚点,21 条全部命中(与手工核对的数字一致)。 +# 它不是积压金丝雀 —— 红了就意味着刚刚有东西被改坏,而不是「还有一堆没清」。 +# +# 🔴 关于触发范围,这里做了一个刻意的选择:**不加 paths 过滤。** +# +# 这道门的主要失效场景是「有人在 cli.ts 里把一个函数改名」,而不是「有人改了 doc」。 +# 如果按 `docs/**` 过滤,那么改源码的 PR 不会触发它 —— 门还在、判据也对,但在 +# 最需要它的那一类改动上**永远不会被触发**。整个脚本跑完不到一秒,省这点没有意义。 +# +# (锚点当前指向 agent-network/bin/cli.ts、agent-network/src/normalize-runtime.ts、 +# server/src/index.ts。把这几条写进 paths 也能工作,但下一条锚点指向新文件时 +# 就会静默漏掉 —— 那正是「改扫描范围让门悄悄失效」的形状。) + +name: lint (doc symbol anchors) + +on: + pull_request: + push: + branches: [main] + +jobs: + scan: + # 这个 name 是 GitHub 上 check 的名字,也是分支保护里 required check 唯一能写的 + # 标识符,必须全仓唯一。 + name: doc-symbol-anchors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # 先跑取集自检。这道门最可能的坏法不是判据写错,是**一条锚点都没扫到** + # 然后打印一片绿 —— 那种假绿和真绿逐字相同。selftest 里第 8 条专门钉 + # 「没有锚点时计数必须是 0」,而主程序据此 exit 2 而不是 exit 0。 + - run: python3 .github/scripts/check-doc-symbol-anchors.py --selftest + - run: python3 .github/scripts/check-doc-symbol-anchors.py From 5a330a09c8b982eead1fcc32e5309ecc35346bd0 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 07:17:05 +0800 Subject: [PATCH 54/59] =?UTF-8?q?docs(changelog):=20v0.10.1=20=E9=82=A3?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=20cli.ts=20=E5=BC=95=E7=94=A8=E9=92=89?= =?UTF-8?q?=E5=88=B0=E5=BD=93=E6=97=B6=E7=9A=84=E6=8F=90=E4=BA=A4(?= =?UTF-8?q?=E8=A1=8C=E5=8F=B7=E5=9C=A8=E8=8C=83=E5=9B=B4=E5=86=85=E4=BD=86?= =?UTF-8?q?=E5=B7=B2=E7=BB=8F=E6=8C=87=E9=94=99)=20(#851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changelog:713 的两条引用按 blob/main 钉行号,现在都已经指错了 —— 因为没有越界, 所以 #834 那种「文件行数 vs 引用行号」的判据抓不到它们。 cli.ts:61 声称是 PINNED_SERVER_VERSION → main 上真实在 791 行, 61 行现在是 } from "../src/opencode-preset"; cli.ts:2589 声称是 bunx commhub-server 启动点 → 真实在 5765 行, 2589 行现在是 opencode auth-login 的帮助文本 钉到 3a387204(2026-05-17),不是修复提交 4d240241,理由: 两个提交上 L61 / L2589 都精确命中,但 3a387204 是 4d240241 的父提交, 它的 PINNED_SERVER_VERSION 值是 "0.8.0" —— 正是正文描述的那个 bug 状态 (「仍 hardcode 0.8.0」「实际 bunx --bun @sleep2agi/commhub-server@0.8.0」)。 钉修复后那次会让链接显示 0.8.2,和正文对不上。 3a387204 是 origin/main 的祖先,blob 链接实打 HTTP 200。 Co-authored-by: vansin Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs-site/docs/changelog.md | 2 +- docs-site/docs/en/changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 5111e4e4a..d6f18ceb7 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -710,7 +710,7 @@ anet project restart # 重启项目(拉新 agent-n ### Fix -[`agent-network/bin/cli.ts` 的 `PINNED_SERVER_VERSION`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 61 行) 跨 v0.9.x + v0.10.0 promote 漏 bump,仍 hardcode `0.8.0` —— `anet hub start` 实际 `bunx --bun @sleep2agi/commhub-server@0.8.0` 启服务([`cli.ts` 里 `anet hub start` 的 `bunx --bun @sleep2agi/commhub-server@…` 那处](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 2589 行)),跑的是老 server 不是 v0.10.0 ship 的 `0.8.2`。直接影响: +[`agent-network/bin/cli.ts` 的 `PINNED_SERVER_VERSION`](https://github.com/sleep2agi/agent-network/blob/3a387204/agent-network/bin/cli.ts#L61)(钉在当时的提交 `3a387204`,第 61 行) 跨 v0.9.x + v0.10.0 promote 漏 bump,仍 hardcode `0.8.0` —— `anet hub start` 实际 `bunx --bun @sleep2agi/commhub-server@0.8.0` 启服务([`cli.ts` 里 `anet hub start` 的 `bunx --bun @sleep2agi/commhub-server@…` 那处](https://github.com/sleep2agi/agent-network/blob/3a387204/agent-network/bin/cli.ts#L2589)(同一提交,第 2589 行)),跑的是老 server 不是 v0.10.0 ship 的 `0.8.2`。直接影响: - [#99](https://github.com/sleep2agi/agent-network/issues/99) 守护节点 endpoint family `GET /api/server/:host/health` + `GET /api/server/:host/agents` 在 0.8.0 不存在 → **404** - [#142](https://github.com/sleep2agi/agent-network/issues/142) server schema align `process_telemetry` 字段在 0.8.0 没接 → 老 schema silent-drop 字段 diff --git a/docs-site/docs/en/changelog.md b/docs-site/docs/en/changelog.md index ab7a75987..de4ce63cc 100644 --- a/docs-site/docs/en/changelog.md +++ b/docs-site/docs/en/changelog.md @@ -709,7 +709,7 @@ Release flow follows the [v0.9.0 split-brain lessons #126](https://github.com/sl ### Fix -[`agent-network/bin/cli.ts` 的 `PINNED_SERVER_VERSION`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 61 行) was never bumped across the v0.9.x + v0.10.0 promotes — it stayed hardcoded at `0.8.0`. That meant `anet hub start` was actually running `bunx --bun @sleep2agi/commhub-server@0.8.0` ([`cli.ts` 里 `anet hub start` 的 `bunx --bun @sleep2agi/commhub-server@…` 那处](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts)(当时在 2589 行)) — the old server, not the v0.10.0-shipped `0.8.2`. Direct impact: +[`PINNED_SERVER_VERSION` in `agent-network/bin/cli.ts`](https://github.com/sleep2agi/agent-network/blob/3a387204/agent-network/bin/cli.ts#L61) (pinned to commit `3a387204`, line 61 at the time) was never bumped across the v0.9.x + v0.10.0 promotes — it stayed hardcoded at `0.8.0`. That meant `anet hub start` was actually running `bunx --bun @sleep2agi/commhub-server@0.8.0` ([the `bunx --bun @sleep2agi/commhub-server@…` call site in `cli.ts`](https://github.com/sleep2agi/agent-network/blob/3a387204/agent-network/bin/cli.ts#L2589), same commit, line 2589) — the old server, not the v0.10.0-shipped `0.8.2`. Direct impact: - The [#99](https://github.com/sleep2agi/agent-network/issues/99) per-server daemon endpoints `GET /api/server/:host/health` + `GET /api/server/:host/agents` don't exist in 0.8.0 → **404** - [#142](https://github.com/sleep2agi/agent-network/issues/142) server schema alignment for `process_telemetry` isn't wired in 0.8.0 → the older schema silently drops the field From de0a93af283e07c602ff2775836435f80b99f13e Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 07:17:13 +0800 Subject: [PATCH 55/59] =?UTF-8?q?docs(changelog):=20=E6=8A=8A=20RFC-014=20?= =?UTF-8?q?=E9=82=A3=E6=9D=A1=E7=9A=84=E6=BA=90=E7=A0=81=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E9=92=89=E5=88=B0=E5=BD=93=E6=97=B6=E7=9A=84=E6=8F=90=E4=BA=A4?= =?UTF-8?q?,=E4=B8=8D=E5=86=8D=E9=92=89=20main=20(#834)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changelog 中英两处都指向 blob/main/server/src/index.ts#L253。这个链接 现在指到一个只有 15 行的文件的第 253 行 —— 因为 #438 把 index.ts 改成了 run-entry shim,真实代码搬到了 server.ts: // Run-entry shim (#438 corrective). // All real code lives in ./server.ts … 改钉 22ed1886(写这条 changelog 的那次提交)。核过:那时 index.ts 有 1623 行,:253 正是这条 changelog 描述的 disk 告警逻辑 (disk_avail_gb < 1 → red),:253-326 区间里 disk_*_gb 出现 6 次。 病因不是"行号漂了",是 ref 选错了:changelog 条目描述的是一个冻结的 历史时刻,却指向会移动的 main —— 这样的链接必然烂,而且是无声地烂。 换成当时的 SHA 之后,它永远成立。 这与参考页(api/rest.md 等)的修法不同:那边该改钉符号,因为它描述的是 "现在的行为";changelog 描述的是"当时发生了什么",该钉当时的 commit。 把两者混为一谈会修错 —— 给 changelog 更新行号,下次重构又坏。 详见 #831。 Co-authored-by: vansin Co-authored-by: t Co-authored-by: Claude Opus 5 --- docs-site/docs/changelog.md | 2 +- docs-site/docs/en/changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index d6f18ceb7..18b923f21 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -658,7 +658,7 @@ v0.10.4 Vincent 紧急 ship 跳过 测试团队 Docker smoke gate(不在生产 - `HostTelemetry` interface 加 `disk_total_gb` / `disk_used_gb` / `disk_avail_gb`,`getHostTelemetry()` 通过 `toGb()` 同 mem/cpu 同 path 合成 - **Backward compat**:老 server 端 schema silent-drop unknown keys;agent / server 可独立升 -接 [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `/api/server/:host/health` 响应现在带 disk 三字段 + 24h 分桶 history 也含 `disk_avail_min` / `disk_used_max`;`alert_level` 加 `disk < 1GB critical / < 5GB warn` 触发([`server/src/index.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts)(当时在 253-258 行;行号已漂,按当时的符号名搜))。 +接 [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `/api/server/:host/health` 响应现在带 disk 三字段 + 24h 分桶 history 也含 `disk_avail_min` / `disk_used_max`;`alert_level` 加 `disk < 1GB critical / < 5GB warn` 触发([`server/src/index.ts:253-258`](https://github.com/sleep2agi/agent-network/blob/22ed1886/server/src/index.ts#L253),钉在当时的提交 `22ed1886`;该文件此后已被拆分,main 上只剩 16 行,所以这里不指向 main)。 测试团队 Docker Linux smoke 3/3 PASS(disk 299.8 GB total / 216 used / 71.5 avail,alert green,backward compat verified)。 diff --git a/docs-site/docs/en/changelog.md b/docs-site/docs/en/changelog.md index de4ce63cc..cc05ae83c 100644 --- a/docs-site/docs/en/changelog.md +++ b/docs-site/docs/en/changelog.md @@ -657,7 +657,7 @@ See the [v0.10.3 release notes](https://github.com/sleep2agi/agent-network/relea - `HostTelemetry` interface gains `disk_total_gb` / `disk_used_gb` / `disk_avail_gb`; `getHostTelemetry()` composes disk via `toGb()` on the same path as mem/cpu - **Backward compat**: older servers silently drop unknown keys; agents and servers upgrade independently -Wires through [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `GET /api/server/:host/health` now returns disk's three fields, the 24h bucketed history includes `disk_avail_min` / `disk_used_max`, and `alert_level` adds `disk < 1GB critical / < 5GB warn` triggers ([`server/src/index.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts)(当时在 253-258 行;行号已漂,按当时的符号名搜)). +Wires through [RFC-014](https://github.com/sleep2agi/agent-network/issues/99) — `GET /api/server/:host/health` now returns disk's three fields, the 24h bucketed history includes `disk_avail_min` / `disk_used_max`, and `alert_level` adds `disk < 1GB critical / < 5GB warn` triggers ([`server/src/index.ts:253-258`](https://github.com/sleep2agi/agent-network/blob/22ed1886/server/src/index.ts#L253), pinned to commit `22ed1886`; the file has since been split up and is only 16 lines on `main`, which is why this does not link to `main`). Test lead Docker Linux smoke 3/3 PASS (disk 299.8 GB total / 216 used / 71.5 avail, alert green, backward compat verified). From 4a93eaccf7662af33be2cef1af062529cc23f76e Mon Sep 17 00:00:00 2001 From: t Date: Tue, 18 Aug 2026 07:30:04 +0800 Subject: [PATCH 56/59] =?UTF-8?q?=E5=9F=BA=E7=BA=BF=E5=88=A0=E6=8E=89=20se?= =?UTF-8?q?rver/src/index.ts#L253=20=E2=80=94=E2=80=94=20=E5=AE=83?= =?UTF-8?q?=E5=B7=B2=E8=A2=AB=20#834=20=E4=BF=AE=E5=A5=BD(=E8=BF=99?= =?UTF-8?q?=E9=81=93=E9=97=A8=E8=87=AA=E5=B7=B1=E6=8A=93=E5=88=B0=E7=9A=84?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 里 `doc source-pin floor (Docker)` 红了,报的是: FAIL: 1 个基线条目对应的引用已经不在文档里了,请从基线里删掉 server/src/index.ts#L253 **这是这道门在正确工作,不是它坏了。** 两小时前合的 #834 把 changelog 里 那条 `server/src/index.ts#L253` 从 `blob/main/…#L253` 改成了钉在提交 `blob/22ed1886/…#L253`,于是它不再是一条「指向 main 的失效行号 pin」—— `pins_on_immutable_ref=6` 里包含了它。基线里那条就成了残留。 它自己的文件头写着「修一条就把它从这里删掉 —— 不删的话基线会变成坟场」。照做。 🔴 **没有用 `--write-baseline`。** 那会整份重新生成,而重新生成的基线会把 **这期间新出现的坏 pin 一起收进来** —— 一次「修好了一条」的提交,顺手把 新的失效面也洗白了,而 diff 看起来只是几行增减。改成只删这一行,并断言: removed=1 added=0 跑完:`broken_pins=4 baseline_entries=4`,rc=0 「失效 pin 4 个,基线 4 条 —— 没有新增,也没有该清的残留。」 Co-Authored-By: Claude Opus 5 --- docs/doc-source-pins-baseline.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/doc-source-pins-baseline.txt b/docs/doc-source-pins-baseline.txt index 0e08cc0e5..67ce7efef 100644 --- a/docs/doc-source-pins-baseline.txt +++ b/docs/doc-source-pins-baseline.txt @@ -14,7 +14,6 @@ # 修法见 #831:优先把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏)。 server/src/auth.ts#L184 -server/src/index.ts#L253 server/src/push.ts#L38 server/src/tools.ts#L521 server/src/tools.ts#L571 From e65faa7e461309479c550eaccb45c1a60fbdd34f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 18 Aug 2026 07:41:25 +0800 Subject: [PATCH 57/59] =?UTF-8?q?test831=20=E7=9A=84=E5=88=86=E6=AF=8D?= =?UTF-8?q?=E9=A2=84=E6=9C=9F=2018=20=E2=86=92=2015=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E6=98=AF=E8=BF=9B=E5=B1=95=E4=B8=8D=E6=98=AF=E6=89=AB?= =?UTF-8?q?=E6=BC=8F,=E9=80=90=E6=9D=A1=E8=83=BD=E6=8C=87=E5=87=BA?= =?UTF-8?q?=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 第二轮红在: FAIL: 预期 18 个唯一 pin,实际 15 **这道断言就是设计成「变了要人确认」的**(run.sh 里原注释写着)。所以我去确认了, 而不是把它改宽。 变化来自今晚合的两个 PR —— 三条引用**钉了提交**,于是从 `unique_pins` 挪进了 `pins_on_immutable_ref`: agent-network/bin/cli.ts#L61 -> 2 个文件(中/英 changelog) #851 agent-network/bin/cli.ts#L2589 -> 2 个文件 #851 server/src/index.ts#L253 -> 2 个文件 #834 正好 3 条,18 − 3 = 15;`pins_on_immutable_ref=6` = 3 × 2,对得上。 🔴 另外两个数**一个都没动**:`occ` 仍 35(钉提交不减少「出现次数」,只改引用形式), `files` 仍 106。**只有一个数变了,而且变的原因能逐条指名** —— 如果三个数一起变, 那才是该怀疑扫漏的信号。 把这段推导写进 run.sh 的注释里,下一个人不用重新推一遍。 Co-Authored-By: Claude Opus 5 --- docs/tests/report-test831.txt | 4 ++-- tests/test831-doc-source-pins/run.sh | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/tests/report-test831.txt b/docs/tests/report-test831.txt index db0bcb875..f4446d2a6 100644 --- a/docs/tests/report-test831.txt +++ b/docs/tests/report-test831.txt @@ -12,14 +12,14 @@ python=Python 3.12.13 pin_occurrences=35 pins_on_immutable_ref=0 pin_doc_pairs=35 - unique_pins=18 + unique_pins=15 broken_pins=5 baseline_entries=5 OK: 失效 pin 5 个,基线 5 条 —— 没有新增,也没有该清的残留。 注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、 只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。 - OK walk 路径与 git 路径给出同一份清单(106 文件 / 18 唯一 pin / 35 处) + OK walk 路径与 git 路径给出同一份清单(106 文件 / 15 唯一 pin / 35 处) [L1] clean tree passes OK rc=0 broken_pins=5(全部在基线里) [L2] witnessed-red: a NEW broken pin must turn it red diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 12aba6bba..05c24efbc 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -55,14 +55,27 @@ broken=$(printf '%s' "$out" | sed -nE 's/^broken_pins=([0-9]+)$/\1/p') # 容器内遍历得到的数字必须与仓库里 git ls-files 得到的一致,否则容器内外 # 扫的范围分叉,「容器里绿」就推不出「仓库里绿」。这两个数是写死的预期值, # 变了要人来确认是真变了还是扫漏了。 -# 这三个数随 #831 的符号锚点改造逐批下降:106/70/141 → 106/53/107 → 106/27/53 → 106/18/35。 +# 这三个数随 #831 的符号锚点改造逐批下降:106/70/141 → 106/53/107 → 106/27/53 +# → 106/18/35 → 106/15/35。 # mcp-tools.md 中英两版各 17 处 `[源码 ↗]` 行号链接换成了「文件链接 + grep 提示」。 # 这道断言本来就是设计成"变了要人确认"的:那次变化是逐条核过的(17/17 原本 # 都指错,漂移 35–1194 行),不是扫漏。 +# +# 2026-08-18:18 → 15。这次不是符号锚点改造,是 changelog 里三条引用**钉了提交** +# (#851 的 cli.ts#L61 / cli.ts#L2589,#834 的 server/src/index.ts#L253), +# 于是它们从 unique_pins 挪进了 pins_on_immutable_ref。逐条核过: +# +# immutable: agent-network/bin/cli.ts#L61 -> 2 个文件(中/英 changelog) +# immutable: agent-network/bin/cli.ts#L2589 -> 2 个文件 +# immutable: server/src/index.ts#L253 -> 2 个文件 +# +# 正好 3 条,18 - 3 = 15。`occ` 仍是 35(钉提交不减少"出现次数",只改变引用形式), +# `files` 仍是 106 —— **只有一个数变了,而且变的原因能逐条指出来**。这正是这道 +# 断言想逼出来的动作:数字变了要有人说清是进展还是扫漏,而不是把它改宽。 [[ "$files" -eq 106 ]] || fail "预期扫 106 个文档文件(= git ls-files 的结果),实际 $files" -[[ "$uniq" -eq 18 ]] || fail "预期 18 个唯一 pin,实际 $uniq" +[[ "$uniq" -eq 15 ]] || fail "预期 15 个唯一 pin,实际 $uniq" [[ "$occ" -eq 35 ]] || fail "预期 35 处原始出现,实际 $occ" -echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 18 唯一 pin / 35 处)" +echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 15 唯一 pin / 35 处)" # --------------------------------------------------------------------------- # L1 — 干净树上必须绿 From 606d96d9e7132b805f5bc6d022ee226cb34a0853 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 18 Aug 2026 07:52:03 +0800 Subject: [PATCH 58/59] =?UTF-8?q?test831=20L5=E2=91=A0=20=E6=94=B9?= =?UTF-8?q?=E6=88=90=E6=96=AD=E8=A8=80=E5=A2=9E=E9=87=8F=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=86=99=E6=AD=BB=E7=9A=84=20`=3D1`=20=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E3=80=8C=E4=BB=93=E9=87=8C=E4=B8=80=E6=9D=A1=E9=92=89?= =?UTF-8?q?=20SHA=20=E7=9A=84=E5=BC=95=E7=94=A8=E9=83=BD=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E3=80=8D=E6=97=B6=E6=88=90=E7=AB=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 第三轮红在: FAIL: ① 钉 SHA 的引用没有被单独计数:pins_on_immutable_ref=7 L5① 注入**一条**钉 SHA 的引用,然后断言 `pins_on_immutable_ref=1`。 🔴 这条断言的意图是「注入的那一条被单独计数了」,但它写成了绝对值 —— **只有在仓里原本一条钉 SHA 的引用都没有时才等价。** 写下它的时候确实是 0, 所以它当时是对的,而且输出和一个真正正确的断言**逐字相同**。 今晚仓里多了 6 条(#851 的 `cli.ts#L61` / `#L2589` 各 ×2 语言,#834 的 `server/src/index.ts#L253` ×2),注入第 7 条,写死的 1 就红了 —— **而红它的正是「有人按这道门建议的做法,把会漂的行号 pin 钉成了提交」, 也就是这道门自己想促成的进展。** 改成先量基线再断言恰好 +1: base_imm=$(… pins_on_immutable_ref …) # 注入前 …注入… after_imm 必须 == base_imm + 1 本机实测:`base=6 → after=7 rc=0`,与期望一致;victim 文件 `cmp` 还原确认。 同类提醒写进注释:**一个「只有在某个背景事实恰好为 0 时才成立」的断言, 和一个正确的断言,在当时的输出上完全一样。** Co-Authored-By: Claude Opus 5 --- tests/test831-doc-source-pins/run.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 05c24efbc..1e8a7f776 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -162,13 +162,25 @@ restore2() { cp /tmp/victim2.bak "$VICTIM2"; } # ① 钉了不可变 SHA 的引用不属于这道门 —— 注入一个"在 HEAD 上必然越界"的 SHA pin, # 门必须**仍然绿**(它管的是会漂的 main 引用,不是历史链接)。 +# +# 🔴 断言的是**增量**,不是绝对值。原来这里写死 `pins_on_immutable_ref=1`, +# 那只在「仓里一条钉 SHA 的引用都没有」时成立 —— 写下它的时候确实成立, +# 所以它当时是对的。2026-08-18 之后仓里有 6 条(#851 三条 ×2 语言、#834 +# 一条 ×2 语言),注入第 7 条,写死的 1 就红了 —— 而红的原因是**别人按这 +# 道门的建议把行号 pin 钉成了提交**,也就是它自己想要的进展。 +# +# 一个「只有在某个背景事实恰好为 0 时才成立」的断言,和一个正确的断言, +# 在当时的输出上完全一样。所以先量基线,再断言恰好 +1。 +base_imm=$(python3 "$CHECK" "$ROOT" 2>/dev/null | sed -nE 's/^pins_on_immutable_ref=([0-9]+)$/\1/p') +[[ -n "$base_imm" ]] || fail "① 拿不到注入前的 pins_on_immutable_ref 基线" printf '\n[sha](https://github.com/sleep2agi/agent-network/blob/0123456789abcdef0123456789abcdef01234567/server/src/index.ts#L99999)\n' >> "$VICTIM2" set +e; out5=$(python3 "$CHECK" "$ROOT" 2>&1); rc5=$?; set -e restore2 [[ "$rc5" -eq 0 ]] || fail "① 钉 SHA 的引用被当成漂移失效了(rc=$rc5)—— 那会惩罚按本工具建议做出的修改" -printf '%s' "$out5" | grep -q "pins_on_immutable_ref=1" \ - || fail "① 钉 SHA 的引用没有被单独计数:$(printf '%s' "$out5" | grep pins_on_immutable || true)" -echo " ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿" +after_imm=$(printf '%s' "$out5" | sed -nE 's/^pins_on_immutable_ref=([0-9]+)$/\1/p') +[[ "$after_imm" -eq $((base_imm + 1)) ]] \ + || fail "① 钉 SHA 的引用没有被单独计数:注入前 $base_imm,注入后 $after_imm(应为 $((base_imm + 1)))" +echo " ① 不可变 ref 被排除且单独计数(${base_imm} → ${after_imm}),门仍绿" # ② #L0 必须判成越界。第一版只挡上界,content[-1] 会读到最后一行。 printf '\n[zero](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L0)\n' >> "$VICTIM2" From 25e8fa3b3e6c13f7a8bedd3d0e9fd406ccd369c7 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 18 Aug 2026 08:03:31 +0800 Subject: [PATCH 59/59] =?UTF-8?q?test831=20L7=20=E9=80=A0=E5=9C=BA?= =?UTF-8?q?=E6=99=AF=E6=94=B9=E7=94=A8=E5=90=88=E6=88=90=20SHA=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=8E=9F=E6=9D=A5=E5=80=9F=E7=9A=84=E9=82=A3?= =?UTF-8?q?=E4=B8=AA=E7=9C=9F=E5=AE=9E=E6=8F=90=E4=BA=A4,=E4=BB=8A?= =?UTF-8?q?=E6=99=9A=E5=8F=98=E6=88=90=E4=BA=86=E7=9C=9F=E5=AE=9E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 第四轮红在: FAIL: 1 个新的失效 pin(不在基线里) FAIL: L7 复原之后没有回绿 L7 ③ 造「引用消失」的场景:把基线里某条 pin 的引用从 `blob/main/…` 改成 `blob/22ed1886/…`;末尾复原时**全局**把 `blob/22ed1886/` 换回 `blob/main/`。 🔴 而 #834 今晚往 changelog 里写进了一条**真实的** `blob/22ed1886/server/src/index.ts#L253` (那正是这道门建议的修法)。于是复原那一步把**别人有意钉的提交引用改回了会漂的 main 引用**,留下一个不在基线里的失效 pin —— L7 永远回不了绿。 **「借来的 SHA 恰好不与真实数据相撞」是一个会过期的巧合,不是一条性质。** 而在它过期之前,这段代码和正确的代码在输出上完全一样。 两处改动: 1. 造场景改用合成 SHA `0123456789abcdef…4567`(L5① 已在用同一个)—— 仓里不可能出现,撞不上真实数据; 2. 把改过的文件名记进 `.l7-touched`,复原只碰这些文件,并在复原后断言 **合成 SHA 在整个 docs-site 里一个都不剩**,不彻底就直接抛错 (原来复原是否彻底没有任何断言,失败会拖到最后一步才以别的面目出现)。 本机实跑整段 L7: ① 干净树:不改写,报「已是最新」 MUTATION_RED write-baseline-refuses-new-failure rc=1 (造场景:把 server/src/auth.ts#L184 的 1 处引用改钉合成 SHA,涉及 1 个文件) ③ 引用消失时删对了(4 → 3),表头保留,门转绿 复原后回绿 ✓ RESULT: PASS rc=0 跑完 `git status --porcelain` 与跑前逐字节相同。 Co-Authored-By: Claude Opus 5 --- tests/test831-doc-source-pins/run.sh | 34 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 1e8a7f776..1907539f4 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -305,21 +305,31 @@ echo " MUTATION_RED write-baseline-refuses-new-failure rc=$rc12" # ③ 引用消失时要删对,并保留表头注释 BL_BEFORE=$(grep -cv '^\s*#\|^\s*$' "$BASELINE") +# 🔴 造场景用的 SHA 必须是**仓里不可能出现的**合成值,不能借用一个真实提交。 +# 原来这里用 22ed1886 —— 而 #834 之后 changelog 里**真的**有 +# `blob/22ed1886/server/src/index.ts#L253`,于是下面的复原(全局把 +# 22ed1886 换回 main)会把那条真实的、有意为之的提交钉**改回会漂的 main 引用**, +# 留下一个不在基线里的失效 pin,L7 末尾因此永远回不了绿。 +# 「合成值恰好不与真实数据相撞」是一个会过期的巧合,不是一条性质。 +# 另外把改过的文件名记下来,复原时只碰这些文件。 python3 - "$ROOT" <<'PYX' import sys, pathlib root = pathlib.Path(sys.argv[1]) -# 造一个「引用消失」:把基线里某条 pin 的所有引用改锚到不可变 SHA +FAKE = "0123456789abcdef0123456789abcdef01234567" # 合成 SHA,仓里不会有 base = [l.strip() for l in (root/'docs/doc-source-pins-baseline.txt').read_text(encoding='utf-8').splitlines() if l.strip() and not l.lstrip().startswith('#')] target = base[0] old = f"blob/main/{target}" -new = f"blob/22ed1886/{target}" -n = 0 +new = f"blob/{FAKE}/{target}" +touched, n = [], 0 for f in (root/'docs-site').rglob('*.md'): t = f.read_text(encoding='utf-8') if old in t: - f.write_text(t.replace(old, new), encoding='utf-8'); n += t.count(old) -print(f" (造场景:把 {target} 的 {n} 处引用改钉 SHA)") + n += t.count(old) + f.write_text(t.replace(old, new), encoding='utf-8') + touched.append(str(f)) +(root/'.l7-touched').write_text("\n".join(touched), encoding='utf-8') +print(f" (造场景:把 {target} 的 {n} 处引用改钉合成 SHA,涉及 {len(touched)} 个文件)") PYX out13=$(python3 "$CHECK" "$ROOT" --write-baseline) || fail "③ 有该删的条目时 --write-baseline 却非零" printf '%s' "$out13" | grep -qF "已改写基线" || fail "③ 没报告改写" @@ -334,10 +344,18 @@ cd "$ROOT" && git status >/dev/null 2>&1 || true python3 - "$ROOT" <<'PYX' import sys, pathlib root = pathlib.Path(sys.argv[1]) -for f in (root/'docs-site').rglob('*.md'): +FAKE = "0123456789abcdef0123456789abcdef01234567" +listing = root/'.l7-touched' +files = [pathlib.Path(p) for p in listing.read_text(encoding='utf-8').split("\n") if p.strip()] if listing.exists() else [] +for f in files: t = f.read_text(encoding='utf-8') - if 'blob/22ed1886/' in t: - f.write_text(t.replace('blob/22ed1886/', 'blob/main/'), encoding='utf-8') + f.write_text(t.replace(f"blob/{FAKE}/", "blob/main/"), encoding='utf-8') +if listing.exists(): + listing.unlink() +# 断言复原彻底:合成 SHA 在整个 docs-site 里必须一个都不剩。 +left = [str(f) for f in (root/'docs-site').rglob('*.md') if FAKE in f.read_text(encoding='utf-8')] +if left: + raise SystemExit(f"L7 复原不彻底,合成 SHA 仍残留于: {left}") PYX cp /tmp/bl7.bak "$BASELINE" python3 "$CHECK" "$ROOT" >/dev/null || fail "L7 复原之后没有回绿"