From 08f54e8b86e5d596a67681a14779044b39e96a9a Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 16:05:18 +0800 Subject: [PATCH 01/56] =?UTF-8?q?test(#823):=20L1=20=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E9=97=B8=E9=97=A8=E7=9A=84=20Docker=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E5=A5=97=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查指出这道闸门没有可复现的回归:仓里搜 QA_L1_MAX_PAR 只有 qa.sh 一处, 提交信息里的人工采样无法从仓库复现,于是下一次 fail-open 的计数/解析回归 会静默恢复无上限运行。 套件跑的是**真的 scripts/qa.sh**,不是逻辑副本:把 docker 换成 PATH 上的桩 (qa.sh 的 dockerrun() 是 bash -c "$*",会解析到桩),真实闸门代码原样执行。 峰值用事件流算最大重叠,不用采样 —— 采样会漏峰值。 四个用例(审查点名的四种): cap=2 生效值 2,峰值 2 上限确实生效 非法值 two 告警,生效值退回 nproc=8 不是静默不限 前导零 08 生效值 8 按十进制,不撞八进制 0 生效值 0,峰值 7 保留「不限」逃生口 对照:cap=2 峰值 2,而不限/8 时峰值 7 —— 断言有分辨力,不是恒真。 写这个套件时它自己抓到我两个 harness bug: 1) 生效值提取用 grep -oE '[0-9]+',先命中了 "L1" 里的 1,四个用例全报 1 —— 判据没在已知输入上校准过。改成只取 `= ` 之后那个数,并用两组已知 输入(8 / 0)校准; 2) 桩对 build 和 run 一视同仁各睡 0.35s,而 build 是同步的,导致 run 之间 几乎不重叠、峰值恒为 1 —— 高上限下断言没有分辨力。改成 build 立即返回、 run 睡 1.2s。 另:容器是 --network none,qa.sh 会跑 npm view 做 registry 快照,真 npm 会 等 DNS 超时而不是快速失败(第一版就这么跑成超时)。npm 一并桩掉,让被测 闸门成为唯一耗时来源。 --- tests/test823-l1-concurrency-cap/Dockerfile | 19 +++ tests/test823-l1-concurrency-cap/run.sh | 121 ++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/test823-l1-concurrency-cap/Dockerfile create mode 100755 tests/test823-l1-concurrency-cap/run.sh diff --git a/tests/test823-l1-concurrency-cap/Dockerfile b/tests/test823-l1-concurrency-cap/Dockerfile new file mode 100644 index 000000000..82836a5a4 --- /dev/null +++ b/tests/test823-l1-concurrency-cap/Dockerfile @@ -0,0 +1,19 @@ +ARG SOURCE_COMMIT +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates coreutils procps \ + && rm -rf /var/lib/apt/lists/* + +ARG SOURCE_COMMIT +ENV TEST823_SOURCE_COMMIT=${SOURCE_COMMIT} + +WORKDIR /workspace +COPY scripts/qa.sh /workspace/scripts/qa.sh +COPY tests/test823-l1-concurrency-cap/run.sh /workspace/run.sh + +# 非 root:与本仓其它套件一致 +RUN chown -R node:node /workspace +USER node + +ENTRYPOINT ["bash", "/workspace/run.sh"] diff --git a/tests/test823-l1-concurrency-cap/run.sh b/tests/test823-l1-concurrency-cap/run.sh new file mode 100755 index 000000000..16e48406f --- /dev/null +++ b/tests/test823-l1-concurrency-cap/run.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# test823 — scripts/qa.sh 的 L1 并发上限闸门 +# +# 这道闸门的失效方向是 fail-open:QA_L1_MAX_PAR 拿到非数字时,bash 在算术 +# 上下文里把它当 0,而 0 的语义恰好是「不限」—— 于是一个笔误会静默恢复 +# 无上限运行。无上限时实测宿主 load1 顶到 58(8 核,同时跑着生产 hub、 +# dashboard 与约 200 个 session),所以这条不是形式主义。 +# +# 🔴 本套件跑的是**真的 scripts/qa.sh**,不是逻辑副本。 +# 做法:把 `docker` 换成 PATH 上的桩。qa.sh 的 dockerrun() 是 +# `bash -c "$*"`,所以它会解析到桩;真实的闸门代码原样执行。 +# 在副本上测只能证明副本自洽 —— 那正是本仓反复栽过的坑。 +# +# 峰值用**事件流**算,不用采样:每次桩调用写下精确的 START / END 纳秒 +# 时间戳,事后排序求最大重叠。采样会漏掉峰值,事件流不会。 +set -uo pipefail + +ROOT=/workspace +SRC=${TEST823_SOURCE_COMMIT:-} +[[ "$SRC" =~ ^[0-9a-f]{40}$ ]] || { echo "FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '${SRC}')" >&2; exit 1; } + +BIN=/tmp/t823-bin +EV=/tmp/t823-events +mkdir -p "$BIN" + +# ── docker 桩 ──────────────────────────────────────────────────────────── +# build 是同步的,run 是后台的 —— 只有 run 会重叠。两者都记事件, +# 这样如果哪天 build 也被后台化,峰值会立刻反映出来。 +cat > "$BIN/docker" <<'STUB' +#!/usr/bin/env bash +# build 是同步的、run 是后台的 —— 只有 run 会重叠。若两者同样耗时, +# run 之间几乎不重叠,峰值恒为 1,高上限下断言就失去分辨力(第一版如此)。 +# 所以 build 尽量快,run 拉长,让并发真正显现出来。 +if [ "${1:-}" = "build" ]; then exit 0; fi +printf 'S %s %s\n' "$(date +%s%N)" "$$" >> "$T823_EV" +sleep 1.2 +printf 'E %s %s\n' "$(date +%s%N)" "$$" >> "$T823_EV" +exit 0 +STUB +chmod +x "$BIN/docker" + +# npm 也要桩:qa.sh 会跑 `npm view … dist-tags.preview` 做 registry 快照。 +# 容器是 --network none,真 npm 会一直等 DNS/连接超时,而不是快速失败 —— +# 第一版就是这么跑成超时的。桩掉它,让被测的闸门成为唯一的耗时来源。 +cat > "$BIN/npm" <<'NPMSTUB' +#!/usr/bin/env bash +echo "0.0.0-stub" +exit 0 +NPMSTUB +chmod +x "$BIN/npm" + +export PATH="$BIN:$PATH" +export T823_EV="$EV" + +peak() { # 从事件流算最大重叠 + sort -k2,2n "$1" | awk ' + $1=="S" { c++; if (c>m) m=c } + $1=="E" { c-- } + END { print m+0 }' +} + +nproc_val=$(nproc 2>/dev/null || echo 4) +fails=0 +report=/tmp/report-test823.txt +: > "$report" + +say() { echo "$*" | tee -a "$report"; } + +say "# test823 — L1 concurrency cap gate" +say "source_commit=$SRC" +say "nproc=$nproc_val" +say "" + +run_case() { # $1=用例名 $2=QA_L1_MAX_PAR 取值(空=不设) + local name=$1 val=${2-} + : > "$EV" + local out=/tmp/t823-$name.log + if [[ -n "${val:-}" || "${2+set}" == "set" ]]; then + QA_L1_MAX_PAR="$val" bash "$ROOT/scripts/qa.sh" --l1 > "$out" 2>&1 || true + else + bash "$ROOT/scripts/qa.sh" --l1 > "$out" 2>&1 || true + fi + local p; p=$(peak "$EV") + # 只取 `= ` 之后那个数。原来用 grep -oE '[0-9]+' 会先命中 "L1" 里的 1 —— + # 判据没在已知输入上校准过,于是四个用例全部报 1。 + local eff; eff=$(sed -n 's/.*L1 并发上限 = \([0-9][0-9]*\).*/\1/p' "$out" | head -1) + [[ -n "$eff" ]] || eff="?" + local warned=0; grep -q '不是非负整数' "$out" && warned=1 + echo "$p|$eff|$warned" +} + +check() { # $1=用例 $2=实测 $3=期望 $4=说明 + if [[ "$2" == "$3" ]]; then say " ok $1: $4 (= $2)" + else say " FAIL $1: $4 —— 期望 $3,实测 $2"; fails=$((fails+1)); fi +} + +say "## 用例" + +IFS='|' read -r p eff warned <<< "$(run_case cap2 2)" +say "- cap=2 峰值=$p 生效值=$eff 告警=$warned" +check cap2 "$eff" 2 "生效上限" +[[ "$p" -le 2 && "$p" -ge 1 ]] && say " ok cap2: 峰值 $p ≤ 2" || { say " FAIL cap2: 峰值 $p 超过上限 2"; fails=$((fails+1)); } + +IFS='|' read -r p eff warned <<< "$(run_case bad two)" +say "- 非法值 two 峰值=$p 生效值=$eff 告警=$warned" +check bad_warn "$warned" 1 "必须告警" +check bad_eff "$eff" "$nproc_val" "退回默认(不是静默不限)" + +IFS='|' read -r p eff warned <<< "$(run_case octal 08)" +say "- 前导零 08 峰值=$p 生效值=$eff 告警=$warned" +check octal_eff "$eff" 8 "按十进制解释,不是八进制报错/不限" + +IFS='|' read -r p eff warned <<< "$(run_case zero 0)" +say "- 0(不限) 峰值=$p 生效值=$eff 告警=$warned" +check zero_eff "$eff" 0 "0 保留为「不限」的逃生口" + +say "" +say "failures=$fails" +if [[ "$fails" -eq 0 ]]; then say "RESULT: PASS"; else say "RESULT: FAIL"; fi +cat "$report" +[[ "$fails" -eq 0 ]] From 37dcbf38bc1d70f7efe8fab3986913351d724aaa Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 16:06:25 +0800 Subject: [PATCH 02/56] =?UTF-8?q?docs(tests):=20report-test823=20=E2=80=94?= =?UTF-8?q?=20=E5=9C=A8=2008f54e8b=20=E4=B8=8A=E7=9A=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=BB=93=E6=9E=9C(report-only=20child)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source 08f54e8b 是包含被测套件本身的那个提交,不是它的父提交 —— #801 上有一条 P1 正是「report 里的 SHA 早于套件本身,证据无法从其 标注的版本复现」。这里刻意先提交套件、再按该 SHA 建镜像跑,最后 把结果作为 report-only 子提交落下。 Exit 0 / RESULT: PASS,并附去掉校验段的变异见证(RESULT: FAIL)。 --- docs/tests/report-test823.txt | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/tests/report-test823.txt diff --git a/docs/tests/report-test823.txt b/docs/tests/report-test823.txt new file mode 100644 index 000000000..8c4f880fe --- /dev/null +++ b/docs/tests/report-test823.txt @@ -0,0 +1,42 @@ +# report-test823 — L1 concurrency cap gate + +Date: 2026-08-13 (Asia/Shanghai) +Base: 034f00647d42d38d5086d7fc057eb7824a441791 +Source: 08f54e8b86e5d596a67681a14779044b39e96a9a +Image: anet-test823-l1-concurrency-cap +Run: docker run --rm --network none anet-test823-l1-concurrency-cap +Exit: 0 + +## 变异见证(去掉 qa.sh 里的取值校验段) + +``` + FAIL bad_warn: 必须告警 —— 期望 1,实测 0 + FAIL bad_eff: 退回默认(不是静默不限) —— 期望 8,实测 ? + FAIL octal_eff: 按十进制解释,不是八进制报错/不限 —— 期望 8,实测 08 +RESULT: FAIL +``` + +## 本次运行输出 + +``` +# test823 — L1 concurrency cap gate +source_commit=08f54e8b86e5d596a67681a14779044b39e96a9a +nproc=8 + +## 用例 +- cap=2 峰值=2 生效值=2 告警=0 + ok cap2: 生效上限 (= 2) + ok cap2: 峰值 2 ≤ 2 +- 非法值 two 峰值=7 生效值=8 告警=1 + ok bad_warn: 必须告警 (= 1) + ok bad_eff: 退回默认(不是静默不限) (= 8) +- 前导零 08 峰值=3 生效值=8 告警=0 + ok octal_eff: 按十进制解释,不是八进制报错/不限 (= 8) +- 0(不限) 峰值=7 生效值=0 告警=0 + ok zero_eff: 0 保留为「不限」的逃生口 (= 0) + +failures=0 +RESULT: PASS +# test823 — L1 concurrency cap gate +source_commit=08f54e8b86e5d596a67681a14779044b39e96a9a +``` From 3171aa339e56d5c04282f8fc5ce301f032464f37 Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 16:12:49 +0800 Subject: [PATCH 03/56] =?UTF-8?q?test(#823):=20=E6=8A=8A=20test823=20?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=BF=9B=20L1=5FTESTS=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E4=B8=8A=E4=B8=80=E7=89=88=E5=BB=BA=E4=BA=86=E4=B8=AA?= =?UTF-8?q?=E6=B2=A1=E4=BA=BA=E4=BC=9A=E8=B7=91=E7=9A=84=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自查发现:上一版新增了 tests/test823-l1-concurrency-cap/,但全仓没有任何 地方引用它。用阳性对照判实的 —— test823 在自身目录之外命中 0 处,而同类 的 test745 命中 1 处;同时确认被查文件可读、grep 能命中已知串,排除"查询 没生效"。 也就是说我在一条"补上缺失回归"的 PR 里,交付了一个不会被任何东西调用的 套件 —— 正是 #801 那条 P1(gate_is_wired)说的情形,只不过这次在我自己 的产出上。 注册后再验:test823 命中 1 处,与对照 test745 一致。 自包含也验了:注册之后套件会在自己的 L1 列表里看到自己。实测仍 PASS (退出码 0,四个用例全绿,峰值对照依旧有分辨力:cap=2 → 峰值 2, 不限 → 峰值 7),没有递归或自锁。 --- scripts/qa.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/qa.sh b/scripts/qa.sh index e71354732..fb65b7465 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -57,6 +57,9 @@ L0_TESTS=( # 它的 CI 归属是会安装依赖的层级;本地跑法见该文件头注释的门禁命令。 ) L1_TESTS=( + # 这道闸门自己的回归。放在最前:它跑的是本脚本,若闸门坏了应当最先暴露。 + # (注册这一步不是可选的 —— 一个没被任何东西调用的套件等于不存在。) + "test823-l1-concurrency-cap" "qa-cli-01-hub-start" "qa-cli-02-network-create" "qa-dash-07-auth-boundary" From 76c12e98379b67eb074a2a42e2a170e6aa94db1f Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 16:18:09 +0800 Subject: [PATCH 04/56] =?UTF-8?q?test(#823):=20=E4=B8=89=E6=9D=A1=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=84=8F=E8=A7=81=20=E2=80=94=E2=80=94=20=E6=A1=A9?= =?UTF-8?q?=E5=8F=AA=E8=AE=B0=20run=E3=80=81=E6=96=AD=E8=A8=80=E4=B8=8D?= =?UTF-8?q?=E9=99=90=E7=9C=9F=E6=94=BE=E5=BC=80=E3=80=81SHA=20=E7=BB=91?= =?UTF-8?q?=E5=88=B0=E8=A2=AB=E6=B5=8B=E5=AD=97=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 桩原来对任何非 build 的 docker 子命令都记事件,峰值会被无关调用抬高。 改成只有 `docker run` 才记。 ② 0(不限)原来只断言生效值为 0 —— 那只证明它被这么解析,没证明它真的 放开了并发。补一条:不限时峰值必须明显高于 cap=2 的峰值。 ③ SOURCE_COMMIT 原来只验 40 位十六进制格式。任何 SHA 都能通过,而报告 里那个 SHA 可能根本不含镜像里被测的文件 —— 这正是我自己在 #801 上 提的那条 P1,建这个套件时原样犯了一遍。 改成:构建时把 run.sh 在该 commit 下的 git blob 哈希作为 build-arg 传入,容器内就地重算并比对(blob 哈希 = sha1("blob \\0"+内容), 不需要容器里装 git)。 第四条「接进自动 workflow」上一提交已自查修掉(注册进 L1_TESTS), 审查针对的是修之前的坐标。 --- tests/test823-l1-concurrency-cap/Dockerfile | 4 ++++ tests/test823-l1-concurrency-cap/run.sh | 24 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/test823-l1-concurrency-cap/Dockerfile b/tests/test823-l1-concurrency-cap/Dockerfile index 82836a5a4..5de276967 100644 --- a/tests/test823-l1-concurrency-cap/Dockerfile +++ b/tests/test823-l1-concurrency-cap/Dockerfile @@ -6,7 +6,11 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* ARG SOURCE_COMMIT +ARG RUNSH_BLOB ENV TEST823_SOURCE_COMMIT=${SOURCE_COMMIT} +# 把 run.sh 在 SOURCE_COMMIT 下的 git blob 哈希带进来,让容器内能验证 +# "报告里的 SHA 确实对应镜像里被测的字节",而不是只验 SHA 的格式。 +ENV TEST823_RUNSH_BLOB=${RUNSH_BLOB} WORKDIR /workspace COPY scripts/qa.sh /workspace/scripts/qa.sh diff --git a/tests/test823-l1-concurrency-cap/run.sh b/tests/test823-l1-concurrency-cap/run.sh index 16e48406f..7c4e3ebb4 100755 --- a/tests/test823-l1-concurrency-cap/run.sh +++ b/tests/test823-l1-concurrency-cap/run.sh @@ -19,6 +19,22 @@ ROOT=/workspace SRC=${TEST823_SOURCE_COMMIT:-} [[ "$SRC" =~ ^[0-9a-f]{40}$ ]] || { echo "FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '${SRC}')" >&2; exit 1; } +# 🔴 光验格式不够 —— 任何 40 位十六进制都能通过,而报告里那个 SHA 可能 +# 根本不含镜像里被测的文件。这正是 #801 上那条 P1 说的病,我建这个 +# 套件时原样犯了一遍。 +# 做法:构建时把 run.sh 在该 commit 下的 git blob 哈希作为 build-arg +# 传进来,这里就地重算镜像内文件的 blob 哈希并比对。git blob 哈希 = +# sha1("blob \0" + 内容),不需要容器里装 git。 +EXPECT=${TEST823_RUNSH_BLOB:-} +[[ "$EXPECT" =~ ^[0-9a-f]{40}$ ]] || { echo "FAIL: TEST823_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节" >&2; exit 1; } +self=/workspace/run.sh +actual=$( { printf 'blob %d\0' "$(wc -c < "$self")"; cat "$self"; } | sha1sum | cut -d" " -f1 ) +if [[ "$actual" != "$EXPECT" ]]; then + echo "FAIL: 镜像里的 run.sh 与 SOURCE_COMMIT=$SRC 声称的不是同一份" >&2 + echo " 期望 blob $EXPECT,实际 $actual" >&2 + exit 1 +fi + BIN=/tmp/t823-bin EV=/tmp/t823-events mkdir -p "$BIN" @@ -31,7 +47,9 @@ cat > "$BIN/docker" <<'STUB' # build 是同步的、run 是后台的 —— 只有 run 会重叠。若两者同样耗时, # run 之间几乎不重叠,峰值恒为 1,高上限下断言就失去分辨力(第一版如此)。 # 所以 build 尽量快,run 拉长,让并发真正显现出来。 -if [ "${1:-}" = "build" ]; then exit 0; fi +# 只有真正的 `docker run` 才记事件。任何其它子命令(build/ps/rmi…) +# 都不该计入并发峰值 —— 否则峰值会被无关调用抬高,断言就不再是在测闸门。 +[ "${1:-}" = "run" ] || exit 0 printf 'S %s %s\n' "$(date +%s%N)" "$$" >> "$T823_EV" sleep 1.2 printf 'E %s %s\n' "$(date +%s%N)" "$$" >> "$T823_EV" @@ -113,6 +131,10 @@ check octal_eff "$eff" 8 "按十进制解释,不是八进制报错/不限" IFS='|' read -r p eff warned <<< "$(run_case zero 0)" say "- 0(不限) 峰值=$p 生效值=$eff 告警=$warned" check zero_eff "$eff" 0 "0 保留为「不限」的逃生口" +# 只断言"生效值是 0"不够 —— 那只证明它被这么解析,没证明它真的放开了并发。 +# 拿 cap=2 的峰值当对照:不限时必须明显更高。 +if [[ "$p" -gt 2 ]]; then say " ok zero_conc: 不限时峰值 $p > cap2 的 2,确实放开了并发" +else say " FAIL zero_conc: 不限时峰值只有 $p,与 cap=2 无区别 —— 「不限」没有被验证"; fails=$((fails+1)); fi say "" say "failures=$fails" From 55ab1f54bdd638b07b0d1f70eacccd082de4c637 Mon Sep 17 00:00:00 2001 From: vansin Date: Thu, 13 Aug 2026 16:19:30 +0800 Subject: [PATCH 05/56] =?UTF-8?q?docs(tests):=20report-test823=20=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=88=B0=2076c12e98379b67eb074a2a42e2a170e6aa94db1f(?= =?UTF-8?q?=E5=90=AB=20blob=20=E7=BB=91=E5=AE=9A=E4=B8=8E=E4=B8=89?= =?UTF-8?q?=E7=A7=8D=E8=A7=81=E8=AF=81=E7=BA=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tests/report-test823.txt | 48 +++++++++++++++-------------------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/docs/tests/report-test823.txt b/docs/tests/report-test823.txt index 8c4f880fe..2ec740b5f 100644 --- a/docs/tests/report-test823.txt +++ b/docs/tests/report-test823.txt @@ -1,42 +1,36 @@ # report-test823 — L1 concurrency cap gate -Date: 2026-08-13 (Asia/Shanghai) -Base: 034f00647d42d38d5086d7fc057eb7824a441791 -Source: 08f54e8b86e5d596a67681a14779044b39e96a9a -Image: anet-test823-l1-concurrency-cap -Run: docker run --rm --network none anet-test823-l1-concurrency-cap +Date: 2026-08-13 (Asia/Shanghai) +Base: 034f00647d42d38d5086d7fc057eb7824a441791 +Source: 76c12e98379b67eb074a2a42e2a170e6aa94db1f +run.sh blob: 7c4e3ebb4ecf13710d166d9a0d3f26eb43d8d9d1 (容器内就地重算并比对,不只验 SHA 格式) +Run: docker build --build-arg SOURCE_COMMIT=$SRC --build-arg RUNSH_BLOB=$BLOB … && docker run --rm --network none … Exit: 0 -## 变异见证(去掉 qa.sh 里的取值校验段) - -``` - FAIL bad_warn: 必须告警 —— 期望 1,实测 0 - FAIL bad_eff: 退回默认(不是静默不限) —— 期望 8,实测 ? - FAIL octal_eff: 按十进制解释,不是八进制报错/不限 —— 期望 8,实测 08 -RESULT: FAIL +## 本次运行 ``` - -## 本次运行输出 - -``` -# test823 — L1 concurrency cap gate -source_commit=08f54e8b86e5d596a67681a14779044b39e96a9a -nproc=8 - -## 用例 - cap=2 峰值=2 生效值=2 告警=0 ok cap2: 生效上限 (= 2) ok cap2: 峰值 2 ≤ 2 -- 非法值 two 峰值=7 生效值=8 告警=1 +- 非法值 two 峰值=8 生效值=8 告警=1 ok bad_warn: 必须告警 (= 1) ok bad_eff: 退回默认(不是静默不限) (= 8) -- 前导零 08 峰值=3 生效值=8 告警=0 +- 前导零 08 峰值=8 生效值=8 告警=0 ok octal_eff: 按十进制解释,不是八进制报错/不限 (= 8) -- 0(不限) 峰值=7 生效值=0 告警=0 +- 0(不限) 峰值=13 生效值=0 告警=0 ok zero_eff: 0 保留为「不限」的逃生口 (= 0) - + ok zero_conc: 不限时峰值 13 > cap2 的 2,确实放开了并发 failures=0 RESULT: PASS -# test823 — L1 concurrency cap gate -source_commit=08f54e8b86e5d596a67681a14779044b39e96a9a +- cap=2 峰值=2 生效值=2 告警=0 +``` + +## 见证红(三种) +``` +1. 去掉 qa.sh 的取值校验段: + FAIL bad_warn: 必须告警 —— 期望 1,实测 0 + FAIL bad_eff: 退回默认(不是静默不限) —— 期望 8,实测 ? + FAIL octal_eff: 按十进制解释,不是八进制报错/不限 —— 期望 8,实测 08 +2. 传错的 run.sh blob 哈希 → FAIL: 镜像里的 run.sh 与 SOURCE_COMMIT 声称的不是同一份 (exit 1) +3. blob 参数对但文件被篡改 → 同上 (exit 1) ``` From 2bb734afb89b00cf110933c88cab6e48ffef5f1e Mon Sep 17 00:00:00 2001 From: vansin Date: Fri, 14 Aug 2026 00:29:24 +0800 Subject: [PATCH 06/56] =?UTF-8?q?ci(qa.sh):=20SOURCE=5FCOMMIT=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8C=89=E5=A5=97=E4=BB=B6=E5=90=8D=E6=8E=A8=E5=AF=BC?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E9=80=90=E5=A5=97=E4=BB=B6=20elif=20?= =?UTF-8?q?=E6=AD=A3=E6=98=AF=E6=9C=AC=20PR=20=E6=92=9E=E7=BA=A2=E7=9A=84?= =?UTF-8?q?=E6=88=90=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本 PR 的 L0+L1 稳定红,失败行只有一句: FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '') 根因不在被测的门,在供给侧。qa.sh 里原本是一串逐套件的 elif: if [[ "$t" == "test686-rest-shape-golden" ]]; then --build-arg TEST686_SOURCE_COMMIT=… elif [[ "$t" == "test765-batch-runtime-gate" ]]; then … elif [[ "$t" == "test766-bunx-preflight" ]]; then … elif [[ "$t" == "test746-setup-bun-pin" ]]; then … fi 本 PR 把 test823 加进了 L1_TESTS,但没人记得这里也要加一条 —— 于是 TEST823_SOURCE_COMMIT 是空串,门正确地 fail-closed。 只补一条 elif 能让它变绿,但下一个新套件还会踩同一个坑: 「注册了套件」和「在供给侧登记」是两处,分开就会漂。所以改成按名推导: testNNN-... → --build-arg TESTNNN_SOURCE_COMMIT=$(git rev-parse HEAD) qa-*-... → 不传(与原行为一致,它们的门不要这个变量) 行为等价性验证(对当前 L1_TESTS 全部 18 个套件逐个模拟): test823-l1-concurrency-cap → TEST823_SOURCE_COMMIT (新增,本 PR 需要的) test686-rest-shape-golden → TEST686_SOURCE_COMMIT (与原 elif 一致) test765-batch-runtime-gate → TEST765_SOURCE_COMMIT (一致) test766-bunx-preflight → TEST766_SOURCE_COMMIT (一致) test746-setup-bun-pin → TEST746_SOURCE_COMMIT (一致) qa-cli-01 / qa-hub-05 / qa-node-03b / … → 不传 (一致) bash -n 退出码 0。 顺带记一条同类:#801 的红是同一个形状 —— run.sh 要求 TEST798_RUNSH_BLOB、 Dockerfile 接了线、workflow 的 docker build 从没传。都是「门要求 X, 供给侧不知道要给 X」。 --- scripts/qa.sh | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/qa.sh b/scripts/qa.sh index fb65b7465..74e793b58 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -162,15 +162,16 @@ if [[ $RUN_L1 -eq 1 ]]; then for t in "${L1_TESTS[@]}"; do # Build (cached if recent) note "build $t" + # 🔴 原来这里是一串逐套件的 elif。那正是本 PR 撞红的成因: + # test823 被加进 L1_TESTS,却没人记得在这里也加一条,于是 + # TEST823_SOURCE_COMMIT 是空串,门 fail-closed: + # FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '') + # 「注册了套件但忘了在供给侧登记」会一直复发,所以改成按套件名推导: + # testNNN-... → --build-arg TESTNNN_SOURCE_COMMIT= + # qa-*-... 形态不匹配,和以前一样不传(它们的门不要这个变量)。 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)" + if [[ "$t" =~ ^test([0-9]+)- ]]; then + build_args="--build-arg TEST${BASH_REMATCH[1]}_SOURCE_COMMIT=$(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" From 25a64ce6993e54a8d3453689ab8819edbc883d13 Mon Sep 17 00:00:00 2001 From: vansin Date: Fri, 14 Aug 2026 00:36:19 +0800 Subject: [PATCH 07/56] =?UTF-8?q?ci(qa.sh):=20=E4=B8=A4=E5=A5=97=20build-a?= =?UTF-8?q?rg=20=E5=91=BD=E5=90=8D=E9=83=BD=E4=BE=9B=E7=BB=99=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=B8=8A=E4=B8=80=E7=89=88=E5=8F=AA=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E4=BA=86=E6=97=A7=E7=9A=84=E9=82=A3=E5=A5=97,test823?= =?UTF-8?q?=20=E7=85=A7=E6=97=A7=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一个提交(2bb734af)把逐套件 elif 改成按名推导 TESTNNN_SOURCE_COMMIT。 方向对,但**覆盖不全**:CI 照旧红在同一行 FAIL: TEST823_SOURCE_COMMIT 必须是一个完整的小写 SHA(收到 '') 原因是仓里并存两套命名,而我只按其中一套推导: tests/test686-rest-shape-golden/Dockerfile ARG TEST686_SOURCE_COMMIT tests/test765-batch-runtime-gate/Dockerfile ARG TEST765_SOURCE_COMMIT tests/test766-bunx-preflight/Dockerfile ARG TEST766_SOURCE_COMMIT tests/test746-setup-bun-pin/Dockerfile ARG TEST746_SOURCE_COMMIT tests/test823-l1-concurrency-cap/Dockerfile ARG SOURCE_COMMIT / ARG RUNSH_BLOB ← 不一样 test823 的 Dockerfile 收的是 `SOURCE_COMMIT`,再由它自己组装 `ENV TEST823_SOURCE_COMMIT=${SOURCE_COMMIT}`。我传的是 TEST823_SOURCE_COMMIT, 名字对不上 → ARG 空 → ENV 空 → 门 fail-closed。它还要 RUNSH_BLOB(run.sh:28)。 这次两套都传。未被 Dockerfile 声明的 build-arg 只产生一条警告,不影响构建。 blob 等价性实测(本分支 head 上): git rev-parse HEAD:tests/test823-l1-concurrency-cap/run.sh { printf 'blob %d\0' "$(wc -c < run.sh)"; cat run.sh; } | sha1sum 两者相同 —— 与 run.sh:31 的算法一致。 bash -n 退出码 0;对 L1_TESTS 里各形态逐个模拟,qa-* 仍不传。 🔴 记一条:上一版我验证了「四个旧套件行为逐条复现」,那个验证是对的, 但它只覆盖了我知道的那套约定 —— **我没有去核每个 Dockerfile 实际声明了什么 ARG**。 「与原行为一致」不等于「对所有套件都正确」。 --- scripts/qa.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/qa.sh b/scripts/qa.sh index 74e793b58..1f4751bb9 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -171,7 +171,19 @@ if [[ $RUN_L1 -eq 1 ]]; then # qa-*-... 形态不匹配,和以前一样不传(它们的门不要这个变量)。 build_args="" if [[ "$t" =~ ^test([0-9]+)- ]]; then - build_args="--build-arg TEST${BASH_REMATCH[1]}_SOURCE_COMMIT=$(git rev-parse HEAD)" + # 🔴 仓里并存两套 build-arg 命名,必须都供给,否则名字对不上就是空串: + # 旧:test686/765/766/746 的 Dockerfile 收 TESTNNN_SOURCE_COMMIT + # 新:test823(以及 test798/test831)的 Dockerfile 收 SOURCE_COMMIT + RUNSH_BLOB + # 我上一版只按套件名推导出 TESTNNN_SOURCE_COMMIT,test823 收的是 SOURCE_COMMIT, + # 于是照旧红在 "TEST823_SOURCE_COMMIT 收到 ''"。 + # 未被 Dockerfile 声明的 build-arg 只会产生一条警告,不影响构建,所以两套都传。 + _qa_sha="$(git rev-parse HEAD)" + build_args="--build-arg TEST${BASH_REMATCH[1]}_SOURCE_COMMIT=$_qa_sha --build-arg SOURCE_COMMIT=$_qa_sha" + # blob 绑定:把 SOURCE_COMMIT 钉到被测的那份 run.sh 字节上(git blob object id) + if [ -f "tests/$t/run.sh" ]; then + _qa_blob="$(git rev-parse "HEAD:tests/$t/run.sh" 2>/dev/null || true)" + [ -n "$_qa_blob" ] && build_args="$build_args --build-arg RUNSH_BLOB=$_qa_blob" + fi 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" From 76bd74ae6b904a3298288d2bf465d4d6e477c932 Mon Sep 17 00:00:00 2001 From: vansin Date: Fri, 14 Aug 2026 00:42:36 +0800 Subject: [PATCH 08/56] =?UTF-8?q?ci(qa.sh):=20git=20=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=9D=9E=E8=87=B4=E5=91=BD=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=B8=8A=E4=B8=80=E7=89=88=E6=8A=8A=E9=97=B8?= =?UTF-8?q?=E9=97=A8=E8=87=AA=E5=B7=B1=E7=9A=84=E5=9B=9E=E5=BD=92=E6=89=93?= =?UTF-8?q?=E7=BA=A2=E4=BA=86(=E6=88=91=E5=BC=95=E5=85=A5=E7=9A=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三次 CI 仍红,但**换了一种红法**,而且这次是我造成的。 前两次红的是 `TEST823_SOURCE_COMMIT 收到 ''`。那个已经修好了 —— 本次日志里 `source_commit=1f2ab57a…` 正常出现、blob 校验也过了。 这次红在: - cap=2 峰值=0 生效值=2 告警=0 FAIL cap2 - 0(不限) 峰值=0 生效值=0 告警=0 FAIL zero_conc failures=2 **每个用例的峰值都是 0** —— 桩一次都没被调用。根因: scripts/qa.sh:17 set -euo pipefail test823 的 Dockerfile 只装 bash / ca-certificates / coreutils / procps —— **没有 git** test823 的 run.sh 桩了 docker 和 npm,**没有桩 git** 而我上一版把 `$(git rev-parse HEAD)` 从「4 个具名套件」扩到了「所有 testNNN 套件」。 于是在 test823 重放 qa.sh 的那个容器里:git 不存在 → 127 → set -e 当场中断 → docker 桩一次没被调用 → 事件流为空 → 峰值恒 0 → 闸门自己的回归失败。 **这不是被测代码的问题,是我改出来的回归。** 修法:git 调用全部 `2>/dev/null || true`,取不到就不拼 build_args —— 无 git 环境下退回到「和我动手之前一样」的行为(不传 build-arg), 真 CI 里 git 在,照常传。 模拟验证(PATH 置空以制造无 git 环境,带 set -euo pipefail): 未中断,build_args 为空。bash -n 退出码 0。 🔴 教训:我改的是**一个会被别的门重放的脚本**。给它加依赖(git)时, 我只想着「CI runner 上当然有 git」,没想过它还会在一个刻意最小化的容器里被重放。 「这个环境肯定有 X」——当脚本本身是被测对象时,这句话要先证明。 --- scripts/qa.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/qa.sh b/scripts/qa.sh index 1f4751bb9..fbc532325 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -177,12 +177,19 @@ if [[ $RUN_L1 -eq 1 ]]; then # 我上一版只按套件名推导出 TESTNNN_SOURCE_COMMIT,test823 收的是 SOURCE_COMMIT, # 于是照旧红在 "TEST823_SOURCE_COMMIT 收到 ''"。 # 未被 Dockerfile 声明的 build-arg 只会产生一条警告,不影响构建,所以两套都传。 - _qa_sha="$(git rev-parse HEAD)" - build_args="--build-arg TEST${BASH_REMATCH[1]}_SOURCE_COMMIT=$_qa_sha --build-arg SOURCE_COMMIT=$_qa_sha" - # blob 绑定:把 SOURCE_COMMIT 钉到被测的那份 run.sh 字节上(git blob object id) - if [ -f "tests/$t/run.sh" ]; then - _qa_blob="$(git rev-parse "HEAD:tests/$t/run.sh" 2>/dev/null || true)" - [ -n "$_qa_blob" ] && build_args="$build_args --build-arg RUNSH_BLOB=$_qa_blob" + # 🔴 git 调用必须是非致命的。qa.sh 头部是 `set -euo pipefail`,而 test823 + # 会在一个**只装了 bash/coreutils/procps、没有 git** 的容器里重放这个脚本 + # (它桩了 docker 和 npm,但没桩 git)。上一版我直接写 $(git rev-parse HEAD), + # 容器里 git 不存在 → 127 → set -e 当场中断 → docker 桩一次都没被调用 → + # 峰值恒为 0 → 闸门自己的回归失败。这是我改出来的回归,不是被测代码的问题。 + _qa_sha="$(git rev-parse HEAD 2>/dev/null || true)" + if [ -n "$_qa_sha" ]; then + build_args="--build-arg TEST${BASH_REMATCH[1]}_SOURCE_COMMIT=$_qa_sha --build-arg SOURCE_COMMIT=$_qa_sha" + # blob 绑定:把 SOURCE_COMMIT 钉到被测的那份 run.sh 字节上(git blob object id) + if [ -f "tests/$t/run.sh" ]; then + _qa_blob="$(git rev-parse "HEAD:tests/$t/run.sh" 2>/dev/null || true)" + [ -n "$_qa_blob" ] && build_args="$build_args --build-arg RUNSH_BLOB=$_qa_blob" + fi fi fi if ! dockerrun "docker build -q $build_args -t anet-$t -f tests/$t/Dockerfile ." >/tmp/qa-l1-$t-build.log 2>&1; then From f565e9b8152c5d81b2afb19c376780bc3627f76e Mon Sep 17 00:00:00 2001 From: vansin Date: Mon, 17 Aug 2026 23:31:52 +0800 Subject: [PATCH 09/56] =?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 10/56] =?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 11/56] =?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 12/56] =?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 13/56] =?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 14/56] =?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 15/56] =?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 16/56] 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 17/56] 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 18/56] =?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 19/56] 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 20/56] 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 21/56] =?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 22/56] 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 23/56] =?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 24/56] =?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 25/56] =?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 26/56] =?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 27/56] =?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 28/56] =?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 29/56] =?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 30/56] =?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 31/56] =?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 32/56] =?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 33/56] =?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 34/56] =?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 35/56] =?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 36/56] =?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 37/56] =?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 38/56] =?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 39/56] =?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 40/56] =?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 41/56] =?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 42/56] =?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 43/56] =?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 44/56] =?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 45/56] =?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 46/56] =?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 47/56] =?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 48/56] =?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 49/56] =?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 50/56] =?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 51/56] =?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 52/56] =?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 53/56] =?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 a6205d65ca289793df6c2f49ed8a2b71be784f35 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 08:23:23 +0800 Subject: [PATCH 54/56] =?UTF-8?q?ci(qa):=20L1=20=E5=8A=A0=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=8A=E9=99=90(=E9=BB=98=E8=AE=A4=20nproc);?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E7=89=88=E7=94=A8=20jobs=20=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E6=98=AF=E5=9D=8F=E7=9A=84,=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E6=89=8D=E5=8F=91=E7=8E=B0=20(#823)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(qa): 给 L1 加并发上限,默认 nproc,可用 QA_L1_MAX_PAR 覆盖 L1 原来是无节制后台化:L1_TESTS 有多少条就同时拉起多少个容器(当前 17 条)。 在专用 CI runner 上没问题;在开发/生产共用的机器上不行 —— 实测本机(8 核,同时跑着生产 hub、dashboard 与约 200 个 agent session) 一次 `qa.sh --l1` 把 load1 顶到 58,即 7.3x 超订;跑完回落到 30 一线。 默认上限取 `nproc`(不是更激进的 nproc/2),要同时满足两件事: 小核 CI runner 上尽量不改变现有耗时,大核共享机上把超订压下来。 `QA_L1_MAX_PAR=0` 表示不限,可完全恢复旧行为。 ## 第一版是坏的,靠实测才发现 最初写的闸门是: while … (( $(jobs -rp | wc -l) >= QA_L1_MAX_PAR )); do sleep 0.2; done **它从不阻塞。** `$( )` 会开子 shell,而 `jobs` 的作业表不跨子 shell 继承, 所以那个计数恒为 0。实测坐实:上限设 2,`docker ps` 采样到的 anet-* 容器峰值仍是 **3**。 改成在父 shell 里用 `kill -0` 数活着的 pid 之后: 上限 2 → 采样峰值 **2**(分布:0×6、1×3、2×29),`ALL PASS in 40s` 两版用的是同一组 4 个套件、同一台机器、同样的采样方式(每秒数一次 `docker ps --format '{{.Image}}' | grep -c '^anet-'`),只差闸门实现。 ## NOT COVERED - **没有测 CI 上的墙钟影响**。GitHub runner 核数少,默认上限会等于那个核数, 与现在的 17 路并发不同。当前 L0+L1 job 实测用 141–148s / 预算 300s, 我无法在本地可靠复现 runner 的时序 —— 若复核认为有风险, 可以在 workflow 里显式设 `QA_L1_MAX_PAR=0` 保持旧行为,或设一个更大的值。 - 只限制了 `docker run` 的并发;**build 仍是串行**,未改。 * ci(qa): 并发上限的值必须先校验 —— 否则一个笔误就静默恢复无上限 闸门条件是 `[[ "$QA_L1_MAX_PAR" -gt 0 ]]`,而 bash 在算术上下文里把非数字 当 0 —— 0 的语义恰好是「不限」。于是 QA_L1_MAX_PAR=two / =4x 会**静默恢复 本 PR 要消除的无上限行为**,同时那行 note 还照打「L1 并发上限 = two」, 输出主动确认了一个并不存在的上限。 这条不是理论问题:无上限时实测本机 load1 顶到 58(8 核,同时跑着生产 hub、 dashboard 与约 200 个 session)。fail-open 的方向正好是压垮共享机那一边。 改成 fail-closed:值不是非负整数就退回默认并大声报出来。 另修一个我自己第一版没料到的洞 —— 全数字还不够:bash 把前导零当八进制, `[[ "08" -gt 0 ]]` 报 `value too great for base` 并返回非零,闸门照样失效; `010` 则会被静默当成 8。加 `$((10#…))` 规范化。这个洞是跑对照表时发现的 (用例里放了 08),不是想出来的。 对照(旧 → 新): '4' 开 → 4 开 '0' 不限 → 0 不限 (有意保留) 'two' 不限 → 8 开 + 告警 '4x' 不限 → 8 开 + 告警 ' 3' 开 → 8 开 + 告警 '08' 不限+报错 → 8 开 '04' 开 → 4 开 * Merge origin/main into #823 + 更正一句注释的机制描述 结论和修法都对,合。但注释里的机制说法我复核后不准确,顺手改准。 原文:「命令替换会开子 shell,而 `jobs` 的作业表不跨子 shell 继承, 那样数出来**恒为 0**、闸门形同虚设。」 实测(bash 5.2.21,非交互脚本,MAX=2,启 8 个后台任务): jobs 口径采样序列: 0 1 1 1 0 1 0 1 kill-0 口径峰值 : 2 **不是恒 0,是从来到不了上限值** —— `(( n < MAX ))` 因此永远为真,闸门永远放行。 坏的结果一样(PR 里实测「上限设 2、docker ps 峰值 3」是对的),但机制不同。 为什么值得改这一句:下一个人照「恒为 0」去排查,一跑发现是 1, 很可能得出「这条注释过时了/不适用了」,从而把 `jobs` 版本改回来。 **一个正确的结论配一个编错的机制,会把下一步的动作导向错的地方。** 同时把另外两条陷阱各自实测确认(它们的描述是准确的): [[ two -gt 0 ]] → 假 ⇒ while 不进入 ⇒ 无上限(非数字在算术上下文当 0) [[ 08 -gt 0 ]] → bash: [[: 08: value too great for base ⇒ rc=1 ⇒ 同样静默失效 $((10#08)) = 8 Co-Authored-By: Claude Opus 5 --------- Co-authored-by: vansin Co-authored-by: t Co-authored-by: Claude Opus 5 --- scripts/qa.sh | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/scripts/qa.sh b/scripts/qa.sh index 082fd62fc..061e55563 100755 --- a/scripts/qa.sh +++ b/scripts/qa.sh @@ -145,6 +145,22 @@ if [[ $RUN_L1 -eq 1 ]]; then echo " @sleep2agi/$pkg@preview -> $v" done } | tee /tmp/qa-l1-registry-snapshot.txt + QA_L1_MAX_PAR="${QA_L1_MAX_PAR:-$(nproc 2>/dev/null || echo 4)}" + # 🔴 必须先校验再用。下面的闸门条件是 `[[ "$QA_L1_MAX_PAR" -gt 0 ]]`,而 bash + # 在算术上下文里把非数字当 0 —— 而 0 的语义恰好是「不限」。于是一个笔误 + # (`QA_L1_MAX_PAR=two`、`=4x`)会**静默恢复本节要消除的无上限行为**, + # 而下面那行 note 还会照打「L1 并发上限 = two」,输出主动确认一个不存在的上限。 + # 这里 fail-closed:值不合法就退回默认,并大声说出来。 + if [[ ! "$QA_L1_MAX_PAR" =~ ^[0-9]+$ ]]; then + _bad="$QA_L1_MAX_PAR" + QA_L1_MAX_PAR="$(nproc 2>/dev/null || echo 4)" + note "⚠ QA_L1_MAX_PAR='${_bad}' 不是非负整数 —— 已退回默认 ${QA_L1_MAX_PAR}(否则闸门会静默失效)" + fi + # 全数字还不够:bash 把前导零当八进制,`[[ "08" -gt 0 ]]` 会报 + # `value too great for base` 并返回非零 —— 闸门照样静默失效。 + # 这个洞是写完上面那段校验之后、跑对照表时才发现的(用例里放了 08)。 + QA_L1_MAX_PAR=$((10#$QA_L1_MAX_PAR)) + note "L1 并发上限 = ${QA_L1_MAX_PAR}(0 = 不限;用 QA_L1_MAX_PAR 覆盖)" pids=() declare -A pid_to_test for t in "${L1_TESTS[@]}"; do @@ -172,7 +188,36 @@ if [[ $RUN_L1 -eq 1 ]]; then FAILED=$((FAILED+1)) continue fi - # Run in background + # Run in background —— 但要有并发上限。 + # + # 原来这里是无节制后台化:L1_TESTS 有多少条,就同时拉起多少个容器。 + # 在专用 CI runner 上没问题;在开发/生产共用的机器上不行 —— + # 实测本机(8 核,同时跑着生产 hub、dashboard 与 ~200 个 agent session) + # 一次 `qa.sh --l1` 把 load1 顶到 58,即 7.3x 超订。 + # + # 默认上限取 nproc(而不是更激进的 nproc/2),因为要同时满足两件事: + # 在小核 CI runner 上尽量不拖慢现有耗时,在大核共享机上把超订压下来。 + # 需要时用 QA_L1_MAX_PAR 覆盖;设成 0 表示不限(恢复旧行为)。 + # 注意:这里**不能**用 `$(jobs -rp | wc -l)` —— 它在这个位置**系统性少数**, + # 于是上限 N 实际表现成 N+1/N+2。实测过:用 jobs 版本、上限设 2, + # `docker ps` 采到的 anet-* 峰值仍是 3。 + # + # 合并时复核了一次这条注释的**机制**部分(bash 5.2.21,脚本非交互): + # 原文写「数出来恒为 0」——不准确。同样的循环里采样序列是 + # `0 1 1 1 0 1 0 1`:它**不是恒 0,而是从来到不了上限值**, + # 所以 `(( n < MAX ))` 永远为真、闸门永远放行。 + # 结论和修法都不变(少数就够坏了),但机制说清楚一点,免得下一个人 + # 照着「恒为 0」去排查,发现不是 0 就以为这条注释过时了。 + # 改成在父 shell 里用 kill -0 数还活着的 pid —— 它数的是进程本身, + # 不依赖 shell 的作业表。 + while [[ "$QA_L1_MAX_PAR" -gt 0 ]]; do + live=0 + for _p in "${pids[@]:-}"; do + [[ -n "$_p" ]] && kill -0 "$_p" 2>/dev/null && live=$((live+1)) + done + (( live < QA_L1_MAX_PAR )) && break + sleep 0.2 + done (dockerrun "docker run --rm anet-$t" >/tmp/qa-l1-$t-run.log 2>&1) & pid=$! pids+=("$pid") From ba61e742638fef95e46946cf4db6617ca9d21271 Mon Sep 17 00:00:00 2001 From: vansin Date: Tue, 18 Aug 2026 08:27:46 +0800 Subject: [PATCH 55/56] =?UTF-8?q?fix(ci):=20bash=20=E6=95=B0=E7=BB=84?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8=E8=A2=AB=E6=B3=A8=E9=87=8A=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20`)`=20=E6=88=AA=E6=96=AD=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=80=E9=81=93=E9=97=A8=20exit=202,=E5=AE=83=E7=9A=84?= =?UTF-8?q?=E5=AD=AA=E7=94=9F=E5=85=84=E5=BC=9F=E9=9D=99=E9=BB=98=E5=88=A4?= =?UTF-8?q?=E7=BB=BF=20(#933)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个 checker 用同一个正则从 `scripts/qa.sh` 里取数组: re.search(rf"{name}=\(([^)]*)\)", text, re.S) `[^)]*` 在**第一个** `)` 处停下。而 bash 数组里注释是合法的,注释里出现 `)` 也是合法的。#835 往 L1_TESTS 顶部加了一行: L1_TESTS=( # (注册这一步不是可选的 —— 一个没被任何东西调用的套件等于不存在。) "test823-l1-concurrency-cap" ... 正则在那个 `)` 处截断,捕获内容里**一个套件名都没有**。 🔴 **判据完全正确,塌的是取集。** ## 两道门吃同一个洞,表现完全不同 —— 这才是要紧的部分 把那行注释注入 `origin/main` 的 qa.sh,A/B 跑: | checker | 未修复 | 修复后 | |---|---|---| | `check-l1-paths-sync.py` | **exit 2**「found no L1_TESTS entries — parse regression, refusing to pass」 | rc=0,17 个套件 | | `check-qa-trigger-coverage.py` | **rc=0**,`CI-executed: 7` | rc=0,`CI-executed: 11` | **第二个静默判绿,并且把 11 个套件悄悄算成了 7 个** —— 少的正是 test686 / test746 / test765 / test766(它们只出现在 L1_TESTS 里)。 它照常打印「all 7 CI-executed test directory/ies can re-trigger qa.yml」, **一句真话,建立在一个塌掉的分母上。** 同一个 bug,一个 fail-closed 所以被看见,一个 fail-open 所以不会。 **看见它的那个救了另一个** —— 否则 trigger-coverage 会带着 7/11 的分母 一直绿下去,而它存在的全部意义就是那个分母。 ## 修法 加 `_strip_comments()`:按行剥掉 `#` 之后的内容再匹配。数组元素是 kebab-case 的套件名,不含 `#`,按行剥是安全的。 ## 见红 selftest 加两条夹具(照着实际撞红的那行写),然后把 `_strip_comments` 变成 恒等函数(`return text`)验证它们真的守着这件事: 正常: selftest: 10/10 ok rc=0 变异: FAIL 注释里的 ) 不截断数组 → 1 case(s) rc=1 另一条「数组后面别处的 `)` 不影响」用来钉住剥注释没有把范围放宽。 Co-authored-by: t Co-authored-by: Claude Opus 5 --- .github/scripts/check-l1-paths-sync.py | 37 +++++++++++++++++++- .github/scripts/check-qa-trigger-coverage.py | 25 ++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/scripts/check-l1-paths-sync.py b/.github/scripts/check-l1-paths-sync.py index d67ca25cd..952c745c8 100644 --- a/.github/scripts/check-l1-paths-sync.py +++ b/.github/scripts/check-l1-paths-sync.py @@ -39,9 +39,32 @@ QA_YML = ".github/workflows/qa.yml" +def _strip_comments(text: str) -> str: + """去掉每行的 `#` 注释。 + + 🔴 这不是洁癖,是一个**取集**缺陷的修复。下面的数组正则用 `[^)]*`, + 它在遇到第一个 `)` 时停下 —— 而 bash 数组里**注释是合法的**,注释里出现 + `)` 也是合法的: + + L1_TESTS=( + # (注册这一步不是可选的 —— 一个没被调用的套件等于不存在。) + "test823-l1-concurrency-cap" + ... + ) + + 正则在那个中文注释的 `)` 处截断,捕获到的内容里**一个套件名都没有**, + 于是 `l1_suites()` 返回 [] —— 判据完全正确,取集塌了。 + + 这次它 fail-closed(exit 2「parse regression」)所以被看见了。同一个洞 + 如果长在一个「没找到就当没有」的检查里,就是一片安静的假绿。 + 数组里的元素不会含 `#`(套件名是 kebab-case),所以按行剥注释是安全的。 + """ + return "\n".join(line.split("#", 1)[0] for line in text.split("\n")) + + 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) + m = re.search(r"L1_TESTS=\(([^)]*)\)", _strip_comments(text), re.S) if not m: return [] return re.findall(r'"([^"]+)"', m.group(1)) @@ -116,6 +139,18 @@ def selftest() -> int: } cases = [ ("L1_TESTS parsed in full", l1_suites(sh) == ["qa-a", "test-b"]), + # 🔴 见 _strip_comments:数组里一条**含右括号的注释**会让 `[^)]*` 提前截断, + # 捕获内容里一个套件名都没有。这条夹具照着实际撞红的那次写(#835 往 + # L1_TESTS 里加了一行「(注册这一步不是可选的 …)」)。 + ( + "注释里的 ) 不截断数组", + l1_suites('L1_TESTS=(\n # (注册不是可选的)\n "qa-a"\n "test-b"\n)\n') + == ["qa-a", "test-b"], + ), + ( + "数组后面别处的 ) 不影响", + l1_suites('L1_TESTS=(\n "qa-a"\n)\nfoo() { :; }\n') == ["qa-a"], + ), ("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), diff --git a/.github/scripts/check-qa-trigger-coverage.py b/.github/scripts/check-qa-trigger-coverage.py index 985f086e5..97a9e63db 100755 --- a/.github/scripts/check-qa-trigger-coverage.py +++ b/.github/scripts/check-qa-trigger-coverage.py @@ -31,9 +31,32 @@ WORKFLOWS = Path(".github/workflows") +def _strip_comments(text: str) -> str: + """去掉每行的 `#` 注释。 + + 🔴 这不是洁癖,是一个**取集**缺陷的修复。下面的数组正则用 `[^)]*`, + 它在遇到第一个 `)` 时停下 —— 而 bash 数组里**注释是合法的**,注释里出现 + `)` 也是合法的: + + L1_TESTS=( + # (注册这一步不是可选的 —— 一个没被调用的套件等于不存在。) + "test823-l1-concurrency-cap" + ... + ) + + 正则在那个中文注释的 `)` 处截断,捕获到的内容里**一个套件名都没有**, + 于是 `l1_suites()` 返回 [] —— 判据完全正确,取集塌了。 + + 这次它 fail-closed(exit 2「parse regression」)所以被看见了。同一个洞 + 如果长在一个「没找到就当没有」的检查里,就是一片安静的假绿。 + 数组里的元素不会含 `#`(套件名是 kebab-case),所以按行剥注释是安全的。 + """ + return "\n".join(line.split("#", 1)[0] for line in text.split("\n")) + + 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) + m = re.search(rf"{name}=\(([^)]*)\)", _strip_comments(text), re.S) return re.findall(r'"([^"]+)"', m.group(1)) if m else [] From 290dfc102139400e667c552b6610e02f27bf1552 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 18 Aug 2026 08:28:54 +0800 Subject: [PATCH 56/56] =?UTF-8?q?qa.yml=20=E8=A1=A5=E4=B8=8A=20test823=20?= =?UTF-8?q?=E7=9A=84=20paths=20=E2=80=94=E2=80=94=20=E8=BF=99=E9=81=93?= =?UTF-8?q?=E9=97=A8=E8=87=AA=E5=B7=B1=E6=8A=93=E5=88=B0=E7=9A=84,?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E6=88=91=E5=85=88=E6=83=B3=E5=88=B0=E7=9A=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合完 #933(修好数组解析)之后,`check-l1-paths-sync` 立刻报出本 PR 的一个真缺口: ::error file=scripts/qa.sh::L1 suite 'test823-l1-concurrency-cap' is run by qa.sh but no `paths:` entry in .github/workflows/qa.yml matches tests/test823-l1-concurrency-cap/. Editing that suite will not trigger the workflow that runs it, and nothing else would report that. 也就是:**套件注册进了 L1_TESTS,但改这个套件不会触发跑它的那条 workflow。** 对一个「测这道闸门自己」的套件来说,这一格尤其要命 —— 改坏了它自己不会响。 `pull_request.paths` 与 `push.paths` 各补一条。 复核: check-l1-paths-sync.py 18 个 L1 套件 / 21 条 path,全部有触发 rc=0 check-qa-trigger-coverage.py CI-executed 12 个,全部能重触发 qa.yml rc=0 🔴 时间顺序值得记一笔:这个缺口在 #933 之前**是看不见的** —— 那时解析器被 注释里的 `)` 截断,`l1_suites()` 返回空,门 exit 2 报的是「parse regression」。 修好取集之后,它报的才是真正的问题。**门坏掉的时候,它连自己在漏什么都说不出来。** Co-Authored-By: Claude Opus 5 --- .github/workflows/qa.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 74ee47e95..51a73ff4b 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -31,6 +31,9 @@ on: - 'tests/test686-rest-shape-golden/**' - 'tests/test765-batch-runtime-gate/**' - 'tests/test766-bunx-preflight/**' + # test823 是这道 L1 并发闸门自己的回归,跑的是真的 scripts/qa.sh + # (把 docker/npm 换成 PATH 上的桩)。改它必须能重跑跑它的 workflow。 + - 'tests/test823-l1-concurrency-cap/**' - 'tests/test798-server-unit-ci/**' # test798 的镜像 COPY 了 test601 的 race-worker.ts, # 且 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」—— @@ -61,6 +64,9 @@ on: - 'tests/test686-rest-shape-golden/**' - 'tests/test765-batch-runtime-gate/**' - 'tests/test766-bunx-preflight/**' + # test823 是这道 L1 并发闸门自己的回归,跑的是真的 scripts/qa.sh + # (把 docker/npm 换成 PATH 上的桩)。改它必须能重跑跑它的 workflow。 + - 'tests/test823-l1-concurrency-cap/**' - 'tests/test798-server-unit-ci/**' # test798 的镜像 COPY 了 test601 的 race-worker.ts, # 且 server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一 occurrence」——