# 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
1
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 });
  });
};
1
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); }
}
1
2
3
4

miniWs.ts 则手写了 WebSocket 两件事:

HTTP Upgrade 握手:Sec-WebSocket-Key + GUID → sha1 → Accept
帧解析:opcode / length / mask,客户端帧 XOR 解码
1
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 后端配一个真正的浏览器页面,做聊天框、流式打字机和权限弹窗。