Conversation
规格(2026-09-12-pi-backend-switch-design.md)是设计权威,本计划把它展开为
11 个可逐任务执行的步骤,并补齐规格里没有的执行信息:
- 环境前提实测表:pi 0.85.1(正是规格实测版本)、node v24.20.0、
pi-web-access v0.29.0 均就位;PI_CODING_AGENT_DIR 未设置、
~/.pi/agent/web-search.json 不存在,分别由 Task 2 与 Task 1 收口
- Plan 1 遗留的接缝缺口:Protocol.Bin() 定义了却零调用,6 处 spawn 仍用
claudeBin/BinPath,client.go:145/:217 硬编码 -p 且绕过 OnceArgs,
api/documents.go:467 直接 exec.Command("claude", ...)。不收口这些,
agent.Current() 返回 PiProtocol 时仍会 spawn claude
- 三处决策(D1 get_state 握手改为新增 InitCommands() 并把响应归一化成
system/init;D2 once-call 的 prompt 一律走 stdin;D3 --model sonnet 改为
model hint),其中 D1/D2 是对规格字面表述的有意偏离,理由与代价写在文档里
- 风险登记 5 条,含「不能用 --no-extensions 因此其余 pi 包会在 spawn 时
加载并执行任意代码」这一残留风险的运维处置
同时记下两处规格与代码的漂移:main.go 的 ClaudeBin 注入实测 10 处(规格写
11 处,第 315 行并非注入点);ClaudeProtocol.OnceArgs 实测不含 --model。
PiBin:env PI_BIN,默认 "pi",与既有 ClaudeBin 完全对称,只在 agent.Init 时用。
web 工具名解析(PiWebSearchConfigPath / LoadPiWebToolNames / Names())作为
pi 的 --tools 白名单与 PI_WEB_TOOLS env 的单一事实来源,避免工具名被
web-search.json 的 toolNames 改写后两边静默漂移。
schema 一律从本机已安装的 pi-web-access v0.29.0 源码核实,不照设计文档推测:
- toolNames 在配置根级,键为驼峰 webSearch/fetchContent/getSearchContent
(index.ts:173 的 WebSearchConfig.toolNames、:227-232 的 ToolNames 类型)
- 默认名 web_search/fetch_content/get_search_content(index.ts:234-239 的
DEFAULT_TOOL_NAMES)
- 值先 trim 再按 ^[A-Za-z][A-Za-z0-9_-]{0,63}$ 校验(index.ts:240 的
TOOL_NAME_PATTERN 与 :297-301),Go 侧镜像同一判据
- 配置路径优先 PI_CODING_AGENT_DIR,否则 ~/.pi/agent(utils.ts:10-26);
utils.ts 的 XDG_CONFIG_HOME 分支故意不镜像 —— Env() 会显式注入
PI_CODING_AGENT_DIR,生产路径下第一分支必然命中
- source_check 故意不解析也不授予(研究场景专用,且多一个需校验 URL 的入口),
在部署模板里由 tools.sourceCheck.enabled=false 显式关闭;它默认是**开**的
(index.ts:271-274 的 isToolEnabled)
回退行为:文件缺失、JSON 非法、toolNames 不是对象、值不合式,一律静默用默认名,
不返回 error。宽容是刻意的且与 pi 侧的严格不矛盾:pi 对这些情况抛错、扩展加载
即失败;Go 侧回退只会让白名单名字与真实注册名不符,而 --tools 是 fail-closed 的,
最坏结果是联网功能不可用,不会放行未预期的工具。
新增 backend/scripts/web-search.json.sample 部署模板(部署目标
$PI_CODING_AGENT_DIR/web-search.json),显式钉死 allowBrowserCookies:false、
ssrf.allowRanges:[]、ssrf.trustEnvProxy:false、tools.sourceCheck.enabled:false、
fetchContent.domainPolicy.{allow,deny}:[]。由 TestWebSearchSample_PinsSecurityKeys
守护,字段全用指针以区分「显式写死」与「靠默认值」——键缺席即失败。
偏离计划(已裁决,方案 A):计划与规格把该模板写在仓库根 scripts/,但
.gitignore:6 的 /scripts/ 整体忽略该目录(git ls-files 证实 tracked 的只有
backend/scripts/ 下的 SECURITY_DEPLOYMENT.md 与 path-validator.py,根
scripts/path-validator.py 是未跟踪的本地运行时副本)。放在被忽略目录里,新克隆
根本不存在该文件;Task 6 的沙箱 extension 若同样处理,pi 路径将完全没有工具调用
拦截 = 安全 fail-open。故按既有范式(tracked 源在 backend/scripts/,部署时 cp
到运行时 scripts/,见 SECURITY_DEPLOYMENT.md:90)落到 backend/scripts/。
另记一处规格与源码不符:域名策略的真实路径是 fetchContent.domainPolicy.{allow,deny}
(ssrf-protection.ts:67-85),规格表格写的 fetchContent.deny/.allow 少了
domainPolicy 一层。以源码为准;空数组等价于 DEFAULT_DOMAIN_POLICY
(ssrf-protection.ts:65),且 assertDomainPolicy(:265-273)只在 allow 非空时才做
白名单,故 [] 不限制任何域名。
Task 1 实现者从 pi-web-access v0.29.0 源码核实 schema 时发现规格有两处与
源码不符,控制方独立复核确认:
1. 域名策略少写一层:规格作 `fetchContent.deny`/`.allow`,真实路径是
`fetchContent.domainPolicy.{allow,deny}`(ssrf-protection.ts:75 的
`(fetchContent as { domainPolicy?: unknown }).domainPolicy`,错误消息
:78/:90/:94 同)。若不修正,这条会顺着 Task 10 的 README 传播成部署错配。
2. `sourceCheck` 的默认值是**开**:`isToolEnabled`(index.ts:271-274)的
`key !== "webSearch" && key !== "sourceCheck" || config.webSearch?.enabled
!== false` 按 && 优先于 || 展开后,sourceCheck 落到后半句 → 默认 true。
因此 `tools.sourceCheck.enabled=false` 不是冗余保险,而是真正关掉它的唯一
手段;`--tools` 白名单只决定「不授予调用」。已补进规格与计划(风险 R7)。
计划文档同时回写:
- Task 1 标记完成(ef6500b),并留下已核实的 schema 事实表(键名/默认值/
校验正则/配置目录优先级/domainPolicy),后续任务直接引用不必重新推导
- 闸门措辞修正:api 包的出网类用例有 TestWebClippingXArticle 与 TestFetchHTML
两个名字,同在 web_test.go,哪个失败随本机出网状况变化;并要求「怀疑是既有
环境问题时须 stash 后在基线复跑并贴逐字输出」(Task 1 正是这么做证的)
- Task 2 增两条:web 工具名一律取自 config.LoadPiWebToolNames()、agent 包内
不得再写工具名字面量;source_check 两道防线都要在位
- 记录接受的覆盖缺口:Load() 内部 flag.Parse() 使其不可二次调用,PiBin 的 env
解析无单测;正确修法(拆纯函数)列为开放项 4
Task 1(ef6500b)审查轮的 3 条 Important + 4 条 Minor。只改 config.go 与 config_test.go 两个文件,无行为扩散:LoadPiWebToolNames/PiWebSearchConfigPath 在 config 包外目前零调用者(Task 2 才接线),故本次改动不影响任何其他包。 I-1 注释把后果论证错了,且缺一条运维可见信号(config.go:152-186、:196-247) 原注释称配置写错时「扩展加载即失败、联网工具整体不可用」「最坏结果是联网 功能不可用」。真实后果严重得多:resolveToolNames 在 index.ts:1068 被调用, 位于 loadConfigForExtensionInit 的 try/catch 之外,而四个工具的注册都在其后 (:1789/:2387/:2486/:2830),抛错即一个工具都不注册;pi 记为扩展加载错误 (loader.js:483-486 → main.js:631-634),main.js:722 的 hasRuntimeErrors 为真 即在 :726-731 process.exit(1) —— 这段在所有 mode 的公共启动路径上,连不用 web 工具的 ingest 链路一起死。且 pi --version 在 main.js:483-486 提前 exit(0) 不加载扩展,所以 Task 2 的 Probe 与 Task 7 的切换探测都抓不到。 改法:①注释按「pi 吞掉 / pi 抛错」两栏重写(文件缺失、JSON 非法、根不是对象 属前者;toolNames 不是对象/为 null、值不是字符串、值不合 TOOL_NAME_PATTERN 属后者),config_test.go 的表驱动注释同步改准确;②仅在后者这一支加 log.Printf(带配置文件路径与后果说明),前者保持静默以免刷屏 —— 规格禁止的 是返回 error,不禁止日志。解析改为两级(root → toolNames),才能区分这两类; 并显式拦下 toolNames 为 null(Go 的 json.Unmarshal(null) 到 map 不报错, 而 pi 的 `!== undefined && !toolNames` 判定会抛错)。 证据等级:实测,不只是源码推证。pi 0.85.1,临时 PI_CODING_AGENT_DIR, auth.json/settings.json/npm/bin 均为指向真实目录的只读 symlink,两组唯一 差异是 web-search.json: {"allowBrowserCookies":false} → 退出码 0,stderr 为空,rpc 模式正常输出事件 {"toolNames":{"webSearch":42}} → 退出码 1,stderr: Error: Failed to load extension ".../pi-web-access/index.ts": Failed to load extension: toolNames.webSearch in ".../web-search.json" must be a string / Hint: Start without extensions using "pi -ne". 对照组退出码 0 排除了「因缺凭据/模型而失败」的混淆。真实 ~/.pi/agent 未被写入。 新增 TestLoadPiWebToolNames_WarnsOnlyWhenPiWouldRejectConfig(11 个子用例)把 「哪一类该打日志」从注释里的承诺变成可执行断言。 I-2 把「XDG 分支不可达」写成既成事实(config.go:108-140) 改为条件句,并显式点名不变式 I1(Task 2 的书面约束):每次 pi spawn,Env() 注入的 PI_CODING_AGENT_DIR 必须逐字等于 filepath.Dir(PiWebSearchConfigPath()) 在 Go 进程内解析出的目录,且必须是「派生」而非重算,并非空、绝对、不含 ~ (utils.ts:13-14 不做 tilde 展开,而 pi 的 getAgentDir() 会做,config.js:408-409 与 :422-425)。同时记下只镜像第 1、4 级是有意的:那两级 XDG/legacy 回退是 pi-web-access 独有的怪癖,pi 本体的 getAgentDir()(config.js:421-427)没有 XDG 回退。并注明 I1 尚未实现前,PI_CODING_AGENT_DIR 未设而 XDG_CONFIG_HOME 已设 时两边会读到不同文件。 I-3 home 取不到时回退 "." 造成 fail-open(config.go:141-150、:195-200) 原先 home 解析失败会返回相对路径 .pi/agent/web-search.json,而 LoadPiWebToolNames 真的会去 ReadFile 它,即读服务进程的 CWD。CWD 在部署里 常可写(systemd DynamicUser、容器),植入一份 web-search.json 即可改写工具名, 让 --tools 白名单与沙箱 extension 两道 source_check 防线一起失效。 改法:PiWebSearchConfigPath 在 home 解析失败时返回 "",LoadPiWebToolNames 开头显式判空后直接用默认名(不依赖 os.ReadFile("") 报错)。注释说明为何不 沿用 Load()(:36-40)的 home 回退 —— 那里 home 用于算 dataDir(写入目标)。 新增 TestLoadPiWebToolNames_UnresolvablePathIgnoresCWD:用 t.Chdir 把 CWD 换成 临时目录并在其中放一份把三个工具名全改为 source_check 的诱饵配置,断言它 不会被读取;并给 TestPiWebSearchConfigPath 补「非空时必须绝对」与「home 取 不到时为空串」两个子用例。 M-3 strings.TrimSpace 与 JS trim() 的字符集差异(config.go:230-234) 注释补半句:Go 剥 U+0085(NEL) 不剥 U+FEFF(BOM),JS 相反;两个方向的后果都 只是名字与 pi 实际注册名不符,而 --tools 是 fail-closed 的,不构成安全问题, 故不引入逐字符对齐。未改代码。 M-4 名不副实的子测试(config_test.go:255-262、:264-280) 原用例「source_check 的改名不被采纳」的断言恒真(结构体只有三个带 tag 的 字段,任何未识别键都得到同样结果)。改为:用例名改成能反映实际断言的措辞, 并在表驱动循环里对每个用例追加检查 —— 任何输入下 Names() 都不得含 source_check(它在 pi-web-access 里默认是开的,index.ts:271-274,一旦混进 --tools 与 PI_WEB_TOOLS 就等于多开一个需校验 URL 的入口)。 M-5 覆盖缺口 ① 正则唯一的量词 {0,63} 补边界:TestPiToolNamePattern_LengthBoundary 断言 64 字符名被采纳(含端到端进入 Names())、65 字符被拒,并在表里加一行 65 字符用例;② TestLoadPiWebToolNames_ToolNamesOverride 补改名后 Names() 的顺序断言(原先只断言默认序);③ 补 {"toolNames":null}、toolNames 为 字符串、根是数组、根是标量四条。 M-6 断言与消息不一致(config_test.go:415-421) 消息说「allow 与 deny 都必须显式写出为空数组」,断言却只查 != nil。按要求 改消息不改断言:收紧会让将来往模板里填真实域名收紧策略的人莫名变红,而 ssrf-protection.ts:65 的 DEFAULT_DOMAIN_POLICY 与 :265-273 的 assertDomainPolicy(仅在 allow 非空时才做白名单)证明 allow 为空与缺席等价。 明确未做(已裁定延后):M-2(Names() 不去重 / 未镜像 pi 的重名检查)留给 Task 2, 因为那里才知道 PI_WEB_TOOLS 怎么拼;M-7(LoadPiWebToolNames 每次调用都重读文件) 属 Task 2 的设计点,应在 NewPiProtocol 里解析一次并持有。 顺带上报一个既有偶发失败(未修,不在本任务可改文件范围内): go test ./... 首轮出现 api.TestDocChat_PersistsChatSessionIDOnInit 失败,耗时 3.02s。机制是既有测试的硬编码时序预算 —— writeFakeClaude(api/docchat_test.go:17-29) 生成的假 claude 脚本 printf init 事件后 sleep 5,而测试等 onRealSessionID 回调 的预算是 time.After(3 * time.Second)(:71-78),并行跑多包时 spawn /bin/sh + goroutine 调度即可超时。定性依据:①该用例单独跑与包内 -v 跑均 PASS,连跑 10 次 ok;②带本次改动连跑 3 轮全量均未复现;③stash 掉本次改动的基线也未复现; ④LoadPiWebToolNames/PiWebSearchConfigPath/PiWebToolNames 在 config 包外零调用者, 不存在因果路径。建议把该用例的 3s 预算放宽或改由 Task 11 的稳定性清单跟踪。
Task 1 经审查(0 Critical / 3 Important / 7 Minor)与修复轮(9a873b7)收口, 把过程中产生的、后续任务必须遵守的结论写进计划: - 全局约束新增**不变式 I1**:pi spawn 时 Env() 注入的 PI_CODING_AGENT_DIR 必须 由 filepath.Dir(config.PiWebSearchConfigPath()) 派生(不得重算/硬编码),且非空、 绝对、不含 ~。这是消除「Go 读的配置文件 ≠ pi 子进程读的配置文件」漂移的唯一 书面约束;此前 config.go 的注释把它当既成事实,而提交里没有任何东西保证它 - 记录 I-1 的**实测**结论(对照组退出 0 / 实验组退出 1 及 stderr 原文)与完整 源码链,并据此给 Task 10 加运维警示、给风险登记加 R8:toolNames 写错会让整个 pi 后端 exit 1,而切换探测跑 pi --version 不加载扩展、探测不到 - Task 2 增 4 条检查项:落实 I1 及其守护测试、拒绝相对路径的 PI_CODING_AGENT_DIR (修复轮新发现,与 I-3 同类)、工具名在 NewPiProtocol 里解析一次(M-7)、 决定 Names() 去重(M-2) - Task 11 记一条既有脆弱用例 api.TestDocChat_PersistsChatSessionIDOnInit(假 claude sleep 5 而测试预算 3s,多包并行时偶发超时)。它恰好是 D1「把 pi 的 get_state 响应归一化成 system/init」最直接的回归护栏,顺手放宽预算到 8s
新增 backend/scripts/pi-path-validator.ts —— path-validator.py(Claude CLI 的
PreToolUse hook)在 pi 侧的对应物,并把它接进既有的跨语言防漂移测试。
## hook 入参与 block 返回形状的核实依据
规格给的是伪代码,以下均从 pi 0.85.1 的 docs 与 dist 核实:
- 入参读得到:`event.toolName` 与 `event.input`(可变),docs/extensions.md:798-806;
类型定义 dist/core/extensions/types.d.ts:678-724。六个内置文件工具的 input 各有
精确类型,但**路径字段都叫 path**(core/tools/{read,grep,find,ls,write,edit}.d.ts
的 schema:read/write/edit 必填,grep/find/ls 可选);自定义工具走 CustomToolCallEvent,
input 是 Record<string, unknown>(types.d.ts:717-720),fetch_content 属这一支。
- block 形状:`{ block?: boolean; reason?: string; terminate?: boolean }`,
types.d.ts:818-828,与规格的 `{ block: true, reason }` 一致;docs/extensions.md:792 同。
- handler 抛错即 block(fail-safe),docs/extensions.md:2925。这条决定了 realpath
失败不能冒泡,见下。
- ctx.cwd 存在:types.d.ts:217。pi 内部用 resolveToCwd(searchDir || ".", ctx?.cwd || cwd)
解析相对路径(core/tools/find.js:65、grep.js:57),故 hook 用它而非 process.cwd()。
## 敏感路径正则与 Python 版的逐条对应
44 条,与 path-validator.py 的 SENSITIVE_PATH_PATTERNS **逐字节相同、顺序也相同**
(Linux 系统 10 条 / macOS 软链目标 7 条 / Linux 用户凭据 13 条 / macOS 用户凭据 14 条,
含 Library/Keychains)。TS 侧用 String.raw`...` 书写:普通字符串会把未知转义的反斜杠
吃掉('\.' === '.'),正则语义就与 Python 不再一致;String.raw 让反引号内文本与 r'...'
逐字节相同,同步测试因此可直接比源码文本、无需反转义。
TestDangerousToolsCrossLanguageSync 由两语言扩为三语言,除正则表外还校验:Go 的每个
危险工具必须在 TS 的 DENIED_TOOL_MAPPING(有 pi 对应物:Bash→bash)或
DENIED_TOOLS_WITHOUT_PI_COUNTERPART(Task/NotebookEdit/KillShell/BashOutput/
SlashCommand,pi 内置工具里没有等价物)之中表态;TS 的 ALWAYS_DENIED_TOOLS 必须恰好
等于「映射值 ∪ PI_ONLY_DENIED_TOOLS」;且与 FILE_TOOLS 不相交。
已做变异检验证明该测试有牙:删一条 TS 正则、往拒绝集合注入 read、改一条 Python 正则,
三种漂移均被捕获(后一种双向报错),还原后复绿。
## URL 校验堵的是哪条向量
pi-web-access 的 fetch_content 支持"local videos",其实现里
video-extract.ts:337 是 readFile(info.absolutePath)(读到的内容紧接着在 :338-341 被
PUT 上传到 Gemini,即任意绝对路径读取 + 外泄),:211-214 是
execFileSync("ffmpeg", [..., "-i", filePath, ...])(任意路径 spawn 进程)。--tools
白名单拦不住:白名单只决定工具能不能被调,不校验参数;pi-web-access 也没有"只关本地
视频"的开关。故在 hook 里只允许 http:/https:,拒绝本地路径与其余一切 scheme。
**按入参键名判断,不按工具名判断**:fetch_content / get_search_content 的名字都可被
web-search.json 的 toolNames 改写(pi-web-access/index.ts:234-239),硬编码名字会在
运维改名后静默失效。顺带覆盖 get_search_content 的 url(index.ts:2840)—— 它的
execute 是从已存缓存取(index.ts:2851-2864)、不发起抓取,因此不构成第二条文件向量,
但那是第三方实现细节,统一校验成本近乎零。
刻意不做 IP/DNS 级 SSRF 校验(那是 pi-web-access/ssrf-protection.ts 的职责,规格已
论证它更严),故 http://127.0.0.1/ 在本层放行、由它去拦,测试里对此有显式用例与注释。
## 其他
- 相对路径以 ctx.cwd 为基准(= pi 的实际行为),而非 Python 版的 ALLOWED_DIR;两者在
生产布局(cwd == ALLOWED_DIR)下等价,不一致时以 cwd 为准才不会放行 pi 真会访问的路径。
- realpath 逐级回退到最近的存在祖先:write 新建文件时路径必然不存在,Node 的
realpathSync 会抛 ENOENT,冒泡则按 fail-safe 语义把所有新建文件的写操作一并拦掉。
- ALLOWED_DIR 未配置时拒绝一切(含联网工具),与 Python 版 main() 在分派前就 deny 一致。
- 文件末尾的 CLI 入口仅在被直接执行时生效(pi 以模块 import 时 argv[1] 是 pi 自己的
入口),使 Go 测试能用与 Python 版完全相同的手法驱动:stdin 喂 JSON、exit 2 = deny。
- 产物已复制到运行时 scripts/(未被 git 跟踪,属部署产物),供 Task 11 的集成测试使用;
SECURITY_DEPLOYMENT.md 的目录树、cp 步骤、env 表与 Dockerfile COPY 一并补上该文件
(计划 Task 6 要求)。
测试:新增 TestPiPathValidator_PathBoundary(17 例)、TestPiPathValidator_FetchContentURL
(19 例);既有 TestPathValidator_WebFetchSSRF 的 11 例未受影响。node 不在 PATH 时 t.Skip。
类型检查用仓库已有的 frontend/node_modules/.bin/tsc(6.0.3)+ @types/node(24.12.2),
未联网、未改 frontend/package.json。
Task 6(pi 沙箱 extension)已完成 acadd70,控制方独立复核通过:44 条敏感路径 正则 Python↔TS 逐条同序相同(程序化比对)、pi hook API 引用属实 (docs/extensions.md:798-806 的 event.input 可读可改、types.d.ts:818-828 的 {block?,reason?,terminate?})、tsc 退出码 0、变异检验证明同步测试有牙。 回写内容: - 修正 Task 6 的类型检查闸门为实测可用的命令。原计划的 `npx tsc --noEmit scripts/pi-path-validator.ts` 有四个问题:路径已改、npx 会 联网下载、TS 6.0 起在有 tsconfig.json 的 cwd 下指定文件会报 TS5112、 缺 --types node 会出 15 个 TS2591 并级联一个假的 TS2534 - 记下已核实的 pi 侧事实表(六个文件工具的路径字段都叫 path;find/grep 的 pattern 不需校验;tool_call 抛错即 block;fetch_content 入参 url+urls[]; ctx.cwd 可用于解析相对路径),供 Task 2/11 直接引用 - 记下比规格更严重的一条:video-extract.ts:337 读到的内容紧接 :338-341 会 PUT 上传到 Gemini,即「任意绝对路径读取 + 外泄」,不只是读取 - 记下 4 处偏离(URL 校验按入参键名而非工具名、ALLOWED_DIR 缺失时拒绝一切、 相对路径以 ctx.cwd 为基准、不 import ExtensionAPI 类型)及其理由 - Task 11 补一条硬要求:集成测试必须证明 extension 确实被加载 —— 一个静默 未加载的沙箱与一个正常工作的沙箱,在「没有越界访问发生」时看起来完全一样
带入 89c3862(PR #92 审查修复):StreamEvent.Delta 的 json tag 改为 "-"、 新增 TestParseLine_EmptyInputJSONDeltaProducesNoDelta、Plan 1 文档的 OnceArgs 签名订正。与 Plan 2 已完成的 Task 1/6 无文件交集,merge-tree 试算无冲突。 其中空 input_json_delta 守卫对 Plan 2 Task 4 有直接约束:Process 对空 ToolInput 不再二次守卫,故 PiProtocol.ParseLine 处理 toolcall_delta 时同样必须对空 delta 产出「无 Delta」,否则会发出携带上一条累积输入的重复 tool_input 事件。
同步 main 后带入的 89c3862 有两处直接约束 Plan 2: 1. Process(claude/stream.go:159-168)对空 ToolInput 不再二次守卫,claude 侧 claude_parse.go:106 的空 partial_json 守卫是 Plan 1 重构孤立出来的唯一防线, 且此前无测试覆盖(89c3862 才补上,并用变异验证判别力)。pi 侧的 toolcall_delta → DeltaToolInput 是同一个坑:空 delta 放行就会发出携带上一条累积输入的重复 tool_input 事件。已写进 Task 4 的清单并要求配对应用例。 2. StreamEvent.Delta 的 json tag 改为 "-",即它是内部归一化增量、绝不进 wire。 api/translate.go 是「直接 marshal StreamEvent」的既有先例,故把它提升为 Plan 2 的全局约束,防止新增路径把内部字段泄露进 SSE/JSON。
现象:`TestClaudeEncodeInterrupt_RequestIDsAreUnique` 实测 5 次挂 2 次
(报 duplicate request_id),而 `backend/agent` 与 origin/main 逐字相同
(`git diff origin/main HEAD -- backend/agent/` 为空),故属既有缺陷、
非本分支引入。
根因:`EncodeInterrupt` 用 `fmt.Sprintf("%d", time.Now().UnixNano())` 当
request_id。`UnixNano()` 剥掉单调读数只取墙钟,且本机实测粒度约 1µs
(观测到的值尾数恒为 000),50 次紧循环内相邻调用会落到同一时刻。
修法:叠加包级 `atomic.Uint64` 序号。墙钟非递减 + 序号严格递增 ⇒ 二者之和
严格递增,唯一性由构造保证而非依赖时钟分辨率。数值仍是同量级的纳秒时间戳
(实测 `1789276674660292001`)、仍是纯数字字符串,wire 形状不变。
选它而不是「计数器单独成值」或「时间戳-序号」拼接,是为了让 request_id 继续
可读作时间戳(排障时有用),把对既有行为的可观测改动压到最小。
验证:`go test ./agent/ -run TestClaudeEncodeInterrupt -count=200`
(= 10000 个 request_id)零碰撞;`go build ./... && go vet ./...` 通过。
注:request_id 在本仓是只写字段,全仓无任何解析/配对逻辑(grep 零命中),
故重复不产生错误行为 —— 修它是为了让 Plan 2 的 Task 2~5 能以
`go test ./...` 全绿作为可判定的闸门。
承 Plan 2 计划 Task 11 的既定项(「本任务顺手把预算放宽到 8s」),提前到此处 执行:Task 2~5 的收尾闸门都是 `go test ./...` 全绿,这个随机红会让实现者的 结果无法判定。 现象:`go test ./...` 多包并行时该用例以 3.01s 失败(撞满 3s 预算), 单独跑 5/5 PASS 且仅 0.52s。计划 Task 11 已记录同一现象,并指出它 「恰好是 D1『把 pi 的 get_state 响应归一化成 system/init』最直接的回归护栏」, 即 Task 5 施工期间必须保持它绿 —— 一个会随机红的护栏等于没有护栏。 根因不在被测逻辑:`writeFakeClaude` 的桩脚本 `printf` init 事件后 `sleep 5`, 回调实际在毫秒级触发;3s 预算吞不下并行负载下 spawn `/bin/sh` + 调度的开销。 放宽到 8s 对最坏观测值(3.01s)留 2.6x 余量,且 `defer session.Close()` 与桩的 5s 生命周期不变,真正回归时仍会失败(只是慢 5s),不会变成假绿。 验证:连续两遍 `go test ./...` 全绿。 已知遗留(本次未动,计划也未点名):同文件 `TestDocChat_ResumeFailureClearsCachedID` (:236)仍是 `time.After(3 * time.Second)`,`:125`/`:175`/`:367` 三处 `deadline := time.Now().Add(3 * time.Second)` 是同类轮询预算。均未观测到 flake, 按最小改动原则不一并放宽;若后续在 Task 5/11 期间偶发红,应先怀疑这几处。
Plan 2 Task 2。交付 backend/agent/pi_args.go(新)、pi_args_test.go(新,
21 个用例)、probe_test.go(新,4 个用例);并按计划 D1/D3 改 Protocol 接口。
== 接口变更(两处,均为计划已论证的决策点)==
1. 新增 InitCommands() [][]byte(D1)。ClaudeProtocol 返回 nil;PiProtocol 返回
`{"id":"init-1","type":"get_state"}`(自带结尾换行,与 Encode* 约定一致)。
ParseLine 的归一化在 Task 4 落地,届时上层既有的 waitForInit / onSessionID
别名注册 / local-<UnixNano> fallback / onResumeFailed 降级链一行都不用改。
2. OnceArgs 增 model hint 形参(D3)。ClaudeProtocol 在 model != "" 时追加
--model <model>,空串保持现状;PiProtocol 一律忽略(规格决策 3)。
连带把 client.go:51/:161 与 3 处既有单测的调用点补上 "",两者都传空串 ——
--model sonnet 只属于 documents.go 的 PDF 逐页转换那一处(Task 8 处理),
下沉进 OnceArgs 会把摘要/分节/翻译一并改成 sonnet,是实打实的成本变化。
claude 侧旗标集合与语义不变,只有顺序变化(--model 从 secureArgs 之前移到之后)。
PiProtocol 尚未满足 Protocol 接口(缺 Encode*/ParseLine,属 Task 3/4),故本提交
**不写** `var _ Protocol = (*PiProtocol)(nil)`,该断言由 Task 4 补上。
== D4:--session-dir 改由 env 注入(对规格的一处新偏离,已核实源码)==
规格写的是 `--session-dir <userDir>/.pi-sessions`,但 Plan 1 冻结的
SessionArgs(sysPrompt, tools) 签名里没有 userDir。照字面实现只有两条路,都更差:
①传相对值 .pi-sessions —— 能工作,但把正确性挂在「子进程 cwd 恰好等于 userDir」
这个隐式耦合上;②给三个 *Args 加 userDir 形参 —— 波及 ClaudeProtocol 与全部
调用点,直接违反「claude 路径行为不变」。
改走 env:pi 的优先级是 旗标 > PI_CODING_AGENT_SESSION_DIR > settings,不传旗标
即由本 env 生效,且它压过 settings.json 里可能被运维设过的 sessionDir。
Env(allowedDir) 本就拿到 userDir 且已做 EvalSymlinks,于是会话目录与 ALLOWED_DIR
同源同 realpath,不可能漂移。
核实依据(pi 0.85.1,与规格实测同版本):
- dist/main.js:531-534 三级优先级,`expandTildePath(envSessionDir)`
- dist/cli/args.js:88-89 `--session-dir` 逐字取值,不做任何解析
- dist/core/session-manager.js:600 `normalizePath(sessionDir)`,而
dist/utils/paths.js:58-80 的 normalizePath 只 trim/展开 ~/Windows 规范化,
**不绝对化** —— 故旗标与 env 两条路都不会把相对值变绝对
- `grep -rn "process.chdir" dist/` 零命中:pi 从不改 cwd
== 其余对 pi 0.85.1 的源码核实(Task 3/4 可直接引用)==
| 事实 | 依据 |
|---|---|
| --mode rpc→rpc、json→json;否则管道 stdin/stdout 即 print | main.js:80-91 resolveAppMode |
| rpc 模式**不读**管道 stdin(留作 JSON-RPC);json/print 会读并**前置**拼进初始 prompt | main.js:701-708、cli/initial-message.js:6-18 |
| `-p` 会贪婪吞掉紧随其后那个不以 "-" 开头的实参当 message | cli/args.js:172-176 |
| 未知 `--xxx` 旗标被收进 unknownFlags 静默忽略;未知单横线选项才报错 | cli/args.js(parseArgs 尾部) |
| 工具名重名 → resolveToolNames 抛错(仅限**已启用**的键)→ pi exit 1 | pi-web-access/index.ts:303-311 |
由第 2、3 条得出两条实现约束:once-call 的 prompt 绝不能进 argv(否则与 stdin
内容被拼接),且 `-p` 必须放末尾(让误吞在结构上不可能)—— 都有用例钉住。
== 安全相关的三个把关 ==
1. fail-closed 沙箱前置校验:照抄 claude/security.go:131-133 的先例,三个 *Args
在拼 `-e` 之前 os.Stat,缺失即返回错误,绝不产出一条没有工具调用拦截的 argv
(风险登记 R6)。用例覆盖「目录存在但文件缺失」「scriptsDir 为空串」「目录不存在」。
2. `-e` 的值**必须绝对**:Go 的 Stat 以服务进程 CWD 解析相对路径,而 pi 子进程以
cwd(= userDir)解析 —— 基准不同就会出现「Stat 通过但 pi 加载不到」,即一条
完全没有拦截的 pi 路径。有用例专门堵这条。
3. 落实 I1:PI_CODING_AGENT_DIR 由 `filepath.Dir(config.PiWebSearchConfigPath())`
**派生**而非重算,并新增两条把关(Task 1 修复轮要求):含 ~ 时不注入(pi 的
getAgentDir 会 expandTildePath 而 pi-web-access 的 utils.ts:13-14 不会,注入
会让 auth 目录与配置目录分家);相对路径按服务进程 CWD 绝对化(与 Go 读
web-search.json 的基准同一个)。三种「返回空即不注入」的情形都先剔除继承来的
坏值,不靠「后写覆盖先写」这种实现细节。
I1 守护测试的关键断言不是「等于同一公式」(同义反复),而是把注入值与
web-search.json 拼回去必须逐字得到 Go 自己读的那个路径。
== M-2 处置:web 工具名按首次出现去重 + 告警 ==
config 的 Names() 不去重,而重名在 pi 侧是致命的(resolveToolNames 抛错 →
扩展加载失败 → exit 1,且 `pi --version` 探测不到)。Task 1 的 warnInvalid 只覆盖
单键非法、不覆盖跨键重名,这条路径此前是静默的。PiProtocol 在构造时(M-7:解析
一次并持有)去重并补上告警。source_check 永不授予,且即使运维把它改名成已授予
工具的同名值也断言不出现 —— 它在 pi-web-access 里默认是启用的,白名单只是两道
防线之一。
== 验证 ==
- 变异检验 5 处,全部被抓:去掉 -na;OnceArgs 误授 web 工具;I1 改成重读 env
(报出「子进程会读 backend/agent/web-search.json 而 Go 读 ~/.pi/agent/
web-search.json」);去掉 requireSandbox;去掉重名去重。
- 闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包全 ok,
本次连计划列举的环境性失败都没出现)。
- 测试密封:每个用例都用 t.Setenv 把 PI_CODING_AGENT_DIR 钉到空临时目录,否则
会读开发机/CI 上真实的 ~/.pi/agent/web-search.json,一旦运维改过 toolNames,
所有关于工具名的断言都随环境漂移。
Plan 2 Task 3。交付 backend/agent/pi_encode.go 与 pi_encode_test.go(6 个用例)。
wire 形状对 pi 0.85.1 的 docs/rpc.md 逐字核实(:43-58 prompt、:78 images、
:124-134 abort),不是凭规格描述推测:
{"id":"msg-1","type":"prompt","message":"..."}
{"id":"msg-1","type":"prompt","message":"...","images":[{"type":"image","data":"<b64>","mimeType":"image/png"}]}
{"type":"abort"}
与 Claude 的三处结构差异(所以不能照搬 ClaudeProtocol.EncodeUserMessage):
1. pi 的 message 恒为**字符串**,图片走**兄弟字段** images;Claude 的 message.content
是 string 或 content-block 数组,图片塞进数组里
2. pi 的键是 mimeType(驼峰)且无 source 包装;Claude 是 source.media_type(蛇形)
3. abort 只有一个键、无 request_id;Claude 是 control_request + request_id +
request.subtype。按文档原样,不加推测性字段
prompt 的 id 用包级 atomic 计数器而不是 time.Now().UnixNano():后者在紧循环下会撞
(正是本分支前一个提交 061e7dd 修的那个坑),而 pi 会回显 id 用于关联 response。
ParseLine 靠 response 里的 command 字段区分 get_state 与 prompt、不依赖 id,但用例
仍然钉住「prompt 的 id 绝不与 InitCommands 的 get_state id 撞上」—— 混淆两者会
直接坏掉会话 ID 捕获,或把 prompt 的 response 误当完成信号而提前发 done。
另核实并记进注释:rpc 模式下 pi **不读**管道 stdin 当 prompt(main.js:701-703
显式跳过,stdin 留作 JSON-RPC),所以会话路径每一轮都必须走本函数编码;这与
once-call 走 stdin 传 prompt(Task 2 的 D2)是两条不同的路。abort 之后队列里若还有
消息 pi 会继续处理(rpc.md:158),但本项目从不使用 steer/follow_up 排队,故不发
clear_queue。
== 变异检验 ==
4 处变异被抓:图片改用 Claude 的 source/media_type 形状;prompt 丢掉结尾换行;
abort 改成 Claude 的 control_request;id 变常量或与 init-1 撞名。
一处**没抓到**且已如实写进注释,不留假论证:id 改回 UnixNano 后连跑 5 次全绿。
原因是时序相关 —— 本用例的循环体(含多字节文本的 json.Marshal)每次 >1µs,在本机
约 1µs 的时钟粒度下反而不撞;我先按「2000 次必撞(生日悖论)」把迭代数提到 2000,
实测推翻后已改回 200 并重写注释:唯一性由构造(计数器)保证,不依赖本用例。
闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包全 ok)。
Task 3 为核实 prompt 的 wire 形状而读 docs/rpc.md 时,发现一条**规格与实现计划都
未覆盖**的注入面:rpc 模式下以 `/` 开头的用户消息会被 pi 当成扩展命令派发执行。
对 pi 0.85.1 的源码链路:
dist/modes/rpc/rpc-mode.js:301-304 session.prompt(command.message, {...})
—— **没有**传 expandPromptTemplates
dist/core/agent-session.js:822 const expandPromptTemplates = options?.expandPromptTemplates ?? true
:828-834 if (expandPromptTemplates && text.startsWith("/"))
_tryExecuteExtensionCommand(text)
:954-961 getCommand(name) 命中即执行
docs/rpc.md:66 也明写扩展命令 "executes immediately even during streaming...
manage their own LLM interaction via pi.sendMessage()"。
四道既有防线**全拦不住**它:
- `--tools` 白名单只管工具调用,而扩展命令是扩展自己的 JS 代码
- `--no-skills` / `--no-prompt-templates` 只关 skill 与模板,管不到扩展命令
- 沙箱 extension 的 `input` hook 在 agent-session.js:839-851,位于 :828 的命令
派发**之后**,无法否决
- `-na` 只忽略项目本地文件,与命令派发无关
已实测的可利用面:pi-web-access 自己就注册了 4 个命令(index.ts:3164 websearch、
:3427 curator、:3469 google-account、:3517 search),本机装的 pi-subagents 也注册。
其中 `/curator` 会拉起浏览器(规格提到全局 npm 目录里有 playwright-core/
patchright-core/betterwright 供 curator 用),是明显的能力升级;而且扩展命令自行
驱动 LLM,绕过我们注入的 --system-prompt(文档上下文)。
**这不是额外收紧,而是追平两个后端的安全强度**:Claude 侧的对应物是 SlashCommand
工具,它早就在 ClaudeDangerousDisallowedTools 里被硬阻断(且由
TestDangerousToolsCrossLanguageSync 与 path-validator.py 双向守护)。pi 路径此前
有一个敞开的等价物。
修法:部署模板显式关掉这 4 个命令。isCommandEnabled(index.ts:277-279)是
`config.commands?.[name]?.enabled !== false`,即**默认是开的**,必须显式关。
守护测试扩进既有的 TestWebSearchSample_PinsSecurityKeys(而不是新开一个重叠用例):
用遍历而非四条并列断言,于是「模板里多出一个未知的 enabled:true 命令」也会变红 ——
升级 pi-web-access 后它若新增命令,默认就是开的。变异检验 4 处全部被抓:模板缺
curator;curator.enabled 改成 true;多出未知的 newcmd;整个 commands 段缺失。
残留风险(已登记为计划 R9):其他已加载包的命令不由本模板覆盖,只能靠 R1 的运维
隔离(生产用独立 PI_CODING_AGENT_DIR,其 settings.json 的 packages 只含 pin 过的
pi-web-access)。
故意**不**在 EncodeUserMessage 里改写以 `/` 开头的用户文本:那会污染 LLM 输入与
DB 里的历史消息,且与 claude 后端的「/ 就是普通文本」不一致 —— 两个后端对同一条
用户消息的行为必须等价。
闸门:go build ./... && go vet ./... 通过;config/agent/claude 三包 go test 全绿。
另:顺手发现 backend/config/config.go 缺行尾换行(gofmt -l 会标记),属 Task 1
提交带进来的既有问题、与本次修复无关,故未改 —— 本仓 claude/ 与 api/ 下另有 19 个
文件同样未通过 gofmt,说明 gofmt 并非本仓闸门,单独补这一个字节只是 diff 噪音。
配合 fbe6e45。三处回写: 1. 风险登记新增 R9:rpc 模式下以 `/` 开头的用户消息会被 pi 当扩展命令派发执行。 记下完整源码链路(rpc-mode.js:301-304 未传 expandPromptTemplates → agent-session.js:822 默认 true → :828-834 派发 → :954-961 执行)、四道既有 防线为何全拦不住、已实测的可利用面(pi-web-access 自己注册 4 个命令, /curator 会拉起浏览器且绕过 --system-prompt),以及「Claude 侧对应物 SlashCommand 早已硬阻断,故这是追平安全强度而非额外收紧」这条定性。 残留部分(其他已加载包的命令)明确归给 R1 的运维隔离。 2. Task 10 增第四条运维警示:部署文档必须写清 commands.* 要显式关,以及四道 防线各自失效的原因 —— 否则运维看到 `--tools` 白名单会误以为已经收口。 3. Task 11 增验证项:用部署模板 spawn 真实 pi,发 `/curator hello` 与 `/search foo` 断言命令未执行;**并要求对照组**(临时把 enabled 改成 true, 断言命令确实会执行)。没有对照组就无法区分「被关掉了」与「本来就没触发」, 那正是 Task 6 对沙箱 extension 提出的同一条要求。 4. 回写 Task 11 的 3s→8s 预算项已提前完成(a815e62),并记下提前做的理由 (Task 2~5 的闸门都是 go test ./... 全绿,随机红的护栏等于没有护栏)与 同文件仍未动的 4 处同类预算(:236、:125、:175、:367),供后续偶发红时排查。
按本仓惯例回写,使计划文档对下一个执行者/审查者仍然是单一事实来源。 1. 决策点新增 **D4**:`--session-dir` 改由 env 注入。此前该偏离只记在代码注释与 b54a735 的提交信息里,而「决策点」一节才是审查者会去看的地方 —— 计划开头明写 「两者冲突时以规格为准,但本计划『决策点』一节记录的偏离已经过论证」,不补进 这里就等于让下一位执行者以为规格那条 `--session-dir <userDir>/.pi-sessions` 还没实现。记下三条核实依据(args.js:88-89 逐字取值、session-manager.js:600 + utils/paths.js:58-80 的 normalizePath 不绝对化、`grep process.chdir` 零命中), 以及为何 env 方案比两个替代方案都强:它压过 settings.json 里可能被运维设过的 sessionDir,且与 ALLOWED_DIR 同源同 realpath(相对值方案不经 realpath,macOS 上 /tmp 与 /private/tmp 会让两者分家)。 2. Task 2 ✅ 记录:交付清单、5 张已核实的 pi 事实表(--mode 非法值被静默忽略、 rpc 不读管道 stdin、-p 贪婪吞下一个实参、未知 --xxx 被静默忽略、重名 → exit 1)、 由其中两条推出的实现约束(prompt 绝不进 argv、-p 必须放末尾)、M-2 处置、 三个安全把关、5 处变异检验结果、测试密封的理由,以及**两处接受的缺口** (Probe 的 5s 超时未测;ctx 已取消分支未测 —— 我最初写了该用例,发现选的 二进制会立刻退出、证明不了取消语义,遂删除而不是留一个没有牙的假护栏)。 3. Task 3 ✅ 记录:与 Claude 的三处结构差异、id 用计数器而非 UnixNano 的理由、 4 处变异被抓,以及**一处没抓到并如实写明**(id 改回 UnixNano 连跑 5 次全绿; 我最初按「2000 次必撞(生日悖论)」提高迭代数,被实测推翻后改回 200 并重写 注释)—— 不把已被推翻的论证留在文档里。 4. 新增⚠️ 小节记 R9 的发现经过与定性(追平 Claude 侧早已硬阻断 SlashCommand 的 安全强度,不是额外收紧),并把「是否在 Go 侧启动时对 commands 仍开着告警」 显式列为**尚未决定的开放项** —— 不做则运维漏配时是静默 fail-open。
R9 开放项按选定方案 (b) 落地:只告警,不阻止启动。 == 为什么需要这个检测 == fbe6e45 把四个扩展命令写进了部署模板,但那份模板要由运维复制到 $PI_CODING_AGENT_DIR/web-search.json 才生效 —— 文件在运维手里、不在仓库里。 漏配时(例如运维那个文件里已经放了搜索 API key,合并时漏掉 commands 段): 服务照常启动、文档问答照常工作、零信号,直到某天有人输入 /curator。 这就是静默 fail-open,而本提交把它变成服务日志里的一行。 == 实现 == config.PiWebCommandsEnabled() 返回仍然启用的命令名,判定逐条镜像 isCommandEnabled(index.ts:277-279)的 `config.commands?.[name]?.enabled !== false`。 关键在于那些「看上去像关了、其实没关」的情形都必须算开着,否则告警就是假阴性、 等于没有:文件不存在、JSON 非法、根不是对象、commands 缺席、commands 为 null/非对象、命令键缺席、entry 不是对象、enabled 缺席、enabled 为 true, 以及 **enabled 是字符串 "false"**(JS 的 `!== false` 对字符串为真 —— 这是运维 把 JSON 当 YAML 写时最容易犯的错)。只有显式布尔 false 才算关。 告警由 agent 包在 NewPiProtocol 时发出,消息里带上:配置路径、仍开着的命令名 (带斜杠,与用户实际输入一致)、后果(直接执行扩展代码而不进 LLM、/curator 会拉 浏览器、绕过我们注入的 --system-prompt)、修法(写成 {"enabled": false} 并指向 仓库里的 sample),以及**本告警的覆盖边界** —— 它只管 pi-web-access 自己的命令, 其他已加载包(如 pi-subagents 的 /run)只能靠运维隔离。 == 一处与 Task 1 有意不同:告警去重 == Task 1 的 warnInvalid 不去重,本条去重(按「配置路径 + 仍启用的命令集合」)。 差别在触发频率:warnInvalid 只在 toolNames 非法时才响(罕见、且后果是整个 pi 后端 exit 1);而本条在**默认状态**下就会响 —— web-search.json 不存在时四个命令 全开。NewPiProtocol 由 resolver 构造、带 5s TTL,不去重就是流量期间每 5s 一行、 一天上万行,那不叫信号叫噪声,而噪声会被运维直接忽略。 代价:「修好 → 又改坏成同一样子」不会再次告警。取这个取舍是因为本条的目的是 部署时提醒一次,不是持续监控。有用例钉住(构造 3 次只响 1 次)。 == 不采用 fail-closed 的理由 == 直接报错拒绝启动会把部署脆弱性转移到可用性上:运维漏一个键就让整个 pi 后端起不 来,而这个键与 toolNames 不同,它不影响功能正确性。与 Task 1 对 toolNames 的处置 一致 —— 规格禁止的是返回 error,不禁止日志。 == 顺带修的两件事 == 1. agent 包加 TestMain 把 log 输出默认丢弃。本包几乎每个用例都要构造 PiProtocol, 而夹具用的是空临时目录(= 配置不存在 = 四个命令全开),于是每次 go test 都往 stderr 打二十多行巨型告警,淹掉真正有用的输出。需要断言日志的用例用 captureAgentLog 显式接管。 2. 我自己写错过一处断言并已修正:原本用 Contains(logged, "/curator") 判「已关闭 的命令不该出现在告警里」,但正文里另有一句解释性的「其中 /curator 会拉起浏览器」 对任何告警都在,于是断言命中的是那句解释而不是命令清单 —— 没有判别力。改为断言 清单片段本身("扩展命令 /websearch, /google-account ——")与修法片段。 == 验证 == 变异检验 4 处全部被抓:文件缺失当成全关(假阴性);enabled 为字符串 "false" 时 误判为已关(被表驱动用例与 MatchesSample **同时**抓到,证明后者不是冗余); NewPiProtocol 不再告警;去掉告警去重。 新增用例:config 的 TestPiWebCommandsEnabled(13 个子用例)+ TestPiWebCommandsEnabled_MatchesSample(把部署模板与检测器接上 —— 模板自己若 fail-open,运维照它部署完告警依旧会响);agent 的 TestNewPiProtocol_WarnsWhenWebCommandsEnabled(4 个子用例)。 闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包全 ok)。
Plan 2 Task 4(计划自标的最高风险任务)。交付 pi_parse.go(309 行)、
pi_parse_test.go(60 个表驱动子用例)、pi_constraints_test.go(9 个约束用例)。
PiProtocol 至此满足 Protocol,已补上编译期断言 var _ Protocol = (*PiProtocol)(nil)。
== 权威来源的选择:rpc.md 而不是 pi-ai 的 .d.ts ==
两者**不一致**,ParseLine 吃的是 rpc 线格式,故一律以 docs/rpc.md 为准:
1. pi-ai types.d.ts:411-455 里每个 assistantMessageEvent 都带
`partial: AssistantMessage`(累积快照),而 rpc.md:992-993 明写 rpc 线格式
"intentionally omits the former cumulative message field and
assistantMessageEvent.partial"。照 .d.ts 写会去读一个线上根本不存在的字段 ——
而这正是风险登记 R2 记载的那次破坏性变更。
2. pi-ai types.d.ts:442 的 toolcall_start 只有 contentIndex,而 rpc.md:990 的线上
示例带 id 与 toolName(rpc 层补的)。规格的实测结论与 rpc.md 一致。
按计划要求,**没有**写 `{"type":"session",...}` 头行的用例(rpc 模式不发该头行,
它只属于 --mode json);未知类型静默跳过由一条独立用例覆盖。
== 规格未覆盖、实现时从源码查出的坑:块类型必须翻译 ==
pi 的内容块叫 **"toolCall"**、入参字段叫 **arguments 且是个对象**
(pi-ai types.d.ts:256-264);而 claude 包的 ExtractToolUseFromAssistantMsg
硬编码判 block.Type == "tool_use"、ContentBlock.Input 是 json.RawMessage。
直接把 message_end.message 塞进归一化 Message 会让 pi 的工具块被**静默丢弃**。
正常流式路径下看不出来(前端已从 toolcall_start 拿到 tool_start),这个洞只在
**SSE 重连**时暴露:重连走 assistant 完整消息 + sentToolIDs 去重那条路,拿不到
工具块就永远补不回 tool_start。故 convertPiMessage 做三步翻译:
text→text、thinking→thinking(内容字段 thinking→Text)、toolCall→tool_use
(arguments 原样透传为 RawMessage,Process 用 string(Input) 填 SSE 的 toolInput,
前端拿到的仍是一段 JSON 文本)。约束 8 穿 StreamProcessor 钉住这条。
== D5:tool_execution_end 不映射为 DeltaToolEnd(对计划的一处偏离) ==
计划 Task 4 写的是「toolcall_end **或** tool_execution_end(→DeltaToolEnd)」。
只实现前者,理由两条:
1. tool_execution_end 是**顶层**事件,带的是 toolCallId 而**没有 contentIndex**
(rpc.md:1042-1050)。映射成 DeltaToolEnd 会让 Delta.Index 落到零值 0,
可能误关掉另一个正在进行的工具(规格实测:thinking 占 0、text/tool 占 1,
index 0 是真实存在的槽位)。
2. Process 的 activeTools 是 map[int]*activeTool,**只按 Index 关联**;Delta 虽然
有 ToolID 字段,但 DeltaToolEnd 分支不读它。要按 toolCallId 关联就得改
claude 包的 Process —— 那是 Task 4 的范围外,且会动到 claude 路径。
不丢功能:toolcall_end 是 assistant 消息流的一部分,工具调用完成时必然到达;
即便某轮缺席,agent_settled → result 会让 Process 重置 activeTools,不会泄漏。
tool_execution_start/update/end 三者一并加入忽略清单(update 的 partialResult 是
**累积快照**而非增量,rpc.md:1052-1054,放行会让前端重复显示)。
== 其余关键映射与守卫 ==
- agent_settled → result,**不是 agent_end**:rpc.md:893 说 agent_end 之后
"may still be followed by retry, compaction, or queued continuations"
- get_state 的成功 response → system/init(D1);sessionId 为空时退回 sessionFile
(pi 的 --session 接受路径或 UUID 前缀);两者都拿不到时**不产出** init,
让既有的 local-<UnixNano> fallback 兜住,避免空值污染 onSessionID → DB 那条链
- prompt/abort 等命令的成功 response → 跳过(规格实测它比首个 message_update
早 1.15s 到达,当完成信号会在模型开口前就发 done)
- success=false(任何命令)与 extension_error → error
- 空增量守卫两处:text_delta 与 toolcall_delta。后者是硬要求 —— Process 对空
ToolInput **没有**二次守卫,放行就会 tool.input += "" 再下发一个携带上一条
累积输入的重复 tool_input 事件(承 main 的 89c3862)
- text_end.content / thinking_* 一律忽略(规格实测约束 3:同一段文本一轮里出现
三次,放行任何一份完整内容都会与增量重复)
- 未知事件类型静默跳过而不是报错(R2:pi 有破坏性变更历史,报错会让一次 pi
升级变成整条链路失败)
- framing:只剥除行尾 \r,不按 U+2028/U+2029 切分(rpc.md 明确警告)
== 验证 ==
变异检验 **8 处全部被抓**,且每条报错都直指用户可见后果:
去掉 role 过滤 → 用户自己的提问被当助手回复推回前端(got full "请读一下这份文档");
放行 text_end.content → 前端收到 3 段 delta(文本重复);
agent_end 也驱动 done → 自动重试期间发出 3 个 done;
prompt response 当完成信号 → 模型还没开口就 done;
去掉空 toolcall_delta 守卫 → 多出一个 ToolInput 完全相同的 tool_input;
不翻译 toolCall → 工具块被静默丢弃(只剩 full 文本);
Delta.Index 恒为 0 → 工具入参被 Process 丢弃;
get_state 不归一化 → 上层拿不到真实 sessionId。
约束 2 与约束 7 各带**对照组**:约束 2 用同样形状换成 role=assistant 断言必须下发
full(否则无法区分「被 role 过滤」与「本来就不产生事件」);约束 7 用 index 错位
的 delta 断言确实被丢弃(证明 Index 是被真实使用的键,而不是填了没人看的字段)。
pi_constraints_test.go 用**外部测试包** agent_test:它要把 ParseLine 的输出穿过
claude 包的 StreamProcessor,而 claude 已 import agent,包内测试无法反向 import;
agent_test 与 agent 是两个不同的包,agent_test → claude → agent 不成环。
闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包全 ok),
含计划额外要求的 go test ./agent/... ./claude/... 逐条绿。
== 计数漂移与接受的缺口 ==
1. 计划写「规格的 5 条约束逐条一个用例」,但规格的「实测确认的 pi 专有约束」只有
**3 条**编号约束(另有一节「其他实测细节」5 个要点)。与计划自己指出的
ClaudeBin「11 处 vs 实测 10 处」同类,属文档计数漂移。实际写了 9 个约束用例:
3 条编号约束 + prompt response 时序 + agent_settled vs agent_end + 空增量守卫 +
contentIndex 一致性 + toolCall 翻译 + 错误可达前端。
2. 未做的事:没有真跑一次 pi 来重新采集事件序列。规格明写它那一列「以
2026-09-12 实测为准(pi 0.85.1 + qwen3.8-max,--mode rpc,单轮 prompt)」,而本机
正是 pi 0.85.1 + 同一模型(Task 2 的探针已确认 model.id=qwen3.8-max),故沿用
规格实测 + rpc.md 双重来源,不为此消耗配额。真实 spawn 下的端到端行为属
Task 11 的集成测试范围。
3. 实测抓到并补进忽略清单的一个计划外事件:`extension_ui_request`
(pi-subagents 在无任何请求时主动推 setWidget)。它不在计划的忽略清单里,
靠「未知类型静默跳过」落地,但已写成用例钉住真实样本,免得后人给它加分支。
配合 731c0e4。 1. 决策点新增 **D5**:`tool_execution_end` 不映射为 DeltaToolEnd。计划 Task 4 原文 写的是「toolcall_end **或** tool_execution_end」,实现时发现前者才对: tool_execution_end 是顶层事件、带 toolCallId 而**没有 contentIndex** (rpc.md:1042-1050),映射过去会让 Delta.Index 落到零值 0,而 index 0 是真实存在 的槽位(规格实测 thinking 占 0),于是可能误关掉另一个正在进行的工具;要按 toolCallId 关联就得改 claude 包的 Process(只按 Index 键控 activeTools),那是 Task 4 范围外且会动到 claude 路径。记下「不丢功能」的论证:toolcall_end 是 assistant 消息流的一部分必然到达,即便缺席,agent_settled → result 会让 Process 重置 activeTools。 2. Task 4 ✅ 记录:权威源为何取 rpc.md 而不是 pi-ai 的 .d.ts(4 条事实对照表,其中 「.d.ts 的 assistantMessageEvent 带 partial 而线格式已移除」正是风险登记 R2 记载的那次破坏性变更,照 .d.ts 写会去读一个线上不存在的字段);规格未覆盖的 toolCall → tool_use 翻译坑(只在 SSE 重连时暴露,因为重连走完整消息 + sentToolIDs 去重那条路);8 处变异检验的逐条后果;两个带对照组的约束用例 (否则分不清「被正确过滤」与「本来就不产生事件」);为何必须用外部测试包 agent_test。 3. 记下计数漂移:本计划写「规格的 5 条约束」,但规格的「实测确认的 pi 专有约束」 只有 3 条编号约束(另有「其他实测细节」5 个要点),与计划自己指出的 ClaudeBin 「11 处 vs 实测 10 处」同类。实际写了 9 个约束用例,逐条列出对应关系,免得 下一位执行者去找那「5 条」。 4. 记下接受的缺口:没有真跑一次 pi 重新采集事件序列(规格那一列本就是 0.85.1 + qwen3.8-max 的实测,本机版本与模型都相同,不为此消耗配额);真实 spawn 下的 端到端行为属 Task 11。 5. 记下计划外收获:实测抓到 extension_ui_request(pi-subagents 主动推 setWidget), 不在忽略清单里,已用真实样本钉住。
Plan 2 Task 5。这是把前四个任务的产物真正接上电的一步:此前 PiProtocol 已完整,
但没有任何生产代码会构造它。
== 交付 ==
新增 agent/resolver.go:Init / Current(5s TTL 缓存)/ Invalidate。未知值与空值一律
回退 claude(fail-safe 到既有行为);未 Init 时 Current() 返回**点名 agent.Init** 的
错误,而不是静默构造一个 bin 为空的 ClaudeProtocol —— 后者会一路走到 exec 才失败,
错误信息里看不出是接线漏了。读 DB 用 First 而不是 api.ensureGlobalSettings 的
FirstOrCreate:解析 Protocol 是热路径,不该有「读配置顺手建了一行」这种写副作用。
新增 agent/invariant_test.go:把「后端差异只能落在 agent 包内」这条核心不变式变成
可执行断言(扫描 backend/ 生产源码)。三条规则 + 一条正向对照。
改造 backend/claude:
- buildCmdWithEnv 改收 agent.Protocol 而不是 bin 字符串,内部用 proto.Bin()。
收 Protocol 是为了让「二进制路径必走 proto.Bin()」在**类型上**成立 —— 传字符串
的话调用方完全可能再把一个硬编码的 "claude" 递进来。
- SessionPool / QuerySessionPool 删掉 claudeBin 字段,NewSessionPool /
NewQuerySessionPool 删掉该形参;StartSession / StartResumedSession 同样删掉。
- 三处 spawn 全部改为 agent.Current() 取 Protocol。
- D1 落地:抽出 writeInitCommands(proto, stdin),在 cmd.Start() 之后、
go readEvents() 之前调用。Claude 返回 nil 故为 no-op,既有行为逐字不变;pi 返回
get_state。**没有任何 `if backend == pi` 分支**。
query_pool 的两个 Start* 是先 readEvents 再 waitForInit(5s),而 waitForInit 等的
正是这个响应,所以握手必须写在 readEvents 之前 —— 写晚了 pi 的响应就没人读,
白等一轮 5s 超时。SessionPool.StartSession 则写在 onSessionID 接线之后,保证响应
被读到时回调已就位(该处原有注释已说明为何不能落在 nil 回调上)。
- 写握手失败即 cancel + 返回错误,不带着 fallback ID 苟延残喘:写不进去说明子进程
已经死了(例如 --session 指向一个对当前后端无意义的存量 ID,即切换后端那一刻的
情形),此时快失败优于让上层以为会话已建立。
D2 落地(client.go):SendSimpleWithRead 的 `args = append(args, prompt)` 是 Plan 1
遗留的最后一处 argv prompt,已改为写 stdin。SendSimple / SendWithOutput 一并改走
OnceArgs + stdin。**注意后两者的旗标集因此变化**(原本只有裸 `-p`,现在带 secure
旗标):这是必需的,不走 OnceArgs 就拿不到 pi 的硬化旗标,pi 会以全部内置工具
(含 bash)启动。两者在**生产代码里零调用点**(SendWithOutput 完全无人调用,
SendSimple 只有两个错误路径单测),故不影响现有行为;顺带修掉一个既有怪癖 ——
SendWithOutput 原先把 prompt 同时放进 argv **和** stdin,两遍都送。
== 我自己写出并被自己的测试抓住的两个问题 ==
1. **TTL 缓存顺序写反**:原先 `backendNameProvider()` 在缓存判断**之前**调用,于是
每次 Current() 都读一次 DB,缓存只剩「不重建 Protocol」这一点收益,完全违背
「避免每次 spawn 都打一次 DB」的目的。TestResolver_TTLCacheAvoidsPerSpawnDBRead
报出「TTL 内 5 次 Current() 只该读 1 次,实际 5 次」。已改为先判 TTL 再读配置。
修正后 cachedName 变成只写不读的死状态,一并删除。
2. **client.go:23 的惰性分支漏改**:计划 Task 5 清单明写要替换
`agent.NewClaudeProtocol(c.BinPath, ...)`,我第一遍漏了,是 invariant_test 判红
才补上。protocol() 现改为返回 (Protocol, error) 并走 agent.Current()。
== 测试注入方式迁移,以及一处假绿 ==
claudeBin 形参正是测试注入假二进制的接缝(api/docchat_test.go 7 处、
api/query_test.go 1 处、claude/query_pool_test.go 4 处),删掉形参后改由 agent.Init
驱动 —— 这正是计划说的「本任务用测试内的 Init 驱动,保证包可独立验证」。
agent.Init 是进程级全局状态,故 helper 在 t.Cleanup 里把 ResolverOptions 清空:
否则后续用例万一 spawn 会静默复用上一个用例留下的假脚本,那种串味在失败信息里
完全看不出来;清空后误用会立刻以 "exec: no command" 失败。
**发现并修掉一处假绿**:protocol() 改走 Current() 之后 BinPath 不再决定 spawn 哪个
二进制,而 client_test.go 那 4 个用例仍在用 NewClientWithPath 注入。它们**依旧通过**
—— 因为 Current() 未 Init 时返回的错误同样满足 `err != nil`。实测错误文本是
"agent.Init was never called",也就是说「二进制不存在」「上下文已取消」这两个行为
一次都没被执行到。已改为 initTestBackend + NewClient(),并新增 assertNotResolverError:
错误里出现 "agent.Init" 即判红,让这种假绿无法再次静默发生(变异验证:去掉
initTestBackend 后该断言确实报错)。
== invariant_test 的豁免清单与反 stale 机制 ==
exec.Command("claude" 的豁免:dependencies/checker.go(规格明确豁免)、
api/documents.go(**Task 8 待办**)。agent.NewClaudeProtocol 的豁免:
claude/security.go 的两个兼容垫片 BuildSecureArgs / BuildSecureEnv(Task 8 把
documents.go 迁走后它们将只剩测试调用点,应一并删除)。
另加一条断言:**豁免项若已不再命中就判红** —— 否则清单只增不减,Task 8 做完也没人
记得回来删,留着就等于给未来的泄漏开一张空白通行证。
扫描还带一条 scanned < 20 的下限断言:我第一版把 WalkDir 的根节点("..",其
d.Name() 也是 "..")当隐藏目录整个跳掉了,扫到 0 个文件;没有这条下限断言,
该测试会静默变成永真。
== 一处有意的越界(R4) ==
main.go 只改了 2 行:NewSessionPool / NewQuerySessionPool 的实参。R4 允许 Task 5
改「backend/claude 内部与其直接调用方」,而这两处正是那两个构造函数的直接调用点;
不改则本任务的「go build ./... 全绿」闸门不可能成立(计划把「去掉 claudeBin 实参」
列在 Task 8,但 Task 5 删了形参,两者必须有一个先动)。其余 8 处 ClaudeBin 字段注入
留给 Task 8,未触碰。
同理 db/models.go 的 LLMBackend 字段从 Task 7 提前到此处:Current() 要读它,否则
Task 5 无法编译。Task 7 仍负责 API 层的校验、探测与 Invalidate 接线,以及 :22/:48
两处注释的后端中立化(本次未动,保持任务边界可追溯)。
顺带把后端专有的措辞中立化(直接由本次改动产生):"failed to start claude" →
"failed to start agent process"、"[session] Claude stderr" → "[session] agent stderr"
(共 3 处错误消息 + 3 处日志前缀)。否则 pi 启动失败时日志会指向 claude,误导排障。
== 闸门 ==
go build ./... && go vet ./... && go test ./... **全绿(10 个包全 ok)**;
计划点名的 D1 回归护栏 TestDocChat_PersistsChatSessionIDOnInit 通过(0.17s,
预算放宽到 8s 后不再贴近边界),api 包 7 个 docchat 用例全过。
**计划要求的另一半闸门 `pytest tests/e2e/test_chat_streaming.py`(12 passed)未执行**,
原因是环境不具备而非跳过:9090 上没有服务,tests/e2e/.auth/state.json 不存在,而
conftest.py:74-75 的用户名/密码是**空串**(有意留空,否则等于把凭据提交进仓库)。
这 12 个用例还会真实调用 claude(断言流式内容、stop 中断、切换会话),需要配额。
故该闸门需由人在本地起栈并填入凭据后执行;Go 侧的等价覆盖是 api 包那 7 个
docchat 用例(用假 claude 脚本走完整 SSE + resume 链路)。
配合 c317065。记录的重点是三类「文档里看不到、但下一个人会踩」的东西: 1. 两个我自己写出、又被自己的测试抓住的问题:TTL 缓存顺序写反(每次 Current() 都读 DB,缓存只剩「不重建 Protocol」的收益)、client.go:23 的惰性分支漏改 (本任务清单明写要替换,是 invariant_test 判红才补上)。后者正是「把不变式写成 可执行断言而不是写在文档里」的价值证明。 2. 一处**假绿**及其护栏:protocol() 改走 Current() 后 BinPath 不再决定 spawn 哪个 二进制,而 client_test.go 那 4 个用例仍在用 NewClientWithPath 注入 —— 它们依旧 通过,因为未 Init 的错误同样满足 err != nil,实测错误文本是 "agent.Init was never called",即它们声称要测的两个行为一次都没被执行到。 已加 assertNotResolverError 让这种假绿无法再次静默发生。 同类护栏还有 invariant_test 的 scanned < 20 下限断言(第一版把 WalkDir 根节点 ".." 当隐藏目录跳掉,扫到 0 个文件,没有下限断言该测试会静默变成永真)与 豁免清单的反 stale 机制(豁免项不再命中即判红,否则清单只增不减)。 3. 两处有意的越界及其论证:main.go 改 2 行(R4 允许「直接调用方」,且不改则本任务 的 build 闸门不可能成立 —— 计划把「去掉 claudeBin 实参」列在 Task 8,但 Task 5 删了形参,两者必须有一个先动);db/models.go 的 LLMBackend 从 Task 7 提前 (Current() 要读它)。并写明 Task 7 仍负责什么,避免边界含糊。 4. D2 的连带影响显式记录:SendSimple / SendWithOutput 的旗标集变了,以及为什么这是 必需的(不走 OnceArgs 就拿不到 pi 的硬化旗标,pi 会以全部内置工具含 bash 启动)、 为什么可以接受(两者生产代码零调用点)、以及若将来启用 SendSimple 该重新评估什么。 另记下 Client.BinPath 已成为遗留入参、Task 8 应连同 NewClientWithPath 一并移除。 5. **e2e 闸门标为 ❌ 未执行而不是含糊带过**:写清是环境不具备(9090 无服务、 .auth/state.json 不存在、conftest.py:74-75 的凭据是有意留空的空串)且这 12 个 用例需真实调用 claude 消耗配额,需由人执行;同时指明 Go 侧的等价覆盖是 api 包 那 7 个 docchat 用例。计划原文只写「闸门:全局闸门 + pytest ... 12 passed」, 若不标注,下一个人会以为它已经绿过。
在本分支上执行 Task 5 时发现仓库根多出一个未跟踪的 .pi/,内含
settings.json({"packages":["npm:betterwright"]})与装好的 betterwright /
playwright-core / patchright-core / rookie-cookies —— 那是 pi 运行时自己建的
项目级状态目录(curator 的浏览器驱动),不是本项目的产物。
已排除「测试污染仓库」这一可能:删除 .pi/ 后重跑 go test ./agent/ ./claude/
./config/ 全绿且 .pi/ 不再重现,故与本次改造无关。
.gitignore 里已有一批同类条目(.claude/、.qwen/、.playwright-mcp/、.vscode/、
.venv/),.pi/ 属同一性质却漏了。git ls-files 确认仓库里没有任何已跟踪的 .pi/
内容,忽略它是安全的。
顺带一个与本设计相关的事实:该目录里的 packages 声明对我们的 pi spawn **无效** ——
硬化旗标带 -na(--no-approve,"Ignore project-local files for this run"),
userDir 内的 .pi/ 因此无法注入包。这正是风险登记 R1 所依赖的那道防线,
此处算是一次侧面印证。
Task 5 把所有 spawn 点改成经 agent.Current() 取 Protocol,但计划把 agent.Init 的
调用点排在 Task 8。结果是 Task 5 与 Task 8 之间**应用是坏的**,而且是实测坏的:
POST /api/query/message → HTTP 500 {"error":"failed to create session"}
链路:main.go 从不调用 agent.Init → resolverReady 为 false → Current() 返回
"agent.Init was never called" → StartSession / StartResumedSession 失败 →
handler 吞掉细节只回一句 "failed to create session"。日志里既没有 [session] 行,
也没有 resolve 错误 —— 因为 handler 没把底层错误打出来,这也是它难发现的原因。
**这暴露了计划的一处自相矛盾:** Task 5 的闸门写的是「全局闸门 +
pytest tests/e2e/test_chat_streaming.py 12 passed(claude 路径回归)」,而同一份计划
又把 agent.Init 排在 Task 8。那道闸门在 Task 5 不可能满足。故把 Init 提前到此处,
与 Task 5 已提前的 db.GlobalSettings.LLMBackend 字段同理(Current() 要读它)。
Task 8 仍负责其余 8 处 ClaudeBin 字段注入的删除与 ingest/api 的形参改造。
ClaudeSettingsPath 传的是**函数** claude.GetSettingsPath 而不是它的当前值,于是本行
与上面 InitSecurityConfig 的先后顺序无关。若传值,顺序写错时路径会被固定成空串 →
ClaudeProtocol 不加 --settings → path-validator.py 的 hook 整个不生效 → 服务照常
启动、无任何报错,是一条静默 fail-open。这条理由已写进 agent.ResolverOptions 的
注释,并由 TestResolver_ClaudeSettingsPathIsLateBound 钉住。
== 修复前后的实测对比(同一套 12 个 e2e 用例)==
修复前 修复后
POST /api/query/message 0 次 11 次(全 200)
POST /api/query/interrupt 0 次 3 次(全 200)
[session] Sent message 0 条 11 条
e2e 结果 9 passed 12 passed(198s)
3 failed
三个先前失败的用例(test_thinking_indicator_shown、
test_send_blocked_while_switch_in_flight、test_stop_preserves_partial_content)
修复后全部通过,故它们**不是既有 flaky,而是本次接线不完整的真回归**。
直接 API 探测也已端到端验证:建会话 → 发消息 "Reply with exactly one word: ok"
→ 200 → 助手回复 "ok" 落库。
== 顺带查出:e2e 套件对「后端坏掉」很不敏感 ==
修复前那 9 个「通过」的用例其实是**假绿**:它们的断言是输入框 disabled/enabled
(wait_streaming_start/complete)与用户气泡可见(乐观 UI,与后端无关),而
test_no_* 那类是负向断言,在登录页上也照样通过。也就是说「12 passed」这道闸门
在后端完全不可用时仍能拿到 9 passed。本次不是去改测试(超出范围),但记录在案:
Task 11 若要拿它当验收依据,应同时核对服务端日志里确有 [session] Sent message
与 POST /api/query/message 200,否则可能验收了一个空转的前端。
闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包全 ok)。
配合 b9e99d3。 1. Task 5 的闸门状态从 ❌ 改为 ✅ 12 passed(198s),但不是简单打个勾 —— 记下它是 分两步才拿到的,以及第一跑 9 passed/3 failed 的真实根因:**计划自身的任务排序 矛盾**。Task 5 把 spawn 改成经 agent.Current(),而 agent.Init 被排在 Task 8, 两者之间应用是坏的(实测 POST /api/query/message → 500 failed to create session, 且 handler 吞掉底层错误、日志里既无 [session] 行也无 resolve 错误,这是它难发现 的原因)。而 Task 5 的闸门恰恰写的就是「pytest 12 passed(claude 路径回归)」, 那道闸门在 Task 5 不可能满足。附上修复前后的端点计数对比表(message 0→11、 interrupt 0→3、[session] 0→11),以及三个先前失败的用例名 —— 它们不是既有 flaky,是接线不完整的真回归。 2. 记下 **e2e 套件对「后端坏掉」很不敏感**:修复前那 9 个通过是假绿(断言的是输入框 disabled/enabled 与乐观 UI 的用户气泡;test_no_* 那类负向断言在登录页上也通过, 已用 test_desktop_no_mobile_dom.py 在登录态失效时 2 passed/1 failed 实测到)。 即后端完全不可用时这道闸门仍能拿到 9 passed。本次不改测试(超范围),但明确写给 Task 11:拿 e2e 当验收依据时必须同时核对服务端日志,否则可能验收了一个空转的前端。 3. 补上计划未记的 **e2e 实际运行前提**:./start.sh 即可起栈(make build 把前端 embed 进 backend/fs/dist,单二进制一个端口);首次登录必须人在场(pytest.ini 是 --headed,conftest 故意把凭据留空并等 90s 让人工填账号+认 4 位验证码,默认管理员 密码是随机生成的);登录后状态存 .auth/state.json 供后续非交互复用。 以及一个实测踩到的坑:**该文件过期后 conftest.py:43-52 的有效性检查会误判为 仍登录**(它 goto("/") 后立刻看 URL,而 SPA 的重定向是异步的),后果是跳过人工 登录、用例在登录页上空转 —— 解法是删掉 state.json 再跑。 4. Task 8 的清单同步更新:agent.Init 已完成(划掉并标注提交号),ClaudeBin 注入从 「10 处」改为「剩下 8 处」并列出具体行号(258/313 两处池构造实参已随 Task 5 删除); 新增一条 Task 5 遗留的连带清理(Client.BinPath 与 NewClientWithPath 的 5 个调用点、 security.go 的两个兼容垫片及其 invariant_test 豁免)。
Plan 2 Task 7。交付 api/admin_settings.go 的开关与校验、api/admin_settings_test.go
(13 个用例,该接口此前无任何测试)、agent.ProbePi、config.ValidatePiWebConfig;
并补上 db/models.go:22/:48 两处注释的后端中立化(仅注释,不动字段名与 JSON tag)。
db.GlobalSettings.LLMBackend 字段本身已随 Task 5 提前落地(agent.Current 要读它),
本任务只做 API 层。
== R8 的处置决定:静态预检,不在 handler 里 spawn ==
计划把「探测不能只靠 pi --version」留给 Task 7 决定。R8 的后果是「切换探测通过、
随后每个请求都失败」,而 `pi --version` 在 pi 的 main.js:483-486 提前 exit(0)、
**根本不加载扩展**,所以它在结构上就探测不到 web-search.json 的致命配置。
考虑过「追加一次带扩展的最小 spawn」,否决,三条理由:
1. 慢:扩展加载 + npm 包解析是秒级到数十秒,放在 PUT handler 里容易撞 HTTP 超时
2. 有副作用:会在 pi 的 agent 目录留下会话文件,并触发已加载包的加载期代码(即 R1)
3. 收尾不可靠:实测 pi 在 rpc 模式下 stdin EOF 后**并不退出**(Task 4 前的探针等了
25s 仍需外部 kill),要可靠收尾就得在 handler 里自己管进程生命周期
改用静态预检:Go 与 pi-web-access 读的是**同一个文件**(不变式 I1 保证),所以 Go
完全能算出 pi 会不会在 resolveToolNames 上抛错。零成本、确定性、可单测。
「pi 能否真的带着扩展启动」留给 Task 11 的集成测试 —— 那才是它该被验证的地方
(在 CI 里跑,不占用户的一次 Settings 保存)。
为此把 Task 1 的判定逻辑抽成 config.ValidatePiWebConfig() (names, problem):
与 LoadPiWebToolNames 共用同一份实现(后者变成「调用它 + 打日志」的薄壳),所以
两条路径不可能漂移。problem 不带 "[config] " 前缀,因为它还要直接进 400 响应体。
agent.ProbePi(ctx) 于是是三段,由浅入深:①LookPath + pi --version;②SessionArgs
(沙箱 extension 缺失即报错,把 R6 从「首次聊天才炸」提前到「切换时就 400」);
③ValidatePiWebConfig 的 problem 为空。
顺带补上 Task 1 未覆盖的**跨键重名**检测。它必须按 tools.*.enabled 过滤 ——
pi 的 resolveToolNames(index.ts:303-311)只对**已启用**的键查重名,不过滤就会对
pi 其实接受的重名误报,把一次合法的后端切换拦成 400。有用例带对照组钉住
(sourceCheck 关掉时重名合法、开着时必须 400)。同时删掉 Task 2 在
resolvePiWebTools 里那份重复的重名告警(检测已下沉到 config,两处各告一次只会让
同一条问题在日志里出现两遍),去重本身保留。
== API 行为 ==
- GET 响应增 llmBackend
- PUT 输入增 LLMBackend,沿用既有部分更新风格(空串表示不改)
- 非 "claude"/"pi" → 400,消息点名 llmBackend 并回显收到的值。**不静默忽略**:
前端下拉框只有两个选项,能走到这里说明请求是手造的或前后端版本不一致,
静默忽略会让调用方以为切换成功了
- "pi" → 探测失败 400,消息含安装指引 npm install -g @earendil-works/pi-coding-agent
- "claude" → **不探测**。claude 是默认后端与回退值,给它加探测会堵住「pi 已经坏了、
切回 claude 自救」这条路,而那正是运维最需要的逃生门。有用例用 marker 文件
证明假 pi 确实没被调用
- 400 与中间件的 403("admin access required")消息可区分(规格硬要求),有用例钉住
- 保存成功且**确实改了后端**时才调 agent.Invalidate();改翻译配置不该把会话层的
Protocol 缓存也丢掉
== 变异检验 ==
5 处被抓:切到 pi 时不探测(200 而非 400,且 llmBackend 被写进 DB);保存后不调
Invalidate(Current() 仍返回 claude);切到 claude 也探测(marker 文件出现);
ProbePi 去掉 R8 预检(3 个子用例全部变成 200);重名检测不按 enabled 过滤
(对 pi 其实接受的重名误报 400)。
另:invariant_test 的规则 3 单独验过有牙(在 api 里注入一处
`pr.Backend() == agent.BackendPi` → 判红)。
== 修掉自己写的一个误报 ==
invariant_test 规则 3 原先用裸子串 `BackendPi` 匹配,结果把 api 层自己定义的常量
`llmBackendPi` 误判成「后端种类判断」—— 那是 API 契约里的取值字面量,不是后端分支。
改为匹配限定名 `agent.BackendPi`/`agent.BackendClaude` 与 `.Backend() ==`/`!=`。
修的是测试的匹配精度,而不是为了让糙测试通过去改一个合理的命名。
== 两次「变异没生效却以为通过」的教训(已改进做法)==
本轮有两处我最初以为变异被抓/通过,实际是变异根本没落地:一次 perl 模式没匹配上,
一次替换让 `fmt` 变成未使用而编译失败,而我的 grep 只过滤 `^(ok|--- FAIL)`、
把 "FAIL ... [build failed]" 漏掉了。现在每次变异都先 grep 确认标记文本存在,
再跑测试,且 grep 模式包含 build failed。
闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包全 ok)。
配合 0320e7a。 D6 记录 R8 的处置决定:改用**静态预检**而不是计划提议的「追加一次带扩展的最小 spawn」。三条否决理由(慢、有副作用即 R1 的加载期代码、收尾不可靠 —— 实测 pi 在 rpc 模式下 stdin EOF 后并不退出,探针等 25s 仍需外部 kill),以及静态预检成立的 前提(Go 与 pi-web-access 读同一个文件,即 I1)。同时写清它的**边界**:覆盖不了 「pi-web-access 根本没装」与「其他包加载期抛错」,那些留给 Task 10 的 start.sh 检查与 Task 11 的集成测试。 Task 7 ✅ 记录:ProbePi 的三段校验、跨键重名检测为何必须按 tools.*.enabled 过滤 (pi 只对已启用的键查重,不过滤会把合法切换拦成 400)、API 行为的四个决定 (尤其「claude 不探测」是为了保住 pi 坏了时切回 claude 的逃生门,并用 marker 文件 证明而非推断)、5 处变异检验、以及我自己写的 invariant_test 规则 3 的误报与修法 (修测试精度而不是改一个合理的命名)。 还记下两次「变异没生效却以为通过」的教训与改进做法(先 grep 确认变异落地、 grep 模式要包含 build failed),以及一个**没写成代码**的发现:我原本准备为 「GORM 可能让 LLMBackend 是空串」加归一化兜底,先用用例实测发现它确实返回 claude,于是没加 —— 用例留下以防 GORM 行为变化。
Plan 2 Task 8。删掉 main.go 剩余 8 处 ClaudeBin 注入、7 个 handler 的 ClaudeBin
字段、Client.BinPath、NewClientWithPath,以及 claude/security.go 的三个兼容垫片。
api/documents.go 那处 exec.Command("claude", ...) 改走 agent.Current()。
invariant_test 的两条临时豁免随之摘除 —— 摘除后仍然全绿,才算真的收口了。
== 一处比计划描述更简单的发现 ==
计划 Step 3 说要把 ingest/api 从 NewClientWithPath 迁到 agent.Current(),Step 1 还
给了一个 agent.NewClient() 的草案。实际不需要:Task 5 已经让 Client.protocol() 在
Proto 为 nil 时惰性走 agent.Current(),所以 **claude.NewClient()(无参)本身就是
后端中立的**。改动于是退化成「NewClientWithPath(x) → NewClient()」+ 摘掉形参,
不必新增任何 API。
计划草案里那个 agent.NewClient() 出错时返回 nil,是个地雷(调用方会 nil-deref)。
既然不需要它,就没有引入。
== 比计划更大的一块:ClaudeBin 同时是功能开关 ==
h.ClaudeBin != "" 在 10 处被当作「LLM 是否可用」的开关(异步摘要生成的入口),
api/sections.go 另有 2 处 == "" 的 503 早退。**只删 main.go 的注入而保留这些
gate,会让条件恒假、异步摘要生成静默停摆** —— 这是本任务最容易踩的坑。
先确认再动手:config.go:46-48 把 ClaudeBin 兜底成 "claude"、永不为空,所以
!= "" 恒真、== "" 恒假,它们全是死条件。删字段与删 gate 必须同批完成。
「LLM 到底可不可用」现在由 resolver 在实际调用时判定并返回错误,比在入口处靠一个
字符串是否为空来猜更准确(切到 pi 之后,那个字符串检查压根不反映真实后端)。
== documents.go 的 PDF 逐页转换(遗留接缝里最严重的一处)==
改走 proto.OnceArgs("", []string{"Read"}, false, "sonnet") + proto.Bin() +
proto.Env(tempDir)。安全语义不变:OnceArgs 内部就是 SecureArgs,--disallowedTools
与 --settings 照旧。两处按计划落地:D3 的 model 交给 OnceArgs(claude 侧加
--model sonnet 与原行为一致,pi 侧忽略);D2 的 prompt 从 argv 改走 stdin。
原先它绕过 Protocol 直接 spawn,后果是切到 pi 之后这条路径仍旧 spawn claude 且
**不报错** —— 装了 claude 就恰好能用,没装则静默失败成 "[Error processing page N]"
写进产物文件。
== 垫片删除时保住了测试覆盖 ==
claude/security_test.go 里有 12 个测试,其中 5 个与垫片无关且必须留
(TestCleanupStaleSettings_AgeGated、TestPathValidator_WebFetchSSRF、
TestDangerousToolsCrossLanguageSync,以及 Task 6 的两个 TestPiPathValidator_*)。
所以不能整文件删。剩下 7 个分两类处理:
- 4 个在 agent 包已有等价覆盖(SessionArgs_AlwaysContainsDisallowedTools /
AllowedToolsRespected / RejectsAllowedDangerousOverlap / ClaudeEnv_ResolvesSymlinks
—— 底层都是同一个 SecureArgs/Env),直接删
- 3 个**没有** agent 对应物(EmptyAllowedToolsOmitsFlag、BypassFlagPresent、
DangerousDisallowedTools_CoversKnownAttackVectors),搬进 agent/claude_args_test.go
先逐个核对覆盖再删,否则「删垫片」会顺手删掉唯一的安全断言。
DangerousDisallowedTools 别名不只为兼容外部,security.go:146 内部也在用,
且 Task 6/10 的跨语言漂移测试依赖它 —— 全部改为直连 agent.ClaudeDangerousDisallowedTools。
== 测试 ==
新增 claude/client_test.go 的 TestClientProtocol_FollowsResolver:同一个 Client
实例,只换 resolver 指向,断言 protocol() 分别返回 claude 与 pi。这是 Task 8 的
核心性质。配套的 TestNewClient_DoesNotPinABackend 只证明「构造时没钉死」,证明不了
「真的会跟随」(一个恒返回 claude 的 protocol() 同样能通过它),所以两个都要。
变异验证:把 protocol() 改成恒返回 ClaudeProtocol → 两个后端分支都判红。
原 TestNewClient 断言的是 BinPath == "claude"(即「默认钉死 claude」),与后端开关
直接矛盾,随字段一并删除。
闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包);
go test ./api/ -race 无数据竞争(gate 删除后测试可能新起 goroutine,专门验过);
pytest tests/e2e/test_chat_streaming.py → 12 passed(117s,重建二进制并重启服务后)。
== 我自己犯的一个错,已回退 ==
图省事跑了 gofmt -w ingest/ 和 gofmt -w claude/(整目录),波及 5 个我本不想改的
文件,其中 claude/stream.go 里正是 SSEEvent 结构体 —— 那是「前端聊天代码零 diff」
的关键文件。虽然 JSON tag 没变、只是注释对齐,但这种噪音必须避免:已全部
git checkout 回退。
同理,api/{newsletter,raw,rss,web}.go 在 HEAD 本来就不合规(gofmt -l 命中),
所以对它们跑整文件 gofmt 会混进无关重排(实测确实混进了 childrenCount-1、
结构体字段对齐、整块重缩进)。已回退重做,改成只让**我碰的区域**合规,并用
`gofmt -d <file> | grep <我改的标识符>` 逐个确认为 0。
顺带发现 api/newsletter.go 那个 gate 在 HEAD 就多缩进了一层(3 tab,而外层作用域
是 2 tab)—— 这正是它不合规的原因。这些行已在本次 diff 内,顺手对齐。
== 遗留 ==
PDF 逐页转换这条路径没有任何自动化覆盖(e2e 与 go test 都碰不到,它需要真实
LLM + PDF + 逐页 PNG)。本次只做了逐行复核(确认与 SendSimpleWithRead 的既有
模式一致:-p + stdin + text 输出,且 TestClaudeOnceArgs_TextModeMatchesSendSimpleWithRead
已钉住 arg 形状)。已记入 Task 11。
配合 8cf8aa7。 记三件计划里没写、但实际决定了做法的事: 1. 计划 Step 3 要求迁到 agent.Current()、Step 1 给了 agent.NewClient() 草案, 实际都不需要 —— Task 5 已让 Client.protocol() 惰性走 resolver,所以 claude.NewClient()(无参)本身就是后端中立的。草案里那个「出错返回 nil」 是个 nil-deref 地雷,既然不需要就没引入。 2. 比计划更大的一块:ClaudeBin 在 10 处同时是「LLM 是否可用」的功能开关 (异步摘要生成入口),另有 2 处 == "" 的 503 早退。**只删 main.go 的注入而 保留 gate,会让条件恒假、异步摘要生成静默停摆**。先确认 config.go:46-48 把 ClaudeBin 兜底成永不为空(所以它们全是死条件),再同批删字段与 gate。 struct 实际是 7 个,比计划列的 5 个多。 3. 删垫片前先逐个核对测试覆盖:security_test.go 的 12 个测试里 5 个必须留 (含 Task 6 的两个沙箱测试),7 个中 4 个在 agent 已有等价覆盖可删、 3 个没有对应物必须搬走。不核对就会顺手删掉唯一的安全断言。 也记下我自己犯的一个错:图省事跑 gofmt -w <目录>,波及 5 个不想改的文件, 其中 claude/stream.go 里正是 SSEEvent(前端零 diff 的关键文件);另外 4 个 api 文件在 HEAD 本来就不合规,整文件 gofmt 会混进无关重排(实测确实混进了)。 已全部回退重做。**教训写进文档:这个仓库里不能用 gofmt -w <目录>。** Task 11 新增两条:PDF 逐页转 Markdown 的覆盖缺口(Task 8 改动里唯一既无测试 又难自动化的路径),以及把它加进手工验收清单。
Plan 2 Task 9。types.ts 的 GlobalSettings 增 llmBackend;SettingsPage 增独立的
admin 区块(select + 保存 + 成功/错误横幅);i18n en/zh 补齐;api.ts 修掉一处
会吞掉服务端诊断的错误处理。新增 5 个 e2e 用例,把计划里"手工点开 Settings"
那条闸门的两半都自动化了。
聊天组件零 diff:git diff --stat main -- frontend/src/components/ChatView.tsx
frontend/src/hooks 为空。
== 一处偏离计划的字面描述 ==
计划说"在既有 {isAdmin && (...)} 区块内"加 select。但翻译区块的配置 UI 只在
translationEnabled 为真时渲染 —— 塞进去会让后端开关**在翻译关闭时整个不可见**。
所以做成独立的 admin 区块,沿用同样的视觉范式(hidden md:block、Admin 徽章、
紫色按钮、绿/红横幅)。
== 顺带修掉一个真问题:服务端诊断被前端吞掉 ==
api.ts 的 updateGlobalSettings 原先是 `if (!res.ok) throw new Error('Failed to
update global settings')`。Task 7 精心写的 400 文案(pi 未安装 + 安装命令 /
web-search.json 会让 pi 拒绝启动 / 沙箱 extension 缺哪个文件)在前端边界就没了,
而 Task 9 的交付项恰恰包含"探测失败的错误提示"。改为透出 body.error,解析不出来
才退回原笼统文案 —— 所以任何既有失败路径都不会变得更糟。
这条修复已端到端验证:临时移走 scripts/pi-path-validator.ts 后保存,UI 上原样出现
"无法切换到 pi 后端:pi sandbox extension not found at ...: refusing to spawn pi
without tool-call interception (deploy backend/scripts/pi-path-validator.ts to the
runtime scripts dir, i.e. LLM_SCRIPTS_DIR)"。改之前这里只会显示那句笼统文案。
== e2e:两个角色同时测 ==
计划的闸门要"确认开关可见/普通用户不可见"。普通用户那半原本以为只能手工验,
实际数据库里就有 role=user 的账号,于是两半都自动化了:
- test_admin_sees_switch:不只断言可见,还断言取值集合恰好是 ['claude','pi']
(一个空 select 同样"可见";取值集合就是前后端契约,后端对其它一律 400)
- test_normal_user_does_not_see_switch:要求 **count == 0** 而不是"不可见"——
否则一个把 isAdmin 判断写错、区块照常渲染只靠 CSS 藏起来的实现会蒙混过关
(那种实现下数据仍进 DOM,改一行 CSS 就能看到管理员开关)。同一用例还断言
普通用户 GET /api/admin/settings 得到 403:光靠前端不渲染不算访问控制
- test_select_reflects_server_state:钉住下拉框显示服务端真实值。没有这条,
一个 value 恒为 'claude' 的 select 能通过所有可见性断言,而管理员会看着
"当前是 claude"、实际生效的却是 pi
- test_switch_to_pi_roundtrip:**不预设 pi 在本机可用** —— 探测通过就要绿条且
服务端变 pi,不通过就要红条且服务端保持原值;两种都合法,不合法的是"UI 说成功
了但服务端没变"。所以它对"这台机器装没装 pi"是确定性的。两个分支都实测过
(移走沙箱文件制造失败分支)
- test_switch_back_to_claude_never_probes:从 UI 侧钉住逃生门 —— 即便 pi 完全
不可用,切回 claude 也必须保存成功,否则管理员会被锁死在坏掉的后端上
test_switch_to_pi_roundtrip 每次都在 finally 里把后端还原,否则服务会留在 pi 上,
后续聊天 e2e 就变成在测另一个后端,"claude 路径行为不变"的回归基线也就没了。
实测终值确认为 claude。
== 一个断言写错、被实验纠正的地方 ==
我原本断言"保存失败后下拉框必须回弹到服务端值"。移走沙箱文件实跑后它红了:
服务端确实拒绝(DB 仍是 claude),但下拉框保留用户刚选的 pi。查证后认定
**错的是我的断言**:保留选择 + 显示红色错误条是常规表单语义(让人看到自己选了
什么、为什么失败,修好配置可直接重试而不必重选)。真正防误导的保证是"重新加载后
显示服务端真实值",于是改为断言这个(并 reload 后复核)。
== make_auth_state.py:为什么需要它 ==
conftest 的登录流程要人手输凭据**和验证码**,且只有一个共享的 state.json ——
装的是"最后一个登录的人"。需要两个特定角色的测试没法这么跑。
新脚本按 role 各取最新的有效 session,生成 state-admin.json / state-nonadmin.json。
与 README 已文档化的编程登录(clientType:"extension" 跳验证码)相比,它**不需要
密码**、以 mode=ro 打开数据库因而不写任何东西,代价是需要该角色已有一次登录
(session 有效期 7 天)。两者的分工已写进 tests/e2e/README.md。
缺 state 文件、或 admin 的 must_change_password 仍为 1(前端 PrivateRoute 会把
所有页面重定向到 /change-password、到不了 Settings)时,用例给出可操作的 skip
消息,而不是在永远等不到的选择器上超时。
== 两个既有问题(超出 Plan 2 范围,只报告不改)==
1. db.go:73 `log.Printf("Created default user 'admin', password: %s", ...)` 把
明文初始密码写进日志。本机 ~/.llm-knowledge/logs/app-2026-05-09.log 里至今
可读(本次就是从这里取到 admin 初始密码的)。该密码已由用户在 UI 改掉。
2. auth.go:190 `if req.ClientType != "extension"` 才校验验证码,而 ClientType 由
请求体自报 —— tests/e2e/README.md:44 把它文档化为有意的编程登录路径,所以这是
刻意设计而非疏漏。但生产环境里它意味着验证码对暴力破解基本不设防(配合无速率
限制),值得单独评估。
闸门:npm run build ✓、make build ✓、聊天组件零 diff ✓、
pytest tests/e2e/test_settings_llm_backend.py → 5 passed(成功与失败两个分支都实测)。
Plan 2 Task 10。start.sh 补 pi/pi-web-access 检查与 PATH 修复、修掉一处会阻塞启动
的 set -e 陷阱、自动部署两个沙箱脚本;Makefile 同步补上 pi-path-validator.ts;
README 与 README_ZH 各增一节「LLM 后端切换」。
== start.sh ==
**PATH 修复(计划的判断经实测成立):** 原先只补 /usr/local/bin:/usr/local/go/bin
(Intel mac 的位置)。本机 pi 实际在 /opt/homebrew/bin/pi —— Apple Silicon 上
npm/brew 的全局 bin。之所以一直没暴露,是因为脚本继承了交互 shell 的 PATH。
用最小 PATH 实测对照:
env -i PATH=/usr/bin:/bin -> command -v pi 找不到
同上 + 补 /opt/homebrew/bin -> /opt/homebrew/bin/pi
换成 systemd/launchd 那种最小 PATH 的环境,pi 与 brew 装的 pdftotext 都会"找不到",
进而触发不必要的重装。
导出位置也一并**上移到所有 command -v 检查之前**。原先它在脚本末尾(:135),而依赖
检查在前面 —— 即使补对了路径,检查也看不见。
**pi 检查只告警不退出:** claude 是默认后端,pi 是可选的。硬性退出会让没装 pi 的
部署连启动都启动不了。真正的 fail-closed 在服务端(切换时 400、spawn 时拒绝)。
检查覆盖三项:pi 二进制与版本、pi-web-access 是否安装、web-search.json 是否存在
(不存在时明确提示"默认值下 commands.* 全部启用")。pi-web-access 的探测路径与
Go 侧同源:${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/npm/node_modules/。
**修掉 set -e 陷阱:** :28 的 `brew install poppler` 没有兜底,一次失败会直接终止
启动脚本 —— 而 pdftotext 只影响 PDF 文本提取这一条路径。紧邻的 qpdf 早就写了
`|| echo "警告..."`,这里原先漏了。
**沙箱脚本改为总是覆盖,而不是计划说的"缺则复制":** 一份来自旧版本的
pi-path-validator.ts 会按旧规则**静默放行**(fail-open),那比文件缺失更危险
(缺失是 fail-closed,会拒绝 spawn;陈旧看不出来)。
== Makefile 也要改(计划只提了 start.sh)==
实测 ScriptsDir 就是 <repo>/scripts,而填充它的是 `make build`。只改 start.sh 会
漏掉所有直接 `make build && ./llm-knowledge` 的人(本次开发全程就是这么跑的)。
验证:删掉 scripts/pi-path-validator.ts 后跑 make build,文件被重新复制回来。
== README 的运维警示 ==
五条,前四条是规格要求的,第五条来自 R8:
① 切换后端作废进行中对话的续接能力,且**双向**——"切回原后端可恢复"不成立
② 仅 admin 可切换,不要改名/删除该账号(db/db.go:38 那条无守卫迁移只认字面
username='admin',改名后救不回来,UI 里再没人能看到开关)
③ --tools 拦不住扩展**加载期**代码,须 pin 版本 + 管控 settings.json 的 packages
④ web-search.json 必须显式关掉四个扩展命令(R9),并列表说明四道既有防线为何
都拦不住;强调**默认全启用**,所以"没有配置文件"是最危险的状态而非安全状态
⑤ toolNames 写错让整个 pi 后端 exit 1(连不用 web 工具的 ingest 一起死)
**⑤ 的措辞按 Task 7 的实际实现改了,没有照抄计划。** 计划写的是"Settings 里切换
时的探测跑的是 pi --version、不加载扩展因而探测不到"—— 这在 Task 7 之后已经
不准确:ProbePi 除了 pi --version,还会用 Go 侧同一份解析逻辑做静态预检,把
toolNames 的形状/取值/跨键重名在切换时就以 400 拦下。文档同时写清静态预检
**覆盖不到**什么(pi-web-access 没装、其他包加载期抛错),以及各自由谁兜。
照抄计划会把一个已修好的问题继续描述成未修的。
== 核实过的断言 ==
写进文档的每条都对着源码或产物核过:
- --tools / --no-skills / --no-prompt-templates 确在 pi_args.go:239-241
- resolver 缓存确为 5s(resolver.go:26)
- db/db.go:38 的迁移 SQL 逐字核对
- **Node 版本要求我一开始写错了**:写的"Node.js 20+",而 pi 的 package.json
engines 是 `>=22.19.0`。已改正。照错的写会装上 Node 20 然后撞一个莫名其妙的失败
== 顺带发现(既有问题,未改)==
README 的快速开始写"默认端口 9999"、配置表写 PORT 默认 3456,而 start.sh 实际是
`PORT=${PORT:-9090}`。三处不一致,与本次改动无关,按最小改动原则没有一并修。
闸门:bash -n start.sh ✓;./start.sh 实跑两次均退出 0、服务正常起(health 200)、
沙箱脚本部署成功、pi 检查输出准确(pi 0.85.1 / pi-web-access 已安装 /
web-search.json 未找到并给出默认值警示)。
Plan 2 Task 11(第一部分:零配额与单次 LLM 回合可完成的验证)。
== 1. 沙箱 extension 确实被加载并拦截(Task 6 留下的最大未验证面)==
agent/pi_sandbox_integration_test.go:用**生产路径**构造 argv/env
(PiProtocol.SessionArgs + Env + InitCommands + EncodeUserMessage,不手工拼旗标),
spawn 真实 pi,诱导它 read /etc/passwd,断言被拦。
为什么必须有这一条:pi-path-validator.ts 的判定逻辑已被 security_test.go 的
TestPiPathValidator_PathBoundary / _FetchContentURL 覆盖,但那测的是**导出的纯函数**。
一个因路径写错、文件缺失或加载期抛错而**静默未加载**的沙箱,与一个正常工作的沙箱,
在「没有越界访问发生」时看起来完全一样 —— 纯函数测试对此一无所知。
断言锚点是 extension 自己产出的理由文本,它**只可能来自被加载并执行了的 extension**,
所以它同时就是加载证明;并反向断言 /etc/passwd 的内容(以 "root:*:" / "root:x:0:0"
为判据,比匹配 "root" 更不易误报)没有出现在事件流里。
**收紧过一次断言。** 最初只匹配 "Access denied:" 前缀,但那个前缀也会被「工具不在
白名单」与「ALLOWED_DIR 未配置」用到 —— 拿它当通过条件,用例就会因为**错误的原因**
变绿(沙箱可能压根没做路径校验,只是把整个工具拒了)。改为只认
"path outside allowed directory" / "sensitive file" 两种理由,并对
"ALLOWED_DIR not configured" 直接判失败(那是 Env() 注入没生效、前提不成立)。
收紧后实跑仍 PASS(30 行事件流 / 3.07s),证明拦下它的确实是路径校验本身。
gated 在 LLM_KNOWLEDGE_PI_INTEGRATION=1 之后:它要消耗一次真实 LLM 回合,不能让
`go test ./...`(计划闸门)每次都花钱、都依赖网络。默认 SKIP 已验证。
teardown 显式 kill + Wait 自己 spawn 的进程(R5:不得加剧既有的孤儿进程问题)。
顺带零配额实证了 **D4**:get_state 响应里的 sessionFile 落在 Env() 注入的
PI_CODING_AGENT_SESSION_DIR 之下(此前只有 Go 单测断言过)。同时确认 get_state
**不返回已加载扩展列表**,所以"证明扩展被加载"没有零配额的捷径,必须走一次真实回合。
== 2. 新发现:rpc 的 bash 命令完全绕过沙箱(R10)==
实测:用我们的硬化 argv 启动 pi,发 {"type":"bash","command":"touch <ALLOWED_DIR>/x
&& echo BASH_RAN"} —— shell **真的执行了**、文件真的建了、tool_call hook 一次都没
触发。原因:它是 pi 的直接 shell 执行路径(docs/rpc.md:479-485,"Execute a shell
command and add output to conversation context"),不是工具调用。
且 `pi --help` 里**没有任何旗标能关掉它**(`--tools` 白名单不含 bash 也照跑;
`-nt/--no-tools` 关的是工具)。`-na` 是 --no-approve,与此无关。
于是唯一的屏障是:只有我们的 Go 进程能写 pi 的 stdin,而用户文本一律经
EncodeUserMessage 变成一条 prompt 命令的**字符串字段**。pi 的 rpc 按行分隔,
所以逃逸有两条路,新增的 TestPiEncodeUserMessage_CannotInjectRpcCommands 把两条都钉住:
① 结构逃逸(引号提前结束 message 再塞 "type":"bash");② 行逃逸(真实换行后接一条
完整恶意命令)。断言输出恰好一行 + 结尾换行、整行解析成 type=prompt、message 逐字
等于原文、顶层不出现 command/success 字段。
json.Marshal 按构造就能挡住两者,但"按构造安全"需要测试来防止将来有人为了
"少一次转义"改成手工拼接 —— 那种改动看起来无害,后果是任何用户都能在服务器上
执行任意 shell。变异验证:把 json.Marshal 换成手工拼接 → 三个子用例全部判红。
== 3. PDF 逐页转 Markdown 的覆盖缺口(Task 8 遗留)==
api/documents_llmextract_test.go:用假 claude 二进制(把 argv 与 stdin 分别落文件)
跑真实的 LLMExtract,断言 ① prompt 从 **stdin** 进、argv 里没有(D2);② 假二进制
确实被 spawn(证明用的是 Protocol 给的二进制,而非硬编码 "claude");③ --model sonnet
在 argv 里(D3);④ --disallowedTools / --dangerously-skip-permissions 没因改走
OnceArgs 而丢失;⑤ 假二进制的输出被写进 paper.md(整条链路通)。
变异验证:把 prompt 塞回 argv → 判红。
需要 poppler(pdfinfo/pdftoppm/pdfunite),缺失时 t.Skip。
== 顺带查出一个既有 bug(不在 Plan 2 范围,**未修**,已报告)==
handler 与 pdftoppm 对页码补零的约定不一致:pdftoppm 按**总页数**决定补零宽度
(10 页 -> page-01.png,1 页 -> page-1.png),而 documents.go 硬编码
fmt.Sprintf("page-%02d.png", i)。实测对照确认。
后果:**任何少于 10 页的 PDF 都会静默产出空 paper.md,却返回 200 与
"PDF extracted with LLM successfully"** —— os.Stat 未命中就 continue(且在写入
页眉之前),所以既不报错也没有内容。arXiv 论文通常 ≥10 页,这解释了它为何一直没被发现。
不在这里修的原因:修它会改变 claude 路径的行为,直接违背本计划的验收项之一
(「LLMBackend=claude 时行为与改造前逐条等价」),也会污染回归基线。
测试因此用 pdfunite 把单页样本拼成 10 页来走真实路径,并在注释里写明修好补零后
这段拼接可以删掉。
== 我自己犯的一个错,已恢复 ==
生成 10 页 fixture 时写了 `... | xargs pdfunite`(没有输出文件名)—— pdfunite 把
最后一个**输入**当成输出,于是把仓库里 tracked 的 backend/ingest/testdata/sample.pdf
覆写成了 15 字节的碎片。已 git checkout 恢复(596 字节,pdfinfo 可读),并在测试里
留下注释警告这个陷阱。教训:调用「最后一个位置参数是输出」的工具时永远显式给出输出路径。
闸门:go build ./... && go vet ./... && go test ./... 全绿(10 个包,集成测试默认 SKIP)。
LLM_KNOWLEDGE_PI_INTEGRATION=1 下沙箱集成测试实跑 PASS。
配合 aa73189。 R10 是本次实测新增的:rpc bash 命令完全绕过沙箱 extension(shell 真的执行、 tool_call hook 一次没触发),且 pi 没有任何旗标能关掉它。于是唯一屏障是 「只有 Go 进程能写 pi stdin + 用户文本一律经 json.Marshal 成为 prompt 的字符串 字段」,新增的注入测试是它唯一的护栏 —— 风险登记里明确写了「不得为了少一次转义 而放松」。 Task 11 记为**部分完成**而不是完成:零配额与单次 LLM 回合能做的都做了(沙箱加载 实证、PDF 逐页覆盖、R10 护栏、D4 零配额实证),剩下的六项要么消耗配额、要么计划 明确要求由人执行(手工验收),逐项列清了阻塞点。 其中 e2e 回归另外三个文件的阻塞点值得单独记:state.json 里的 token 已过期 (是 bruceding 的),而 conftest 的刷新流程需要人手输凭据+验证码。 还记下查出的既有 bug(LLMExtract 与 pdftoppm 的页码补零约定不一致,导致 <10 页的 PDF 静默产出空 paper.md 却返回 200)、不在本计划内修它的理由(会改变 claude 路径 行为、违背验收项之一),以及建议的修法。
PDF 页码补零 bug 已按维护者要求单独开 issue 而不在 Plan 2 内修: #93 同时如实记录 R9 扩展命令注入面验证的一次**失败尝试**:想用 rpc 的 get_commands 做 零 LLM 回合的判据,实测两组(commands.*.enabled 真/假)都只返回 1 条命令、都不含 那四个 —— 因为把 PI_CODING_AGENT_DIR 指到空临时目录会让 pi 读不到 settings.json, pi-web-access 根本没被加载。所以这个对照实验没有区分力,结论不确定。 记下这个失败是有价值的:它指明了正确做法必须把真实 settings.json 一并复制进临时 agent 目录,而这又会触发 R1(那份 settings.json 里还有 betterwright / pi-subagents 等包,复制过去等于让它们也加载并执行加载期代码)。**R9 验证仍待做,不得视为已完成。**
Plan 2 Task 11(第二部分:剩余项里可以零配额关掉的两条)。
== 1. R9 扩展命令注入面:agent/pi_command_gate_integration_test.go ==
gated 在 LLM_KNOWLEDGE_PI_INTEGRATION=1(与沙箱集成测试同批),零配额,实跑 PASS 1.6s、
-race 干净。判据是 rpc 的 get_commands:pi-web-access 的四处注册都被 isCommandEnabled
门控(index.ts:3164/3427/3469/3517),所以「注册与否」不需要 LLM 回合就能观测。
四腿:
A 部署模板(4× enabled:false)→ 只返回 [llama](pi 自带 inline 扩展),四个都不在
B 对照组(同一份配置只把 4 个改成 true)→ 四个全部出现,证明这套临时 agent 目录
确实加载了 pi-web-access
C 负对照(模板 + 一个非法 toolNames)→ pi exit 1 且 stderr 指名 pi-web-access/index.ts
与该 web-search.json(R8 的既有行为),证明 A 那次运行扩展**真读了这份配置**
D 端到端:发 /curator hello 与 /search foo,两条 prompt 都 success=false 且 error 落在
模型/凭据层。命令派发跑的是扩展自己的 JS、不需要模型凭据,所以这个失败就是
「没被当命令执行」的证据;pgrep 无浏览器进程
B 与 C 是为 6d8b150 记下的那次失败尝试加的:当时两组都没加载扩展,于是都返回「没有那
四个命令」,结论没有区分力。变异验证复现了它 —— 去掉 settings.json 的 packages 后
A 腿仍然绿,而 B、C 同时判红。另两处变异:模板里把 curator 改回 true → 前置检查判红;
A 腿喂成对照配置 → A 腿判红(证明它不是空洞的绿)。
两处防「因错误的原因变绿」:D 腿不止断言 success=false,还要求 error 命中模型层关键词
(否则「命令被派发后自己失败」会蒙混过关);零配额由构造保证(env 只有 PATH、HOME=临时
目录、PI_CODING_AGENT_DIR,没有任何 provider 凭据),不依赖断言。命令名不硬编码,从部署
模板的 commands 段取,并前置要求每条都是显式 enabled:false。
残留:D 腿的对照组(真打开 curator、看浏览器被拉起)需人确认时机,未自动化;其他包的
命令(pi 自带 llama、pi-subagents 的 /run)不在 web-search.json 管辖内,仍靠 R1 运维隔离。
== 2. e2e 的 state.json 不再需要验证码 ==
conftest 的 saved_auth_state 在 token 过期时会删掉 state.json 并开浏览器等人输凭据+验证码,
于是三个纯 DOM 的 e2e 文件(test_chat_view / test_mobile_chat_view / test_desktop_no_mobile_dom,
其中发消息的用例本来就全是 @pytest.mark.skip)也被这条卡住。make_auth_state.py 已经在用
「只读 DB 复用已签发 session」的手法写两个 role 文件,本次让它顺手维护 state.json:
现有 token 仍有效则 KEEP(不静默换掉测试所用账号),缺失或过期才重写。
三个分支均实测:KEEP、过期 token 重写、文件缺失写入。改完 e2e 19 passed / 4 skipped,零配额。
配合 996e14d。 R9 的剩余项从「结论不确定,不得视为已验证」改为已验证:记明四腿判据、零配额由构造 保证、上次失败尝试的根因与三处变异验证,以及两条残留(D 腿的人工对照组会拉起浏览器; 其他包的命令只能靠 R1 运维隔离)。风险登记 R9 的处置列同步。 e2e 另外三个文件记为完成(14 passed / 4 skipped,零配额),并写明**原记录的阻塞点只对 了一半**:bruceding 在 DB 里确实没有有效 session,但 dingjing/admin 的 session 有效到 2026-09-20,而 make_auth_state.py 已经提供了「只读 DB 复用已签发 session」的免验证码手法。 完成定义勾掉三条并附复跑证据:LLMBackend=claude 时 go test ./... 全绿(38.9s)、前端聊天 代码 diff 为空、Settings 切到不可用的 pi 被 400 拦住(test_settings_llm_backend.py 5 passed)。 术语:「本地文件向量」→「本地文件读取攻击面(local-file attack vector)」,4 处全改。 原词是 attack vector 的直译,与 RAG 的 embedding 向量无关,本轮已经被误读过一次。 fetch_content 那条剩余项按源码级证据收窄:pi 的工具执行只有一个咽喉点 prepareToolCall(), 它从统一注册表取工具(含扩展注册的自定义工具)、先调 beforeToolCall,block 时直接返回 错误结果而**不调用 tool.execute**;agent-session.js:224 把 beforeToolCall 接到 emitToolCall({type:"tool_call"})。所以剩下的经验缺口只有「真跑一次 fetch_content 派发」, 仍需 1 个 LLM 回合(或搭假 provider)。resume 那条注明只需配额、不需人。
合并前 review(5 lane 并行)唯一的 BLOCK 项。两个独立的洞,同一类后果:**切到 pi
之后,once 调用链路会静默产出空内容,而 api/translate.go 拿这个空串无条件覆盖已有的
paper_<lang>.md 并回 complete(200)**。
== 洞 1:解析没跟着 spawn 走 ==
Send 的 spawn 已经跟随 resolver(proto.OnceArgs + proto.Bin()),解析却仍用 claude 的
RawEvent(assistant/result/system)。pi 的 --mode json 词表与它**没有交集**
(session/message_update/message_end/agent_settled/response),于是每个事件都落到
switch 之外:Content/Result 恒空、Message 恒 nil,而 err 也恒 nil。ingest 的进度与
错误日志一起消失。
是本分支引入的:改造前 protocol() 返回硬编码的 ClaudeProtocol,claude-only 解析是
正确的。修法是把解析交给 Protocol 接缝,并在 Send 内保留 claude 的两条既有语义
(system 非 error 不下发;result 的 Content=Result、is_error → error)。RawEvent 随之
只剩本函数一个使用者,一并删除(encoding/json 的 import 也随之移除)。
一处**有意的行为差异**:多 text 块的 assistant 消息,Content 从「最后一个块」变成
「第一个块」(与两个 Protocol 的既有约定一致),仅在多块时可观测。
== 洞 2:pi --mode json 在凭据/模型不可用时退出码是 0 ==
复核洞 1 的修法时实测到的,5 条 review lane 都没报。pi 0.85.1 在无 auth.json 的临时
agent 目录下:stdout 只有一行 {"type":"session",...}(被 ParseLine 正确跳过),真正的
原因 "No API key found for the selected model." 只写在 stderr,而 Send **没有接管
stderr**(cmd.Stderr 为 nil → os/exec 接到 /dev/null),cmd.Wait() 返回 nil。于是洞 1
修好之后这条路径仍然是「零事件 + nil error」,translate 照样覆盖译文并报成功。
切换探测拦不住它:ProbePi 只跑 pi --version + SessionArgs + web-search.json 静态预检,
**不验凭据**。修法:Send 接管 stderr、统计下发过的事件数,Wait() 无错但零事件且
stderr 非空时返回带 stderr 首行的错误。claude 侧同理成立(成功的
--print --output-format stream-json 至少有一个 result 事件)。
== 护栏(假二进制,零配额)与变异验证 ==
TestSend_ParsesEachBackendWireFormat:pi 侧必须解析出内容(洞 1 的回归护栏)、claude
侧逐条等价(完成定义「claude 行为与改造前逐条等价」)。变异:把解析钉死回
agent.NewClaudeProtocol(...).ParseLine → pi 子用例判红,打印出的四个事件 Content 全空,
与 P0 症状逐字一致。
TestSend_ZeroEventsWithStderrIsAnError:零事件 + stderr → 必须报错且带上原因;**并带
反向对照**(有正常事件 + stderr 噪音 → 不得报错),否则「只要 stderr 非空就报错」也能
让前一腿变绿,而那会把所有带告警输出的正常回合一起打成失败。变异:去掉该判定 → 判红。
配合上一个 commit。此前分支上只有每个 Task 的自审记录,没有对整个 diff 的独立 review。 方法:5 条 fresh-context **只读** reviewer lane(无 bash、无写权限),diff 按目录切片 分派,comm 比对确认 68 个改动文件无一落在 lane 之外。每条 lane 只报有代码证据的缺陷、 给 文件:行、以 Merge verdict 收尾;无法自行验证的疑虑单列「需要父进程验证」,由父进程 逐条实测(结果一并记入)。判决:A/B/D/E 为 OK with notes,C 为 BLOCK(理由即那条 P0)。 按维护者指示「不是阻塞合并的就先不修」,P1×7 与 P2×14 **全部未修**,但逐条留档了位置、 后果与修法,以便后续单独处理。其中值得优先看的三条: - pi 路径的回合失败对前端完全不可见(piMessage 没有 stopReason/errorMessage,失败被 归一化成一条空 assistant + 正常 done,运维零日志) - proxy 入参未校验,是绕过 pi-web-access SSRF 防线的第二条通路(normalizeProxyUrl 不做 私网检查,assertPublicAddress 只管目标 URL),与「强度不低于 Python 版」相悖 - allowBrowserCookies:false 可被继承的 PI_ALLOW_BROWSER_COOKIES / FEYNMAN_ 同名变量静默 推翻(env 分支在读配置文件**之前**返回 true),而 Env() 不剔除这两个键 另记录 review 的正面结论(沙箱 44 条正则逐字节相同、URL 绕过候选逐个否定、-na 与 --tools 的实际效果在 pi dist 里找到因果证据、没有任何 fail-open 通路、ingest 确实跟随 开关),以及仍未实测的 5 条(需配额或人):其中最重要的是 D2 的核心假设 —— claude 的 -p 是否真读管道 stdin,若不读则摘要/分节/讲解/PDF 会全部静默返回空串且 err == nil。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
承 #92(Plan 1:Agent 抽象层,等价重构)。本 PR 是 Plan 2:让 LLM 后端可在
claudeCLI 与pi --mode rpc之间切换,管理员在 Settings 里选,前端聊天代码零改动。计划与逐 Task 完成记录:
docs/superpowers/plans/2026-09-12-pi-backend-switch.md;规格:docs/superpowers/specs/2026-09-12-pi-backend-switch-design.md。改了什么
backend/agent/PiProtocol(旗标/env/stdin 编码/事件解析/探测)与包级 resolver(Init/Current/Invalidate);把所有NewClaudeProtocol(...)硬编码构造点换成Current()backend/claude/proto.Bin()/proto.OnceArgs()/proto.ParseLine();pi 的get_state响应归一化成system/init,于是握手、local-fallback ID、resume 降级链一行未改backend/scripts/pi-path-validator.ts(Pythonpath-validator.py的对应物)+ 部署模板web-search.json.samplebackend/config/PiBin;web 工具名的单一事实来源(解析web-search.json的toolNames,同时供--tools与PI_WEB_TOOLS);检测部署配置是否仍开着 pi-web-access 的扩展命令GlobalSettings.LLMBackend(默认claude);PUT /api/admin/settings仅 admin,切到不可用的 pi 返回 400(与 403 文案可区分)ChatView.tsx与hooks/的 diff 为空start.sh补 pi/pi-web-access/web-search.json检查、PATH 补/opt/homebrew/bin、沙箱脚本总是覆盖复制到运行时scripts/;README 双语补前提与 5 条运维警示覆盖范围是两个聊天入口 + ingest(摘要/分节/讲解)+ 翻译 + PDF 逐页转 Markdown。
pi 路径的安全模型
--tools白名单(文件工具 ∪ 三个 web 工具,source_check不授予)、--no-skills、--no-prompt-templates、--no-context-files、-na(挡掉用户可写目录里的.pi/扩展与项目 settings);不含--no-extensions(否则 pi-web-access 加载不了)与任何 skip-permissions 类旗标。tool_callhook 做白名单 + 路径边界 + 44 条敏感路径正则(与 Python 版逐字节同序相同,由三语言同步测试守护)+ URL 校验(堵fetch_content的本地文件读取攻击面)。ALLOWED_DIR未配置时拒绝一切(fail-closed);沙箱文件缺失时OnceArgs/SessionArgs直接报错,不产出 argv(缺失是 fail-closed,陈旧是 fail-open,所以start.sh总是覆盖)。/开头的用户消息会被当扩展命令派发执行(四道既有防线全拦不住,唯一收口是web-search.json的commands.*.enabled=false)、R10 rpc 的bash命令完全绕过沙箱且 pi 没有旗标能关掉它(唯一屏障是「只有 Go 进程能写 pi stdin」,由json.Marshal按构造保证 + 注入测试钉住)。合并前 review
5 条 fresh-context 只读 reviewer lane 并行覆盖整个 diff(
comm比对确认 68 个改动文件无一遗漏),判决 A/B/D/E = OK with notes、C = BLOCK。完整记录见计划文档末尾「分支整体 review」一节。BLOCK 项(P0)已修(
3a05dc8),它是两个独立的洞、同一类后果 —— 切到 pi 之后 once 链路会静默产出空内容,而api/translate.go拿空串无条件覆盖已有的paper_<lang>.md并回 200:Client.Send的 spawn 已跟随 resolver,解析却仍用 claude 的RawEvent;pi 的--mode json词表与它没有交集 → 所有事件落到 switch 之外,Content/Result恒空而err恒 nil。pi --mode json在凭据/模型不可用时 退出码是 0,stdout 只有 session 头行,原因只写在 stderr,而Send没有接管 stderr(nil→/dev/null)→ 修好第 1 条后这条路径仍是「零事件 + nil error」。切换探测拦不住它(ProbePi不验凭据)。两条都有假二进制护栏(零配额)与变异验证;第 2 条还带反向对照(有正常事件 + stderr 噪音不得报错),否则「只要 stderr 非空就报错」也能让前一腿变绿。
P1×7 与 P2×14 按维护者指示未修,全部逐条留档(位置 + 后果 + 修法)。建议优先处理的三条:
piMessage没有stopReason/errorMessage,失败被归一化成一条空 assistant + 正常done,运维零日志(claude 同场景会变成 SSEerror)。proxy入参未校验,是绕过 pi-web-access SSRF 防线的第二条通路(normalizeProxyUrl不做私网/环回检查,assertPublicAddress只管目标 URL,fetchViaCurl会真的curl -x <proxy>建连),与「pi 路径强度不低于 Python 版」相悖。allowBrowserCookies:false可被继承的环境变量静默推翻:isBrowserCookieAccessAllowed()在读配置文件之前就看PI_ALLOW_BROWSER_COOKIES/FEYNMAN_ALLOW_BROWSER_COOKIES,而PiProtocol.Env()不剔除这两个键。另有一条 2 行的编译修复也在 P1 里:
ingest/sections_integration_test.go仍按三参调Sectionize,go vet -tags=integration ./ingest/实测失败(默认闸门看不见,因为它是backend/里唯一带 build tag 的文件)。已跑的闸门
默认
go test ./...保持零配额:两个真实 spawn 的集成测试 gated 在LLM_KNOWLEDGE_PI_INTEGRATION=1之后。合并后仍需人工/配额的验收(计划 Task 11 的剩余项)
fetch_content本地文件读取攻击面真跑一次派发(判定逻辑与「prepareToolCall是唯一咽喉点、block时不调用tool.execute」已源码级证明,缺的只是经验证据)LLMBackend=pi时重跑test_chat_streaming.py(12 条真发消息)-p是否真读管道 stdin —— 若不读,摘要/分节/讲解/PDF 会全部静默返回空串且err == nil已知既有问题(不属本 PR)
LLMExtract与pdftoppm的页码补零约定不一致:少于 10 页的 PDF 会静默产出空paper.md却返回 200。本 PR 未修(修它会改变 claude 路径行为)。api/translate.go可能既有就把Content与全部 text 块重复写入(需一次真实 claude 翻译确认),与本 PR 无关,已记入 review 记录。