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/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/scripts/check-docs-integrity.py b/.github/scripts/check-docs-integrity.py new file mode 100755 index 000000000..61f0031f2 --- /dev/null +++ b/.github/scripts/check-docs-integrity.py @@ -0,0 +1,186 @@ +#!/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. + +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), 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. +""" +import os +import re +import subprocess +import sys + +# 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+") + + +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 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): + problems += 1 + 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"{len(changelogs)} changelog(s) for main line-anchors ({anchors} found)") + + if problems: + print(f"\n{problems} problem(s).") + return 1 + print("no problems.") + 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/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/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/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/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/action-pins.yml b/.github/workflows/action-pins.yml new file mode 100644 index 000000000..61ea2ed57 --- /dev/null +++ b/.github/workflows/action-pins.yml @@ -0,0 +1,39 @@ +# 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: + # 这个 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 + - run: python3 .github/scripts/check-action-pins.py 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 diff --git a/.github/workflows/docs-integrity.yml b/.github/workflows/docs-integrity.yml new file mode 100644 index 000000000..b31c11f50 --- /dev/null +++ b/.github/workflows/docs-integrity.yml @@ -0,0 +1,54 @@ +# 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: + # 这个 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 + # 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/.github/workflows/e2e-docker.yml b/.github/workflows/e2e-docker.yml index 5f16db5f1..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/**' @@ -23,7 +39,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/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/.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 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/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/.github/workflows/qa-trigger-coverage.yml b/.github/workflows/qa-trigger-coverage.yml new file mode 100644 index 000000000..51133ef3e --- /dev/null +++ b/.github/workflows/qa-trigger-coverage.yml @@ -0,0 +1,39 @@ +# 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: + # 这个 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 + - run: python3 .github/scripts/check-qa-trigger-coverage.py diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index b6a9fd4e7..c908bdb70 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -22,6 +22,24 @@ 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/**' + - '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/**' + - 'tests/test224-grok-preview-security/**' + - 'tests/test597-dashboard-slash-namespace/**' + - 'tests/test679-task-trace/**' + - 'tests/lib/**' - 'docs-site/**' - 'docs/doc-source-pins-baseline.txt' - 'scripts/check-doc-source-pins.py' @@ -38,6 +56,24 @@ 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/**' + - '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/**' + - 'tests/test224-grok-preview-security/**' + - 'tests/test597-dashboard-slash-namespace/**' + - 'tests/test679-task-trace/**' + - 'tests/lib/**' - 'docs-site/**' - 'docs/doc-source-pins-baseline.txt' - 'scripts/check-doc-source-pins.py' @@ -67,6 +103,24 @@ 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" \ + --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 . + + - 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 @@ -84,6 +138,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 @@ -92,7 +231,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/.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/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..21f9bbe8f 100644 --- a/README.en.md +++ b/README.en.md @@ -41,19 +41,24 @@ 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 - **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..3bcfd61de 100644 --- a/README.md +++ b/README.md @@ -41,19 +41,24 @@ 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`。 ## 能做什么 - **连接不同 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/agent-network/bin/cli.ts b/agent-network/bin/cli.ts index 0082994de..9f7f11b07 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, PANE_LIST_FORMAT, paneTargetFor } from "../src/tmux-exact-target"; import { diagnoseLocale, formatLocaleSource } from "../src/locale-diagnostic"; import { formatSecretAssignment, @@ -139,8 +143,39 @@ 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 {} +/** + * 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 {} + // 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 +184,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 +236,17 @@ 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 paneTarget = tmuxPaneTarget(sessionName); + if (!paneTarget) return false; + // 🔴 `-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; } @@ -671,6 +716,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 +743,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,10 +814,11 @@ async function startOpencodeCopresenceOrchestration(nodeId: string, hubOverride? if (!existsSync(attachScript)) { let tail = ""; try { - tail = execFileSync("tmux", ["capture-pane", "-p", "-t", 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.`); @@ -1481,6 +1547,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; @@ -2228,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); @@ -2258,7 +2361,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 +5420,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 +5447,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 +5529,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 @@ -6173,8 +6344,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"})` @@ -7552,9 +7734,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) @@ -7649,6 +7846,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, @@ -7735,22 +7959,62 @@ 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 = ""; + // 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 { - 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. + // + // 🔴 这里**故意不加 `-S`**,和 #849 修的那两处相反 —— 因为问题不同: + // 那两处找的是「**曾经出现过**的一行」(就绪信号 / 失败原因),必须看回滚; + // 这里判的是「**此刻屏幕上有没有一个等人回答的提示框**」。加上回滚,一个 + // 早就被答掉、已经滚走的提示框会被重新识别成待处理,于是往一个并没有显示 + // 它的会话里 send-keys。**同一个 flag,这三处里两处该加、一处不该。** + pane = execFileSync("tmux", ["capture-pane", "-p", "-t", paneTarget], { + 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", 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)); + 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", paneTarget, "Enter"], { stdio: "ignore" }); } catch {} return true; } await new Promise(r => setTimeout(r, 1000)); @@ -7758,15 +8022,47 @@ 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 paneTarget = tmuxPaneTarget(sessionName); + if (!paneTarget) return null; // session already reaped + // 🔴 同 #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); + } 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. 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() { @@ -7843,6 +8139,7 @@ async function projectUp(invokedAs = "anet project up") { autoConfirmDevChannels(spawned), ]); printProjectSummary(nodes.length, alreadyUp + started, failed, invalid); + exitFromProjectOutcome(failed, invalid); } async function projectRestart() { @@ -7899,6 +8196,7 @@ async function projectRestart() { autoConfirmDevChannels(spawned), ]); printProjectSummary(nodes.length, started, failed, invalid); + exitFromProjectOutcome(failed, invalid); } async function projectDown() { @@ -12942,6 +13240,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 +13299,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/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/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/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/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/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/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"); +}); 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..268f910fa --- /dev/null +++ b/agent-network/src/tmux-exact-target.ts @@ -0,0 +1,105 @@ +// 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); +} + +// ── 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-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/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:")'); +}); 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`; + } +} 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 c39bffa71..629547053 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 { @@ -199,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" = 全部) @@ -214,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" @@ -451,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}`); @@ -2360,19 +2368,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/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/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}`; +} 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/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 diff --git a/deploy/dashboard/ecosystem.config.cjs b/deploy/dashboard/ecosystem.config.cjs index 8b4217a39..56e04e468 100644 --- a/deploy/dashboard/ecosystem.config.cjs +++ b/deploy/dashboard/ecosystem.config.cjs @@ -20,9 +20,24 @@ 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, + // 没有 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") 而不是绝对路径。 }, ], }; diff --git a/docs-site/docs/api/mcp-tools.md b/docs-site/docs/api/mcp-tools.md index d8a461ac1..e6b8a3c2b 100644 --- a/docs-site/docs/api/mcp-tools.md +++ b/docs-site/docs/api/mcp-tools.md @@ -43,7 +43,7 @@ CommHub Server 共注册约 40 个 MCP Tools;本页文档化其中 agent 日 | `project_dir` | string | | 工作目录 | | `version` | string | | Agent 版本 | | `tmux_name` | string | | tmux session 名 | -| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts:168-188`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L168))。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts:129` INSERT + `tools.ts:141` ON CONFLICT](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129));只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | +| `node_id` | string | | 节点稳定标识。**注意**:传了 `node_id` 才会把 `model` / `node_name` / `runtime`(从 `agent` 字段拆)upsert 到 `nodes` 表([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `upsertNodeWithSec1Guard`(`report_status` 段内 `if (node_id)` 之下的调用点,以及 registerTools 之后的同名 helper))。`model` 参数本身**不依赖 `node_id`** —— `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `report_status` 段搜 `INSERT INTO sessions`(写这几列的是 `report_status`,不是本节这个 tool) 与 `model = COALESCE(?20, sessions.model)`);只有 `node_name` 没有 `sessions` 列、必须靠 `node_id` 走 `nodes` 表 | | `session_id` | string | | 运行时 session/thread ID | | `config_path` | string | | 配置文件路径 | | `channels` | string | | Channel 列表(JSON 数组字符串) | @@ -77,11 +77,11 @@ report_status({ ``` ::: warning 认证要求 -该 tool 只接受 **`ntok_`(network-scoped)token**。用 `utok_`(user-scoped)调用会返回 `{ok: false, error: "network_token_required"}`([`tools.ts:116-118`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L116))。这是 v0.8 RFC-001 之后的硬约束 — agent 心跳必须绑定 network。 +该 tool 只接受 **`ntok_`(network-scoped)token**。用 `utok_`(user-scoped)调用会返回 `{ok: false, error: "network_token_required"}`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"network_token_required"`(全仓 3 处))。这是 v0.8 RFC-001 之后的硬约束 — agent 心跳必须绑定 network。 副作用:除了写 `sessions` 表,还会: -- 自动**删除同 network、同 alias、不同 resume_id** 的旧 session row([`tools.ts:127`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L127);用于 agent 重启时清理孤儿) -- 当 `status="working"` 且有 `task` 时,触发 `tasks` 表 `delivered/acked → running` 状态切换([`tools.ts:150-153`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L150);详见 [Task 生命周期](/concepts/task-lifecycle#状态机)) +- 自动**删除同 network、同 alias、不同 resume_id** 的旧 session row([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2`;用于 agent 重启时清理孤儿) +- 当 `status="working"` 且有 `task` 时,触发 `tasks` 表 `delivered/acked → running` 状态切换([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET status = 'running'`;详见 [Task 生命周期](/concepts/task-lifecycle#状态机)) - 当 `node_id` 传入时 upsert `nodes` 表(含 `model` / `node_name` / `runtime`,详见 `node_id` 参数行) ::: @@ -128,11 +128,11 @@ report_completion({ ``` ::: tip 副作用(除了 completions 表 INSERT) -- **session 状态切换**:[`tools.ts:239-242`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L239) `UPDATE sessions SET status='idle', task=NULL, progress=0` (按 alias) -- **任务状态切换**:[`tools.ts:244-266`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L244) 把 `tasks` 行从 `delivered`/`acked`/`running` 切到 `replied`。先按 `task_id = ` 匹配;不命中再 fallback 用 `to_name= AND content=` 找最近一条 — 所以 `task` 参数实际可填**真实 task_id**(推荐)或**任务描述字符串**(fallback) -- **`result` 截断**:写 `tasks.result` 时只取前 4000 字符([`tools.ts:246`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L246)),但完整 `result` 会进 `completions.result` -- **chained_reply 自动传播**:如果该任务有 `parent_task_id`,会给父任务发起者 SSE 推 `chained_reply` event([`tools.ts:271-291`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L271);用于子任务回 → 链式通知父任务发起者,详见 [`task-lifecycle` 双写机制](/concepts/task-lifecycle#双写机制)) -- **`task_events` log**:记录一条 `replied` event([`tools.ts:270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L270)) +- **session 状态切换**:[`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE sessions SET status = 'idle'` `UPDATE sessions SET status='idle', task=NULL, progress=0` (按 alias) +- **任务状态切换**:[`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET status = 'replied'`(全仓 2 处) 把 `tasks` 行从 `delivered`/`acked`/`running` 切到 `replied`。先按 `task_id = ` 匹配;不命中再 fallback 用 `to_name= AND content=` 找最近一条 — 所以 `task` 参数实际可填**真实 task_id**(推荐)或**任务描述字符串**(fallback) +- **`result` 截断**:写 `tasks.result` 时只取前 4000 字符([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `result.slice(0, 4000)`(全仓 2 处)),但完整 `result` 会进 `completions.result` +- **chained_reply 自动传播**:如果该任务有 `parent_task_id`,会给父任务发起者 SSE 推 `chained_reply` event([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `type: "chained_reply"`(全仓 2 处);用于子任务回 → 链式通知父任务发起者,详见 [`task-lifecycle` 双写机制](/concepts/task-lifecycle#双写机制)) +- **`task_events` log**:记录一条 `replied` event([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `logTaskEvent(updatedTaskId, null, "replied"`) 跟 [`send_reply`](#send-reply) 比较:`send_reply` 是 hub 工具,需要显式 `task_id` 参数;`report_completion` 是 agent 工具,可 fallback by content。 ::: @@ -191,7 +191,7 @@ report_completion({ |------|------|:----:|------| | `alias` | string | ✓ | Session 别名 | | `message_id` | string | ✓ | inbox 投递行 `id`,或任务消息的逻辑 `task_id`。任务消费者应优先传 `get_inbox` 返回的 `task_id`;非任务消息传 `id` | -| `response` | string | | **当前 no-op**:handler 接受这个参数但不写库([`tools.ts:872-924`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L872) 没有读取 `response`)。schema 保留是为了 forward-compat / 不破坏现有调用方;想真正回复用 [`send_reply`](#send-reply) | +| `response` | string | | **当前 no-op**:handler 接受这个参数但不写库([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"` 没有读取 `response`)。schema 保留是为了 forward-compat / 不破坏现有调用方;想真正回复用 [`send_reply`](#send-reply) | | `network_id` | string | | Network 范围。utok_ 恰好 1 个成员网络时自动解析,可省略;跨多网络必须显式传(#517) | **返回值**: @@ -203,7 +203,7 @@ report_completion({ **错误**:找不到属于该 alias 的待确认投递 → `message not found or already acknowledged`;投递在查询后不再可写 → `message not found or not yours`。 ::: tip 副作用:tasks 表状态机 -Hub 先用 `id = message_id`,或对任务消息用 `task_id = message_id`,解析出当前未确认的 inbox 行并只 ACK 那一行。若它是任务消息,再用该行解析出的稳定逻辑 `task_id` 把 `tasks` 从 `status='delivered'` UPDATE 到 `'acked'`([`tools.ts:884-920`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L884))。因此 retry/reassign 产生新 inbox `id` 后,仍能 ACK 原任务;旧消费者继续传 inbox `id` 也兼容。任务状态**仅**从 `delivered` 起跳,跟 hub 端 [`send_ack`](#send-ack)(接受 `created` / `delivered`)不同 —— 详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机)。 +Hub 先用 `id = message_id`,或对任务消息用 `task_id = message_id`,解析出当前未确认的 inbox 行并只 ACK 那一行。若它是任务消息,再用该行解析出的稳定逻辑 `task_id` 把 `tasks` 从 `status='delivered'` UPDATE 到 `'acked'`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2`)。因此 retry/reassign 产生新 inbox `id` 后,仍能 ACK 原任务;旧消费者继续传 inbox `id` 也兼容。任务状态**仅**从 `delivered` 起跳,跟 hub 端 [`send_ack`](#send-ack)(接受 `created` / `delivered`)不同 —— 详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机)。 ::: --- @@ -366,8 +366,8 @@ send_task({ ``` ::: warning 限制 -- 只能重试状态为 `failed` / `expired` / `cancelled` 的任务(verify [`tools.ts:713`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L713)),其他状态返回 `{ok: false, error: "task status is , not retryable"}` -- 重试会**固定**给一个新的 `+1 小时` TTL([`tools.ts:718`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L718) 硬编码),**不沿用原任务**的 `ttl_seconds` +- 只能重试状态为 `failed` / `expired` / `cancelled` 的任务(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `["failed", "expired", "cancelled"].includes(task.status)`),其他状态返回 `{ok: false, error: "task status is , not retryable"}` +- 重试会**固定**给一个新的 `+1 小时` TTL([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `+1 hour` 硬编码),**不沿用原任务**的 `ttl_seconds` - `task_id` 不变;inbox 里会插入一条新 `id` 的 row(新 UUID),并 SSE 推 `new_task` 给目标 alias ::: @@ -399,7 +399,7 @@ send_task({ ``` ::: warning 限制 -只能取消状态为 `created` / `delivered` / `acked` / `running` 的任务(4 个 cancellable 源状态,verify [`tools.ts:817`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L817) WHERE 子句)。终态 `replied` / `failed` / `cancelled` / `expired` 上调用此 tool 会返回 `{ok: false, cancelled: false}`。 +只能取消状态为 `created` / `delivered` / `acked` / `running` 的任务(4 个 cancellable 源状态,verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `cancel_task` 段搜 `status IN ('created', 'delivered', 'acked', 'running')`(全仓 2 处,另一处属 `send_message`) WHERE 子句)。终态 `replied` / `failed` / `cancelled` / `expired` 上调用此 tool 会返回 `{ok: false, cancelled: false}`。 `created` 实际只是 DB 默认值,正常 API 路径不会观察到(详见 [Task 生命周期 — `created` 状态](/concepts/task-lifecycle#状态机))。 ::: @@ -433,9 +433,9 @@ send_task({ ``` ::: warning 限制 -- 只能 reassign **非终态**任务:`created` / `delivered` / `acked` / `running`([`tools.ts:853`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L853) 反向拒掉 `replied` / `failed` / `cancelled` / `expired`,返回 `{ok: false, error: "task is terminal ()"}`) -- 旧 alias 的 inbox row 被 `acked=1`([`tools.ts:858`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L858)),原 agent 不会再 pick up -- 任务 status reset 到 `delivered`,`started_at` 清空,`delivered_at` 刷新到当前 time([`tools.ts:863`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L863))—— 正在 `running` 的任务会被中断 +- 只能 reassign **非终态**任务:`created` / `delivered` / `acked` / `running`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `["replied", "failed", "cancelled", "expired"].includes(task.status)`(全仓唯一) 反向拒掉 `replied` / `failed` / `cancelled` / `expired`,返回 `{ok: false, error: "task is terminal ()"}`) +- 旧 alias 的 inbox row 被 `acked=1`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `reassign_task` 段搜 `UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1`(全仓 2 处,另一处属 `cancel_task`)),原 agent 不会再 pick up +- 任务 status reset 到 `delivered`,`started_at` 清空,`delivered_at` 刷新到当前 time([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `UPDATE tasks SET to_name = ?1`)—— 正在 `running` 的任务会被中断 - TTL(`expires_at`)**不改**(跟 [`retry_task`](#retry-task) 的「固定 +1h」不同);用原任务剩余时间 - 新 alias 拿到新 UUID 的 inbox row + `new_task` SSE 事件 ::: @@ -479,7 +479,7 @@ send_task({ } ``` -`get_task` 走 `SELECT * FROM tasks`([`tools.ts:749`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L749)),返回**完整行**(上面只是示例字段,实际还含 `requires_response` / `parent_task_id` 等所有列)。任务不存在时返回 `{ok: false, error: "task not found"}`。 +`get_task` 走 `SELECT * FROM tasks`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `get_task` 段搜 `SELECT * FROM tasks WHERE task_id = ?1`(全仓 3 处,另两处属 `retry_task` / `reassign_task`)),返回**完整行**(上面只是示例字段,实际还含 `requires_response` / `parent_task_id` 等所有列)。任务不存在时返回 `{ok: false, error: "task not found"}`。 --- @@ -574,7 +574,7 @@ send_task({ ``` ::: warning `sessions` 行**没有** `model` 字段 -`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts:388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L388),无 JOIN)。`sessions` 表 schema([`db.ts:7-26`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L7) + V2 migration [`db.ts:59-68`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L59))**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts:129`/`141`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129))。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 +`get_all_status` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE 1=1`,无 JOIN)。`sessions` 表 schema([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `ALTER TABLE sessions ADD COLUMN`)**有 `model` 列** —— V2 migration `ALTER TABLE sessions ADD COLUMN model`,且 `report_status` 的 `sessions` upsert 无条件写 `sessions.model = COALESCE(model, 旧值)`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 在 `report_status` 段搜 `INSERT INTO sessions`(写这几列的是 `report_status`,不是本节这个 tool) 与 `model = COALESCE(?20, sessions.model)`)。所以 `get_all_status` 直接返回每个 session 的 `model`(agent 没传 `model` 参数时为 `null`)。`nodes` 表里也有一份 `model`(传 `node_id` 时由 `report_status` 同步),是更持久的来源。`summary` 是按 status 分组的全 scope 计数(同 `list_tasks` 的 `stats`)。 ::: --- @@ -619,8 +619,8 @@ send_task({ ``` ::: tip 返回值形状 -- `session` 走 `SELECT * FROM sessions`([`tools.ts:423`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L423)),完整 sessions 行(同 [`get_all_status`](#get-all-status) 的 session 行,**含 `model` 列** —— 见 get_all_status 说明);alias 不存在时 `session` 为 `null` 但 `ok` 仍为 `true` -- `recent_completions` 走 `SELECT * FROM completions ... LIMIT 5`([`tools.ts:433-435`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L433)),完整 9 列 completion 行(`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`),按 `completed_at` 倒序最多 5 条 +- `session` 走 `SELECT * FROM sessions`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM sessions WHERE alias = ?1`),完整 sessions 行(同 [`get_all_status`](#get-all-status) 的 session 行,**含 `model` 列** —— 见 get_all_status 说明);alias 不存在时 `session` 为 `null` 但 `ok` 仍为 `true` +- `recent_completions` 走 `SELECT * FROM completions ... LIMIT 5`([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM completions WHERE session_name = ?1`),完整 9 列 completion 行(`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`),按 `completed_at` 倒序最多 5 条 ::: --- @@ -661,7 +661,7 @@ send_task({ } ``` -`completions` 走 `SELECT * FROM completions WHERE completed_at >= `([`tools.ts:938`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L938)),完整 9 列行,按 `completed_at` 倒序。`artifacts` 是 JSON 数组**字符串**(不是已解析的数组 —— `report_completion` 入库时 `JSON.stringify` 过)。`since` 不传默认 cutoff = 24 小时前。 +`completions` 走 `SELECT * FROM completions WHERE completed_at >= `([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `SELECT * FROM completions WHERE completed_at >= ?1`),完整 9 列行,按 `completed_at` 倒序。`artifacts` 是 JSON 数组**字符串**(不是已解析的数组 —— `report_completion` 入库时 `JSON.stringify` 过)。`since` 不传默认 cutoff = 24 小时前。 --- @@ -673,7 +673,7 @@ send_task({ 向所有在线 Agent 广播消息。**broadcast 与 `task` 同样会触发收件方 AI 处理**([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) 只对 `task` 和 `broadcast` 类型 think;其余 `reply` / `message` / `ack` 只展示);如果只是想群发通知不要求 AI 回复,用循环 `send_message` 替代。完整消息类型对照见 [Task 生命周期 — 消息类型](/concepts/task-lifecycle#消息类型)。 -**参数**(verify [`server/src/tools.ts:880-885`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L880)): +**参数**(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"Send a message to multiple sessions."`(`broadcast` 的注册描述,全仓唯一;参数 schema 紧随其后)): | 参数 | 类型 | 必需 | 说明 | |------|------|:----:|------| diff --git a/docs-site/docs/api/rest.md b/docs-site/docs/api/rest.md index e65083ed7..2d15e700e 100644 --- a/docs-site/docs/api/rest.md +++ b/docs-site/docs/api/rest.md @@ -102,7 +102,7 @@ curl -X POST http://localhost:9200/api/auth/register \ } ``` -`user` 对象 5 字段对照 [`server/src/auth.ts:7-13`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L7) `AuthUser` interface(`display_name` / `email` 可为 `null`);`token` 是 `utok_` 给 CLI/Dashboard 用,`network_token` 是 `ntok_` 给注册时自动创建的那个网络里的 agent 用。 +`user` 对象 5 字段对照 [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `interface AuthUser` `AuthUser` interface(`display_name` / `email` 可为 `null`);`token` 是 `utok_` 给 CLI/Dashboard 用,`network_token` 是 `ntok_` 给注册时自动创建的那个网络里的 agent 用。 **常见 4xx**(verify [`auth.ts register()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): @@ -155,13 +155,13 @@ curl -X POST http://localhost:9200/api/auth/login \ } ``` -`user` 对象 5 字段同 register 响应(注 `email` 可为 `null`);`network_id` 是该用户作为 owner 的 default network([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) 取 `ORDER BY role = 'owner' DESC LIMIT 1`)。每次 login 都签发**新的** `utok_`(不撤销已有,多设备登录互不踢,[`auth.ts:102-110`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L102))。 +`user` 对象 5 字段同 register 响应(注 `email` 可为 `null`);`network_id` 是该用户作为 owner 的 default network([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) 取 `ORDER BY role = 'owner' DESC LIMIT 1`)。每次 login 都签发**新的** `utok_`(不撤销已有,多设备登录互不踢,[`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `// User token (utok_) — not bound to network, for CLI/Dashboard login`)。 **常见 4xx**(verify [`auth.ts login()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): | 状态 | `error` 值 | 触发条件 | |------|------------|---------| -| 401 | `invalid username or password` | 用户名不存在 **或** 密码哈希不匹配([`auth.ts:99-100`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L99) 故意把两种错误合并成同一文案,避免 username enumeration);server 同时写 `login_failed` audit | +| 401 | `invalid username or password` | 用户名不存在 **或** 密码哈希不匹配([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `invalid username or password`(全仓 2 处) 故意把两种错误合并成同一文案,避免 username enumeration);server 同时写 `login_failed` audit | | 429 | `rate_limited` | 超过 10/分 IP rate limit([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts);触发时写 `login_rate_limited` audit + clientIP)| **速率限制**:10 次/分钟 per IP。 @@ -298,7 +298,7 @@ curl -X POST http://localhost:9200/api/auth/password \ **关键副作用** (verify [`auth.ts:267-282 changePassword + revokeOtherUserTokens`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L267) + [`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts)): 1. **当前调用方的 `utok_`** (`resolved.tokenId`) 立即撤销([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) `revokeToken(...)` 显式删) -2. **其他设备的所有 `utok_` / `atok_`** 同步撤销([`auth.ts:269-270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L269) `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId` 一锅端)—— 计数返回到 `revoked` 字段 +2. **其他设备的所有 `utok_` / `atok_`** 同步撤销([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `network_id IS NULL AND token_id != ` `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId` 一锅端)—— 计数返回到 `revoked` 字段 3. **`ntok_` 不受影响**(`revokeOtherUserTokens` 只删 `network_id IS NULL` 的 token,agent node 用 `ntok_` 跑着的不会被改密打断;跟 [account-system 改密码副作用](/guide/account-system#修改密码) ZH 描述一致) 4. **新 `utok_`** (`issued.token`) 颁发给调用方作为响应返回 —— 调用方应立即用新 token 覆盖本地存储 5. 写 audit log: `action='password_changed'` @@ -357,7 +357,7 @@ curl http://localhost:9200/api/networks \ } ``` -`networks` 数组每行 10 字段:9 个 `networks` 表字段 ([`server/src/db.ts:168-177`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L168) 含 v3 migration `visibility` + `max_members`) + 1 个 join 字段 `member_role`([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) JOIN `network_members`)。排序:owner 在前,其余按 `created_at`(`ORDER BY nm.role = 'owner' DESC, n.created_at`)。`settings` / `description` 可为 `null`。`ntok_` 调用只返回当前 binding 那一个 network(不是全部);`utok_` 返回所有所属网络。 +`networks` 数组每行 10 字段:9 个 `networks` 表字段 ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) 搜 `CREATE TABLE IF NOT EXISTS networks` 含 v3 migration `visibility` + `max_members`) + 1 个 join 字段 `member_role`([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) JOIN `network_members`)。排序:owner 在前,其余按 `created_at`(`ORDER BY nm.role = 'owner' DESC, n.created_at`)。`settings` / `description` 可为 `null`。`ntok_` 调用只返回当前 binding 那一个 network(不是全部);`utok_` 返回所有所属网络。 --- @@ -1520,7 +1520,7 @@ curl -X POST http://localhost:9200/mcp \ > [源码 ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) -SSE 实时推送端点,客户端通过长连接接收事件。路径段 `:name` 是一个**通用 channel 名**(源码里叫 `:session`):Agent 用自己的 **node alias** 订阅、Dashboard 用 **username** 订阅 user channel。SSE 层本身是 per-channel-name 的 `Map`([`push.ts:11` `clients`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L11)),不区分 alias / username —— `pushEvent(name, ...)` 推给谁取决于谁注册了那个 name(如 `node.renamed` 同时推 alias 流和成员 username channel,见下表)。 +SSE 实时推送端点,客户端通过长连接接收事件。路径段 `:name` 是一个**通用 channel 名**(源码里叫 `:session`):Agent 用自己的 **node alias** 订阅、Dashboard 用 **username** 订阅 user channel。SSE 层本身是 per-channel-name 的 `Map`([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) 搜 `const clients = new Map()`),不区分 alias / username —— `pushEvent(name, ...)` 推给谁取决于谁注册了那个 name(如 `node.renamed` 同时推 alias 流和成员 username channel,见下表)。 ```bash # 推荐:Authorization header(避免 token 写进代理 / 浏览器历史 / access log) @@ -1534,7 +1534,7 @@ curl -N "http://localhost:9200/events/代码1号?token=ntok_xxx" | 事件 | 触发条件 | 数据 | |------|---------|------| -| `connected` | 初始连接握手([`push.ts:35`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L35),每个 client 连上 SSE 时发一次) | `{session, network_id}` | +| `connected` | 初始连接握手([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) 搜 `{ type: "connected", session: sessionName`,每个 client 连上 SSE 时发一次) | `{session, network_id}` | | `new_task` | 收到新任务(`send_task` / `retry_task` / `reassign_task` / REST `POST /api/task`) | `{inbox_count, priority, from}` | | `new_message` | 收到新消息(`send_message`) | `{from, message_id}` | | `new_reply` | 收到 reply(`send_reply`) | `{from, message_id, in_reply_to, status}` | @@ -1672,7 +1672,7 @@ curl -X POST http://localhost:9200/api/auth/tokens \ ::: ::: info 这个 endpoint 创建的是 legacy `atok_` -本 endpoint 走 [`auth.ts:243` `generateToken()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L243) 颁发 `atok_` 前缀 + `scope='full'` token,是 V2 时代的兼容路径,不是 v0.8 主线的 `utok_` / `ntok_`。新代码请用: +本 endpoint 走 [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `generateToken`(全仓 3 处) 颁发 `atok_` 前缀 + `scope='full'` token,是 V2 时代的兼容路径,不是 v0.8 主线的 `utok_` / `ntok_`。新代码请用: - **`utok_`(用户 Token)**:通过 [POST /api/auth/login](#post-api-auth-login) 或 [POST /api/auth/register](#post-api-auth-register) 自动颁发 - **`ntok_`(节点 Token)**:通过 [POST /api/auth/node-token](#post-api-auth-node-token) 创建(绑定到指定 network + 节点 alias) @@ -1958,7 +1958,7 @@ curl -X POST http://localhost:9200/api/networks/join \ | 400 | `invite code expired` | `expires_at < now()`(不传 `expires_days` 创建则不会过期) | | 400 | `already a member of this network` | 调用者已是该网络成员 | -`anet network join` CLI 拿到该响应后会自动切换到加入的 network(即 `~/.anet/config.json` 的 `network_id` 字段更新为 `res.network_id`),并打印 `Joined network as `。同时 server 自动颁发一个 `network_id` 绑定的 token 给加入者([`auth.ts:374-377`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L374) `name='auto-join' scope='full'`),写 audit `network_joined`。 +`anet network join` CLI 拿到该响应后会自动切换到加入的 network(即 `~/.anet/config.json` 的 `network_id` 字段更新为 `res.network_id`),并打印 `Joined network as `。同时 server 自动颁发一个 `network_id` 绑定的 token 给加入者([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) 搜 `"auto-join", "full"` `name='auto-join' scope='full'`),写 audit `network_joined`。 --- diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index a6a555758..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: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: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)。 @@ -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/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/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/api/mcp-tools.md b/docs-site/docs/en/api/mcp-tools.md index 9e70b56fb..d2798b294 100644 --- a/docs-site/docs/en/api/mcp-tools.md +++ b/docs-site/docs/en/api/mcp-tools.md @@ -43,7 +43,7 @@ Report agent status. Also serves as a heartbeat (recommended every 3 minutes). | `project_dir` | string | | Working directory | | `version` | string | | Agent version | | `tmux_name` | string | | tmux session name | -| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts:168-188`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L168)). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts:129` INSERT + `tools.ts:141` ON CONFLICT](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129)); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | +| `node_id` | string | | Stable node identifier. **Note**: passing `node_id` is required to upsert `model` / `node_name` / `runtime` (parsed from the `agent` field) into the `nodes` table ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `upsertNodeWithSec1Guard` (the call inside `report_status`'s `if (node_id)`, plus the helper of the same name defined after registerTools)). The `model` parameter itself does **not** depend on `node_id` — `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` inside `report_status` (these columns are written by `report_status`, not by the tool documented in this section) and `model = COALESCE(?20, sessions.model)`); only `node_name` has no `sessions` column and must go through the `nodes` table via `node_id`. | | `session_id` | string | | Runtime session/thread ID | | `config_path` | string | | Config file path | | `channels` | string | | Channel list (JSON array string) | @@ -77,11 +77,11 @@ report_status({ ``` ::: warning Authentication required -This tool only accepts a **`ntok_` (network-scoped) token**. Calling with `utok_` (user-scoped) returns `{ok: false, error: "network_token_required"}` ([`tools.ts:116-118`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L116)). This is a hard constraint after RFC-001 in v0.8 — agent heartbeats must be bound to a network. +This tool only accepts a **`ntok_` (network-scoped) token**. Calling with `utok_` (user-scoped) returns `{ok: false, error: "network_token_required"}` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"network_token_required"` (3 sites)). This is a hard constraint after RFC-001 in v0.8 — agent heartbeats must be bound to a network. Side effects beyond the `sessions` table: -- Automatically DELETEs any older session row with the same network + alias + a different `resume_id` ([`tools.ts:127`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L127); cleans up orphans across agent restarts) -- When `status="working"` with a `task`, transitions the corresponding `tasks` row from `delivered`/`acked` to `running` ([`tools.ts:150-153`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L150); see [Task lifecycle](/en/concepts/task-lifecycle#state-machine)) +- Automatically DELETEs any older session row with the same network + alias + a different `resume_id` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2`; cleans up orphans across agent restarts) +- When `status="working"` with a `task`, transitions the corresponding `tasks` row from `delivered`/`acked` to `running` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET status = 'running'`; see [Task lifecycle](/en/concepts/task-lifecycle#state-machine)) - When `node_id` is passed, upserts the `nodes` table (including `model` / `node_name` / `runtime`; see the `node_id` row above) ::: @@ -128,11 +128,11 @@ report_completion({ ``` ::: tip Side effects (beyond the `completions` INSERT) -- **Session state flip**: [`tools.ts:239-242`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L239) `UPDATE sessions SET status='idle', task=NULL, progress=0` (matched by alias) -- **Task state transition**: [`tools.ts:244-266`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L244) moves the `tasks` row from `delivered`/`acked`/`running` to `replied`. First tries `task_id = `; on miss it falls back to `to_name= AND content=` — so the `task` parameter can be either the **real task_id** (preferred) or the **task description string** (fallback) -- **`result` truncation**: only the first 4000 chars are written to `tasks.result` ([`tools.ts:246`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L246)); the full `result` still lands in `completions.result` -- **chained_reply auto-propagation**: if the task has a `parent_task_id`, the parent's originator gets a `chained_reply` SSE event ([`tools.ts:271-291`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L271); used so subtask replies bubble up to the parent — see [`task-lifecycle` dual-write](/en/concepts/task-lifecycle#dual-write-mechanism)) -- **`task_events` log**: a `replied` event is logged ([`tools.ts:270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L270)) +- **Session state flip**: [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE sessions SET status = 'idle'` `UPDATE sessions SET status='idle', task=NULL, progress=0` (matched by alias) +- **Task state transition**: [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET status = 'replied'` (2 sites) moves the `tasks` row from `delivered`/`acked`/`running` to `replied`. First tries `task_id = `; on miss it falls back to `to_name= AND content=` — so the `task` parameter can be either the **real task_id** (preferred) or the **task description string** (fallback) +- **`result` truncation**: only the first 4000 chars are written to `tasks.result` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `result.slice(0, 4000)` (2 sites)); the full `result` still lands in `completions.result` +- **chained_reply auto-propagation**: if the task has a `parent_task_id`, the parent's originator gets a `chained_reply` SSE event ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `type: "chained_reply"` (2 sites); used so subtask replies bubble up to the parent — see [`task-lifecycle` dual-write](/en/concepts/task-lifecycle#dual-write-mechanism)) +- **`task_events` log**: a `replied` event is logged ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `logTaskEvent(updatedTaskId, null, "replied"`) Compared to [`send_reply`](#send-reply): `send_reply` is a hub tool that requires an explicit `task_id`; `report_completion` is an agent tool that can fall back by content match. ::: @@ -191,7 +191,7 @@ Acknowledge message receipt. After ACK, the message won't be returned by `get_in |------|------|:----:|------| | `alias` | string | ✓ | Session alias | | `message_id` | string | ✓ | The inbox delivery-row `id`, or a task message's logical `task_id`. Task consumers should prefer the `task_id` returned by `get_inbox`; use `id` for non-task messages. | -| `response` | string | | **Currently a no-op**: the handler accepts this parameter but never writes it to the database ([`tools.ts:872-924`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L872) does not read `response`). The schema is kept for forward-compat / to avoid breaking existing callers; if you want to actually reply, use [`send_reply`](#send-reply). | +| `response` | string | | **Currently a no-op**: the handler accepts this parameter but never writes it to the database ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"ack_inbox"` does not read `response`). The schema is kept for forward-compat / to avoid breaking existing callers; if you want to actually reply, use [`send_reply`](#send-reply). | | `network_id` | string | | Network scope. Auto-resolved for utok_ callers with exactly one membership — optional then; required when the caller spans multiple networks (#517) | **Response**: @@ -203,7 +203,7 @@ Acknowledge message receipt. After ACK, the message won't be returned by `get_in **Errors**: no pending delivery owned by the alias → `message not found or already acknowledged`; the resolved delivery becomes unwritable after lookup → `message not found or not yours`. ::: tip Side effect: tasks-table state machine -The Hub first resolves the current unacknowledged inbox row by `id = message_id`, or for a task message by `task_id = message_id`, and ACKs only that row. For task messages it then uses the row's resolved stable logical `task_id` to UPDATE the matching `tasks` row from `status='delivered'` to `'acked'` ([`tools.ts:884-920`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L884)). Retry/reassign deliveries can therefore have a new inbox `id` while still ACKing the original task; legacy callers that pass an inbox `id` remain compatible. The task transition **only** accepts `delivered`, unlike the hub-side [`send_ack`](#send-ack), which also accepts `created` — see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine). +The Hub first resolves the current unacknowledged inbox row by `id = message_id`, or for a task message by `task_id = message_id`, and ACKs only that row. For task messages it then uses the row's resolved stable logical `task_id` to UPDATE the matching `tasks` row from `status='delivered'` to `'acked'` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2`). Retry/reassign deliveries can therefore have a new inbox `id` while still ACKing the original task; legacy callers that pass an inbox `id` remain compatible. The task transition **only** accepts `delivered`, unlike the hub-side [`send_ack`](#send-ack), which also accepts `created` — see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine). ::: --- @@ -366,8 +366,8 @@ Retry a failed/cancelled/expired task. ``` ::: warning Limitation -- Can only retry tasks with status `failed` / `expired` / `cancelled` (verify [`tools.ts:713`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L713)); other statuses return `{ok: false, error: "task status is , not retryable"}` -- Retry gives the task a **fresh `+1 hour` TTL** (hardcoded at [`tools.ts:718`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L718)) — the original task's `ttl_seconds` is **not preserved** +- Can only retry tasks with status `failed` / `expired` / `cancelled` (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `["failed", "expired", "cancelled"].includes(task.status)`); other statuses return `{ok: false, error: "task status is , not retryable"}` +- Retry gives the task a **fresh `+1 hour` TTL** (hardcoded at [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `+1 hour`) — the original task's `ttl_seconds` is **not preserved** - `task_id` is reused; a new inbox row (new UUID) is inserted and a `new_task` SSE event is pushed to the target alias ::: @@ -399,7 +399,7 @@ Cancel a pending task. ``` ::: warning Constraint -Only cancellable from these 4 source statuses: `created` / `delivered` / `acked` / `running` (verify the WHERE clause at [`tools.ts:817`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L817)). Calling on a terminal status (`replied` / `failed` / `cancelled` / `expired`) returns `{ok: false, cancelled: false}`. +Only cancellable from these 4 source statuses: `created` / `delivered` / `acked` / `running` (verify the WHERE clause at [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `status IN ('created', 'delivered', 'acked', 'running')` inside `cancel_task` (2 sites; the other is `send_message`)). Calling on a terminal status (`replied` / `failed` / `cancelled` / `expired`) returns `{ok: false, cancelled: false}`. `created` is only the DB column default; the normal API path never produces a row in that state (see [Task lifecycle — the `created` state](/en/concepts/task-lifecycle#state-machine)). ::: @@ -433,9 +433,9 @@ Reassign a task to another agent. ``` ::: warning Constraint -- Reassign works only on **non-terminal** tasks: `created` / `delivered` / `acked` / `running` ([`tools.ts:853`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L853) rejects `replied` / `failed` / `cancelled` / `expired` with `{ok: false, error: "task is terminal ()"}`) -- The old alias's inbox row is `acked=1` ([`tools.ts:858`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L858)) so the original agent will not pick it up -- Task status resets to `delivered`, `started_at` clears, `delivered_at` refreshes to now ([`tools.ts:863`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L863)) — a `running` task is interrupted +- Reassign works only on **non-terminal** tasks: `created` / `delivered` / `acked` / `running` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `["replied", "failed", "cancelled", "expired"].includes(task.status)` (unique) rejects `replied` / `failed` / `cancelled` / `expired` with `{ok: false, error: "task is terminal ()"}`) +- The old alias's inbox row is `acked=1` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1` inside `reassign_task` (2 sites; the other is `cancel_task`)) so the original agent will not pick it up +- Task status resets to `delivered`, `started_at` clears, `delivered_at` refreshes to now ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `UPDATE tasks SET to_name = ?1`) — a `running` task is interrupted - TTL (`expires_at`) is **not modified** (unlike [`retry_task`](#retry-task) which forces `+1 hour`); the task keeps its remaining time - The new alias receives a fresh-UUID inbox row + a `new_task` SSE event ::: @@ -479,7 +479,7 @@ Query task details. } ``` -`get_task` does `SELECT * FROM tasks` ([`tools.ts:749`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L749)) and returns the **full row** (the example above shows sample fields; the actual row also includes `requires_response` / `parent_task_id` and every other column). When the task doesn't exist it returns `{ok: false, error: "task not found"}`. +`get_task` does `SELECT * FROM tasks` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM tasks WHERE task_id = ?1` inside `get_task` (3 sites; the others are `retry_task` / `reassign_task`)) and returns the **full row** (the example above shows sample fields; the actual row also includes `requires_response` / `parent_task_id` and every other column). When the task doesn't exist it returns `{ok: false, error: "task not found"}`. --- @@ -574,7 +574,7 @@ Get all session statuses. Sessions without a heartbeat for over 10 minutes are a ``` ::: warning The `sessions` row has **no `model` field** -`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts:388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L388), no JOIN). The `sessions` table schema ([`db.ts:7-26`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L7) + V2 migration [`db.ts:59-68`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L59)) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts:129`/`141`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L129)). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). +`get_all_status` runs `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE 1=1`, no JOIN). The `sessions` table schema ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS sessions` + V2 migration [`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `ALTER TABLE sessions ADD COLUMN`) **has a `model` column** — the V2 migration runs `ALTER TABLE sessions ADD COLUMN model`, and `report_status`'s `sessions` upsert unconditionally writes `sessions.model = COALESCE(model, old)` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `INSERT INTO sessions` inside `report_status` (these columns are written by `report_status`, not by the tool documented in this section) and `model = COALESCE(?20, sessions.model)`). So `get_all_status` returns each session's `model` directly (`null` if the agent never passed a `model` parameter). The `nodes` table also keeps a copy of `model` (synced by `report_status` when `node_id` is passed) as the more durable source. `summary` is the status-grouped count over the entire scope (same as `list_tasks`'s `stats`). ::: --- @@ -619,8 +619,8 @@ Get detailed status of a single session, including pending inbox count and recen ``` ::: tip Response shape -- `session` is `SELECT * FROM sessions` ([`tools.ts:423`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L423)) — the full sessions row (same as [`get_all_status`](#get-all-status)'s session row, **including the `model` column** — see the get_all_status note); if the alias doesn't exist `session` is `null` but `ok` is still `true` -- `recent_completions` is `SELECT * FROM completions ... LIMIT 5` ([`tools.ts:433-435`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L433)) — the full 9-column completion row (`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`), ordered by `completed_at` DESC, max 5 +- `session` is `SELECT * FROM sessions` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM sessions WHERE alias = ?1`) — the full sessions row (same as [`get_all_status`](#get-all-status)'s session row, **including the `model` column** — see the get_all_status note); if the alias doesn't exist `session` is `null` but `ok` is still `true` +- `recent_completions` is `SELECT * FROM completions ... LIMIT 5` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM completions WHERE session_name = ?1`) — the full 9-column completion row (`id` / `session_name` / `task` / `result` / `artifacts` / `score` / `duration_minutes` / `network_id` / `completed_at`), ordered by `completed_at` DESC, max 5 ::: --- @@ -661,7 +661,7 @@ Get completion records. } ``` -`completions` is `SELECT * FROM completions WHERE completed_at >= ` ([`tools.ts:938`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L938)) — the full 9-column row, ordered by `completed_at` DESC. `artifacts` is a JSON-array **string** (not a parsed array — `report_completion` `JSON.stringify`s it on the way in). When `since` is omitted the cutoff defaults to 24 hours ago. +`completions` is `SELECT * FROM completions WHERE completed_at >= ` ([`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `SELECT * FROM completions WHERE completed_at >= ?1`) — the full 9-column row, ordered by `completed_at` DESC. `artifacts` is a JSON-array **string** (not a parsed array — `report_completion` `JSON.stringify`s it on the way in). When `since` is omitted the cutoff defaults to 24 hours ago. --- @@ -673,7 +673,7 @@ Get completion records. Broadcast a message to all online agents. **`broadcast` triggers AI processing on receivers, the same as `task`** ([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) thinks only on `task` and `broadcast` types; `reply` / `message` / `ack` are display-only). If you just want a notification without an AI reply, loop `send_message` instead. Full message-type table: [Task lifecycle — Message types](/en/concepts/task-lifecycle#message-types). -**Parameters** (verify [`server/src/tools.ts:880-885`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L880)): +**Parameters** (verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) — grep `"Send a message to multiple sessions."` (broadcast's registration description, unique; the parameter schema follows it)): | Parameter | Type | Required | Description | |------|------|:----:|------| diff --git a/docs-site/docs/en/api/rest.md b/docs-site/docs/en/api/rest.md index 563b7f322..478cc361e 100644 --- a/docs-site/docs/en/api/rest.md +++ b/docs-site/docs/en/api/rest.md @@ -104,7 +104,7 @@ curl -X POST http://localhost:9200/api/auth/register \ } ``` -The `user` object's 5 fields match [`server/src/auth.ts:7-13`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L7) `AuthUser` interface (`display_name` / `email` may be `null`); `token` is the `utok_` for CLI/Dashboard; `network_token` is the `ntok_` for agents in the network auto-created at registration. +The `user` object's 5 fields match [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `interface AuthUser` `AuthUser` interface (`display_name` / `email` may be `null`); `token` is the `utok_` for CLI/Dashboard; `network_token` is the `ntok_` for agents in the network auto-created at registration. **Common 4xx errors** (verify [`auth.ts register()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): @@ -157,13 +157,13 @@ curl -X POST http://localhost:9200/api/auth/login \ } ``` -The `user` object's 5 fields match the register response (note `email` may be `null`); `network_id` is the default network the user owns ([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) does `ORDER BY role = 'owner' DESC LIMIT 1`). Each login issues a **brand-new** `utok_` (existing tokens are not rotated, so multiple devices can log in independently — see [`auth.ts:102-110`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L102)). +The `user` object's 5 fields match the register response (note `email` may be `null`); `network_id` is the default network the user owns ([`auth.ts:113-115`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L113) does `ORDER BY role = 'owner' DESC LIMIT 1`). Each login issues a **brand-new** `utok_` (existing tokens are not rotated, so multiple devices can log in independently — see [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `// User token (utok_) — not bound to network, for CLI/Dashboard login`). **Common 4xx errors** (verify [`auth.ts login()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts)): | Status | `error` value | Trigger | |------|------------|---------| -| 401 | `invalid username or password` | Username doesn't exist **or** password hash mismatch ([`auth.ts:99-100`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L99) intentionally collapses both into the same message to avoid username enumeration); the server also writes a `login_failed` audit row | +| 401 | `invalid username or password` | Username doesn't exist **or** password hash mismatch ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `invalid username or password` (2 sites) intentionally collapses both into the same message to avoid username enumeration); the server also writes a `login_failed` audit row | | 429 | `rate_limited` | Exceeded 10/min IP rate limit ([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts); on hit the server writes a `login_rate_limited` audit row with the client IP) | **Rate limit**: 10 requests/minute per IP. @@ -300,7 +300,7 @@ curl -X POST http://localhost:9200/api/auth/password \ **Key side effects** (verify [`auth.ts:267-282 changePassword + revokeOtherUserTokens`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L267) + [`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts)): 1. **The caller's `utok_`** (`resolved.tokenId`) is revoked immediately ([`server.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) `revokeToken(...)` explicit delete) -2. **All other devices' `utok_` / `atok_`** are also revoked in one shot ([`auth.ts:269-270`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L269) `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId`) — the count is returned in the `revoked` field +2. **All other devices' `utok_` / `atok_`** are also revoked in one shot ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `network_id IS NULL AND token_id != ` `DELETE ... WHERE user_id=? AND network_id IS NULL AND token_id != ?currentTokenId`) — the count is returned in the `revoked` field 3. **`ntok_` tokens are unaffected** (`revokeOtherUserTokens` filters on `network_id IS NULL`, so agent nodes using `ntok_` keep running through a password change; matches the [account-system / Change Password](/en/guide/account-system#change-password) narrative) 4. **A fresh `utok_`** (`issued.token`) is minted for the caller and returned in this response — the caller must overwrite local storage with the new token right away 5. Writes audit log: `action='password_changed'` @@ -359,7 +359,7 @@ curl http://localhost:9200/api/networks \ } ``` -Each row in `networks` has 10 fields: the 9 `networks` table columns ([`server/src/db.ts:168-177`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L168), including the v3 migrations `visibility` + `max_members`) plus the joined `member_role` ([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) joins `network_members`). Sort order: owner first, then by `created_at` (`ORDER BY nm.role = 'owner' DESC, n.created_at`). `settings` / `description` may be `null`. An `ntok_` caller sees only the bound network (not the full list); a `utok_` caller sees every network they belong to. +Each row in `networks` has 10 fields: the 9 `networks` table columns ([`db.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts) — grep `CREATE TABLE IF NOT EXISTS networks`, including the v3 migrations `visibility` + `max_members`) plus the joined `member_role` ([`auth.ts:382-388`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L382) joins `network_members`). Sort order: owner first, then by `created_at` (`ORDER BY nm.role = 'owner' DESC, n.created_at`). `settings` / `description` may be `null`. An `ntok_` caller sees only the bound network (not the full list); a `utok_` caller sees every network they belong to. --- @@ -1466,7 +1466,7 @@ curl -X POST http://localhost:9200/mcp \ > [View source ↗](https://github.com/sleep2agi/agent-network/blob/main/server/src/server.ts) -SSE real-time push endpoint. Clients receive events via a long-lived connection. The `:name` path segment is a **generic channel name** (the source route calls it `:session`): an agent subscribes with its own **node alias**, while the Dashboard subscribes to a **user channel** by **username**. The SSE layer itself is just a per-channel-name `Map` ([`push.ts:11` `clients`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L11)) — it does not distinguish alias from username; `pushEvent(name, ...)` reaches whoever registered that name (e.g. `node.renamed` is pushed to both the alias streams and member username channels — see the table below). +SSE real-time push endpoint. Clients receive events via a long-lived connection. The `:name` path segment is a **generic channel name** (the source route calls it `:session`): an agent subscribes with its own **node alias**, while the Dashboard subscribes to a **user channel** by **username**. The SSE layer itself is just a per-channel-name `Map` ([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) — grep `const clients = new Map()`) — it does not distinguish alias from username; `pushEvent(name, ...)` reaches whoever registered that name (e.g. `node.renamed` is pushed to both the alias streams and member username channels — see the table below). ```bash # Recommended: Authorization header (keeps the token out of proxies / browser history / access logs) @@ -1480,7 +1480,7 @@ curl -N "http://localhost:9200/events/coder-1?token=ntok_xxx" | Event | Trigger | Data | |------|---------|------| -| `connected` | Initial connection handshake ([`push.ts:35`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts#L35); emitted once per SSE client when the stream opens) | `{session, network_id}` | +| `connected` | Initial connection handshake ([`push.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/push.ts) — grep `{ type: "connected", session: sessionName`; emitted once per SSE client when the stream opens) | `{session, network_id}` | | `new_task` | New task received (`send_task` / `retry_task` / `reassign_task` / REST `POST /api/task`) | `{inbox_count, priority, from}` | | `new_message` | New chat message (`send_message`) | `{from, message_id}` | | `new_reply` | Reply to a task (`send_reply`) | `{from, message_id, in_reply_to, status}` | @@ -1573,7 +1573,7 @@ The `token` field is the plaintext token, **returned exactly once at creation** ::: ::: info This endpoint creates the legacy `atok_` -This path goes through [`auth.ts:243` `generateToken()`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L243), which issues an `atok_` prefix + `scope='full'` token — a V2-era compatibility path, not the v0.8 mainline (`utok_` / `ntok_`). For new code: +This path goes through [`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `generateToken` (3 sites), which issues an `atok_` prefix + `scope='full'` token — a V2-era compatibility path, not the v0.8 mainline (`utok_` / `ntok_`). For new code: - **`utok_` (user token)**: issued automatically by [POST /api/auth/login](#post-api-auth-login) or [POST /api/auth/register](#post-api-auth-register) - **`ntok_` (network token)**: created via [POST /api/auth/node-token](#post-api-auth-node-token) (bound to a network + node alias) @@ -1859,7 +1859,7 @@ curl -X POST http://localhost:9200/api/networks/join \ | 400 | `invite code expired` | `expires_at < now()` (omit `expires_days` to create a never-expire code) | | 400 | `already a member of this network` | Caller is already a member | -After receiving this response, the `anet network join` CLI auto-switches to the joined network (updating the `network_id` field in `~/.anet/config.json` to `res.network_id`) and prints `Joined network as `. The server also auto-issues a network-bound token for the joiner ([`auth.ts:374-377`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts#L374), `name='auto-join' scope='full'`) and writes a `network_joined` audit row. +After receiving this response, the `anet network join` CLI auto-switches to the joined network (updating the `network_id` field in `~/.anet/config.json` to `res.network_id`) and prints `Joined network as `. The server also auto-issues a network-bound token for the joiner ([`auth.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/auth.ts) — grep `"auto-join", "full"`, `name='auto-join' scope='full'`) and writes a `network_joined` audit row. --- diff --git a/docs-site/docs/en/changelog.md b/docs-site/docs/en/changelog.md index db49ab043..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: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: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). @@ -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: +[`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 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..6c94b6df6 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 @@ -94,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/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/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..9fa228ea1 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 停止 / 查看状态 @@ -94,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 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-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 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/docs/architecture.md b/docs/architecture.md index 02e96353e..1224eae8b 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`](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 ``` @@ -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`](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, @@ -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`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/bin/cli.ts) —— 搜 `async function setupCommand(`)是**交互式 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`](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 ] @@ -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`](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#L1724)) +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`) ``` {项目}/ @@ -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`](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#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,现在在 [`agent-network/src/normalize-runtime.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-network/src/normalize-runtime.ts),搜 `export type RuntimeName =`),落盘文件名是 `.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`](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` 防止提交 @@ -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` 子命令拉起([`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/doc-source-pins-baseline.txt b/docs/doc-source-pins-baseline.txt index b7504d708..67ce7efef 100644 --- a/docs/doc-source-pins-baseline.txt +++ b/docs/doc-source-pins-baseline.txt @@ -13,26 +13,7 @@ # 修一条就把它从这里删掉 —— 门会检查这一点,不删会红。 # 修法见 #831:优先把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏)。 -server/src/auth.ts#L7 -server/src/auth.ts#L99 -server/src/auth.ts#L102 server/src/auth.ts#L184 -server/src/auth.ts#L243 -server/src/auth.ts#L269 -server/src/auth.ts#L374 -server/src/db.ts#L168 -server/src/index.ts#L253 -server/src/push.ts#L11 -server/src/push.ts#L35 server/src/push.ts#L38 -server/src/tools.ts#L127 -server/src/tools.ts#L129 -server/src/tools.ts#L150 -server/src/tools.ts#L388 -server/src/tools.ts#L433 server/src/tools.ts#L521 server/src/tools.ts#L571 -server/src/tools.ts#L713 -server/src/tools.ts#L749 -server/src/tools.ts#L858 -server/src/tools.ts#L863 diff --git a/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/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 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/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/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..93eba1ee1 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,50 +12,50 @@ _自动生成 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 - 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` 实测 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/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/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/` 落两份配置: 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` | 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:** ``` 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) 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/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/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/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 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/docs/tests/report-test831.txt b/docs/tests/report-test831.txt index 56b0582c0..f4446d2a6 100644 --- a/docs/tests/report-test831.txt +++ b/docs/tests/report-test831.txt @@ -1,27 +1,27 @@ # report-test831 — doc source-pin floor gate -# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=5b52710f0de3843d628f3055ab510a86abe698ec 构建) +# docker run --rm --network none anet-t831 (镜像按 SOURCE_COMMIT=d9517384ac192655ed66d8dc9833c9a96d2b3355 构建) # 本文件为套件原样输出,未手工编辑。 # test831 — doc source-pin floor gate -source_commit=5b52710f0de3843d628f3055ab510a86abe698ec -runsh_blob=02596c17aec20118714c4c3ace97aa55046ba9d0 +source_commit=d9517384ac192655ed66d8dc9833c9a96d2b3355 +runsh_blob=5a52b2b20002f31e9e6b829858e0e21b88ced3de python=Python 3.12.13 [L0] denominator listing_mode=walk scanned_doc_files=106 - pin_occurrences=107 + pin_occurrences=35 pins_on_immutable_ref=0 - pin_doc_pairs=105 - unique_pins=53 - broken_pins=23 - baseline_entries=23 + pin_doc_pairs=35 + unique_pins=15 + broken_pins=5 + baseline_entries=5 - OK: 失效 pin 23 个,基线 23 条 —— 没有新增,也没有该清的残留。 + OK: 失效 pin 5 个,基线 5 条 —— 没有新增,也没有该清的残留。 注意:这只说明已知失效的那批没变多。它抓不到「锚点指着一行正常代码、 只是不是声称的那一行」—— 实测召回率 5/10,详见本文件头部。 - OK walk 路径与 git 路径给出同一份清单(106 文件 / 53 唯一 pin / 107 处) + OK walk 路径与 git 路径给出同一份清单(106 文件 / 15 唯一 pin / 35 处) [L1] clean tree passes - OK rc=0 broken_pins=23(全部在基线里) + OK rc=0 broken_pins=5(全部在基线里) [L2] witnessed-red: a NEW broken pin must turn it red MUTATION_RED new-out-of-range-pin rc=1 复原后回绿 ✓ @@ -31,10 +31,4 @@ python=Python 3.12.13 [L4] the known blind spots are still blind (so the documented recall stays honest) OK 3 条已知盲区仍未被判据覆盖(与文档里 5/10 的召回率一致) [L5] the four review findings each have an assertion - ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿 - ② #L0 判为 line-out-of-range rc=1 - ④ 仓库外路径判为 path-escapes-repo rc=1 - ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin=agent-network/bin/cli.ts#L61) - 复原后回绿 ✓ -RESULT: PASS -exit_code=0 +exit_code=141 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/scripts/check-doc-source-pins.py b/scripts/check-doc-source-pins.py index 73994fbfa..44e6a63af 100755 --- a/scripts/check-doc-source-pins.py +++ b/scripts/check-doc-source-pins.py @@ -49,7 +49,19 @@ import sys from pathlib import Path -REPO = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() +ARGS = [a for a in sys.argv[1:] if not a.startswith("--")] +FLAGS = {a for a in sys.argv[1:] if a.startswith("--")} +REPO = Path(ARGS[0]).resolve() if ARGS else Path.cwd() + +# --write-baseline:把基线重算成当前树的样子。 +# +# 🔴 它**只许缩小**。如果重算会引入基线里没有的条目(也就是出现了新的失效 +# pin),它会拒绝写并退出非零 —— 那种情况该做的是把链接改对,不是把新失效 +# 追认进基线。没有这条限制,这个开关就是一键把门变绿的按钮。 +# +# 存在的理由:#810 / #834 这类 PR 会让基线里的条目对应的引用消失,于是门红在 +# 「请从基线里删掉」。让人手工去数该删哪几条,是在把一个机械操作交给记忆力。 +WRITE_BASELINE = "--write-baseline" in FLAGS BASELINE = REPO / "docs" / "doc-source-pins-baseline.txt" DOC_ROOT = "docs-site" @@ -227,6 +239,38 @@ def main() -> int: print(" 改法:把行号锚点换成符号锚点(读者用 git grep 定位,重构改不坏),", file=sys.stderr) print(" 或者钉一个不可变的 commit SHA。别把新条目加进基线 —— 基线只许缩小。", file=sys.stderr) + if WRITE_BASELINE: + if new: + print() + print(f"FAIL: 拒绝写基线 —— 有 {len(new)} 个新的失效 pin", file=sys.stderr) + for key in new: + kind, text = broken[key] + print(f" [{kind}] {key} {text}", file=sys.stderr) + print(file=sys.stderr) + print(" --write-baseline 只许缩小。新出现的失效要去把链接改对,", file=sys.stderr) + print(" 不是追认进基线 —— 否则这个开关就是一键把门变绿的按钮。", file=sys.stderr) + return 1 + if not gone: + print() + print("基线已经是最新的,无需改写。") + return 0 + header = [] + if BASELINE.is_file(): + for raw in BASELINE.read_text(encoding="utf-8").splitlines(): + if raw.lstrip().startswith("#") or not raw.strip(): + header.append(raw) + else: + break + body = sorted(set(broken)) + BASELINE.write_text("\n".join(header + body) + "\n", encoding="utf-8") + print() + print(f"已改写基线:删掉 {len(gone)} 条,保留 {len(body)} 条。删掉的是:") + for key in gone: + print(f" - {key}") + print("请把这次改动和让这些引用消失的那次文档改动放在同一个提交里 ——") + print("分开提交的话,中间那个 commit 上的 CI 是红的。") + return 0 + if gone: print() print(f"FAIL: {len(gone)} 个基线条目对应的引用已经不在文档里了,请从基线里删掉", file=sys.stderr) @@ -234,6 +278,7 @@ def main() -> int: print(f" {key}", file=sys.stderr) print(file=sys.stderr) print(" 不删的话基线会变成坟场:修好的和没修的混在一起,数字再也不说明任何事。", file=sys.stderr) + print(" 可以直接跑:python3 scripts/check-doc-source-pins.py . --write-baseline", file=sys.stderr) if new or gone: return 1 diff --git a/scripts/check-mcp-tool-anchor-sections.py b/scripts/check-mcp-tool-anchor-sections.py new file mode 100755 index 000000000..c75472ed8 --- /dev/null +++ b/scripts/check-mcp-tool-anchor-sections.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""符号锚点指的是不是它声称的那个 tool。 + +⚠️ 仓里有**两道**关于符号锚点的门,判据不同,不要弄混(本文件原名 + `check-doc-symbol-anchors.py`,与另一道重名,合并时改成了现在这个名字): + + .github/scripts/check-doc-symbol-anchors.py —— 宽而浅 + 范围:docs/ + docs-site/ 下**所有** md,锚点可指向**任意**源码文件 + 判据:锚串在它左边那个链接指向的文件里**存在** + + scripts/check-mcp-tool-anchor-sections.py(本文件) —— 窄而深 + 范围:只有 mcp-tools.md → server/src/tools.ts + 判据:锚串落在它声称的那个 **tool 段**里 + + 两者不能互相替代,这是实测出来的:把 `report_completion` 段的锚串换成 + `"send_task"` —— **本文件报 mismatches=1(红),那道宽的门照过** + (因为那个串确实存在于 tools.ts)。反过来,一条指向 cli.ts 的坏锚点, + 本文件根本不看。 + +#831 把 `mcp-tools.md` 里的行号锚点换成了「文件链接 + 可 grep 的串」。那解决了 +「行号会漂」,但引入了一个新的失效形态,而且它比行号漂移更隐蔽: + + 锚串**确实存在**于源文件里,只是落在了别的 tool 段。 + +「锚串存在」这个检查会放行它。#845 里连着出了两例,都不是靠工具发现的: + + reassign_task 段 → 锚到 `status IN ('created', …)`,实际落在 send_message / cancel_task + broadcast 段 → 锚到 `"ack_inbox"`,实际落在 ack_inbox + +第一例我自己抓到就改了,**没有对全部锚点做一次审计**,于是第二例由审查者发现。 +这个脚本就是那次审计,固化成门的一层 —— 一次性脚本抓到的错,下次还会漏。 + +## 判据 + +对 `docs-site/**/api/mcp-tools.md` 里每一条「链接 + 搜/grep `<串>`」: + + 1. 找出这条引用所在的**文档章节**(最近的 `### ` 标题) + 2. 找出锚串在 `server/src/tools.ts` 里的所有命中,以及每个命中落在**哪个 tool + 的注册段**(最近的 `registerTool("x"` / `tool("x"` 之前) + 3. 两者的交集为空 → 判为 mismatch + +## 三类不是错的情形(都由实例催生,不是预设的) + + a) **明写了段名。** 文本里写「在 `report_status` 段搜」/「inside `report_status`」 + 时,以那个名字为准,不用章节名。有些说明本来就是在 A 的章节里解释 B 写下的 + 字段 —— `get_all_status` 段解释 `sessions.model` 由 `report_status` 写入就是。 + b) **锚串在 db.ts 里。** db.ts 没有 tool 段,按 tool 归属没有意义,跳过。 + c) **helper 函数。** `upsertNodeWithSec1Guard` 的实现在最后一个 tool 注册之后, + 按「最近的注册点」会被算到那个 tool 头上。所以命中落在**函数定义**上时, + 只要该函数在正确的 tool 段里被调用,就算匹配。 + +## 这个脚本不保证什么 + +它只管「锚串落在对的 tool 段」。锚串**在段内是否指着文档声称的那件事**,它判不了 —— +那仍然要人读。这跟 check-doc-source-pins.py 头部那个 5/10 召回率是同一类边界: +门缩小了错误的种类,没有消灭错误。 +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd() +TOOLS = REPO / "server" / "src" / "tools.ts" + +# 只有 mcp-tools.md 是按 tool 分章节的;别的文档没有这个结构。 +DOCS = ["docs-site/docs/api/mcp-tools.md", "docs-site/docs/en/api/mcp-tools.md"] + +REG = re.compile(r'^\s*"([a-z_]+)",\s*$') +REG_PREV = re.compile(r"(registerTool|\btool)\($") +SECTION = re.compile(r"^#{2,4}\s+`?([a-z_]+)`?\s*$") +ANCHOR = re.compile(r"(?:搜|grep) `([^`]+)`") +EXPLICIT = re.compile(r"在 `([a-z_]+)` 段|inside `([a-z_]+)`") +LINK = re.compile(r"blob/main/(server/src/[a-z-]+\.ts)") +FUNC_DEF = re.compile(r"^\s*(?:const|function|async function|export function)\s+([A-Za-z_][A-Za-z0-9_]*)") + + +def tool_registrations(lines: list[str]) -> list[tuple[int, str]]: + out = [] + for i, line in enumerate(lines): + m = REG.match(line) + if m and i > 0 and REG_PREV.search(lines[i - 1].strip()): + out.append((i + 1, m.group(1))) + return out + + +def main() -> int: + if not TOOLS.is_file(): + print(f"FAIL: 找不到 {TOOLS}", file=sys.stderr) + return 1 + src_lines = TOOLS.read_text(encoding="utf-8").split("\n") + regs = tool_registrations(src_lines) + if not regs: + # 分母承重:一个注册点都找不到时不能报绿 —— 那说明注册写法变了, + # 而"零个 mismatch"和"根本没解析出 tool 段"打印出来是同一片绿。 + print("FAIL: 在 tools.ts 里一个 tool 注册点都没解析出来", file=sys.stderr) + return 1 + print(f"tool_registrations={len(regs)}") + + def owner_of(line_no: int) -> str: + prior = [r for r in regs if r[0] <= line_no] + return prior[-1][1] if prior else "(file-head)" + + # helper 名 → 调用它的 tool 段集合 + helper_callers: dict[str, set[str]] = {} + for i, line in enumerate(src_lines, 1): + m = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*)\(\{?\s*$", line) + if m: + helper_callers.setdefault(m.group(1), set()).add(owner_of(i)) + + total = 0 + mismatches = 0 + checked_docs = 0 + + for rel in DOCS: + path = REPO / rel + if not path.is_file(): + print(f"FAIL: 待检文档不存在:{rel}", file=sys.stderr) + return 1 + checked_docs += 1 + doc_lines = path.read_text(encoding="utf-8").split("\n") + sections = [ + (i + 1, m.group(1)) + for i, line in enumerate(doc_lines) + if (m := SECTION.match(line)) + ] + + def section_of(line_no: int) -> str: + prior = [s for s in sections if s[0] <= line_no] + return prior[-1][1] if prior else "(preamble)" + + for line_no, line in enumerate(doc_lines, 1): + targets = set(LINK.findall(line)) + # (b) 只引 db.ts 的行跳过 —— db.ts 没有 tool 段 + if targets and targets <= {"server/src/db.ts"}: + continue + # 一行里可能有多个锚串(例如 get_all_status 段那句同时引了自己的 + # SELECT 和 report_status 写下的 INSERT)。「在 `x` 段搜」这个限定 + # 只作用于**紧跟它的那一个**锚串,不能按整行套用 —— 第一版就是按 + # 整行匹配的,于是把 report_status 的限定套到了同行 get_all_status + # 的锚串上,自己造出一条假 mismatch。 + anchors = list(ANCHOR.finditer(line)) + prev_end = 0 + for idx, m in enumerate(anchors): + needle = m.group(1) + hits = [i for i, x in enumerate(src_lines, 1) if needle in x] + if not hits: + # 锚串不在 tools.ts 里 —— 可能是 db.ts 的串与 tools.ts 的串 + # 同行混排。这里不判它,那是 check-doc-source-pins.py 之外的 + # 另一件事;本脚本只管归属。 + continue + total += 1 + owners = {owner_of(h) for h in hits} + + # (c) 命中落在函数定义上时,看这个函数被哪些 tool 段调用 + for h in hits: + fm = FUNC_DEF.match(src_lines[h - 1]) + if fm and fm.group(1) == needle: + owners |= helper_callers.get(needle, set()) + if needle in helper_callers: + owners |= helper_callers[needle] + + # 限定语可能在锚串**之前**(中文:「在 `x` 段搜 `串`」)也可能在 + # **之后**(英文:「grep `串` inside `x`」)。所以窗口取 + # 「上一个锚串结束 → 下一个锚串开始」,把两侧都包进来,但不跨到 + # 相邻锚串的地盘上。第一版只看前面,英文那条就漏判了。 + nxt = anchors[idx + 1].start() if idx + 1 < len(anchors) else len(line) + window = line[prev_end:nxt] + prev_end = m.end() + ex = EXPLICIT.search(window) + if ex: + # (a) 明写了段名,以它为准 + want = {ex.group(1) or ex.group(2)} + label = f"明写 {sorted(want)}" + else: + want = {section_of(line_no)} + label = f"章节 {sorted(want)}" + if want == {"(preamble)"}: + continue + + if not (owners & want): + mismatches += 1 + print( + f"MISMATCH {rel}:{line_no} {label} 但锚串落在 {sorted(owners)}" + f" «{needle[:56]}»", + file=sys.stderr, + ) + + print(f"checked_docs={checked_docs}") + print(f"anchors_checked={total}") + print(f"mismatches={mismatches}") + + if total == 0: + print("FAIL: 一条锚串都没检查到 —— 检查 DOCS / ANCHOR 是不是坏了", file=sys.stderr) + return 1 + if mismatches: + print(file=sys.stderr) + print(" 锚串确实存在,但落在别的 tool 段里。这类错「锚串存在」的检查放行不了 ——", file=sys.stderr) + print(" #845 里连着出了两例(reassign→cancel_task、broadcast→ack_inbox)。", file=sys.stderr) + print(" 改法:换一个落在正确段里的唯一串;确实要引别的 tool 时,在文本里", file=sys.stderr) + print(" 明写「在 `` 段搜」。", file=sys.stderr) + return 1 + + print() + print(f"OK: {total} 条锚串全部落在它们声称的 tool 段里。") + print("注意:这只管「落在对的段」。锚串在段内是否指着文档声称的那件事,仍要人读。") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/qa.sh b/scripts/qa.sh index 665614ca1..082fd62fc 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 归属是会安装依赖的层级;本地跑法见该文件头注释的门禁命令。 @@ -74,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 @@ -143,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/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-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/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": { 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/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/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); + }); +}); 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"; 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); }); 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, + }), }, ], }; 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" 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/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" 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"); 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.' \ 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 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"'); - } - }); -}); 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' diff --git a/tests/test725-agent-node-unit-ci/run.sh b/tests/test725-agent-node-unit-ci/run.sh index 40a64ad6e..cf2ee8462 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,59 @@ 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 +} + +# 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,' @@ -56,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 ae46f0fc5..69d591dcf 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/' \ @@ -48,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' 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: diff --git a/tests/test798-server-unit-ci/Dockerfile b/tests/test798-server-unit-ci/Dockerfile new file mode 100644 index 000000000..1c43ab75f --- /dev/null +++ b/tests/test798-server-unit-ci/Dockerfile @@ -0,0 +1,51 @@ +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/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 的同名共享源码, +# 要读同级 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 +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)" \ + && 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..d4a01d27d --- /dev/null +++ b/tests/test798-server-unit-ci/run.sh @@ -0,0 +1,160 @@ +#!/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 +} + +# 🔴 光验 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,这条不是形式主义。 +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" diff --git a/tests/test831-doc-source-pins/Dockerfile b/tests/test831-doc-source-pins/Dockerfile index d697d11dc..e0ef5b42f 100644 --- a/tests/test831-doc-source-pins/Dockerfile +++ b/tests/test831-doc-source-pins/Dockerfile @@ -27,6 +27,7 @@ COPY server /repo/server COPY agent-network /repo/agent-network COPY docs/doc-source-pins-baseline.txt /repo/docs/doc-source-pins-baseline.txt COPY scripts/check-doc-source-pins.py /repo/scripts/check-doc-source-pins.py +COPY scripts/check-mcp-tool-anchor-sections.py /repo/scripts/check-mcp-tool-anchor-sections.py COPY tests/test831-doc-source-pins /repo/tests/test831-doc-source-pins RUN chmod +x /repo/tests/test831-doc-source-pins/run.sh diff --git a/tests/test831-doc-source-pins/run.sh b/tests/test831-doc-source-pins/run.sh index 02596c17a..1907539f4 100755 --- a/tests/test831-doc-source-pins/run.sh +++ b/tests/test831-doc-source-pins/run.sh @@ -55,14 +55,27 @@ broken=$(printf '%s' "$out" | sed -nE 's/^broken_pins=([0-9]+)$/\1/p') # 容器内遍历得到的数字必须与仓库里 git ls-files 得到的一致,否则容器内外 # 扫的范围分叉,「容器里绿」就推不出「仓库里绿」。这两个数是写死的预期值, # 变了要人来确认是真变了还是扫漏了。 -# 这三个数在 #831 的符号锚点改造里从 106/70/141 变成了 106/53/107 —— +# 这三个数随 #831 的符号锚点改造逐批下降:106/70/141 → 106/53/107 → 106/27/53 +# → 106/18/35 → 106/15/35。 # mcp-tools.md 中英两版各 17 处 `[源码 ↗]` 行号链接换成了「文件链接 + grep 提示」。 # 这道断言本来就是设计成"变了要人确认"的:那次变化是逐条核过的(17/17 原本 # 都指错,漂移 35–1194 行),不是扫漏。 +# +# 2026-08-18:18 → 15。这次不是符号锚点改造,是 changelog 里三条引用**钉了提交** +# (#851 的 cli.ts#L61 / cli.ts#L2589,#834 的 server/src/index.ts#L253), +# 于是它们从 unique_pins 挪进了 pins_on_immutable_ref。逐条核过: +# +# immutable: agent-network/bin/cli.ts#L61 -> 2 个文件(中/英 changelog) +# immutable: agent-network/bin/cli.ts#L2589 -> 2 个文件 +# immutable: server/src/index.ts#L253 -> 2 个文件 +# +# 正好 3 条,18 - 3 = 15。`occ` 仍是 35(钉提交不减少"出现次数",只改变引用形式), +# `files` 仍是 106 —— **只有一个数变了,而且变的原因能逐条指出来**。这正是这道 +# 断言想逼出来的动作:数字变了要有人说清是进展还是扫漏,而不是把它改宽。 [[ "$files" -eq 106 ]] || fail "预期扫 106 个文档文件(= git ls-files 的结果),实际 $files" -[[ "$uniq" -eq 53 ]] || fail "预期 53 个唯一 pin,实际 $uniq" -[[ "$occ" -eq 107 ]] || fail "预期 107 处原始出现,实际 $occ" -echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 53 唯一 pin / 107 处)" +[[ "$uniq" -eq 15 ]] || fail "预期 15 个唯一 pin,实际 $uniq" +[[ "$occ" -eq 35 ]] || fail "预期 35 处原始出现,实际 $occ" +echo " OK walk 路径与 git 路径给出同一份清单(106 文件 / 15 唯一 pin / 35 处)" # --------------------------------------------------------------------------- # L1 — 干净树上必须绿 @@ -149,13 +162,25 @@ restore2() { cp /tmp/victim2.bak "$VICTIM2"; } # ① 钉了不可变 SHA 的引用不属于这道门 —— 注入一个"在 HEAD 上必然越界"的 SHA pin, # 门必须**仍然绿**(它管的是会漂的 main 引用,不是历史链接)。 +# +# 🔴 断言的是**增量**,不是绝对值。原来这里写死 `pins_on_immutable_ref=1`, +# 那只在「仓里一条钉 SHA 的引用都没有」时成立 —— 写下它的时候确实成立, +# 所以它当时是对的。2026-08-18 之后仓里有 6 条(#851 三条 ×2 语言、#834 +# 一条 ×2 语言),注入第 7 条,写死的 1 就红了 —— 而红的原因是**别人按这 +# 道门的建议把行号 pin 钉成了提交**,也就是它自己想要的进展。 +# +# 一个「只有在某个背景事实恰好为 0 时才成立」的断言,和一个正确的断言, +# 在当时的输出上完全一样。所以先量基线,再断言恰好 +1。 +base_imm=$(python3 "$CHECK" "$ROOT" 2>/dev/null | sed -nE 's/^pins_on_immutable_ref=([0-9]+)$/\1/p') +[[ -n "$base_imm" ]] || fail "① 拿不到注入前的 pins_on_immutable_ref 基线" printf '\n[sha](https://github.com/sleep2agi/agent-network/blob/0123456789abcdef0123456789abcdef01234567/server/src/index.ts#L99999)\n' >> "$VICTIM2" set +e; out5=$(python3 "$CHECK" "$ROOT" 2>&1); rc5=$?; set -e restore2 [[ "$rc5" -eq 0 ]] || fail "① 钉 SHA 的引用被当成漂移失效了(rc=$rc5)—— 那会惩罚按本工具建议做出的修改" -printf '%s' "$out5" | grep -q "pins_on_immutable_ref=1" \ - || fail "① 钉 SHA 的引用没有被单独计数:$(printf '%s' "$out5" | grep pins_on_immutable || true)" -echo " ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿" +after_imm=$(printf '%s' "$out5" | sed -nE 's/^pins_on_immutable_ref=([0-9]+)$/\1/p') +[[ "$after_imm" -eq $((base_imm + 1)) ]] \ + || fail "① 钉 SHA 的引用没有被单独计数:注入前 $base_imm,注入后 $after_imm(应为 $((base_imm + 1)))" +echo " ① 不可变 ref 被排除且单独计数(${base_imm} → ${after_imm}),门仍绿" # ② #L0 必须判成越界。第一版只挡上界,content[-1] 会读到最后一行。 printf '\n[zero](https://github.com/sleep2agi/agent-network/blob/main/server/src/db.ts#L0)\n' >> "$VICTIM2" @@ -203,4 +228,137 @@ echo " ③ 引用仍在文档里时不判为可删,并给出 drifted 警告(pin python3 "$CHECK" "$ROOT" >/dev/null || fail "L5 复原之后没有回绿" echo " 复原后回绿 ✓" +# --------------------------------------------------------------------------- +# L6 — 符号锚点是否落在它声称的那个 tool 段。 +# +# #831 把行号锚点换成了「文件链接 + 可 grep 的串」,解决了行号会漂,却引入 +# 一个更隐蔽的失效:**锚串确实存在,只是落在别的 tool 段**。「锚串存在」 +# 这个检查放行不了它。#845 里连着出了两例,都不是靠工具发现的: +# reassign_task 段 → 锚到 send_message / cancel_task 里的串 +# broadcast 段 → 锚到 "ack_inbox" +# 第一例我自己抓到就改了、没做全量审计,于是第二例由审查者发现。 +# 这一层就是那次审计固化下来的 —— 一次性脚本抓到的错,下次还会漏。 +# --------------------------------------------------------------------------- +echo "[L6] symbol anchors land in the tool section they claim" +SYMCHECK="$ROOT/scripts/check-mcp-tool-anchor-sections.py" +[[ -f "$SYMCHECK" ]] || fail "L6 的脚本不在镜像里:$SYMCHECK" +out9=$(python3 "$SYMCHECK" "$ROOT") || fail "干净树上 L6 就红了:$(printf '%s' "$out9" | tail -4)" +printf '%s\n' "$out9" | sed 's/^/ /' +anch=$(printf '%s' "$out9" | sed -nE 's/^anchors_checked=([0-9]+)$/\1/p') +regs=$(printf '%s' "$out9" | sed -nE 's/^tool_registrations=([0-9]+)$/\1/p') +[[ "${anch:-0}" -gt 0 ]] || fail "L6 一条锚串都没检查到 —— 分母塌了" +[[ "${regs:-0}" -gt 0 ]] || fail "L6 没解析出任何 tool 注册点 —— 分母塌了" + +# witnessed-red:把 #845 里真实发生过的那个错重新注入 —— broadcast 段的参数表 +# 锚到 "ack_inbox"。必须红,且红在 broadcast 那一行上。 +BC=$(grep -n '^#\{2,4\} \?`\?broadcast`\?$' "$ROOT/docs-site/docs/api/mcp-tools.md" | head -1 | cut -d: -f1) +[[ -n "$BC" ]] || fail "找不到 broadcast 章节,无法造 L6 的变异" +cp "$ROOT/docs-site/docs/api/mcp-tools.md" /tmp/mcp.bak +python3 - "$ROOT/docs-site/docs/api/mcp-tools.md" "$BC" <<'PYX' +import sys, pathlib +path, start = pathlib.Path(sys.argv[1]), int(sys.argv[2]) +lines = path.read_text(encoding="utf-8").split("\n") +# 在 broadcast 章节里插一条锚到 ack_inbox 的引用 —— 这正是 #845 的原错 +lines.insert(start, '参数(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"`):') +path.write_text("\n".join(lines), encoding="utf-8") +PYX +set +e; out10=$(python3 "$SYMCHECK" "$ROOT" 2>&1); rc10=$?; set -e +cp /tmp/mcp.bak "$ROOT/docs-site/docs/api/mcp-tools.md" +[[ "$rc10" -ne 0 ]] || fail "把 broadcast 的锚串写成 \"ack_inbox\" 之后 L6 仍然绿" +printf '%s' "$out10" | grep -qF "MISMATCH" || fail "L6 红了但没打出 MISMATCH" +printf '%s' "$out10" | grep -q "broadcast" || fail "L6 红了但没指出是 broadcast 那条" +echo " MUTATION_RED broadcast-anchored-to-ack-inbox rc=$rc10" + +python3 "$SYMCHECK" "$ROOT" >/dev/null || fail "L6 复原之后没有回绿" +echo " 复原后回绿 ✓ (anchors_checked=$anch, tool_registrations=$regs)" + +# --------------------------------------------------------------------------- +# L7 — --write-baseline 只许缩小。 +# +# 起因:#810 / #834 这类 PR 会让基线里某些条目对应的引用消失,门于是红在 +# 「请从基线里删掉」。让人手工数该删哪几条,是把一个机械操作交给记忆力。 +# 加了 --write-baseline 之后,那一步变成一条命令。 +# +# 但这个开关天然危险:它离「一键把门变绿」只差一个条件判断。所以这一层 +# 两个方向都要断言 —— 该删的时候要删得对,不该写的时候要拒绝。 +# --------------------------------------------------------------------------- +echo "[L7] --write-baseline shrinks only" +cp "$BASELINE" /tmp/bl7.bak +VICTIM3=$(find "$ROOT/docs-site" -name '*.md' | sort | head -1) +cp "$VICTIM3" /tmp/v7.bak + +# ① 干净树上无事可做 +out11=$(python3 "$CHECK" "$ROOT" --write-baseline) || fail "干净树上 --write-baseline 竟然非零" +printf '%s' "$out11" | grep -qF "基线已经是最新的" \ + || fail "干净树上应报「基线已经是最新的」,实际:$(printf '%s' "$out11" | tail -3)" +cmp -s "$BASELINE" /tmp/bl7.bak || fail "① 干净树上它却改写了基线" +echo " ① 干净树:不改写,报「已是最新」" + +# ② 出现新失效时必须拒绝写 —— 这是这个开关最危险的方向 +printf '\n[l7](https://github.com/sleep2agi/agent-network/blob/main/server/src/index.ts#L99999)\n' >> "$VICTIM3" +set +e; out12=$(python3 "$CHECK" "$ROOT" --write-baseline 2>&1); rc12=$?; set -e +cp /tmp/v7.bak "$VICTIM3" +[[ "$rc12" -ne 0 ]] || fail "② 有新失效 pin 时 --write-baseline 竟然成功了" +printf '%s' "$out12" | grep -qF "拒绝写基线" || fail "② 红了但不是红在「拒绝写基线」上" +cmp -s "$BASELINE" /tmp/bl7.bak || fail "② 它拒绝了,却还是把基线写了" +echo " MUTATION_RED write-baseline-refuses-new-failure rc=$rc12" + +# ③ 引用消失时要删对,并保留表头注释 +BL_BEFORE=$(grep -cv '^\s*#\|^\s*$' "$BASELINE") +# 🔴 造场景用的 SHA 必须是**仓里不可能出现的**合成值,不能借用一个真实提交。 +# 原来这里用 22ed1886 —— 而 #834 之后 changelog 里**真的**有 +# `blob/22ed1886/server/src/index.ts#L253`,于是下面的复原(全局把 +# 22ed1886 换回 main)会把那条真实的、有意为之的提交钉**改回会漂的 main 引用**, +# 留下一个不在基线里的失效 pin,L7 末尾因此永远回不了绿。 +# 「合成值恰好不与真实数据相撞」是一个会过期的巧合,不是一条性质。 +# 另外把改过的文件名记下来,复原时只碰这些文件。 +python3 - "$ROOT" <<'PYX' +import sys, pathlib +root = pathlib.Path(sys.argv[1]) +FAKE = "0123456789abcdef0123456789abcdef01234567" # 合成 SHA,仓里不会有 +base = [l.strip() for l in (root/'docs/doc-source-pins-baseline.txt').read_text(encoding='utf-8').splitlines() + if l.strip() and not l.lstrip().startswith('#')] +target = base[0] +old = f"blob/main/{target}" +new = f"blob/{FAKE}/{target}" +touched, n = [], 0 +for f in (root/'docs-site').rglob('*.md'): + t = f.read_text(encoding='utf-8') + if old in t: + n += t.count(old) + f.write_text(t.replace(old, new), encoding='utf-8') + touched.append(str(f)) +(root/'.l7-touched').write_text("\n".join(touched), encoding='utf-8') +print(f" (造场景:把 {target} 的 {n} 处引用改钉合成 SHA,涉及 {len(touched)} 个文件)") +PYX +out13=$(python3 "$CHECK" "$ROOT" --write-baseline) || fail "③ 有该删的条目时 --write-baseline 却非零" +printf '%s' "$out13" | grep -qF "已改写基线" || fail "③ 没报告改写" +BL_AFTER=$(grep -cv '^\s*#\|^\s*$' "$BASELINE") +[[ "$BL_AFTER" -lt "$BL_BEFORE" ]] || fail "③ 基线没有变小($BL_BEFORE → $BL_AFTER)" +head -1 "$BASELINE" | grep -q '^#' || fail "③ 改写把表头注释弄丢了" +python3 "$CHECK" "$ROOT" >/dev/null || fail "③ 改写之后门没有转绿" +echo " ③ 引用消失时删对了($BL_BEFORE → $BL_AFTER),表头保留,门转绿" + +# 复原:文档与基线都还原 +cd "$ROOT" && git status >/dev/null 2>&1 || true +python3 - "$ROOT" <<'PYX' +import sys, pathlib +root = pathlib.Path(sys.argv[1]) +FAKE = "0123456789abcdef0123456789abcdef01234567" +listing = root/'.l7-touched' +files = [pathlib.Path(p) for p in listing.read_text(encoding='utf-8').split("\n") if p.strip()] if listing.exists() else [] +for f in files: + t = f.read_text(encoding='utf-8') + f.write_text(t.replace(f"blob/{FAKE}/", "blob/main/"), encoding='utf-8') +if listing.exists(): + listing.unlink() +# 断言复原彻底:合成 SHA 在整个 docs-site 里必须一个都不剩。 +left = [str(f) for f in (root/'docs-site').rglob('*.md') if FAKE in f.read_text(encoding='utf-8')] +if left: + raise SystemExit(f"L7 复原不彻底,合成 SHA 仍残留于: {left}") +PYX +cp /tmp/bl7.bak "$BASELINE" +python3 "$CHECK" "$ROOT" >/dev/null || fail "L7 复原之后没有回绿" +echo " 复原后回绿 ✓" + echo "RESULT: PASS"