跳到主要内容

数据截至 (上游 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) └─────┘

三个部件,各自一句话职责:

部件干什么在哪个文件
RpcHandlerManagerCLI 侧:存 handler、加前缀、上报方法名、统一 JSON 编解码cli/src/api/rpc/RpcHandlerManager.ts
RpcRegistryhub 侧:一张 method → socketId 的表,socket 断了就整批清掉hub/src/socket/rpcRegistry.ts
RpcGatewayhub 侧:把"发事件 + 等 ack"包成带超时的 await 函数hub/src/sync/rpcGateway.ts

方法名清单单独放在 shared/src/rpcMethods.tsRPC_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 取值注册处方法名长什么样
会话级sessionIdcli/src/api/apiSession.ts:294-295abc-123:readFile
机器级machine.idcli/src/api/apiMachine.ts:134-137mach-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-143disconnectrpcRegistry.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-346cli/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 id
  • socketIdToMethods:反查,用来在 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_MS30 秒绝大多数调用
MODEL_LIST_RPC_TIMEOUT_MS120 秒列模型 / 列会话 / 归档 Codex 会话

为什么模型列表要 120 秒:这类调用在 CLI 侧要去拉起或询问真实的下游 agent 进程(listCodexModelsForMachine :321-323listCodexSessionsForMachine :325-328listCursorModelsForSession :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-50RPC_METHODS 常量对象里,三端 import 同一份,杜绝字符串手打。按用途分组:

会话控制(对某个正在跑的 agent 下命令)

常量线上方法名干什么
Permissionpermission递交审批结果,本章主角
Abortabort打断当前这一轮
Switchswitch本地/远程模式切换(见 02 本地/远程双模接管)
SetSessionConfigset-session-config改模型、改权限模式等(持久化见 04 Hub 的状态与同步)
HandoffLocalhandoff-local把控制权交回本地终端
KillSessionkillSession结束会话

机器控制(对某台机器的常驻 runner 下命令,见 05 Runner 与远程开会话)

常量线上方法名干什么
SpawnHappySessionspawn-happy-session凭空开一个新会话
StopSessionstop-session停掉某个会话进程
StopRunnerstop-runner停掉 runner 自己

文件与工具(Web 端所有"看起来像本地操作"的功能)

常量组线上方法名
读写readFile · writeFile · readGeneratedImage
目录listDirectory · list-directory · getDirectoryTree · statFiles · path-exists
上传uploadFile · deleteUpload
检索与执行ripgrep · bash · difftastic
gitgit-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-45runRpc 包装器把这层的异常统一转成 { 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/approvehub/src/web/routes/permissions.ts:32-69
拒绝POST /api/sessions/:id/permissions/:requestId/denyhub/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读取其它会话的历史

所以它们两件事都做了:

  1. 不进 always-approve 白名单——权限模式必须仍然能拦住它们;
  2. 主动混进写类清单(: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 选项偏好顺序找不到时行号
approvedallow_onceallow_always{ outcome: 'cancelled' }:205-208
approved_for_sessionallow_alwaysallow_once{ outcome: 'cancelled' }:200-203
deniedreject_oncereject_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. 巧妙之处(可以带走的几招)

  1. 注册幂等 + 断开即失效,就不需要对账。 hub 的方法表是纯内存的,socket 一断整批清(rpcRegistry.ts:37-49);CLI 一重连就全量重播(RpcHandlerManager.ts:64-69)。两边都不需要持久化,也不需要"检查是否已注册"的逻辑。

  2. 错误分层:业务错误走信封,联系不上走异常。 handler 抛的异常在 CLI 侧就被折成 { error } 返回值(RpcHandlerManager.ts:53-61),hub 侧只在"目标不在"或"超时"时才真的 catch 到东西。于是 RpcTargetMissingError(rpcGateway.ts:50-62)能被安全地当成"可容忍状态"处理。

  3. 不可序列化的部分留在原地。 权限审批里 resolve 函数留在 CLI 内存(pendingRequests),只有 {tool, arguments, createdAt} 上网(BasePermissionHandler.ts:181-191)。跨进程传的永远是"够用的最小描述"。

  4. 权限模式做成 getter 而非字段。 一个五行的改动修掉了"轮次进行中改模式被吞"的 bug(permissionHandler.ts:174-181,issue #735)。状态该在哪读,就在哪读。

  5. 风险分类不跟着动作名走。 inspect_peer 在文件系统层面是只读,却被归进写类工具(BasePermissionHandler.ts:48-62),因为它跨越了会话的信任边界。

  6. 降级要看是谁在做决定。 同一个 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-317auth: { 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.tsRpcHandlerManager · registerHandler · handleRequest · onSocketConnect · getPrefixedMethod
RPC 类型定义cli/src/api/rpc/types.tsRpcHandler · RpcRequest · RpcHandlerConfig
会话级作用域接线cli/src/api/apiSession.tsscopePrefix: this.sessionId · updateAgentState
机器级作用域接线cli/src/api/apiMachine.tsscopePrefix: this.machine.id
文件/git/工具 handler 批量注册cli/src/modules/common/registerCommonHandlers.tsregisterCommonHandlers
hub 侧方法表hub/src/socket/rpcRegistry.tsRpcRegistry · register · unregisterAll · getSocketIdForMethod
hub 侧注册事件接线hub/src/socket/handlers/cli/rpcHandlers.tsregisterRpcHandlers
hub 侧调用入口hub/src/sync/rpcGateway.tsRpcGateway · rpcCall · sessionRpc · machineRpc · RpcTargetMissingError · DEFAULT_RPC_TIMEOUT_MS · MODEL_LIST_RPC_TIMEOUT_MS
方法名常量shared/src/rpcMethods.tsRPC_METHODS · RpcMethod
socket 事件签名shared/src/socket.tsServerToClientEvents · ClientToServerEvents
权限审批基类cli/src/modules/common/permission/BasePermissionHandler.tsBasePermissionHandler · addPendingRequest · finalizeRequest · cancelPendingRequests · resolveToolAutoApprovalDecision
自动放行名单cli/src/modules/common/permission/BasePermissionHandler.tsAUTO_APPROVE_TOOL_NAME_HINTS · AUTO_APPROVE_EXACT_TOOL_NAMES · SENSITIVE_TOOL_NAME_HINTS · AUTO_APPROVE_WRITE_TOOL_HINTS
Claude 侧权限实现cli/src/claude/utils/permissionHandler.tsPermissionHandler · handleToolCall · handlePermissionRequest · handlePermissionResponse · resolveToolCallId · parseBashPermission
ACP 侧权限实现cli/src/agent/permissionAdapter.tsPermissionAdapter · pickOptionId · mapDecisionToOutcome · autoApproveRequest · cancelAll
审批 REST 路由hub/src/web/routes/permissions.tscreatePermissionsRoutes
引擎转发层hub/src/sync/syncEngine.tsapprovePermission · denyPermission · archiveSession
Web 文件/git 转发路由hub/src/web/routes/git.tsrunRpc
Web 审批卡web/src/components/ToolCard/PermissionFooter.tsxcodexApprove
Web HTTP 客户端web/src/api/client.tsapprovePermission · denyPermission
权限模式全集shared/src/modes.tsPERMISSION_MODES · CODEX_PERMISSION_MODES · CLAUDE_PERMISSION_MODES

下一章: 权限请求写进 agentState 之后,hub 用什么机制让三方同时改一份状态还不打架 —— 04 Hub 的状态与同步