数据截至 (上游 commit c76af90d88f4)
反向 RPC 与权限审批:手机上按下允许之后发生了什么
30 秒导读: HAPI 里 CLI 是主动拨号的一方(它连上 hub),可实际干活的代码全在 CLI 那台机器上。 于是 HAPI 造了一条反向通道:CLI 把自己会做的事登记给 hub,hub 再把这些登记项当成普通函数来
await。 手机上那张"要不要允许 Claude 改这个文件"的卡片,走的就是这条通道。
前置:拓扑与 socket 底座见 01 三方拓扑与协议底座,本地/远程双模见 02 本地/远程双模接管。
1. 先讲清楚这个问题(零基础也能懂)
一句话: 谁拨号,和谁能命令谁,是两件事。
HAPI 的连接方向是固定的:CLI 主动连 hub。这是必须的——CLI 跑在你的笔记本上,可能在 NAT 后面、可能在公司内网,hub 根本连不过去。
但真正能干活的只有 CLI:只有它能读你的项目文件、跑 git diff、把"允许"这个决定递给正在等着的 Claude 进程。hub 手上只有一根已经建立好的 socket。
于是就有了一个反直觉的需求:
被连的一方(hub),要能命令主动连过来的那一方(CLI)干活。
这就是本章说的反向 RPC(RPC = Remote Procedure Call,远程过程调用;"反向"指调用方向和连接建立方向相反)。
类比: 像客服热线。客户(CLI)打进来并报上自己能办的业务清单,之后坐席(hub)反过来指挥客户:"请你现在读一下这个文件",客户办完把结果报回来。电话是客户打的,指令是坐席发的。
用起来什么样。 你在手机浏览器里点开某个远程会话的文件树、翻一份 git diff、或者按下审批卡上的"允许"——这些全部是 hub 打回你笔记本的一次反向 RPC。Web 端自己没有任何文件系统。
2. 顶层全景:一次反向调用的两个阶段
怎么读这张图: 上半是"报清单"(只在连接/重连时发生),下半是"下指令"(每次调用发生一次)。
① 注册阶段 —— CLI 连上/重连时,把自己会的方法名报给 hub
┌─────┐ emit 'rpc-register' { method: "<sessionId>:readFile" } ┌─────┐
│ CLI │ ────────────────────────────────────────────────────────▶ │ hub │
└─────┘ └──┬──┘
写进方法表:method → socketId
② 调用阶段 —— hub 要 CLI 干活,查表拿到 socket,像调函数一样 await
┌─────┐ emitWithAck 'rpc-request' { method, params: JSON字符串 } ┌─────┐
│ hub │ ────────────────────────────────────────────────────────▶ │ CLI │
│ │ ◀────────── ack 回调:结果 的 JSON 字符串 ─────────────────── │ │
└─────┘ (超时 30s / 模型列表 120s) └─────┘
三个部件,各自一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
RpcHandlerManager | CLI 侧:存 handler、加前缀、上报方法名、统一 JSON 编解码 | cli/src/api/rpc/RpcHandlerManager.ts |
RpcRegistry | hub 侧:一张 method → socketId 的表,socket 断了就整批清掉 | hub/src/socket/rpcRegistry.ts |
RpcGateway | hub 侧:把"发事件 + 等 ack"包成带超时的 await 函数 | hub/src/sync/rpcGateway.ts |
方法名清单单独放在 shared/src/rpcMethods.ts 的 RPC_METHODS,三端共用同一份常量。
3. 注册端:CLI 怎么把自己"挂"到 hub 上
3.1 它要解决的小问题
hub 上同时连着很多会话、很多台机器。如果大家都注册一个叫 readFile 的方法,方法表立刻打架。
3.2 思路:给方法名加作用域前缀
RpcHandlerManager 构造时收一个 scopePrefix,注册时无脑拼在方法名前面:
// cli/src/api/rpc/RpcHandlerManager.ts:89-91
private getPrefixedMethod(method: string): string {
return `${this.scopePrefix}:${method}`
}
前缀是什么,由谁 new 它决定——这就是"会话级 RPC"和"机器级 RPC"的全部区别:
| 作用域 | scopePrefix 取值 | 注册处 | 方法名长什么样 |
|---|---|---|---|
| 会话级 | sessionId | cli/src/api/apiSession.ts:294-295 | abc-123:readFile |
| 机器级 | machine.id | cli/src/api/apiMachine.ts:134-137 | mach-7:spawn-happy-session |
没有第三种作用域。RPC_METHODS 常量表里也不标注某个方法属于哪一级 ——归属完全由"谁注册了它"和"hub 用哪个入口调它"决定(见 §5 末尾的坑)。
3.3 注册就顺手上报
registerHandler 做两件事:塞进本地 Map,然后如果此刻 socket 已连着,立即 emit 一条 rpc-register 告诉 hub:
// cli/src/api/rpc/RpcHandlerManager.ts:35-39
this.handlers.set(prefixedMethod, handler)
if (this.socket) {
this.socket.emit('rpc-register', { method: prefixedMethod })
}
注意那个 if:大部分 handler 是在 socket 连上之前就注册好的(构造函数里就调了 registerCommonHandlers,见 cli/src/api/apiMachine.ts:139),那时 this.socket 还是 null,这条 emit 直接跳过。
3.4 重 连时全量重注册(关键一招)
那些跳过的注册怎么补?靠 onSocketConnect —— 每次 socket 连上(包括断线重连),把 Map 里所有方法名重新播一遍:
// cli/src/api/rpc/RpcHandlerManager.ts:64-69
onSocketConnect(socket: Socket): void {
this.socket = socket
for (const [prefixedMethod] of this.handlers) {
socket.emit('rpc-register', { method: prefixedMethod })
}
}
这一手让"注册状态"变成幂等且自愈的:hub 那边的表是纯内存的,socket 一断整批作废(hub/src/socket/handlers/cli/index.ts:141-143 里 disconnect 调 rpcRegistry.unregisterAll(socket)),但 CLI 一重连就自己全量补回来,不需要任何持久化或对账逻辑。
对称地,onSocketDisconnect 只是把 this.socket 置回 null(RpcHandlerManager.ts:71-73),handler Map 一个不动。
两处 socket 事件的接线:cli/src/api/apiSession.ts:328-342(connect)和 cli/src/api/apiMachine.ts:554-555。
3.5 handleRequest:统一的 JSON 编解码 + 错误信封
CLI 侧收到 rpc-request 的接线只有一行,真正的活全在 handleRequest 里(cli/src/api/apiSession.ts:344-346、cli/src/api/apiMachine.ts:603-605):
this.socket.on('rpc-request', async (data, callback) => {
callback(await this.rpcHandlerManager.handleRequest(data))
})
handleRequest(RpcHandlerManager.ts:42-62)承担四件事,每件都很小但都省掉了各 handler 的样板代码:
| 职责 | 做法 | 行号 |
|---|---|---|
| 方法不存在 | 返回 {"error":"Method not found"},不抛 | :44-48 |
| 入参解码 | safeJsonParse,解析失败给 null 而非抛异常 | :50(:10-16 定义) |
| 出参编码 | 一律 JSON.stringify(result) | :52 |
| 异常兜底 | catch 住,转成 { error: message } 的字符串 | :53-61 |
重点看这里: handler 抛出的任何异常都被折成一个正常返回的 error 信封。这意味着 hub 侧的 await 不会因为 CLI 的业务错误而 reject——hub 只会在"根本联系不上 CLI"或"超时"时才拿到异常。这条边界后面 §4.3 还会用到。
safeJsonParse 的写法也值得一提:解析失败返回 null 而不是抛,于是 handler 拿到的永远是"要么是解好的对象、要么是 null",天然容忍空 params。
4. 路由端:hub 怎么把 socket 事件当函数调
4.1 一张表就是全部路由
RpcRegistry 只维护两个 Map(hub/src/socket/rpcRegistry.ts:4-5):
methodToSocketId:正查,"abc-123:readFile"→ socket idsocketIdToMethods:反查,用来在 socket 断开时一次性清干净
hub 收 rpc-register 的入口在 hub/src/socket/handlers/cli/rpcHandlers.ts:14-20,先用 zod 校验形状(method 必须是非空字符串),再写表。同文件 :22-28 是对称的 rpc-unregister。
unregisterAll(rpcRegistry.ts:37-49)有个细节:删正查表前会先确认 methodToSocketId.get(method) === socket.id,避免同名方法被新 socket 抢注后,旧 socket 的清理误删新登记。重连场景下这不是理论问题——旧 socket 的 disconnect 完全可能晚于新 socket 的 rpc-register 到达。
4.2 把 socket 事件包成带超时的函数
核心只有 rpcCall 一个私有方法(hub/src/sync/rpcGateway.ts:496-521)。先看思路演示:
// 示意,非源码:把"发事件 + 等对方回调"变成一次普通 await
async function callRemote(socket, method, params, timeoutMs) {
// socket.io 的 emitWithAck:对方调 callback 时这个 Promise 才 resolve
// .timeout(ms) 包一层:超时则 reject
const raw = await socket.timeout(timeoutMs).emitWithAck('rpc-request', {
method,
params: JSON.stringify(params) // 参数统一走 JSON 字符串
})
return JSON.parse(raw) // 结果也统一是 JSON 字符串
}
真实实现的两处关键行:
// hub/src/sync/rpcGateway.ts:402-405
const response = await socket.timeout(timeoutMs).emitWithAck('rpc-request', {
method,
params: JSON.stringify(params)
}) as unknown
返回值的解析很宽容(:407-415):不是字符串就原样返回,是字符串但 JSON.parse 失败也原样返回。不因为解码失 败而抛——这和 CLI 侧 safeJsonParse 是同一套哲学。
事件的类型签名在 shared/src/socket.ts:238(rpc-request 属于 ServerToClientEvents)和 :225-226(rpc-register / rpc-unregister 属于 ClientToServerEvents),一眼就能看出这条通道的方向是反的。
4.3 两个前缀化入口 + 两档超时
外部调用者不碰 rpcCall,只用两个薄封装(rpcGateway.ts:478-494):
private async sessionRpc(sessionId, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
return await this.rpcCall(`${sessionId}:${method}`, params, timeoutMs)
}
private async machineRpc(machineId, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
return await this.rpcCall(`${machineId}:${method}`, params, timeoutMs)
}
拼前缀的规则和 CLI 侧 getPrefixedMethod 必须逐字一致——两边各写一遍字符串拼接,这是这套设计里唯一的隐式契约。
超时分两档(rpcGateway.ts:40-41):
| 常量 | 值 | 用在哪 |
|---|---|---|
DEFAULT_RPC_TIMEOUT_MS | 30 秒 | 绝大多数调用 |
MODEL_LIST_RPC_TIMEOUT_MS | 120 秒 | 列模型 / 列会话 / 归档 Codex 会话 |
为什么模型列表要 120 秒:这类调用在 CLI 侧要去拉起或询问真实的下游 agent 进程(listCodexModelsForMachine :321-323、listCodexSessionsForMachine :325-328、listCursorModelsForSession :335-337),冷启动几十秒是常态。用 30 秒会把正常操作误判成挂死。
4.4 RpcTargetMissingError:把"CLI 已经没了"变成可容忍状态
rpcCall 在真正 emit 之前有两道检查(rpcGateway.ts:497-505):
查 RpcRegistry ──没这个方法──▶ throw RpcTargetMissingError(method, 'handler-not-registered')
│
有 socketId
▼
查 io.of('/cli').sockets ──socket 不在──▶ throw RpcTargetMissingError(method, 'socket-disconnected')
为什么要专门定义一个错误类型(rpcGateway.ts:50-62,源码注释引了 issue tiann/hapi#916):因为调用方需要区分"目标不存在"和"目标存在但出错了"。
真实用例在归档会话(hub/src/sync/syncEngine.ts:1662-1681):hub 重启把 runner 杀掉后,缓存里的 active 标记可能还没对账,这时 killSession 必然打空。老行为是把它当 500 抛给前端,用户看到一个删不掉的僵尸会话。现在:
try {
await this.rpcGateway.killSession(sessionId)
} catch (error) {
if (error instanceof RpcTargetMissingError) {
this.sessionCache.markSessionArchivedFromHub(sessionId, 'Archived from hub (CLI unreachable)')
} else {
throw error
}
}
妙在哪: "要杀的进程已经死了"在语义上就是成功。类型化的错误让这个判断能精确做出来——超时、协议错误这些真故障仍然照常冒泡成 5xx,不会被一起吞掉。
5. 方法目录:CLI 到底把哪些能力开放给了 hub
全部方法名集中在 shared/src/rpcMethods.ts:1-50 的 RPC_METHODS 常量对象里,三端 import 同一份,杜绝字符串手打。按用途分组:
会话控制(对某个正在跑的 agent 下命令)
| 常量 | 线上方法名 | 干什么 |
|---|---|---|
Permission | permission | 递交审批结果,本章主角 |
Abort | abort | 打断当前这一轮 |
Switch | switch | 本地/远程模式切换(见 02 本地/远程双模接管) |
SetSessionConfig | set-session-config | 改模型、改权限模式等(持久化见 04 Hub 的状态与同步) |
HandoffLocal | handoff-local | 把控制权交回本地终端 |
KillSession | killSession | 结束会话 |
机器控制(对某台机器的常驻 runner 下命令,见 05 Runner 与远程开会话)
| 常量 | 线上方法名 | 干什么 |
|---|---|---|
SpawnHappySession | spawn-happy-session | 凭空开一个新会话 |
StopSession | stop-session | 停掉某个会话进程 |
StopRunner | stop-runner | 停掉 runner 自己 |
文件与工具(Web 端所有"看起来像本地操作"的功能)
| 常量组 | 线上方法名 |
|---|---|
| 读写 | readFile · writeFile · readGeneratedImage |
| 目录 | listDirectory · list-directory · getDirectoryTree · statFiles · path-exists |
| 上传 | uploadFile · deleteUpload |
| 检索与执行 | ripgrep · bash · difftastic |
| git | git-status · git-diff-numstat · git-diff-file |
能力查询(前端下拉框里的选项从哪来)
| 用途 | 方法名 |
|---|---|
| 斜杠命令 / 技能 | listSlashCommands · listSkills |
| 各 agent 的模型列表 | listCodexModels · listCursorModels · listPiModels · listOpencodeModels · listOpencodeModelsForCwd · listGrokModels · listGrokModelsForCwd |
| 推理强度选项 | listGrokReasoningEffortOptions · listOpencodeReasoningEffortOptions |
| Codex 会话管理 | listCodexSessions · archiveCodexSession |
| Cursor 存储状态 | cursor-chat-store-status |
这批"文件与工具"handler 的注册入口是一个函数:registerCommonHandlers(cli/src/modules/common/registerCommonHandlers.ts:19-35),它一口气挂上 bash / files / directories / ripgrep / difftastic / git / uploads / skills / slashCommands / 各家模型探测共 16 组。会话端和机器端都调用它,所以同一批能力在两个作用域下各存在一份。
Web 端的文件浏览器其实是一层壳
这条链子值得完整看一遍,因为它解释了"为什么 HAPI 的 Web 端不需要任何后端存储":
浏览器点开文件树
└─▶ GET /api/…(hub/src/web/routes/git.ts:295)
└─▶ syncEngine.listDirectory(hub/src/sync/syncEngine.ts:2345-2346)
└─▶ rpcGateway.listDirectory(rpcGateway.ts:285-287)
└─▶ sessionRpc("<sid>:listDirectory")
└─▶ 你笔记本上的 fs.readdir
同样的形状还有三条:git-diff-file(git.ts:128)、readFile(git.ts:157)、ripgrep(git.ts:243)。hub 从头到尾只是个转发器,一个字节的项目内容都不落盘。
git.ts:39-45 的 runRpc 包装器把这层的异常统一转成 { success: false, error },所以 CLI 离线时前端看到的是一条错误提示,而不是白屏。
一个容易踩的坑:作用域不是方法名的属性
同一个方法名可以同时存在于两个作用域,取决于调用入口:
// hub/src/sync/rpcGateway.ts:335-341
async listCursorModelsForSession(sessionId) { return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, ...) }
async listCursorModelsForMachine(machineId) { return await this.machineRpc(machineId, RPC_METHODS.ListCursorModels, ...) }
ListCursorModels 既是会话级也是机器级。看 RPC_METHODS 表本身看不出归属,必须看 gateway 用了哪个入口、以及 CLI 哪一侧注册了它。
另有一个特例:Pi 系 agent 不再写逐方法包装,改走一个泛型入口 callPiRpc(rpcGateway.ts:422-426),方法名由调用点传入(如 hub/src/web/routes/sessions.ts:1523 直接传 RPC_METHODS.ListPiModels 并指定 120 秒超时)。
6. 权限审批闭环:从模型想调工具到手机上那一下点击
这是反向 RPC 最完整的一条路径,也是全章的重点。以 Claude 远程模式为例。
6.1 先看全景时序
怎么读这张图: 竖线是四个角色,横箭头是消息,⏸ 标出那个被挂起、等着被唤醒的 Promise。
Claude SDK CLI 进程 hub 手机/Web
│ │ │ │
① │ canCallTool │ │ │
├───────────────▶│ ⏸ new Promise │ │
│ │ │ │
② │ ├ agentState.requests[id] = {tool,args}│
│ ├────────────────▶│ │
③ │ │ ├── session-updated ▶│ 渲染审批卡
│ │ │ │
④ │ │ │◀── POST …/approve ┤ 用户按"允许"
│ │ │ │
⑤ │ │◀── rpc-request ─┤ sessionRpc( │
│ │ "<sid>:permission" Permission) │
│ │ │ │
⑥ │◀── resolve ────┤ allow / deny │ │
│ 工具继续执行 │ │ │
6.2 ① 拦截:SDK 的 canCallTool 回调
Claude Agent SDK 在真正执行任何工具前会回调一个函数问"能不能调"。HAPI 把 PermissionHandler.handleToolCall(cli/src/claude/utils/permissionHandler.ts:290-348)接到这里。
进入挂起流程前,它先走三层快速通道(命中就直接返回,不惊动用户):
| 顺序 | 条件 | 行号 |
|---|---|---|
| 1 | 该工具已在本会话被"永久允许"过(Bash 还要匹配字面量/前缀) | :294-310 |
| 2 | 权限模式是 bypassPermissions(YOLO) | :319-329 |
| 3 | 权限模式是 acceptEdits 且该工具描述符标记为 edit 类 | :331-333 |
第 1 层的 Bash 处理是个细节:parseBashPermission(:387-411)把前端传来的 Bash(git status) 存成字面量、Bash(git:*) 存成前缀,下次同类命令就不再问。
权限模式的读取方式也有讲究——它是个 getter,每次都从 session 现读:
// cli/src/claude/utils/permissionHandler.ts:179-181
private get permissionMode(): PermissionMode {
return this.session.getPermissionMode() ?? 'default'
}
源码注释(:174-178)写明了原因:以前它是个只在批次边界更新的字段,导致用户在一轮对话进行中从 Web 下拉框改权限模式会被静默忽略(issue #735)。改成 getter 后,SetSessionConfig RPC 写进去的新模式下一次 canCallTool 就生效。
6.3 ② 挂起:把 Promise 存起来,把请求写进 agentState
快速通道都没命中,就走 handlePermissionRequest(:353-381)。它 new 一个 Promise 但不 resolve,把 resolve/reject 两个函数存进 pendingRequests,同时挂上 abort 信号的清理(:361-365)。
存的动作在基类 addPendingRequest(cli/src/modules/common/permission/BasePermissionHandler.ts:173-192):
this.pendingRequests.set(id, { ...handlers, toolName, input })
this.onRequestRegistered(id, toolName, input)
this.client.updateAgentState((currentState) => ({
...currentState,
requests: { ...currentState.requests, [id]: { tool: toolName, arguments: input, createdAt: Date.now() } }
}))
两份状态,一份在内存一份在网上:pendingRequests 里的 resolve 函数是不可序列化的、只能留在 CLI 进程里;agentState.requests 是可序列化的、要同步给全世界看。这个切分是整个闭环成立的前提。
updateAgentState(cli/src/api/apiSession.ts:1321)走的是带版本号的乐观并发写(update-state 事件 + expectedVersion),细节见 04 Hub 的状态与同步。
id 从哪来:resolveToolCallId(:416-431)倒序在已见过的 assistant 消息里找名字相同、入参深度相等、且还没被用过的那次 tool_use。找不到就等 1 秒再找一次(:339-346),因为 SDK 可能先问权限、后吐 tool_use 消息。
6.4 ③④ 广播与回传
hub 收到新 agentState 后广播 session-updated,Web 端渲染出审批卡。用户点击后打的是两条 REST:
| 动作 | 路由 | 定义处 |
|---|---|---|
| 允许 | POST /api/sessions/:id/permissions/:requestId/approve | hub/src/web/routes/permissions.ts:32-69 |
| 拒绝 | POST /api/sessions/:id/permissions/:requestId/deny | hub/src/web/routes/permissions.ts:71-98 |
approve 路由在转发前做四道校验:会话必须存在且 active、body 必须过 zod、requestId 必须真的在 session.agentState.requests 里(:52-55,否则 404)、若带了 mode 则该模式必须为这个 agent flavor 所支持(:57-63)。
前端侧一次点击对应哪种 body,在 web/src/components/ToolCard/PermissionFooter.tsx 看得最清楚:
| 按钮 | 发出的 body | 行号 |
|---|---|---|
| 允许一次 | {} | :154 |
| 允许并转 acceptEdits | { mode: 'acceptEdits' } | :161 |
| 本会话都允许(有工具标识) | { allowTools: [toolIdentifier] } | :171 |
| 本会话都允许(无标识) | { decision: 'approved_for_session' } | :173 |
| 拒绝 | deny,空 body | :181 |
| 中止 | deny,{ decision: 'abort' } | :201 |
HTTP 层封装在 web/src/api/client.ts:713-742(approvePermission / denyPermission)。
6.5 ⑤ 转发:hub 打一次反向 RPC
syncEngine.approvePermission(hub/src/sync/syncEngine.ts:1109-1118)是纯转发,真正干活的是 rpcGateway.approvePermission(rpcGateway.ts:101-117):
await this.sessionRpc(sessionId, RPC_METHODS.Permission, {
id: requestId, approved: true, mode, allowTools, decision, answers
})
denyPermission(:106-116)同形,approved: false。注意这里用的就是 §4 那套通用管线——权限审批在协议上没有任何特殊待遇,就是一次普通的 session RPC。
6.6 ⑥ 落地:唤醒那个 Promise
CLI 侧接住它的 handler 在基类构造时就注册好了(BasePermissionHandler.ts:258-272):
this.client.rpcHandlerManager.registerHandler<TResponse, void>(RPC_METHODS.Permission, async (response) => {
const pending = this.pendingRequests.get(response.id)
if (!pending) { this.handleMissingPendingResponse(response); return }
this.onResponseReceived(response)
this.pendingRequests.delete(response.id)
const completion = await this.handlePermissionResponse(response, pending)
this.finalizeRequest(response.id, completion)
})
handlePermissionResponse 是抽象方法,Claude 的实现在 permissionHandler.ts:197-285,它按工具种类分四条路收尾:
| 工具 | 结局 | 行号 |
|---|---|---|
AskUserQuestion / ask_user_question | 把答案塞进 updatedInput 后 allow;没答案则 deny | :229-242 |
request_user_input | 同上,另一种 answers 形状 | :245-258 |
exit_plan_mode | 永远 deny,但批准时往队列头插一条 PLAN_FAKE_RESTART 让 agent 继续 | :260-276 |
| 其它所有工具 | allow(原样入参)/ deny(带一段告诉模型"停下等指令"的话) | :279-283 |
最后 finalizeRequest(BasePermissionHandler.ts:194-220)把这条记录从 requests 搬到 completedRequests,附上 status / decision / mode / allowTools / answers,Web 端的卡片随之变成"已批准/已拒绝"的历史态。
注意 exit_plan_mode 那条:它是"用 deny 表达 allow"——Claude SDK 没有"批准计划并重启"的原语,HAPI 就用一个假拒绝 + 队列注入把语义凑出来。
6.7 一次性收尾
会话被切回本地或重置时,所有挂着的 Promise 必须有人处理,否则 SDK 那边永远卡住。cancelPendingRequests(BasePermissionHandler.ts:222-256)遍历 reject 掉全部 pending,并把 requests 里剩下的条目批量转成 status: 'canceled'。Claude 侧的调用点在 permissionHandler.ts:497-500,理由字符串写的是 'Session switched to local mode'。
7. 自动放行规则:哪些工具不弹卡片
7.1 它要解决的小问题
如果模型每次改个标题、记条笔记都弹一张审批卡,手机端就没法用了。但放行标准又不能太宽——放错一个就是真的删文件。
7.2 判定顺序(命中即停)
全部逻辑在一个纯函数里:resolveToolAutoApprovalDecision(cli/src/modules/common/permission/BasePermissionHandler.ts:64-105),返回 'approved' / 'approved_for_session' / null(null = 要弹卡片)。
┌ 先算 decisionForMode:mode==='yolo' ? approved_for_session : approved (:71)
▼
① 工具名 ∈ 精确白名单 或 含名字提示词 ──命中──▶ 返回 decisionForMode (:73-78)
│ 不中
▼
② toolCallId 含 id 提示词 ──命中──▶ 返回 decisionForMode (:80-82)
│ 不中
▼
③ mode === 'yolo' ──────▶ approved_for_session (:84-86)
④ mode === 'safe-yolo' ──────▶ approved (:88-90)
⑤ mode === 'read-only' ──────▶ 写类工具? null : approved (:92-95)
⑥ 其它(含 default) ──────▶ null(弹卡片) (:97)
最容易看漏的一点: ①② 排在模式判定之前,所以白名单工具在 default 模式下也自动放行。模式只影响"放行的力度"(一次 vs 整会话),不影响"放不放"。
四种模式的语义:
| 模式 | 语义 | 决定 |
|---|---|---|
yolo | 全放,且记住 | approved_for_session |
safe-yolo | 全放,但只放这一次 | approved |
read-only | 只放读操作 | 写类 → 弹卡片;其余 → approved |
default(及其它) | 全问 | null |
模式全集在 shared/src/modes.ts:52-66(PERMISSION_MODES),各 agent 支持的子集在同文件 :22-44——Claude 那一套是 default/acceptEdits/auto/bypassPermissions/plan(:22);yolo/safe-yolo/read-only 属于 Codex 系,Codex(:25)、Gemini(:31)、Kimi(:34) 三份常量取值完全相同。其中 Gemini 已不能新建会话,只保留历史会话查看(见 06 多 agent、远程终端与外围)。
7.3 两份白名单
// cli/src/modules/common/permission/BasePermissionHandler.ts:20-34
const AUTO_APPROVE_TOOL_NAME_HINTS = [
'change_title', 'happy__change_title', 'hapi_change_title',
'geminireasoning', 'codexreasoning', 'think', 'save_memory'
]
const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([
'skill_lookup', 'hapi_skill_lookup', 'happy__skill_lookup', 'mcp__hapi__skill_lookup'
])
两者的匹配方式不同,这是有意的:
| 名单 | 匹配 | 为什么 |
|---|---|---|
AUTO_APPROVE_TOOL_NAME_HINTS | 子串包含(lowerTool.includes(name),:75) | 同一能力在不同 agent 下带各种前缀(MCP 会加 mcp__xxx__),子串能一网打尽 |
AUTO_APPROVE_EXACT_TOOL_NAMES | 全等(:74) | skill_lookup 这种短词做子串太容易误伤 |
另有 AUTO_APPROVE_TOOL_ID_HINTS(:40)只含 change_title / save_memory,匹配的是 toolCallId 而非工具名——用来兜住那些工具名被 agent 改写、但调用 id 里还留着原名的情况。
7.4 刻意排除:ping_peer 与 inspect_peer
这是本节最值得学的一条。源码注释(:35-39)原文说明了取舍:
ping_peer能恢复另一个会话并往里注入内容;inspect_peer能读取其它会话的历史。
所以它们两件事都做了:
- 不进 always-approve 白名单——权限模式必须仍然能拦住它们;
- 主动混进写类清单(
:41-55,SENSITIVE_TOOL_NAME_HINTS被展开进AUTO_APPROVE_WRITE_TOOL_HINTS),于是在read-only模式下也被判为写类而弹卡片。
const AUTO_APPROVE_WRITE_TOOL_HINTS = [
'write', 'edit', 'create', 'delete', 'patch', 'fs-edit',
...SENSITIVE_TOOL_NAME_HINTS // ping_peer / ping peer / inspect_peer / inspect peer
]
注意提示词里既有下划线版也有空格版('ping peer'),因为 ACP 系 agent 上报的是人类可读的标题("Ping Peer Session"),工具名是从标题推导出来的。
妙在哪: "写"这个概念在这里不是按 API 语义定义的,而是按风险定义的——只读别人的会话历史在文件系统层面是读,在信任边界层面是越权。分类跟着边界走,而不是跟着动作名走。
8. ACP 系 agent 的等价实现
8.1 为什么需要第二套
Claude 走 SDK 的 canCallTool 回调,返回值是 { behavior: 'allow'|'deny' }。ACP(Agent Client Protocol,传输层见 06 多 agent、远程终端与外围)不是这样:agent 会主动推来一个权限请求,附带一组候选选项,客户端必须回一个 optionId 说"我选这个"。
HAPI 的内部决策语汇只有 四个词,得把它们映射到 ACP 的选项上。
8.2 映射规则
PermissionAdapter(cli/src/agent/permissionAdapter.ts:40-242)复用同一个 resolveToolAutoApprovalDecision(:72),所以 §7 的规则对两套 agent 完全一致。差别只在收尾动作。
pickOptionId(:25-38)按偏好顺序在 agent 给的选项里找第一个能用的,找不到再看要不要退回第一项:
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind)
if (match) return match.optionId
}
四个决策的映射表(mapDecisionToOutcome,:192-212):
| HAPI 决策 | ACP 选项偏好顺序 | 找不到时 | 行号 |
|---|---|---|---|
approved | allow_once → allow_always | { outcome: 'cancelled' } | :205-208 |
approved_for_session | allow_always → allow_once | { outcome: 'cancelled' } | :200-203 |
denied | reject_once → reject_always | { outcome: 'cancelled' } | :210-211 |
abort | 不选,直接 { outcome: 'cancelled' } | — | :196-198 |
为什么是"偏好顺序"而不是硬映射: 不是每个 ACP agent 都提供全部四种选项。approved 首选 allow_once,但如果这个 agent 只给了 allow_always,退而求其次总比失败好——降级的方向始终是"语义最接近的那个"。
自动放行路径(autoApproveRequest,:96-138)用同一个 pickOptionId,但显式传了 { fallbackToFirst: false }(:107)。这个差别很关键:用户明示的决定可以降级到"选第一项",机器自动做的决定不可以——宁可 cancelled 也不能瞎选。
abort 的收尾是三连(:158-161):先 cancelPrompt 打断 agent,再对当前这条请求回 cancelled,最后 cancelAll('User aborted') 把其它挂着的请求一并清掉。
8.3 cancelAll
cancelAll(:214-242)和 Claude 侧 cancelPendingRequests 形状一致,但多做一件事:逐条给 backend 回 { outcome: 'cancelled' }(:218-220)。因为 ACP 是双向协议,agent 那头也在等回复,光把 本地 Map 清空会让 agent 永远挂着。
之后同样把 requests 里的残留批量标成 status: 'canceled' + decision: 'abort'(:226-234),Web 端于是看到一组明确终结的卡片,而不是一堆永远转圈的请求。
9. 巧妙之处(可以带走的几招)
-
注册幂等 + 断开即失效,就不需要对账。 hub 的方法表是纯内存的,socket 一断整批清(
rpcRegistry.ts:37-49);CLI 一重连就全量重播(RpcHandlerManager.ts:64-69)。两边都不需要持久化,也不需要"检查是否已注册"的逻辑。 -
错误分层:业务错误走信封,联系不上走异常。 handler 抛的异常在 CLI 侧就被折成
{ error }返回值(RpcHandlerManager.ts:53-61),hub 侧只在"目标不在"或"超时"时才真的 catch 到东西。于是RpcTargetMissingError(rpcGateway.ts:50-62)能被安全地当成"可容忍状态"处理。 -
不可序列化的部分留在原地。 权限审批里 resolve 函数留在 CLI 内存(
pendingRequests),只有{tool, arguments, createdAt}上网(BasePermissionHandler.ts:181-191)。跨进程传的永远是"够用的最小描述"。 -
权限模式做成 getter 而非字段。 一个五行的改动修掉了"轮次进行中改模式被吞"的 bug(
permissionHandler.ts:174-181,issue #735)。状态该在哪读,就在哪读。 -
风险分类不跟着动作名走。
inspect_peer在文件系统层面是只读,却被归进写类工具(BasePermissionHandler.ts:48-62),因为它跨越了会话的信任边界。 -
降级要看是谁在做决定。 同一个
pickOptionId,用户明示的决策允许退回第一项,机器自动决策不允许(permissionAdapter.ts:102-108)。
10. 边界与本章不覆盖的
这套设计刻意不做的事:
- 没有请求 id。 一次
rpc-request只靠 socket.io 的 ack 机制配对,所以同一个方法不能并发多次并区分响应——实践上够用,因为每个调用点都是一次性的 request/response。 - 没有流式返回。 结果必须一次性 JSON 化返回。大文件、长输出走的是别的通道(终端流见 06 多 agent、远程终端与外围)。
- 方法表不做鉴权。
RpcRegistry.register(rpcRegistry.ts:7-20)只校验 method 非空字符串,不检查这个 socket 有没有资格注册这个前缀。安全边界靠的是 socket 连接建立时的 auth(apiSession.ts:303-317的auth: { token, clientType, sessionId })和 REST 层的会话归属检查(permissions.ts:40-44)。 - 前缀拼接是隐式契约。 CLI 侧
`${scopePrefix}:${method}`(RpcHandlerManager.ts:90)和 hub 侧`${sessionId}:${method}`(rpcGateway.ts:484)是两份独立代码,编译器不会帮你对齐。
会在哪出问题:
- 模型列表类调用即便给到 120 秒也可能超时(冷启动一个 agent 进程本身没有上限保证)。
resolveToolCallId(permissionHandler.ts:416-431)靠"名字 + 入参深度相等"匹配,模型在同一轮里用完全相同的入参调同一个工具两次时,靠used标记按顺序配对;若 SDK 的消息顺序和权限询问顺序不一致,理论上可能配错。等 1 秒重试(:341)是对时序问题的经验性兜底,不是保证。
本章不覆盖: hub 如何持久化 permissionMode 与 agentState 版本号(见 04 Hub 的状态与同步);ACP 传输层本身(见 06 多 agent、远程终端与外围);switch / handoff-local 这两个 RPC 背后的双模接管(见 02 本地/远程双模接管)。
11. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| CLI 侧 RPC 注册与分发 | cli/src/api/rpc/RpcHandlerManager.ts | RpcHandlerManager · registerHandler · handleRequest · onSocketConnect · getPrefixedMethod |
| RPC 类型定义 | cli/src/api/rpc/types.ts | RpcHandler · RpcRequest · RpcHandlerConfig |
| 会话级作用域接线 | cli/src/api/apiSession.ts | scopePrefix: this.sessionId · updateAgentState |
| 机器级作用域接线 | cli/src/api/apiMachine.ts | scopePrefix: this.machine.id |
| 文件/git/工具 handler 批量注册 | cli/src/modules/common/registerCommonHandlers.ts | registerCommonHandlers |
| hub 侧方法表 | hub/src/socket/rpcRegistry.ts | RpcRegistry · register · unregisterAll · getSocketIdForMethod |
| hub 侧注册事件接线 | hub/src/socket/handlers/cli/rpcHandlers.ts | registerRpcHandlers |
| hub 侧调用入口 | hub/src/sync/rpcGateway.ts | RpcGateway · rpcCall · sessionRpc · machineRpc · RpcTargetMissingError · DEFAULT_RPC_TIMEOUT_MS · MODEL_LIST_RPC_TIMEOUT_MS |
| 方法名常量 | shared/src/rpcMethods.ts | RPC_METHODS · RpcMethod |
| socket 事件签名 | shared/src/socket.ts | ServerToClientEvents · ClientToServerEvents |
| 权限审批基类 | cli/src/modules/common/permission/BasePermissionHandler.ts | BasePermissionHandler · addPendingRequest · finalizeRequest · cancelPendingRequests · resolveToolAutoApprovalDecision |
| 自动放行名单 | cli/src/modules/common/permission/BasePermissionHandler.ts | AUTO_APPROVE_TOOL_NAME_HINTS · AUTO_APPROVE_EXACT_TOOL_NAMES · SENSITIVE_TOOL_NAME_HINTS · AUTO_APPROVE_WRITE_TOOL_HINTS |
| Claude 侧权限实现 | cli/src/claude/utils/permissionHandler.ts | PermissionHandler · handleToolCall · handlePermissionRequest · handlePermissionResponse · resolveToolCallId · parseBashPermission |
| ACP 侧权限实现 | cli/src/agent/permissionAdapter.ts | PermissionAdapter · pickOptionId · mapDecisionToOutcome · autoApproveRequest · cancelAll |
| 审批 REST 路由 | hub/src/web/routes/permissions.ts | createPermissionsRoutes |
| 引擎转发层 | hub/src/sync/syncEngine.ts | approvePermission · denyPermission · archiveSession |
| Web 文件/git 转发路由 | hub/src/web/routes/git.ts | runRpc |
| Web 审批卡 | web/src/components/ToolCard/PermissionFooter.tsx | codexApprove |
| Web HTTP 客户端 | web/src/api/client.ts | approvePermission · denyPermission |
| 权限模式全集 | shared/src/modes.ts | PERMISSION_MODES · CODEX_PERMISSION_MODES · CLAUDE_PERMISSION_MODES |
下一章: 权限请求写进 agentState 之后,hub 用什么机制让三方同时改一份状态还不打架 —— 04 Hub 的状态与同步。