24 讲路线 · 与 S09 配对
D09: Bridge IDE 深挖 · Bridge IDE
本讲在 S09 主线之上,聚焦实现细节、边界条件与自测;导图与主线相同模块,便于对照。
建议:先读完 S09,再按下方顺序走读源码与练习。
🔬 深挖目标
IDE Bridge 是双主状态同步:编辑器 buffer、宿主会话、文件系统三角形里任意一边更新都可能冲突;本讲抓协议消息与冲突解决策略。
📡 消息类型(抽象)
- 文档级:打开/关闭、变更 diff、光标/选区(视实现而定)。
- 命令级:从 IDE 触发 run、从宿主推送诊断。
- 心跳与重连:WebSocket 断线后如何对齐版本向量?
⚔️ 冲突模型
| 场景 | 典型策略 |
|---|---|
| 宿主写文件同时 IDE 编辑 | Last-write-wins / 提示重载 / OT |
| 大补丁 apply 失败 | 回滚到 snapshot + 通知模型 |
| 二进制文件 | 禁止文本 merge,整文件替换 |
🔗 与工具链
Apply_patch / write 工具应与 bridge 共用「单一写入层」,否则会出现磁盘与 buffer 撕裂(调试极痛)。
📖 走读顺序
- 定位 bridge server 启动点与端口协商。
- 列出所有 WS 事件枚举,标注哪些是 fire-and-forget。
- 断网 10 秒再恢复,观察是否丢事件;若丢,缺陷在哪?
✏️ 自测 1 · 参考答案:「宿主覆盖 IDE 未保存 buffer」测试用例
目标:验证当磁盘已被宿主(工具写文件)更新,而 IDE 里同一文件仍有未保存编辑时,产品行为是否符合预期(提示 / 重载 / diff),且不会静默丢一边的修改。
前置条件
- 工作区文件
src/foo.ts,磁盘内容为版本A。 - IDE 打开该文件,用户改成版本
B,未保存(buffer dirty)。 - Bridge 已连接;可观测 IDE 通知或 RPC 日志。
操作步骤(自动化可脚本化)
- 记录 IDE 侧「文档版本号」或 hash(若有);记录 buffer 文本
B。 - 在宿主侧触发一次合法写盘:例如模拟
Write/apply_patch工具把foo.ts写成版本C(与B冲突)。 - 不向 IDE 发送保存;观察 bridge 是否推送「文件在磁盘已变」类事件。
期望结果(择一或组合,需在需求里写死)
| 策略 | 断言 |
|---|---|
| 安全默认 | IDE 提示「磁盘已在外部修改」,禁止静默用 C 覆盖 buffer B;用户选「重载丢本地」或「另存为」。 |
Diff 流(重建树中有 openDiff 思路) | 打开 diff 页,左侧/右侧与 B/C 一致;用户 Accept 后 buffer 与磁盘对齐为选定版本。 |
| 宿主回读 | 工具链在写盘后若需继续对话,read_file 读到的是 C,且日志中可见「IDE 未保存」标记(若有)。 |
伪代码(集成测试骨架)
// 伪代码:用假 IDE client + 真文件系统即可跑通思路
test('host write while IDE buffer dirty', async () => {
const path = 'src/foo.ts'
await resetFile(path, 'A')
const ide = await openInFakeIDE(path)
ide.setBufferContents('B', { saved: false })
await hostToolWrite(path, 'C') // 应走与正式产品相同的写入层
const ev = await ide.waitForEvent('external_change' /* 或等价 RPC */, 3000)
expect(ev).toBeDefined()
expect(ide.getBufferContents()).not.toBe('C') // 未确认前不应静默等于磁盘
// 用户点击「Reload from disk」后:
ide.simulateReloadFromDisk()
expect(await readDisk(path)).toBe(ide.getBufferContents())
})
重建源码里与「在 IDE 里展示差异、等用户保存/关闭」相关的线索可见 useDiffInIDE.ts 中 callIdeRpc('openDiff', …) 及对 save / closed / rejected 消息的分支——测试用例应对照你的产品是否具备同等显式状态机。
✏️ 自测 2 · 参考答案:Bridge 为何不应直接执行任意 shell?
核心结论
Bridge / IDE 插件运行在另一信任边界(编辑器进程、扩展宿主),若在这里开「任意 shell 执行」旁路,会整体绕过终端侧已实现的 canUseTool、审计、配额与沙箱策略,形成双轨执行面——安全与可观测性都会塌缩。
分点分析
- 权限与同意:Bash 在 CLI 里走 D03 的 ask/auto/deny;若在 bridge 里直接
exec,用户永远看不到同一条确认链,「我明明没批准终端跑 rm」却发生删文件——责任界面断裂。 - 审计与合规:企业场景要回答「谁、在什么会话、对哪条命令点了允许」;旁路 shell 不会进入同一
tool_result/ 日志管道,事后无法复盘。 - 一致的工具契约:正式路径是「模型 → tool_use → 校验 → 权限 → 执行 → tool_result → 下一轮」;bridge 直连 shell 会破坏消息顺序与重试语义(D01)。
- IDE 侧攻击面:扩展可能被恶意配置或供应链污染;若扩展能直接 shell,等于给攻击者多一个入口,而宿主无法统一限流与封禁。
- 测试与复现:所有自动化应只断言「通过 MCP/RPC 触发了等价于某工具的调用」,而不是「机器上曾 spawn 过 sh」,否则 CI 与本地行为不可比。
反模式 vs 推荐模式(示意)
// ❌ 反模式:IDE / bridge 收到消息后偷偷 exec
bridge.on('runCommand', (cmd: string) => {
child_process.exec(cmd) // 无 canUseTool、无 tool_result、无会话归因
})
// ✅ 推荐:bridge 只传「意图」或结构化 payload,由 CLI 宿主调度工具
bridge.on('requestAction', (payload) => {
enqueueAsUserOrSystemMessage(/* … */)
// 最终仍进入 QueryEngine → Tool 执行 → 权限门 → 与终端路径一致
})
实务上,允许 IDE 触发有限、白名单动作(如「在终端里粘贴已生成命令」)可以讨论,但「任意 shell」应始终禁止;与 D03、D01 工具失败处理 同读,可拼出完整宿主安全模型。