主题
代码搜索
本课交付结果
你将交付 searchCode():通过注入的进程执行器调用 rg --json,解析 match 事件为路径、行、列和文本,并处理无匹配、超时、执行错误与结果上限。
完成后,Agent 获取的是稳定 SearchHit,而不是一大段依赖终端颜色和文本格式的 stdout。
岗位问题
搜索通常是 Coding Agent 的第一个定位动作,也最容易被错误实现成 exec("rg " + query)。这会把模型输入送进 shell,产生注入风险;普通文本输出还会因平台、颜色和文件名中的冒号而难以可靠解析。
岗位实现应把 query 当作单个 argv 数据,关闭 shell,消费机器可读 JSON 事件,并为时间与结果数量设置预算。
前置检查
前置知识快照
先确认安全文件边界通过:
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 08理解进程的 argv、退出码和 stdout/stderr。rg 的退出码 1 表示“没有匹配”,不是工具崩溃;退出码 2 才代表实际错误。
操作系统把程序名和参数数组交给进程,不需要 shell 解释。exec("rg " + query) 则先把整段文本交给 shell,分号、重定向、变量替换和命令替换都可能改变含义。模型输入必须永远占据一个独立 argv 元素;即使 query 看起来像命令,也只能被 rg 当作搜索模式。
上一课建立“能读哪里”的文件边界,本课要建立“如何发现证据”的资源边界。搜索不是越多越好:无界结果会占用进程内存、工具返回和模型上下文。搜索工具应该返回可排序、可截断、可追踪的命中结构,而不是替模型倾倒整个仓库。
原理拆解
安全搜索管线包含五步:
- 校验 query、工作区和 maxResults。
- 以固定 executable
rg和数组 argv 调用注入执行器,禁用 shell。 - 区分超时、退出码 1 与其他非零退出。
- 逐行解析 JSON,只接收
type: "match"事件。 - 规范化字段并在达到结果上限时停止。
执行器注入使测试不依赖本机是否安装 rg,也能精确证明参数没有经过 shell 拼接。
mermaid
flowchart LR
A[query] --> B[校验与固定 argv]
B --> C[rg --json -- query .]
C --> D{退出状态}
D -- 1 无匹配 --> E[空命中]
D -- 超时或异常 --> F[结构化失败]
D -- 0 --> G[逐行 JSON 事件]
G --> H[验证 match 字段]
H --> I[路径/行/列/文本]
I --> J[数量与输出预算]固定工作目录比把绝对 workspace 当作自由参数更容易审计。总项目用 execFile、固定 cwd、shell: false,并默认忽略 .git 与 node_modules。Lab 为了让 argv 断言清晰,把 workspace 放在最后一个参数;两者核心相同:程序固定、参数分离、工作区可信、资源有限。
-- 是命令行协议中的安全细节。它告诉 rg 后续值不再是选项,所以 -g 或 --help 形式的查询不会改变执行配置。关闭 shell 解决的是 shell 注入,-- 解决的是目标程序选项注入;两层威胁不同,必须同时处理。
为什么选 JSON 而不是 path:line:column:text?文本中的冒号、换行、颜色码和 Windows 盘符会破坏切割。rg --json 把事件类型和嵌套数据显式编码,解析器只消费 match,自然忽略 begin、end、summary 等事件。协议仍然是不可信数据,所以每个所需字段都要验证。
列号需要明确语义。Lab 使用第一个 submatch 的零基 start 加一得到列号;行号来自事件的 line_number;文本去掉末尾换行但保留内容。多字节字符下,工具给出的偏移可能是字节而不是用户感知字符,产品展示若要求精确高亮,需要把协议语义写清并做专门转换。
退出码属于工具协议,不能一概看成异常。对 rg,零表示匹配成功,一表示正常但无匹配,其他非零才是故障。Agent 经常搜索不存在的符号,空结果是重要证据,不应触发相同查询的盲目重试。错误分类越准确,循环越能选择扩大范围、改变查询或停止。
代码实验
失败实现
危险实现把模型文本拼入命令:
ts
export async function unsafeSearch(query: string) {
return exec(`rg --line-number ${query} .`);
}安全的执行边界使用固定程序与参数数组,并把进程能力注入:
ts
const result = await runner("rg", [
"--json",
"--glob", "*.ts",
"--",
query,
".",
], { cwd: workspace, shell: false, timeoutMs });
if (result.timedOut) throw new SearchError("SEARCH_TIMEOUT");
if (result.exitCode === 1) return [];
if (result.exitCode !== 0) throw new SearchError("SEARCH_FAILED");注入 runner 的价值不只是方便 mock。它把“允许执行什么”集中在调用点,测试能断言 executable、每个 argv、cwd、shell 和超时,不需要真的启动子进程。正式实现仍要对 runner 的返回做边界校验,不能因为它是内部接口就默认 stdout 合法。
bash
pnpm --dir bootcamps/coding-agent/labs/09-code-search/starter test
pnpm --dir bootcamps/coding-agent/labs/09-code-search/solution testStarter 会以 EXERCISE_NOT_IMPLEMENTED:实现 rg JSON 事件解析 失败。Solution 应通过 5 项测试,覆盖安全 argv、事件解析、无匹配、超时和结果截断。
解析测试应混入非 match 事件,并验证第一个子匹配的列:
ts
it("只解析有效 match 事件", async () => {
const runner = vi.fn(async () => ({ exitCode: 0, timedOut: false, stdout:
'{"type":"begin","data":{}}\n' +
'{"type":"match","data":{"path":{"text":"src/a.ts"},"lines":{"text":"const hit = 1;\\n"},"line_number":4,"submatches":[{"start":6}]}}\n' }));
const hits = await searchCode({ workspace: ".", query: "hit", maxResults: 5, runner });
expect(hits).toEqual([{ path: "src/a.ts", line: 4, column: 7, text: "const hit = 1;" }]);
});关键实现讲解
命令与参数必须分离:
ts
await run("rg", ["--json", "--line-number", "--column", "--", query, workspace], {
shell: false,
timeoutMs,
});-- 明确结束选项,避免以 - 开头的 query 被解释为 flag。解析 JSON 时验证 data.path.text、line_number、submatches[0].start 等嵌套字段;畸形行应产生受控错误或被明确忽略,不能让 undefined 潜入上下文。
结果上限应在解析过程中生效。一旦收集到 maxResults 个完整 match 事件,就停止构造更多对象;生产 runner 还应主动终止进程或限制 stdout buffer,否则只是少返回,子进程仍可能生成海量数据。数量上限、字节上限与超时共同形成资源预算。
解析策略要在“严格失败”和“容忍单行损坏”之间明确选择。内部固定版本的 rg 出现畸形 JSON 通常意味着协议或执行器异常,严格失败更容易发现问题;跨版本远程适配器可以跳过未知事件,但必须统计丢弃数量并在结果中标记不完整。悄悄吞掉所有错误会把协议故障伪装成无匹配。
运行与验证
真实运行输出
bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 09
pnpm --filter @coding-agent/tools test单课门禁应报告 observedTests: 5。检查执行器收到的 executable 恰为 rg、shell 为 false、query 只占一个 argv;再确认退出码 1 返回空数组而非失败。
实际执行 Starter 五项失败,Solution 五项通过:
text
starter: 5 failed, 0 passed
solution: 5 passed, 0 failed
覆盖: 安全 argv / JSON match 解析 / 无匹配 / 超时 / 结果截断再用包含分号、空格和以短横线开头的查询检查 runner spy。无论内容多像命令,参数数组长度与位置都不应改变。把退出码改成一时得到空数组,改成二时得到结构化失败,证明协议分支没有混淆。
常见失败与排查
故障案例 1
查询文本改变了命令含义。
症状:搜索包含分号或重定向字符时出现额外进程、文件或异常 shell 输出。
根因:模型 query 被拼进命令字符串并交由 shell 解释。
定位:给 runner 传入带元字符的测试查询,检查调用是否只有一个命令字符串以及 shell 是否开启。
修复:固定 executable,使用 argv 数组和 shell: false,再用 -- 终止 rg 选项解析。
故障案例 2
正常无匹配被当成系统故障。
症状:搜索冷门符号后 Agent 重复相同查询,最终报告工具不可用。
根因:通用子进程封装把所有非零退出码都抛成异常,没有保留 rg 的退出协议。
定位:让 runner 返回退出码一和空 stderr;若结果不是空数组,协议映射错误。
修复:在搜索适配器中显式处理零、一与其他状态,保持空证据和执行失败的差异。
故障案例 3
结果已截断,资源却仍被耗尽。
症状:接口只返回十条命中,进程仍因巨大 stdout 超时或超过内存。
根因:先缓冲并解析全部输出,最后才对数组调用 slice。
定位:生成大量匹配并监测 runner 输出字节、解析对象数量和子进程持续时间。
修复:流式逐行解析,达到数量或字节预算后终止子进程;同时设置超时和最大 buffer。
课后作业
增加 glob allowlist 与二进制文件排除,并写测试覆盖包含 shell 元字符的 query、畸形 JSON 事件和多字节字符列号。设计一个总输出字节上限,使执行器在结果过多时主动终止进程。
进阶要求是实现流式解析器:输入任意切分的 stdout chunk,仍能正确拼接完整 JSON 行;达到预算后发送取消并等待子进程退出。再记录查询、忽略规则、耗时、扫描文件数和截断状态,但不要把可能包含秘密的完整匹配文本写进长期日志。
验收 Rubric
| 维度 | 通过标准 | 常见扣分 |
|---|---|---|
| 进程安全 | 固定程序、数组 argv、关闭 shell | 拼接模型输入为命令字符串 |
| 语义 | 退出码 1 映射为空结果 | 无匹配被当成系统故障 |
| 解析 | 只接受验证后的 JSON match 事件 | 依赖人类可读输出切割 |
| 预算 | 超时与结果数量都有硬上限 | 搜索可无限运行或返回全部仓库 |
总项目增量
总项目包:@coding-agent/tools
总项目路径:packages/tools/src/search-tool.ts
总项目验证命令:pnpm --filter @coding-agent/tools test
总项目现在能安全定位候选代码。下一课会按相关性与字符预算筛选这些候选,防止“找到很多”演变成“全部塞进提示词”。
延伸阅读
方案对比与工程取舍
shell grep 拼接实现最快,却有注入、转义和文本解析问题;固定 argv 的 rg --json 性能好、协议清楚,但依赖外部二进制及版本;在进程内遍历并用正则匹配便于完全控制,却要自己实现忽略规则、二进制判断、编码与高性能扫描。对本地 Coding Agent,受限 rg 是成熟能力与实现成本之间的合理选择。
spawn 适合流式消费与主动终止,能在达到预算时停止;execFile 接口更简单,会缓冲输出并可设置最大 buffer。小仓库或严格低上限可以使用 execFile,大型仓库更适合 spawn 加逐行解析。无论选择哪种,都应保持 shell 关闭,并把超时、取消和退出码映射放在同一适配器中。
搜索范围决定信噪比。默认扫描整个工作区简单,却会把依赖、构建产物和版本控制对象淹没结果。内置忽略 .git、node_modules、输出目录和二进制文件,结合项目忽略规则,通常能显著降低成本。用户显式指定 glob 时仍要经过 allowlist,不能让参数变成任意 rg 选项通道。
查询本身也应有长度与模式复杂度上限。虽然 ripgrep 的正则引擎避免许多灾难性回溯,超长模式、海量交替或空模式仍会浪费资源。合同可以默认固定字符串搜索,只在明确字段开启正则,并限制模式长度。把“文本”和“选项”分成不同 Schema 字段,比让模型拼一段命令更易校验。
固定字符串与正则是产品取舍。代码符号定位多数时候固定文本足够,转义更少、结果更可预测;结构搜索或迁移任务需要正则。可以提供两个明确工具或一个枚举模式,而不是根据查询字符猜测。隐式猜测会让同一输入在转义变化后突然改变含义。
结果结构应保留最小可用证据:规范化相对路径、行号、列号、匹配行和必要上下文。返回整个文件会绕过预算,只有路径又迫使模型发起大量读取。上下文行数可以作为受限参数,并在总字节预算内计算。所有命中应标记是否截断,让 Agent 知道证据不完整。
排序也会影响模型行为。ripgrep 的遍历顺序可能随文件系统变化;总项目对结果稳定排序,降低评测噪声。另一种做法是保留扫描顺序以尽早返回,之后在上下文预算层按相关性重排。关键是把选择写清:性能优先的流式顺序与确定性优先的排序不能同时假装免费。
超时错误应包含可行动信息,但不能让 Agent 原样无限重试。结果可以建议缩小 glob、改用更具体查询或降低上下文行数。Runtime 结合剩余预算限制重试次数。若超时只返回“失败”,模型缺乏改进方向;若自动用更宽范围重试,则会放大故障。
取消与超时不同。超时由系统预算触发,取消可能来自用户停止或上层会话结束。二者都要终止子进程并回收句柄,但错误码和界面语义不同。总项目把 AbortSignal 传入执行边界,使搜索不会在会话结束后继续占用 CPU。
环境变量同样属于执行面。即使 shell 关闭,子进程仍可能继承包含密钥的环境;通常 rg 不会主动输出它们,但最小环境更稳妥。固定 PATH 或解析受信二进制位置,避免恶意仓库通过路径优先级提供同名 rg。工作目录必须来自可信 Context,而不是模型 input。
JSON 事件解析要防御巨大单行与无换行输出。流式实现维护有限缓冲,超过单行上限就终止;每个事件先确认对象、类型和字段,再构造命中。捕获 JSON.parse 异常时记录行号与受限摘要,不回显整段可能敏感内容。
列偏移的展示需要测试非 ASCII 文本。字节偏移、UTF-16 索引和用户看到的 Unicode 字符位置可能不同。若列只用于模型参考,可以明确沿用 rg 协议;若编辑器要精确定位,应从原行按字节转换,或使用 rg 提供的语义并与编辑器协议对齐。未经说明的“加一”容易在中文代码注释中错位。
无匹配也值得进入 Trace,它能解释 Agent 为什么扩大搜索范围。记录查询哈希、范围、耗时和结果数即可,完整 query 可能含用户秘密时需要脱敏。连续多个空结果是评测信号:可能是模型选词差,也可能是索引范围配置错误。
测试应完全控制 runner 返回,覆盖 argv 元字符、退出码一、退出码二、超时、取消、畸形事件、未知事件、缺字段、多个 submatch、结果上限和稳定路径。另保留一项真正调用本机 rg 的可选集成测试,防止 mock 协议与实际版本漂移;它不应成为所有单元测试的环境前提。
安全评审最后要问:程序名是否固定,shell 是否关闭,参数是否有 --,cwd 是否可信,环境是否最小,输出是否有三重预算,退出码是否按协议解释,解析是否验证字段,结果是否标记截断,子进程是否在取消后真正退出。任何一个遗漏都可能在大型或不可信仓库里放大。
以一次真实定位任务串起这些设计。用户说“修复订单状态转换”,Agent 先搜精确符号 transitionOrder。query 作为一个 argv 传给固定 rg,cwd 是可信工作区,忽略依赖目录;JSON 事件返回三个 TypeScript 命中。解析器验证路径与行号,规范化相对路径,达到上限前结束。Agent 先读取定义与测试,而不是把三份完整文件都塞进上下文。搜索工具提供的是导航证据,不是最终答案。
若精确符号无匹配,退出码一映射成空数组,循环可以搜索 order status 或文件名。这个分支不消耗错误重试额度,因为系统没有故障。若 rg 不存在或返回退出码二,则应该提示工具环境问题,继续换查询没有意义。准确分类直接决定 Agent 是否采取有效下一步。
文件路径本身也要规范化和约束。即使 rg 在受信 cwd 运行,输出仍来自仓库遍历与外部进程,解析后应用安全路径规则确认是工作区相对路径。不要允许绝对路径悄悄进入后续 read_file,也不要把反斜杠差异传播到缓存与 Trace。工具之间共享同一相对路径合同,组合才安全。
命中行可能非常长,例如压缩后的生成文件或内嵌数据。仅限制命中数量无法控制总输出;每行应有字符上限,总结果还有字节上限,并标记文本被截断。最好在扫描层排除生成与二进制目录,避免花费 CPU 产生最终会丢弃的证据。
多行匹配需要特别设计。rg 的 match 事件可能包含多行文本,单个 line_number 表示起始位置,submatch 偏移相对于事件文本。若产品只支持单行 SearchHit,应在合同中限制或明确折叠;如果支持多行,应返回起止行列而不是假装只有一行。数据结构必须忠实于协议,不能为了方便静默丢语义。
搜索结果的相关性不应只由出现顺序决定。定义、测试、调用点的价值因任务而异;文件名、路径深度、符号完整匹配和近期修改都可形成评分。但评分属于检索与上下文层,不应污染底层执行安全。底层先提供确定、有限的事实,上层再按任务策略选择,这种分层便于独立评测召回率与选择质量。
如果安装了代码索引,可将 ripgrep 与语义搜索组合。精确文本搜索可解释、更新即时、无需预建索引;语义检索适合用户只描述概念而不知道符号名,却可能返回过时或难解释结果。常见策略是语义检索提出候选,再用文本搜索和文件读取验证。无论检索多智能,最终证据仍应回到受限工作区工具。
在 CI 或远程容器里,rg 版本差异可能改变事件字段或默认忽略行为。启动时记录版本,集成测试用受支持版本运行一个固定夹具,协议升级时比较快照。不要依赖用户全局配置;明确传递所需选项并禁用会改变语义的环境配置,让同一仓库得到可复现结果。
可观测性至少包含排队时间、进程时间、解析时间、退出状态、命中数、丢弃事件数、输出字节和是否截断。出现“搜索慢”时,才能区分进程扫描慢、输出过大还是解析器阻塞。仅记录总耗时会让优化变成猜测。
错误消息的建议必须与原因一致。超时建议缩小范围,输出超限建议更具体查询,正则错误提示修正模式,二进制缺失提示安装或切换内置后备;权限拒绝则不应建议重试。结构化错误码让这些建议可以在界面本地化,而不是靠模型解析 stderr。
最后做攻击性测试:query 依次包含空格、引号、分号、管道、美元符号、反引号、换行、前导短横线和 Unicode。断言每次 runner 都收到相同的固定参数骨架,query 只占一个位置,没有新文件或额外进程。这样的测试比代码评审中一句“这里应该安全”更有说服力。
完成后保存这组恶意查询为永久回归夹具,并在更换子进程库、升级运行时或支持新平台时重复执行。安全属性必须跨重构保持,而不是只对当前实现偶然成立。
下一课衔接
搜索工具现在能返回一组有界命中,但十个命中加上若干文件仍可能超过模型窗口。下一课实现 Context Budget:按相关性稳定排序,只选择能完整装入预算的片段,遇到过大的候选继续尝试后面的小片段,并明确报告是否截断。搜索负责召回,预算器负责取舍。
- ripgrep JSON 输出事件协议。
- Node 子进程
spawn与 shell 执行的安全差异。 - 结构化工具输出为何比终端文本更适合 Agent。
即使关闭 shell,也要限制可执行程序、工作目录、环境变量和运行资源;argv 安全只是执行策略的一部分。