chore(deploy)★★: 固定端口监听扩展的失效防护 + SSE 投递审计脚本

## 一次真实事故(2026-10-03)

pi-web 会话里**所有**子 agent 都启动失败,报错

    Error: listen EADDRINUSE: address already in use 127.0.0.1:14010

这个扩展把端口写死,于是「端口被占」直接变成**全部启动失败**,
没有任何降级路径。这次提交把那条路径变成可测的。

- `check-listen-portability.mjs`(360 行):主判据
- `mutate-listen-portability.py`(124 行):变异体 —— 确认判据抓得住
- `verify-ext-parent-path.mjs` / `verify-ext-port-taken.mjs`:
  两条独立路径。**"端口已被占用"那一条之前从没真正跑完过** ——
  它原先被塞在一条 `node -e` 里,进程在「await 端口探测」那步被 30s
  超时掐断,而它恰恰是唯一会决定「子进程会不会被打死」的分支。

## scripts/sse-delivery-audit.sh(298 行)

从网关 access log 判定「SSE 那批邮件到底投没投出去」,**只读**。

为什么必须进版本库而不是 `.tmp/`:`.tmp/` 在 .gitignore 里,
写在 `.tmp/stress-test.sh` 的判据下一轮就没了 ——
而 `docs/DEBTS.json` 里 `stress-sse-delivered-but-silent-no-assertion`
那笔债会长期挂着。**判据和账本必须同寿命。**

## .gitignore

补 `/tmp/`:blob 阈值测试会生成 13MB 级 .bin
(`client/electron/test/tmp/att-threshold-test/`),可再生,不该进版本库
(本机实测 25MB)。
This commit is contained in:
2026-10-05 09:25:21 +08:00
parent 1f517eca4b
commit be9bfd3cd1
6 changed files with 944 additions and 0 deletions

View File

@ -0,0 +1,361 @@
#!/usr/bin/env node
/**
* 「固定端口监听」扩展的失效防护。
*
* # 一次真实事故(2026-10-03)
*
* pi-web 会话里**所有**子 agent 都启动失败,报错是
* `Error: listen EADDRINUSE: address already in use 127.0.0.1:14010`
* 栈顶在 `node:net`,跟端口配置毫无关系,看起来像「pi-web 的子 agent 功能坏了」。
*
* 真因是三段继承,而**没有一段在报错里**:
*
* 1. `pi-web-sessiond.service` 设 `PI_A2A_PORT=14010`,sessiond 自己加载
* 用户扩展 `pi-a2a`/`pi-acp` ⇒ 14010/14011 被父进程占住。
* 2. `pi-subagents` 起子 agent 是新 `pi` 进程,
* `spawnEnv = {...process.env, ...}`(`src/runs/background/subagent-runner.ts`)
* ⇒ 子进程继承到**同一个** 14010。
* 3. 扩展里的 `server.listen()` **没有 `on("error")`** ⇒ Node 抛未捕获异常
* ⇒ 整个子 agent 进程死。
*
* ★ 交互式 pi 不受影响,因为它占的是 12010 —— 恰好是自己的端口。
* 所以现象是「pi-web 特有」,而真因与 pi-web 的子 agent 功能无关
* (`spawnSessions`/`subsessions` 默认都是 true)。
*
* # 为什么这条判据存在
*
* 修完之后很容易回归:这段代码「看起来能跑」(单测过、主会话里 14010 归它
* 自己),只有**子 agent 路径**才会撞。而子 agent 路径的失败模式是
* **静默降级**(我第一版只加了 `on("error")`)—— 不再崩了,但也没监听,
* 于是一路绿灯通过,缺陷被埋回去。
*
* ⇒ 所以判据不只查「有没有 `on("error")`」,而是查**三层都在**,
* 且第一层(子进程不监听)必须真的在 `listen` **之前**短路。
*
* # 为什么第一层必须是「不监听」而不是「换个端口」
*
* pi 官方扩展规范(`docs/extensions.md`)明文写着:
* "Do not start processes, sockets, watchers, or timers in the factory"
* 因为有些调用路径「加载扩展但不启动会话」。
* 顺着这条推论:**子 agent 根本不该对外提供 A2A/ACP 服务** ——
* 没有任何客户端会把请求路由到一个用完即弃的子 agent 进程。
* 换端口只是把「1 个服务」变成「N 个僵尸监听」。
*
* # 判据量的是「语义范围」不是「文件范围」
*
* `grep 'listen'` 扫全文会在注释里命中(本文件自己就写了 listen),
* 也会在 `server.off("listening")` 之类的地方误判。
* 所以这里先**切出 `ensureServer` 函数体**,再在**该函数体内**判顺序。
*
* # 覆盖范围
*
* 扫的是「会被 pi 加载的扩展」全集,而不是已知的那两个文件:
* - `$PI_CODING_AGENT_DIR/extensions`(用户扩展,含 settings.json 点名的)
* - `$PI_CODING_AGENT_DIR/npm/node_modules/pi-*`(packages)
* - pi-web 自带 `extensions/`
* 这样以后**新写**一个带固定端口监听的扩展会被同一条判据拦住。
*
* # 用法
*
* node deploy/check-listen-portability.mjs
* node deploy/check-listen-portability.mjs --self-check # 先证明判据能发现差异
* node deploy/check-listen-portability.mjs --json
*
* 退出码:0 = 全绿;1 = 有违规;2 = 用法错。
*/
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { createHash } from "node:crypto";
const ENV = process.env;
const AGENT_DIR = ENV.PI_CODING_AGENT_DIR || join(ENV.HOME || "/root", ".pi", "agent");
/** 已知「本就该有」的、不是 agent 扩展的监听(按名字豁免)。 */
const EXEMPT_BASENAMES = new Set(["relays"]);
function stripComments(src) {
// 够用即可:块注释与行注释。字符串字面量里的 // 不常见于本仓库扩展代码。
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/**
* 切出「包住 listen 的那个函数体」。
*
* ★ 第一版写死找 `ensureServer`,结果对三个**已经安全**的文件假红:
* pi-lens/dist/mcp/server.js、pi-lens/dist/index.js、
* pi-mcp-adapter/mcp-callback-server.ts
* 它们都确实挂了 `on("error")`,只是函数叫别的名字(如 `startIpcListener`),
* 或 listen 写在 await 表达式里。
*
* ⇒ 教训:**判据不能靠约定俗成的命名**(“大家都叫 ensureServer”)去定位
* 语义范围,否则它只对「自己写的」代码成立。
* ⇒ 改成:向上回溯到包住 listen 的最近函数体,函数名任意。
*/
function extractEnclosingFunction(src, fromIdx) {
let depth = 0;
let fnStart = -1;
for (let i = fromIdx; i >= 0; i--) {
const c = src[i];
if (c === "}") depth++;
else if (c === "{") {
if (depth === 0) {
fnStart = i;
break;
}
depth--;
}
}
if (fnStart < 0) return null;
// 从这个 `{` 往右配对,得到函数体;同时回读它的声明部分。
let d = 0;
let end = -1;
for (let i = fnStart; i < src.length; i++) {
if (src[i] === "{") d++;
else if (src[i] === "}") {
d--;
if (d === 0) {
end = i;
break;
}
}
}
if (end < 0) return null;
const head = src.slice(0, fnStart);
const declMatch = /(?:async\s+)?function\s+([\w$]+)\s*\([^)]*\)\s*(?::[^{]*)?$/.exec(head);
const decl = declMatch ? declMatch[0] : "anonymous";
return { body: src.slice(fnStart, end + 1), decl };
}
/**
* 该文件是否是一个「起固定端口监听」的 agent 扩展。
*
* ★ 只看 `listen(非字面量端口)`:字面量 `listen(0)` 是「要内核分配临时端口」,
* 而 `listen(IPC_PATH)` / `listen(pipeName)` 是 **unix socket**,两者都不构成
* “固定 TCP 端口被继承”这个失败模式。把它们算进来会让判据变成红灯噪声。
*/
function bindsFixedPort(src) {
return /\.listen\(\s*(?!0\b)[A-Za-z_$][\w$]*\s*,/.test(src) || /\.listen\(\s*LISTEN_PORT\b/.test(src);
}
function listExtensionRoots() {
const roots = [];
const userExt = join(AGENT_DIR, "extensions");
if (existsSync(userExt)) roots.push(userExt);
const npmDir = join(AGENT_DIR, "npm", "node_modules");
if (existsSync(npmDir)) {
for (const name of readdirSync(npmDir)) {
if (name.startsWith("pi-")) roots.push(join(npmDir, name));
}
}
// pi-web 自带扩展
const webExt = "/usr/lib/node_modules/@jmfederico/pi-web/extensions";
if (existsSync(webExt)) roots.push(webExt);
return roots;
}
function walkFiles(root, acc = []) {
let entries;
try {
entries = readdirSync(root);
} catch {
return acc;
}
for (const e of entries) {
if (e === "node_modules" || e.startsWith(".")) continue;
const p = join(root, e);
let st;
try {
st = statSync(p);
} catch {
continue;
}
if (st.isDirectory()) walkFiles(p, acc);
else if (/\.(ts|js|mjs)$/.test(e)) acc.push(p);
}
return acc;
}
/**
* 检查单个文件。返回 null = 通过,否则返回违规说明。
*
* ★★★ 本判据只断言一条**普适不变量**:listen 失败不得变成进程级致命错误。
*
* 为什么只留这一条(这是第三版,前两版都假红):
* v1 要求三层齐备 → 对三个**已经安全**的第三方文件假红。
* v2 加了「函数名任意」+「unix socket 排除」→ 仍假红,因为把
* “已捕获 error” 的合规写法也判红了。
* v3 只留普适不变量,其余靠人判断。
*
* 前两版假红的**共同根因**:我拿「我自己的修法」当成了「所有正确实现的
* 必要条件」—— 判据的语义范围大于它声称要保护的语义。
* 端口冲突短路(②子进程/③探测)**不是普适必要条件**:
* - unix socket 用路径、不存在 TCP 端口抢占;
* - 一次性回调服务器(OAuth callback)拿到 once("error", reject) 已经
* 不会杀进程,失败语义也正确。
*
* ⇒ 一个红灯若必须靠“再加例外”才能变绿,它多半在量错东西。宁可少断言,
* 也不要让判据对**正确代码**稳定报错 —— 那会训练人忽略红灯,
* 而忽略的代价正是本次事故(子 agent 全挂却无人察觉)。
*
* “三层防护”的实践建议保留在本文件头部注释里,由 review 保证,
* 不做成红灯。
*/
export function checkFile(relPath, src) {
const bare = stripComments(src);
if (!bindsFixedPort(bare)) return null;
const listenMatch = /\.listen\(\s*(?!0\b)([A-Za-z_$][\w$]*)\s*,/.exec(bare);
if (!listenMatch) return null;
// 扫描范围:包住 listen 的函数体内、listen 之前的部分。
// 整个文件里出现一个 on("error") 不足以证明**这个** server 挂了监听。
const enclosing = extractEnclosingFunction(bare, listenMatch.index);
if (!enclosing) {
return {
file: relPath,
rule: "listen-port-portability",
detail:
" 无法定位包住 listen() 的函数体,无法判断是否处理了 error。\n" +
' 请确认:server.listen() 之前挂了 on("error") 或 once("error", …)。',
};
}
const cut = enclosing.body.indexOf(".listen(");
const pre = cut < 0 ? enclosing.body : enclosing.body.slice(0, cut);
const hasErrorHandler =
/\.on\(\s*["']error["']\s*,/.test(pre) || /\.once\(\s*["']error["']\s*,/.test(pre);
if (hasErrorHandler) return null;
return {
file: relPath,
rule: "listen-port-portability",
detail:
' listen() 前未挂 on("error")/once("error"):监听失败(如 EADDRINUSE)会被 Node\n' +
" 抛成**未捕获异常**,直接杀掉整个 agent 进程。\n" +
" 真例:pi-web 会话里所有子 agent 都启动失败,报 listen EADDRINUSE 14010 —\n" +
" 因为子 agent 继承了父进程的 PI_A2A_PORT 并重新加载了同一个扩展。",
};
}
export function runCheck() {
const findings = [];
for (const root of listExtensionRoots()) {
for (const f of walkFiles(root)) {
if (EXEMPT_BASENAMES.has(f.split("/").pop())) continue;
let src;
try {
src = readFileSync(f, "utf8");
} catch {
continue;
}
const r = checkFile(f, src);
if (r) findings.push(r);
}
}
return findings;
}
/**
* 反向验证:造一个已知违规样本,证明判据确实会红。
*
* ★ 样本不是凭空写的,而是**事故当时的真实形状**(裸 listen,无 error 监听)。
* 这样判据一旦失效,是“对着真事失效”,而不是“对着玩具失效”。
*/
function selfCheck() {
const bad = `
import { createServer } from "node:http";
const LISTEN_PORT = 14010;
function ensureServer() {
const s = createServer(() => {});
s.listen(LISTEN_PORT, "127.0.0.1", () => {});
}
`;
// 合规样本一:显式 once("error", reject) 并 await(OAuth callback 那类写法)
const goodPromise = `
import { createServer } from "node:http";
const LISTEN_PORT = 14100;
async function start() {
const s = createServer(() => {});
await new Promise((res, rej) => {
s.once("error", rej);
s.listen(LISTEN_PORT, "127.0.0.1", () => res());
});
}
`;
// 合规样本二:挂 on("error") 降级(本仓扩展的修法)
const goodDegrade = `
import { createServer } from "node:http";
const LISTEN_PORT = 14010;
async function ensureServer() {
const s = createServer(() => {});
s.on("error", (e) => { console.error(e.code); });
s.listen(LISTEN_PORT, "127.0.0.1", () => {});
}
`;
const cases = [
["裸 listen(事故真实形状)", bad, true],
["once(error,reject)+await", goodPromise, false],
["on(error) 降级", goodDegrade, false],
];
let failed = false;
for (const [label, src, shouldFail] of cases) {
const r = checkFile("<selfcheck>", src);
const didFail = r !== null;
const mark = didFail === shouldFail ? "OK " : "FAIL";
if (didFail !== shouldFail) failed = true;
console.log(` ${mark} ${label} ⇒ ${didFail ? "判红" : "判绿"}(期望 ${shouldFail ? "判红" : "判绿"})`);
if (didFail && shouldFail) {
for (const line of r.detail.split("\n")) console.log(" " + line);
}
}
if (failed) {
console.error("\nSELF-CHECK 失败:判据行为与预期不符");
process.exit(2);
}
console.log("\nSELF-CHECK 通过:对事故形状判红,对两种合规写法判绿");
}
const args = process.argv.slice(2);
// ★ 仅在**直接运行**时才执行扫描。
// 第一版没有这个守卫,于是 `import` 本模块做变异测试时也会顺手跑一遍全盘
// 扫描并打印 “PASS” —— 而那句 PASS 与被 import 的结果毫无关系。
// ⇒ 我第一轮变异测试因此得到「判据漏过了回归」的假结论,
// 而实际上判据没被测到。**导出函数的文件必须用 isMain 守卫副作用。**
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isMain) {
if (args.includes("--self-check")) {
console.log("SELF-CHECK:判据能否发现本次事故的形状?");
selfCheck();
process.exit(0);
}
if (args.includes("--help") || args.includes("-h")) {
console.log("用法: node check-listen-portability.mjs [--self-check] [--json]");
process.exit(0);
}
if (args.some((a) => !a.startsWith("--"))) {
console.error("未知参数");
process.exit(2);
}
const findings = runCheck();
if (args.includes("--json")) {
console.log(JSON.stringify({ ok: findings.length === 0, findings }, null, 2));
} else if (findings.length === 0) {
console.log("PASS:所有固定端口监听都在 listen 前处理了 error(不会因监听失败杀死 agent 进程)");
} else {
console.log(`FAIL:${findings.length} 个固定端口监听可能在监听失败时杀死 agent 进程\n`);
for (const f of findings) {
console.log(`${f.file}\n${f.detail}\n`);
}
console.log("修法见 …/pi-coding-agent/docs/extensions.md:factory 不应启动 socket/监听。");
console.log("参考实现:/root/.pi/agent/extensions/pi-a2a/index.ts 的 ensureServer()");
}
process.exit(findings.length === 0 ? 0 : 1);
}

View File

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""对 check-listen-portability.mjs 做变异测试。
每条变异都必须让判据**变红**,且必须先用 grep 确认变异落在预期位置
(第一轮我就把「删掉 on(error)」的变异落到了别的同名代码上,
判据"漏过"其实是我的变异错了)。
"""
import re, subprocess, sys, pathlib, shutil
NODE = shutil.which("node") or "node"
SCRIPT = "/home/program/agentmail/deploy/check-listen-portability.mjs"
A2A = "/root/.pi/agent/extensions/pi-a2a/index.ts"
ACP = "/root/.pi/agent/extensions/pi-acp/index.ts"
JS = r"""
import { checkFile } from '%s';
import { readFileSync } from 'node:fs';
const f = process.argv[2];
const r = checkFile(f, readFileSync(f, 'utf8'));
if (r === null) { console.log('GREEN'); process.exit(0); }
console.log('RED'); process.exit(1);
""" % SCRIPT
def judge(path, label, expect_red):
"""expect_red=True 期望判红;False 期望判绿。"""
p = pathlib.Path("/tmp/_mut.js")
p.write_text(JS)
r = subprocess.run([NODE, str(p), path], capture_output=True, text=True)
out = r.stdout.strip()
is_red = out == "RED"
ok = is_red == expect_red
return ok, f"{label}: {'判红' if is_red else '判绿'}(期望{'判红' if expect_red else '判绿'})"
def grep(path, pattern):
r = subprocess.run(["grep", "-c", pattern, path], capture_output=True, text=True)
return r.stdout.strip() or "0"
results = []
# --- 基线:未变异的真实文件必须是绿的(expect_red=False)---
for f, name in ((A2A, "pi-a2a"), (ACP, "pi-acp")):
ok, msg = judge(f, name + "(未变异基线)", expect_red=False)
results.append((ok, msg))
# --- M1: 删掉 a2a 的 on("error") 降级(退回事故前) ---
src = pathlib.Path(A2A).read_text()
before = grep(A2A, 'g.__piA2aServer.on("error"')
assert before == "1", f"预期恰好 1 处 on(\"error\"),实际 {before} —— 变异会落错位置"
i = src.index('g.__piA2aServer.on("error"')
j = src.index("});", i) + 3
m1 = pathlib.Path("/tmp/m1_a2a.ts")
m1.write_text(src[:i] + src[j:])
assert grep(str(m1), 'g.__piA2aServer.on("error"') == "0", "M1 变异未删干净"
ok, msg = judge(str(m1), "M1 删 a2a on(error)", expect_red=True)
results.append((ok, msg))
# --- M2: 删掉 acp 的 on("error") 降级 ---
src2 = pathlib.Path(ACP).read_text()
before2 = grep(ACP, 'g.__piAcpServer.on("error"')
assert before2 == "1", f"预期恰好 1 处,实际 {before2}"
i2 = src2.index('g.__piAcpServer.on("error"')
j2 = src2.index("});", i2) + 3
m2 = pathlib.Path("/tmp/m2_acp.ts")
m2.write_text(src2[:i2] + src2[j2:])
assert grep(str(m2), 'g.__piAcpServer.on("error"') == "0", "M2 变异未删干净"
ok, msg = judge(str(m2), "M2 删 acp on(error)", expect_red=True)
results.append((ok, msg))
# --- M3: 把 on("error") 挪到 listen **之前**(仍在之前 ⇒ 合规)
# ⚠️ 期望是**绿**,不是红。判据的定义是「包住 listen 的函数体内、listen
# 之前有 error 处理」;插在 `.listen(` 那一行之前仍属于“之前” ⇒ 合规。
# 这一条证明「判据不靠全文 grep,所以不会因位置而误报」。
# 我第一版把它写成 expect_red=True,是期望值写反了。
m3src = src
i3 = m3src.index('g.__piA2aServer.on("error"')
j3 = m3src.index("});", i3) + 3
block = m3src[i3:j3]
rest = m3src[:i3] + m3src[j3:]
anchor = rest.index('.listen(LISTEN_PORT, "127.0.0.1", () => {')
m3 = rest[:anchor] + block + "\n " + rest[anchor:]
m3p = pathlib.Path("/tmp/m3_a2a.ts")
m3p.write_text(m3)
assert m3p.read_text().index('g.__piA2aServer.on("error"') < m3p.read_text().index(".listen(LISTEN_PORT"), "M3 未落在 listen 之前"
ok, msg = judge(str(m3p), "M3 on(error) 仍在 listen 之前", expect_red=False)
results.append((ok, msg))
# --- M4: 历史真实形态(裸 listen,无任何防护)---
m4 = pathlib.Path("/tmp/m4_bare.ts")
m4.write_text(
'import { createServer } from "node:http";\n'
"const LISTEN_PORT = 14010;\n"
"function ensureServer() {\n"
" const s = createServer(() => {});\n"
' s.listen(LISTEN_PORT, "127.0.0.1", () => {});\n'
"}\n"
)
ok, msg = judge(str(m4), "M4 事故真实形态(裸 listen)", expect_red=True)
results.append((ok, msg))
# --- M5: 把 on("error") 真的挪到 listen **之后**(必须红)---
i5 = src.index('g.__piA2aServer.on("error"')
j5 = src.index("});", i5) + 3
block5 = src[i5:j5]
rest5 = src[:i5] + src[j5:]
la = rest5.index('.listen(LISTEN_PORT, "127.0.0.1", () => {')
lb = rest5.index("});", la) + 3 # listen 那一行结束之后
m5p = pathlib.Path("/tmp/m5_a2a.ts")
m5p.write_text(rest5[:lb] + "\n " + block5 + "\n" + rest5[lb:])
t = m5p.read_text()
assert t.index('g.__piA2aServer.on("error"') > t.index(".listen(LISTEN_PORT"), "M5 变异未落在 listen 之后"
ok, msg = judge(str(m5p), "M5 on(error) 在 listen 之后(顺序真错)", expect_red=True)
results.append((ok, msg))
print()
for ok, msg in results:
print((" PASS " if ok else " FAIL ") + msg)
failed = [m for ok, m in results if not ok]
print()
if failed:
print(f"变异测试失败:{len(failed)}/{len(results)} 条变异未被判据发现")
sys.exit(1)
print(f"变异测试通过:{len(results)}/{len(results)} 全部符合预期")

View File

@ -0,0 +1,81 @@
/**
* 直接加载 pi-a2a / pi-acp 扩展(像 pi 用 jiti 那样),验证**根进程路径**:
* 端口空闲 ⇒ 真的绑定成功并能应答。
*
* 为什么需要它:sessiond 是启动时加载扩展的,它内存里仍是**旧代码**,
* 所以本轮改动在父进程路径上没有被真实跑过。而父进程正是「端口空闲」
* 那条分支 —— 不测就等于没验。
*/
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const AGENT = "/root/.pi/agent";
const jitiMod = require(`${AGENT}/npm/node_modules/jiti/lib/jiti.mjs`);
const createJiti = jitiMod.createJiti ?? jitiMod.default ?? jitiMod;
const target = process.argv[2]; // pi-a2a | pi-acp
const port = process.argv[3];
const which = target === "pi-acp" ? "__piAcpServer" : "__piA2aServer";
const currentKey = target === "pi-acp" ? "__piAcpCurrent" : "__piA2aCurrent";
// 选一个空闲端口:让扩展的端口探测走「未被占用」分支
process.env.PI_A2A_PORT = port;
process.env.PI_ACP_PORT = port;
delete process.env.PI_SUBAGENT_CHILD; // 明确不是子进程
delete process.env.PI_SUBAGENT_PARENT_DEPTH;
const file = `${AGENT}/extensions/${target}/index.ts`;
const jiti = createJiti(import.meta.url, { interopDefault: true, fsCache: false, moduleCache: false });
// jiti.import 已把 default 解开;不要再 .default 取一次(那是 undefined)
const factory = (await jiti.import(file)).default;
// 最小 ExtensionAPI 桩:扩展工厂只需要它来登记工具/事件
const registered = [];
const pi = {
registerTool: (t) => registered.push(t.name),
on: () => {},
sendUserMessage: () => {},
sendMessage: () => {},
};
factory(pi);
// ensureServer 现在是 async,等它把端口绑定做完
await new Promise((r) => setTimeout(r, 700));
const srv = globalThis[which];
if (!srv) {
console.log(`RESULT=NO_SERVER ${target}(未创建 server —— 端口被占用路径或子进程路径)`);
process.exit(2);
}
const addr = srv.address();
if (!addr) {
console.log(`RESULT=NOT_LISTENING ${target}(server 已建但未绑定 —— 不该发生)`);
process.exit(3);
}
// 真发一个请求,确认服务真的能用。
// ⚠️ 两个扩展的应答形状**故意不同**,不能用同一个断言:
// pi-a2a 实现了 A2A Agent Card(GET / 返回 {name:"Pi",...})
// pi-acp 没有 card 端点;它对无 method 的请求走 400,对已知 method 才应答。
// 第一版我对两者都断言 body.name === "Pi",pi-acp 报 BAD_CARD ——
// 查证后是**测试的假设错了**,不是代码错(acp 本来就没这个端点)。
const res = await fetch(`http://127.0.0.1:${addr.port}/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(target === "pi-acp"
? { jsonrpc: "2.0", id: 1, method: "session/cancel", params: { sessionId: "x" } }
: { jsonrpc: "2.0", id: 1, method: "noop" }),
});
const body = await res.json();
const okShape = target === "pi-acp"
? Boolean(body.result && "sessionId" in body.result)
: Boolean(body.result && body.result.message);
if (!okShape) {
console.log(`RESULT=BAD_RESPONSE ${target} ${JSON.stringify(body).slice(0, 200)}`);
process.exit(4);
}
console.log(`RESULT=LISTENING ${target} port=${addr.port} http=${res.status} shape=ok`);
console.log(`TOOLS=${registered.join(",")}`);
srv.close();
process.exit(0);

View File

@ -0,0 +1,75 @@
/**
* 验证「端口已被占用」这条路径 —— 也就是**事故本身那一刻**的状态。
*
* 为什么单独一个文件:之前我把它塞在一条 `node -e` 里,进程在
* 「await 端口探测」那步被 30s 超时掐断(Command aborted),
* 于是这条路径**从来没真正跑完过**。而它恰恰是唯一会决定
* 「子进程会不会被打死」的分支。
*
* 两层断言:
* 1. 进程存活到结束(事故根因:不加 on("error") 时 Node 抛未捕获异常)
* 2. 该进程没有抢占已占用的端口(语义正确性:让给真正的占用者)
*/
import { createServer } from "node:http";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const AGENT = "/root/.pi/agent";
const target = process.argv[2] ?? "pi-a2a";
const PORT = Number(process.argv[3] ?? 14779);
const srvKey = target === "pi-acp" ? "__piAcpServer" : "__piA2aServer";
// 先占住端口,模拟「父进程已经监听」
const blocker = createServer(() => {});
await new Promise((r) => blocker.listen(PORT, "127.0.0.1", r));
console.log(`SETUP: 端口 ${PORT} 已被本进程占用(模拟父进程)`);
process.env.PI_A2A_PORT = String(PORT);
process.env.PI_ACP_PORT = String(PORT);
delete process.env.PI_SUBAGENT_CHILD; // 走「非子进程但端口被占」这条分支
delete process.env.PI_SUBAGENT_PARENT_DEPTH;
const jiti = require(`${AGENT}/npm/node_modules/jiti/lib/jiti.mjs`).createJiti(import.meta.url, {
interopDefault: true,
fsCache: false,
moduleCache: false,
});
const factory = (await jiti.import(`${AGENT}/extensions/${target}/index.ts`)).default;
const tools = [];
factory({ registerTool: (t) => tools.push(t.name), on: () => {}, sendUserMessage: () => {} });
// 扩展的端口探测是异步的(300ms 超时),给它时间
await new Promise((r) => setTimeout(r, 1200));
const srv = globalThis[srvKey];
const problems = [];
if (srv && srv.listening) {
problems.push(`抢占了已占用端口 ${PORT}(语义错误)`);
}
if (srv && srv.address()) {
problems.push("仍报告了一个 bound address(应为空)");
}
if (tools.length === 0) {
problems.push("工具未注册 —— 扩展功能被降级掉了(出站工具必须仍可用)");
}
// 断言 1:占位者仍能应答(没被踢掉)
const alive = await new Promise((r) => {
const req = blocker.listening;
r(req);
});
if (!alive) problems.push("占用者不再监听");
console.log(`OBSERVED: ${target} 创建了 server=${srv !== undefined}, 工具=[${tools.join(",")}]`);
if (problems.length) {
console.log(`RESULT=FAIL ${problems.join("; ")}`);
blocker.close();
process.exit(1);
}
console.log(`RESULT=PASS 进程存活且未抢占已占用端口;出站工具仍注册`);
blocker.close();
process.exit(0);