源码篇第8章 收银台排队——pi-coding-agent

本章导读: 源码路线第 ⑦ 站(产品层)。仍在
pi-coding-agent,但聚焦core/下的三个子系统,读tools/file-mutation-queue.ts、extensions/runner.ts和project-trust.ts。这一站要划清三条容易混的线:同一文件的写入怎么排队、扩展加载成功不等于可信、项目授权到底拦的是什么。
启动链把会话和资源装配起来了,但小A心里还有两个疙瘩。第一个:他让 Agent 连续两次读同一个文件然后改它,忽然想到,两个写请求几乎同时到达同一个文件,会不会互相覆盖?第二个:他在项目里放了一个扩展目录,扩展确实加载成功了,但“加载成功”就等于“可以信任”吗?
“两个问题的答案都在代码里。”老Z说,“一个看文件写入怎么排队,一个看扩展和项目授权怎么接入。先说清楚:这些机制不构成完整沙箱。”
本章源码证据均来自上游 pi 标签 v1.0.0(提交 a13d35a742c6ef8462812a28fbe1d8c8b7431c32,包版本 1.0.0,2026-10-01)。本章跟读工具输出截断、同一文件写入排队、扩展的发现加载绑定,以及项目信任的判定顺序;不覆盖每个内置工具的参数 schema、扩展包管理、具体沙箱实现和第三方扩展审计。
内置工具通过会话进入循环
小A先把工具的事摆上桌:“那内置工具从哪来?全都塞给模型吗?”
“先看工具输出怎么控制体积。”老Z打开 packages/coding-agent/src/core/tools/truncate.ts:
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024;
export function truncateHead(content: string, options = {}): TruncationResult {
// 返回 content、truncated、totalLines、totalBytes 等元数据
}“这两个默认值写死,是这个快照里的源码事实,但函数接受 options,不能把它外推成所有调用的绝对限制。关键是结果包含截断元数据,让上层区分‘命令只有这些输出’和‘显示只保留这些输出’。”
“两条线是怎么算的?”
“两条独立线,谁先到谁先截。”老Z说,“splitLinesForCounting 数行,Buffer.byteLength(content, 'utf-8') 数字节。maxLines 或 maxBytes 任一超限就切,truncatedBy 字段记下是 'lines' 还是 'bytes'。文件读用 truncateHead 留头部,命令输出用 truncateTail 留尾部:日志末尾通常是结论,文件开头通常是声明,两边‘最有信息量’的方向不一样。”
“truncateHead 和 truncateTail 的边界行为也不一样?”
“对,这正是它们不可互换的原因。”老Z说,“truncateHead 承诺不返回半行:第一行本身就超过字节上限时,它直接返回空内容并置 firstLineExceedsLimit: true。truncateTail 允许例外,它从文件末尾往回攒,最后一行太大时,会从行尾截出 maxBytes 那段并把 lastLinePartial 置位,因为 bash 输出的最后一行往往是最重要的错误消息。连‘截断从哪头算’都影响元数据语义。”
再看 OutputAccumulator.snapshot():
snapshot(options = {}): OutputSnapshot {
const truncation = truncateTail(this.getSnapshotText(), ...);
if (options.persistIfTruncated && truncation.truncated) this.ensureTempFile();
return { content: truncation.content, truncation, fullOutputPath: this.tempFilePath };
}“它把滚动中的原始字节、可显示尾部和可能的完整输出路径分开。v1.0.0 还加了 readFullOutput(maxBytes),给能接收更多内容的调用方读完整输出:超过上限时保留头尾各一半,中间换成省略标记,并按字符边界切。正常路径返回可放入上下文的快照;超限时仍保留截断事实。临时文件创建或写入失败属于执行层必须处理的失败,不是模型可以忽略的文本。”
“为什么 OutputAccumulator 要单独维护 totalLines 和 totalDecodedBytes,而不是直接读 snapshot 的结果?”
“因为 snapshot 的 truncation 是对已组装文本算的,OutputAccumulator 累积的却是流式字节,append 时一边解码一边累加 totalDecodedBytes,遇到不完整的 UTF-8 边界会暂存到下一个 chunk。”老Z说,“所以 truncated 的判定用 this.totalLines > maxLines || this.totalDecodedBytes > maxBytes,跟 tailTruncation.truncated 可能不一致:前者是‘原始流超了’,后者是‘组装后文本超了’。这种分离让中间的坏字节不至于撑爆上下文。”
“那它怎么控制内存?”
“maxRollingBytes = maxBytes * 2,append 后如果 tailBytes 超过 maxRollingBytes * 2 就 trimTail(),只留尾部一块,并从 UTF-8 边界处切,尽量对齐行边界。”老Z说,“shouldUseTempFile() 只要任一线超了就开始写临时文件,ensureTempFile() 把此前攒的 rawChunks 一次性回填进文件流。所以内存占用有上限,完整输出不丢,前提是临时文件能写得成,那属于执行层的失败。”
core/tools/index.ts 汇集内置工具;read.ts、write.ts、edit.ts、bash.ts、powershell.ts、grep.ts、find.ts 与 ls.ts 分别实现具体能力,由 AgentSession 的工具配置和会话运行时接入模型循环。allToolNames 定义这八个工具名,powershell 是 v1.0.0 新增的,createToolDefinition/createCodingToolDefinitions 按工具名把 schema 和执行器组装出来。
要点:收益与代价
收益是上下文不会被单个命令输出轻易占满;代价是模型看到的是删节结果,可能遗漏位于中间的关键信息。截断信息及完整输出位置必须作为结果的一部分,不能伪装成完整日志。
同一文件的写入被显式排队
小A:“回到我的第一个疙瘩。”
“看 withFileMutationQueue()。”老Z打开 packages/coding-agent/src/core/tools/file-mutation-queue.ts:
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
await currentQueue;
try { return await fn(); }
finally { releaseNext(); /* 清理同一 queue */ }“逐段解释:先登记 key 对应的前序 Promise,再安装后继 Promise,之后才等待前序,最后无论 fn 成功或抛错都释放后继。这个 finally 是队列不会因失败永久堵塞的关键。正常路径是同 key 的 mutation 按提交顺序执行;边界一是 key 归一化失败;边界二是 fn 抛错,后续 mutation 仍必须获得释放。”
“这个伪码省略了什么?”
“省略了 registrationQueue。”老Z说,“实际代码不直接读写 fileMutationQueues。登记阶段把“算 key、读旧值、写新值”套进 registrationQueue.then(...) 串行执行,用来安装该 key 的队列链;真正读写文件内容的 mutation 本体,之后由这条 Promise 链串行执行。原因是 getMutationQueueKey 是 async 的(要 realpath),两个调用并发进入时可能在 Map 上 race:A 读到空,B 也读到空,两个都装自己的后继,互相看不见,后装的把先装的覆盖掉。registrationQueue 把这段登记串成原子操作,同 key 的链才不会断。”
把队列时序画出来,registrationQueue 的作用更直观:
两次几乎同时的 withFileMutationQueue("/x.txt", fn)(理想化时序):
mutation A 进入 mutation B 进入
registrationQueue.then(...) registrationQueue.then(...) ← 串行登记
| keyA = realpath → /real |(等 A 的登记完成)
| 查 Map → 空 | keyB = realpath → /real
| 建 current=A0 | 查 Map → 已是 A 的链
| Map[key] = A0 → nextA | Map[key] = nextA → nextB
| 完成登记 | 完成登记
| await current(A0) | await current(nextA)
| fn() | fn() ← 严格串行
| finally: releaseNext() | finally: releaseNext()“释放端也有讲究。”老Z接着说,“finally 里 release 后继之后,只有 fileMutationQueues.get(key) === chainedQueue 才删 key,链仍是当前链时才清理,否则会把别人刚装的链删掉。”
小A:“key 归一化怎么做?我猜是直接拿传进来的路径当 key?”
“先 resolve(filePath),再 realpath。”老Z说,“realpath 会解开符号链接,所以软链指向同一文件的两次写落到同一个 key、被串行。已存在路径会先经 realpath 归一;但文件不存在时 realpath 抛 ENOENT/ENOTDIR,isMissingPathError 捕获后回退用 resolvedPath,创建前的软链等价性会丢失。外部进程写入也不受这条队列管理。代价在这:写一个还没创建的文件,符号链接等价性就丢了,/tmp/new.txt 和软链 /link/new.txt 在创建前是两个不同的 key,可能并发执行。”
“所以不同文件不共享队列?”
“对。withFileMutationQueue(filePath, fn) 先将路径解析为队列 key,再等待该 key 的当前 Promise。不同 key 各走各的队列,互不共享。”
比喻:收银台排队
同一文件像同一队收银台,同 key 依次结账;不同的队之间没有承诺。
小A:“那我把所有写文件的操作都套上这个队列,写入就安全了?”
“安全谈不上。”老Z说,“它为同一规范化文件目标的 mutation 提供串行化,这是一条可核对的边界;不同路径指向同一资源、外部进程修改、业务语义冲突、跨文件原子性,都还在它外面。别把‘按文件排队’听成‘所有写入都安全’。”
扩展加载、绑定与事件
小A想起自己在项目里放的那个扩展目录:“加载确实成功了,可这到底证明了什么?”
“只证明了它被发现了、读进来了,不证明它能执行,更不证明它可信。”老Z翻出 packages/coding-agent/src/core/extensions/loader.ts:
addPaths(discoverExtensionsInDir(localExtDir));
addPaths(discoverExtensionsInDir(globalExtDir));
for (const p of configuredPaths) { /* 目录或显式文件 */ }
return loadExtensions(allPaths, resolvedCwd, eventBus);“发现顺序是项目本地、全局、显式配置;seen 集合避免同一解析路径重复加载。发现归发现,loadExtensions() 的结果还携带 errors,启动链会将其中部分错误提升为 diagnostics。”
“seen 去重用的是哪个路径?”
“path.resolve(p) 后比较。”老Z说,“它去重的是发现阶段的路径,存进 allPaths 的还是原始的 p 而非 resolved,因为 loadExtensions 内部还会再 resolve 一次、带上 cwd 上下文。同一个文件经两条路径进来,resolve 后相同就只加载一次。配置路径里指向目录的则先 resolveExtensionEntries 找 manifest,找不到再 discoverExtensionsInDir 列文件。”
“目录发现的规则具体是什么?”
“三档,不递归超过一层。”老Z说,“目录下直接放 *.ts/*.js 文件算一档;子目录里有 index.ts/index.js 算一档;子目录里有带 pi.extensions 字段的 package.json 也算一档,按 manifest 声明的路径加载。更复杂的包必须用 manifest,加载器注释里那句‘no recursion beyond one level’说的就是这个。addPaths 拿到的就是这三档的产物,再逐条交给 loadExtensionModule 用 jiti 动态 import,工厂函数执行时才有机会 registerTool/registerCommand。”
绑定这一步看 ExtensionRunner.bindCore():
this.runtime.sendMessage = actions.sendMessage;
this.runtime.refreshTools = actions.refreshTools;
this.getModel = contextActions.getModel;
this.isProjectTrustedFn = contextActions.isProjectTrusted;小A盯着这串赋值:“绑完之后,扩展就在自己的地盘里跑了?”
“绑定只是把扩展 API 指向会话动作和上下文读取器,它不做进程隔离。Provider 注册既可能在加载时排队,也可能在绑定后直接生效;注册异常会变成 extension error,而不是悄悄消失。扩展 API 的事件类型位于 extensions/types.ts:会话、消息、Provider 请求、工具执行、项目授权等事件都有类型化入口和结果类型。这套结构提供的是拦截和订阅点;每个 handler 依然没有被隔离执行,扩展来源也不能自动当成安全。”
“加载阶段的 API 和绑定后的 API 一样吗?”
“接口一样,行为不一样。”老Z说,“createExtensionRuntime() 里加载阶段的 action 方法是抛错 stub,sendMessage、appendEntry 这些直接 throw。合法的只有注册类调用:registerTool 写进扩展自己的对象,registerProvider、registerNativeProvider 和 v1.0.0 新增的 registerVirtualModel 往各自的 pending 数组里排队;注释写得很直白,notInitialized 会抛 ‘Extension runtime not initialized’。bindCore() 把 runtime 的 action 字段逐项换成真实实现,上下文读取器(getModel、isProjectTrusted 等)也在这里赋值。所以一个扩展如果在加载期调 sendMessage,会立刻抛错。别把它当 bug,加载期边界就是这么实现的。”
“bindCore 里那串赋值之后还做了什么?”
“它会 flush 加载期排队的 Provider 注册,遍历 pendingProviderRegistrations 调 providerActions.registerProvider,异常收成 diagnostic。”老Z说,“这里埋着一个默认值:isProjectTrustedFn 默认 () => true,getModel 默认 () => undefined,这是为了让 ExtensionRunner 在没绑定时也能跑(比如测试)。但这样没绑定也不拦,真正决定信任的是 resolveProjectTrusted(),不是这个字段默认值。”
项目信任决定资源是否可直接进入运行时
小A:“项目授权这层呢?是不是模型提示词里加一句‘要小心’就行?”
“不是。项目授权是资源加载过程中的一项条件,跟提示词没关系。”老Z切到 packages/coding-agent/src/core/project-trust.ts:
// packages/coding-agent/src/core/project-trust.ts,resolveProjectTrusted(v1.0.0,节选)
if (options.trustOverride !== undefined) return options.trustOverride;
if (!hasTrustRequiringProjectResources(options.cwd)) return true;
// 扩展的 project_trust 事件在这里先发言,见下文
const decision = options.trustStore.get(options.cwd);
if (decision !== null) return decision;
if (!options.projectTrustContext.hasUI) return false;“这段顺序区分三种概念:项目授权决定某些项目资源能否加载;beforeToolCall 一类 approval 决定某一次工具调用是否放行;OS 沙箱决定进程实际能访问什么。三者互补,但不可互换。”
小A:“要我说,最容易漏的是 trustStore 那步?”
“更容易漏的是扩展的 project_trust 事件。”老Z说,“它在已存 trustStore 决策之前,也就是扩展可以拦下来,自己回答‘信不信任’。emitProjectTrustEvent 把事件发给所有扩展,若任一返回 { result } 就用它的 trusted 字段,remember === true 时还会写进 trustStore。所以项目授权不是单纯的‘问用户’,扩展可以替你决定。代价也在这:一个恶意的已加载扩展能在这个事件里撒谎返回 trusted。”
“扩展事件里多个 handler 怎么算?”
“按扩展顺序逐个 handler 跑,第一个返回 yes/no 的赢,undecided 继续往下,全部 undecided 才落到 trustStore。”老Z说,“单个 handler 抛错会记成 ExtensionError 并继续,错误不阻塞其他 handler,也不等于拒绝。‘扩展先于存储’指的就是这个先手:已加载扩展对授权决策有先发言的资格,无论它本身可信与否。”
老Z把整条链的求值顺序和出口列成一张表:
resolveProjectTrusted() 顺序(v1.0.0):
| 第几步 | 检查 | 结果 |
| --- | --- | --- |
| 1 | trustOverride !== undefined | 直接返回 override |
| 2 | !hasTrustRequiringProjectResources(cwd) | 直接 true(无需信任资源) |
| 3 | 扩展 project_trust 事件 | 有 yes/no → 返回(可写 trustStore) |
| 4 | trustStore.get(cwd) !== null | 返回已存决策 |
| 5 | defaultProjectTrust | always→true / never→false / ask→往下 |
| 6 | projectTrustContext.hasUI 为假 | 返回 false(无 UI 默认不信任) |
| 7 | 有 UI 的 ask | 选择器,无选择也返回 false |“第 5、6 步要连起来看:always 和 never 不需要 UI,ask 才往下走;而 hasUI 为假时 ask 直接 return false。弹不出框,结果仍是不放行。”
“那 defaultProjectTrust 的 always/never/ask 又卡在哪?”
“卡在扩展事件没给出结果、且 trustStore 没存过决策之后。”老Z说,“always 直接信任、never 直接拒绝、ask 才走 UI。顺序整体是:override → 无需信任 → 扩展事件 → 已存决策 → 默认策略 → UI。这条链短路求值,前面的定了后面就不走,所以‘扩展先于存储’和‘存储先于默认’这两个相对位置是设计选择,调换会改变语义。”
要点:信任 ≠ 内容可信
项目已获信任不等于文件内容可信;项目未获信任也不等于所有风险消失。扩展代码、shell、网络和凭据仍需要各自的权限、审计和环境隔离策略。
边界责任表
| 边界 | 入口 | 允许什么 | 不保证什么 |
|---|---|---|---|
| 工具参数 | Tool schema/执行器 | 解析并返回错误结果 | 参数符合用户意图 |
| 文件 mutation | queue key | 同 key 串行 | 跨文件原子性、外部写入 |
| 扩展 | loader/runner | 发现、加载、事件绑定 | 扩展代码安全或兼容 |
| 项目授权 | resolveProjectTrusted() | 是否加载需信任资源 | 模型免受注入 |
| OS 隔离 | 不在这些符号中 | 无法由本章确认 | 文件、网络、进程最小权限 |
core/trust-manager.ts 识别需要信任的项目配置资源,并以 ProjectTrustStore 保存决策;resolveProjectTrusted() 按顺序处理显式覆盖、无需信任的项目、扩展的 project_trust 事件、已存决策和默认策略。
小A:“所以这已经是沙箱了吗?”
“不是。”老Z说,“从这些文件可确认的是发现、授权决策和调用边界;进程、文件系统、网络访问有没有被操作系统隔离,这些文件给不出证明。安全这事,得拿着实际执行器和部署环境去核验。”
失败路径与设计代价
“那扩展加载失败会怎样?”小A问。
“扩展加载或 Provider 注册错误会被汇入诊断,启动入口可将 error 诊断作为失败退出,并提示以禁用扩展的方式重启。工具输出截断、扩展 handler 异常和授权拒绝也都应保留为可观察结果,不能让模型拿一句‘已完成’把它们覆盖掉。”
“错误诊断怎么从‘warning’升级成‘error’?”
“看来源。”老Z说,“collectSettingsDiagnostics 把 settings 错误一律标 warning;resourceLoader.getExtensions().errors(模块加载失败)标 error;Provider 注册 catch 里标 error。main() 末端只检查 diagnostics.some(d => d.type === "error") 才退出 1,所以 warning 不阻断启动但会打印。这就是为什么‘扩展加载失败’会停、‘settings 字段拼错’只警告:前者影响功能,后者通常是历史兼容。”
“这里头还有个细节。”老Z补充,“error 里混着两种不同的来源:扩展模块本身 import 失败,和注册 Provider 时抛的异常。main() 里 runtime.diagnostics.some(...message.includes("Failed to load extension")) 时还会追加一条提示:Hint: Start without extensions using "pi -ne".,v1.0.0 里命令名取自 APP_NAME。也就是说,退出码同样是 1,提示文本会按失败类别切换,给用户的下一步动作不一样。”
“这套设计的账要两头算。”老Z收了个尾,“收益是核心不改主程序就能增加 Provider、工具、命令、界面和生命周期行为;代价是加载顺序、兼容性、可观测性和信任面都扩大。另外,所有内置工具的参数 schema、扩展包管理、具体 sandbox 实现、第三方扩展审计、用户级凭据策略,这一章都没碰。”
小结
这一章把“它能做什么”和“谁把能力带进来”这两个风险集中的问题落到了源码层面。工具层用截断和输出累积控制回填给模型的体积:默认两千行、五十 KB 的上限是可配置的边界,目的是防止单个命令输出撑爆上下文。同一文件的写入被显式排队,withFileMutationQueue 按 path 归一化的 key 串行化 mutation,finally 里释放下一项,保证队列不会因为一次失败永久堵塞。但这条边界只保护同一文件,跨文件、外部进程修改或业务语义冲突都不在它的管辖范围。扩展层负责发现、加载、绑定和事件分发;模块被读进来,跟它能执行、它可信,是三件不同的事。
队列的细节显出并发边界的两面:归一化用 realpath 解软链,让指向同一文件的两次写落到同一个 key;但文件不存在时回退到 resolvedPath,创建前的软链等价性就丢了。registrationQueue 把“算 key、读旧值、写新值”收拢成原子登记,杜绝并发读 Map 的 race;释放端只在链仍是当前链时才删除 key。扩展侧的对应物是“加载期排队、绑定后落地”:加载时 action 方法是抛错 stub,只有 Provider 注册能排队,bindCore 才把真实动作装进去并 flush 积压注册。加载失败、注册异常都以诊断形式保留,error 让启动停止并视类别给出 -ne 提示,warning 只打印不阻断。
项目授权决定某些本地资源能否直接参与运行时;无 UI 时 ask 的默认结果是不信任,不能用默认允许取代人工确认。resolveProjectTrusted() 的短路链里,扩展的 project_trust 事件排在已存决策之前,已加载扩展对授权有先手发言权,remember: true 还能改写 trustStore,这是设计选择,也是信任面扩大的来源。工具并发、扩展加载、项目授权这三层减少的只是部分工程风险,并不构成对不可信代码和副作用的完整隔离证明。把“项目已信任”“扩展已加载”当成“可以放心执行任意代码”,是这一章要纠正的误读。
源码走查
先读 truncate.ts 与 OutputAccumulator.snapshot(),确认截断元数据和完整输出路径如何产生;再从 withFileMutationQueue() 跟踪等待和 finally 释放。之后比较 discoverAndLoadExtensions()、ExtensionRunner.bindCore() 和 resolveProjectTrusted(),列出扩展从发现到取得会话能力前经过的决策点,并刻意检查无 UI 的 ask 分支。
