# Step 67: WebSocket 双向权限
一句话导读:把
canUseTool变成一次跨网络的请求-响应:服务端挂起 Promise,浏览器弹窗,用户回答后引擎继续。
# 一、这一步做了什么(What)
step67 手写最小 WebSocket 服务端,并把 Web 版权限从“自动放行”升级成“浏览器交互审批”。当引擎要调用工具时,服务端发送 permission_request,浏览器返回 permission_response,服务端 resolve 挂起的 canUseTool Promise。
# 二、面试官视角:为什么要做?(Why)
面试题:SSE 已经能流式输出了,为什么还要 WebSocket?
因为权限审批不是广播事件,而是请求-响应:模型要用工具,系统必须问用户“允许吗”,然后等用户回答。SSE 只能服务器到浏览器,无法承载这个交互闭环。
canUseTool()
→ permission_request
→ 用户点击允许/拒绝
→ permission_response
→ resolve Promise
→ 引擎继续或 denied
2
3
4
5
6
这正是 Bridge 控制通道的本质。
# 三、原理:它是怎么工作的(How)
step67/server/wsServer.ts 的核心是 pending map:
const pending = new Map<string, (allow: boolean) => void>();
const canUseTool = async (name, input) => {
const id = randomUUID();
return await new Promise<boolean>((resolve) => {
pending.set(id, resolve);
ws.send({ type: "permission_request", id, tool: name, input, risk });
});
};
2
3
4
5
6
7
8
9
收到浏览器响应:
if (msg.type === "permission_response") {
const r = pending.get(msg.id);
if (r) { pending.delete(msg.id); r(!!msg.allow); }
}
2
3
4
miniWs.ts 则手写了 WebSocket 两件事:
HTTP Upgrade 握手:Sec-WebSocket-Key + GUID → sha1 → Accept
帧解析:opcode / length / mask,客户端帧 XOR 解码
2
# 四、深入追问(面试常见 follow-up)
Q:为什么 canUseTool 可以返回 Promise?
A:QueryEngine 本来就 await canUseTool。把用户终端输入换成网络往返,只要最终 resolve 布尔值,内核无需感知前端在哪。
Q:pending map 为什么要用 id?
A:多个权限请求可能交错。id 把 response 关联回对应 Promise,避免答错请求。
Q:连接断开时 pending 怎么办?
A:step67 断开时把所有 pending resolve(false),避免引擎永远挂起。真实系统还会有 cancel/reconnect。
Q:为什么手写 WebSocket?
A:教学目的。手写握手和帧解析能看清 WS 不是魔法;生产会用成熟库。
# 五、踩坑 / 设计权衡
WebSocket 客户端到服务端的帧必须带 mask,服务端要 XOR 解开;服务端发给客户端不加 mask。这个协议细节很容易漏。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
permission_request/response | control_request can_use_tool / control_response |
| pending map 挂起 Promise | bridge permission callbacks |
| 手写 miniWs | ws / remote SessionsWebSocket |
| 单连接单会话 | workSecret、鉴权、重连、取消 |
# 七、一句话总结
Web 版 agent 的难点不是“把字显示出来”,而是把 CLI 的交互式权限变成可跨网络等待和恢复的控制消息。
# 下一节预告
下一篇是 step68:给 WebSocket 后端配一个真正的浏览器页面,做聊天框、流式打字机和权限弹窗。