跳到主要内容

数据截至 (上游 commit 8d6cbee1b527)

网关:控制平面与进程形态

30 秒导读: OpenClaw 的「网关(Gateway)」是跑在你自己机器上的一个常驻进程。它对外只开一个端口(默认 18789),上面跑一条 WebSocket 长连接协议;所有客户端——命令行、浏览器控制台、手机 App——都用同一套 req/res/event 帧和同一张方法表跟它说话。本章只讲进程怎么起来、连接怎么建立、请求怎么被放行,不碰消息内容本身。


1. 这章讲什么(以及不讲什么)

一句话: 讲 OpenClaw 的「控制平面」——进程层 + 协议层。

控制平面(control plane) 这个词在本章是承重术语,固定一个意思:接收指令、做鉴权、分发调用的那一层,与「真正干活的那一层(跑模型、发消息)」分开。README 自己也是这么说的:"The Gateway is just the control plane"。

本章覆盖五件事:

#主题一句话
1进程形态openclaw 这条命令如何变成一个常驻进程,以及它怎么被装成开机自启服务
2服务器生命周期startGatewayServer() 到「就绪」,再到关闭时的排水顺序
3WS 帧级 RPC三种帧、TypeBox schema、惰性编译校验器、错误码
4认证与暴露面令牌怎么解析、有几种鉴权模式、限流打在哪、除了 WS 还开了哪些 HTTP 口
5设备节点手机/Mac 伴侣端如何配对、声明能力,以及网关如何反向调用它们

明确不覆盖(在同组其他章):


2. 先建立直觉:一台本地常驻的控制平面

2.1 它像什么

把网关想成你自己机器上的一台小型服务器,只不过它默认只听 127.0.0.1

  • 你在终端敲 openclaw chat —— 这是一个客户端,连到网关。
  • 你打开浏览器里的控制 UI —— 也是一个客户端,连到同一个网关。
  • 你手机上的 OpenClaw App —— 还是客户端,但角色不同(叫 node),连的仍是同一个网关。

三类客户端走同一条 WebSocket、用同一套帧格式、过同一道鉴权闸门。这就是"控制平面"的含义:所有控制指令收敛到一处。

2.2 顶层结构图

怎么读这张图:左边是三类客户端,中间是网关进程,右边是网关自己要拉起来的东西。箭头是控制流方向。

客户端 网关进程 (常驻, 默认 :18789) 被网关驱动的东西
┌──────────┐ ┌───────────────────────────────┐
│ CLI/TUI │──WS req───▶│ ① 鉴权闸门 │
└──────────┘ │ token / password / │
┌──────────┐ │ tailscale / trusted-proxy │
│ 控制 UI │──WS req───▶│ │ │
│ (浏览器) │ │ ▼ │ ┌──────────────┐
└──────────┘ │ ② 方法表 (role + scope) │─────▶│ 通道插件 │
┌──────────┐ │ │ │ │ 模型商 / 工具 │
│ 手机/Mac │◀─event────│ ▼ │ └──────────────┘
│ 节点 node │──WS req───▶│ ③ 惰性加载的 handler 家族 │ ┌──────────────┐
└──────────┘ │ │─────▶│ agent 运行时 │
┌──────────┐ │ ④ 少量 HTTP 口:控制 UI / │ └──────────────┘
│ HTTP 客户 │──POST────▶│ /v1/chat/completions / │
│ (OpenAI) │ │ /healthz │
└──────────┘ └───────────────────────────────┘

反向箭头值得注意: 手机节点那一行是双向的。网关不只是被节点调用,它还能主动向节点发 node.invoke.request 事件,让手机去拍照、读日历、跑一条命令。这是第 7 节的主题。


3. 进程形态:一条命令怎么变成常驻进程

3.1 三层入口,一层比一层"重"

很多人以为 openclaw 就是一个脚本。实际上启动路径分三层,每层都有明确的"省启动时间"动机。

用户敲 `openclaw gateway run`


① openclaw.mjs ← 纯 JS 启动器,不加载任何业务代码
│ · 检查 Node 版本是否在支持区间
│ · --version / --help 快路径直接打印
│ · 配置 V8 compile cache,必要时重启自己

② dist/entry.js ← 编译后的 TS 入口 (src/entry.ts)
│ · isMainModule 守卫,防止被当依赖导入时重复启动
│ · 解析 --profile / --container 等根级选项
│ · 再试一次 help 快路径

③ cli/run-main.ts ← 真正的 Commander 程序,注册全部子命令


gateway run → startGatewayServer()

第一层为什么存在? 因为大多数调用其实什么都不用干。openclaw --version 直接由启动器打印并 process.exit(0)(openclaw.mjs:51-53 调用 tryOutputLauncherVersion,定义在 openclaw.mjs:572);openclaw --help 从预先生成的 dist/cli-startup-metadata.json 里读一段现成文本(openclaw.mjs:721,tryOutputBareRootHelp,元数据读取在 openclaw.mjs:563)。这两条路径一行业务代码都不加载

Node 版本门槛硬编码在启动器最前面,而且是一个区间而不是单一下限:

const SUPPORTED_NODE_RANGE = ">=22.22.3 <23, >=24.15.0 <25, or >=25.9.0";

openclaw.mjs:13,由 ensureSupportedRuntimeVersion(openclaw.mjs:16,在 openclaw.mjs:46 于任何业务 import 之前执行)检查——版本不够就打印 nvm 提示并退出;Bun 则改成探测 node:sqlite 能力再决定放行与否。

只有落不到快路径,才真正 import("./dist/entry.js")(openclaw.mjs:776)。

第二层的守卫值得单独看。src/entry.ts:113 那个 isMainModule 判断带着一段很实在的注释(src/entry.ts:108-111):打包器可能把 entry.js 当成共享依赖被 dist/index.js 导入,如果没有这层守卫,顶层代码会第二次调用 runCli,启动一个重复的网关,然后在端口/锁上崩掉。

第三层由 runMainOrRootHelp(src/entry.ts:280)动态 import cli/run-main.js 并调用 runCli(argv)(定义在 src/cli/run-main.ts:1051)。src/index.ts 是另一个遗留入口,同样用 isMainModule 区分「被当库导入」和「被当程序跑」(src/index.ts:65runLegacyCliEntry:76 的守卫)。

3.2 子命令分发:一张声明式的"启动策略表"

OpenClaw 的子命令不是简单的 switch。在 Commander 还没注册完插件之前,CLI 需要先知道:这条命令要不要加载插件?要不要读配置?要不要走网络代理?

答案在一张声明式表里:cliCommandCatalog(src/cli/command-catalog.ts:71)。每条目描述一个命令路径及其策略(CliCommandPathPolicy,src/cli/command-catalog.ts:38):

策略字段管什么举例
loadPlugins要不要materialize 插件运行时message"never";channels"always"
bypassConfigGuard跳过配置校验(修配置的命令必须能跑)configuredoctorsecrets
networkProxy"default" 走代理 / "bypass" 不走gateway run 走,gateway status 不走
pluginRegistry.scope只加载哪一类插件channels"configured-channels"
ensureCliPath要不要确保 CLI 在 PATH 上只读命令一律 false

命令路径由 getCommandPathWithRootOptions(argv, 2)(src/cli/argv.ts:499)从 argv 里剥掉根级选项后解析出来。

这张表的价值在哪? 它把"启动多重"这件事变成了可审计的数据,而不是散落在各命令实现里的隐式行为。比如 gateway 条目(src/cli/command-catalog.ts:211 起)写着:只有 gatewaygateway run 用默认网络代理,gateway status 等其余子命令一律 bypass(:218 起)——因为它们只是本机 RPC,不该被公司代理劫持。

3.3 前台模式 vs 守护进程模式

这是网关的两种运行形态,区别很清楚:

openclaw gateway runopenclaw gateway install + start
谁持有进程你的终端系统服务管理器(launchd/systemd/schtasks)
退出条件Ctrl-C / 关终端只有显式 stop;崩溃自动重启
开机自启
日志去向终端 stdout服务管理器的日志文件
典型用途调试、看启动 trace日常使用

前台命令注册在 registerGatewayCli(src/cli/gateway-cli/register.ts:520),run 子命令的描述就是 "Run the WebSocket Gateway (foreground)"(register.ts:542)。它最终进入 runGatewayLoop(src/cli/gateway-cli/run-loop.ts:110)——一个可以原地重启的循环:第一圈是全新启动,后续圈是进程内重启。这让 gateway.restart.request 这类 RPC 可以在不换 PID 的情况下重建整个服务器。

服务类子命令(status / install / uninstall / start / stop / restart)由 addGatewayServiceCommands 注册(src/cli/daemon-cli/register-service-commands.ts:74)。

3.4 平台服务适配层:一个接口,三种实现

三个平台的服务机制完全不同,OpenClaw 用一个统一接口 GatewayService(src/daemon/service.ts:77)把它们抹平,然后用一张注册表选实现:

平台机制服务标识"已装载"叫什么
macOS (darwin)LaunchAgentai.openclaw.gatewayloaded
Linuxsystemd user unitopenclaw-gatewayenabled
Windows (win32)Scheduled Task任务名registered

注册表在 src/daemon/service.ts:351(GATEWAY_SERVICE_REGISTRY),选择函数是 resolveGatewayService()(src/daemon/service.ts:433)。标识常量在 src/daemon/constants.ts:5-6。macOS 的 plist 落在 ~/Library/LaunchAgents/<label>.plist(resolveLaunchAgentPlistPath,src/daemon/launchd-service-files.ts:194;src/daemon/launchd.ts 现在只是这一族的再出口)。

注意是 user 级服务,不是 system 级。 Linux 走的是 systemctl --user,plist 放在用户目录——网关跑在你的用户身份下,不需要 root。

生成的 systemd unit 长这样(由 buildSystemdUnit,src/daemon/systemd-unit.ts:55 拼出来):

[Unit]
After=network-online.target
StartLimitBurst=5
StartLimitIntervalSec=60

[Service]
ExecStart=<node> <openclaw.mjs> gateway run ...
Restart=always
RestartSec=5
RestartPreventExitStatus=78
TimeoutStopSec=30
SuccessExitStatus=0 143
OOMPolicy=continue
KillMode=control-group

[Install]
WantedBy=default.target

三处设计值得记:

  • RestartPreventExitStatus=78 —— 78 是 sysexits.h 的 EX_CONFIG。配置错误时网关用这个码退出(EXIT_CONFIG_ERROR,src/cli/gateway-cli/run.ts:100),systemd 就不再重启run.ts:96-99 的注释直说原因:否则会进入重启风暴,把小内存主机拖垮。
  • OOMPolicy=continue —— 临时子进程(比如一次工具执行)可能先被 OOM killer 选中;那不该让整个网关跟着死。
  • KillMode=control-group —— 重启时把所有子进程一起收走,避免留下孤儿 ACP/runtime worker。

两道额外保险:

其一,所有服务变更(stage/install/uninstall/stop/restart)都被 withGatewayServiceMutationGuards(src/daemon/service.ts:415)包了一层:底层调 assertFutureConfigActionAllowed(src/daemon/future-config-guard.ts:8),如果配置文件是被更新版本的 OpenClaw 写的,旧版二进制不许改写服务文件。

其二,startGatewayService(src/daemon/service.ts:224)在真正重启前先跑一遍体检 inspectGatewayServiceStartRepair,命中任何一条就返回 repair-required 而不是假装启动成功。体检判定集中在 collectGatewayServiceStartRepairIssues(src/daemon/service.ts:124):

问题码判定
port-mismatch服务文件里的端口与当前配置端口不一致(service.ts:139)
temporary-program服务命令指向 /tmp 之类的临时路径(service.ts:145)
missing-program服务命令指向的可执行文件已不存在(service.ts:152)

4. Gateway 服务器的生命周期

4.1 入口签名

对外暴露的 startGatewayServer 是个懒加载壳(src/gateway/server.ts:31):先动态 import 重实现,再转发调用。真正的签名在 startGatewayServerCore:

export async function startGatewayServerCore(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer>

src/gateway/server-start.ts:23-26。壳存在的原因写在 server.ts:1-5 的注释里:让轻量调用方 import 服务器类型时不必付出整条启动依赖图的成本。返回值的核心方法只有一个 close,另带 startupSettled promise 和 Tailscale 端点访问器:

export type GatewayServer = { /* … */ close: (opts?: GatewayCloseOptions) => Promise<void>; startupSettled: Promise<void> };

src/gateway/server-public.ts:13-22

GatewayServerOptions(src/gateway/server-public.ts:24)里最重要的是绑定策略 bind,四选一:

bind绑到哪场景
loopback127.0.0.1默认,只有本机能连
lan0.0.0.0同一局域网内其他设备能连
tailnet仅 Tailscale 的 IPv4 地址(100.64.0.0/10)只对自己的 tailnet 开放
auto优先 loopback,否则 LAN

另外三个开关决定 HTTP 暴露面:controlUiEnabledopenAiChatCompletionsEnabledopenResponsesEnabled,默认值分别取自配置里的 gateway.controlUi.enabledgateway.http.endpoints.chatCompletions.enabledgateway.http.endpoints.responses.enabled(server-public.ts:40-52 的注释)。

4.2 启动流水线

启动不再是一个巨长函数,而是三段接力,每一步都被 startupTrace.measure(...) 包住(设 OPENCLAW_GATEWAY_STARTUP_TRACE=1 可以打印每段耗时):

① createGatewayKernel (server-kernel.ts:123)
└─ prepareGatewayServerBootstrap (server-startup-bootstrap.ts:73)
· config.snapshot 读配置快照 (:166)
· config.auth 解析鉴权:token/password/tailscale/trusted-proxy (:233)
· plugins.bootstrap 加载插件元数据表,拿到 baseMethods (:471)
└─ prepareGatewayKernelState (server-runtime-state-prepare.ts:67)
· runtime.config 解析 bindHost / 控制 UI / HTTP 端点开关 / TLS (:236)
· runtime.state ★ 建 WebSocketServer(noServer) + HTTP server(还没 listen)(:422)
② createGatewayHttpTransport (server-runtime-state.ts,服务器/transports 组装)
③ finishGatewayStartup (server-startup-finish.ts:26)
· gateway.ws-attach 把 connection 处理器挂到 wss 上 (:146)
· http.listen ★ 这时才真正 listen(),开始收连接 (:179)
· runtime.post-attach 通道启动、插件服务、Tailscale、内部 hook (:240)
· ready (:345)

"先建 WS、后 listen"的顺序是刻意的,源码里写了理由:

Create WebSocketServer first (with noServer: true) so we can attach upgrade handlers before HTTP servers start listening. …

src/gateway/server-runtime-state.ts:273-277。也就是说:先把接线做完,再开门——这消灭了"启动瞬间连接被静默 1006 拒绝"的竞态。对应的两处调用点是 gateway.ws-attach(server-startup-finish.ts:147)和 http.listen(server-startup-finish.ts:181)。

绑定本身还带 EADDRINUSE 重试:listenGatewayHttpServer(src/gateway/server/http-listen.ts:20)在端口仍处于 TIME_WAIT 时会退避重试,重试用尽才抛 GatewayLockError,并给出人话消息 "another gateway instance is already listening on ws://..."。

缺 token 的处理很有代表性:配置里没有网关令牌时,启动不会失败,而是生成一个只在本次进程有效的运行时 token 并打印警告(formatRuntimeGatewayAuthTokenWarning,src/gateway/server-kernel.ts:104),提示你用 openclaw config set gateway.auth.token <token> 持久化。重启后 token 会变——这是刻意的"能用但会痛"设计。

4.3 启动后附加阶段(post-attach)

这是很多人会忽略的一段:HTTP 已经在 listen 了,但网关还没"就绪"

startGatewayPostAttachRuntime(src/gateway/server-startup-post-attach.ts:1133)在端口绑定之后才去做那些慢活儿,内部调 startGatewaySidecars(同文件 :567):

  1. 加载内部 hook
  2. 标记因上次重启而孤儿化的主会话,准备恢复
  3. 启动全部通道插件(Telegram / Discord / WhatsApp …)
  4. 启动插件服务
  5. 延迟 250ms 后触发 gateway:startup 内部 hook(server-startup-post-attach.ts:816-822)

第 5 步的延迟带注释解释:先让 sidecar 启动 yield 一次,别让 hook handler 拖慢端口绑定和通道启动

在此期间,isGatewayStartupPending() 为真(src/gateway/server-runtime-state-prepare.ts:440),readiness 检查(createReadinessChecker,src/gateway/server/readiness.ts:86)会告诉探针"还没 ready"。同时有一批方法被放进 unavailableGatewayMethods(src/gateway/server-lifecycle.ts:231,内容来自 STARTUP_UNAVAILABLE_GATEWAY_METHODS,src/gateway/methods/core-descriptors.ts:664),调用它们会返回可重试的 UNAVAILABLE(见 5.6 节)。

4.4 BOOT.md:网关启动后跑的第一次 agent 运行

这是本章唯一一处"控制平面碰到智能体"的地方,值得单独点名。

上面第 5 步触发的 gateway:startup hook,被一个内置 hook 接住:src/hooks/bundled/boot-md/handler.tsrunBootChecklist(handler.ts:13)。它遍历所有 agent 的工作区,对每个不重复的工作区排一个启动任务,任务体就是 runBootOnce(...)(handler.ts:43)。

runBootOnce(src/gateway/boot.ts:109)做的事:

  1. 读工作区根目录的 BOOT.md;不存在或为空就跳过。
  2. 把内容包进一段提示词,前后加上「内部运行时上下文」定界符(buildBootPrompt,boot.ts:45)。
  3. 用一个独立的 boot 会话键 agent:<agentId>:boot(boot.ts:73)跑一次 agent 运行。
  4. 跑完把会话映射还原回去(preserveTemporarySessionMapping,boot.ts:142)。

所以一句话:网关一起来,它做的第一件"智能体的事",就是让 agent 读一遍 BOOT.md 并按里面的话做一次自检。 你可以在 BOOT.md 里写"检查一下昨晚的备份任务,有问题就发我消息"。

两个细节体现了这里被踩过坑:

  • 提示词被 INTERNAL_RUNTIME_CONTEXT_BEGIN/END 包住,防止模型把 BOOT.md 原文复述给用户(boot.ts:45-52,注释引 issue #53732)。
  • 额外注册了一个"回声守卫"setBootEchoContextForSession(boot.ts:151,实现挪到了 src/gateway/boot-echo-guard.ts),用来丢掉那些没保留包裹标记、直接抄大段 BOOT.md 内容的降级模型回复;并且在 finally 里必定清除,免得留下脏状态误伤后续运行。

如果 BOOT.md 让 agent 发消息,提示词要求它用 message 工具,并且发完只回一个 SILENT_REPLY_TOKEN——这样自检不会在聊天窗口里刷屏。

4.5 关闭:一串命名步骤,而不是一个大函数

close() 现在是一张步骤表,由 runGatewayShutdownSteps(src/gateway/server-shutdown.ts:9)逐步执行、逐步记错。步骤表就写在公开入口的返回对象里(src/gateway/server-start.ts:94-118):

close(opts)
├─ ① "close prelude fence" beginClosePrelude:停掉 post-ready 维护定时器
├─ ② "terminal sessions" 先关掉活着的操作员终端会话
├─ ③ "gateway lifetime sidecars" / "post-ready sidecars"
├─ ⑤ "gateway_stop plugin hooks" 跑插件的停止 hook
├─ ⑥ "gateway close prelude" runClosePrelude:释放限流器、诊断心跳、技能刷新、MCP
├─ ⑦ "late sidecar cleanup" sealAndJoinRegisteredSidecarStops
└─ ⑧ "gateway close" createGatewayCloseHandler 的产物 ← 真正的排水与拆除

runClosePreludesrc/gateway/server-close.ts:611;最后一步的处理器由 createGatewayCloseHandler 构造(src/gateway/server-close.ts:711)。其内部按序做(节选):

顺序动作超时
1触发 gateway:shutdown 内部 hook5s
2若是重启,触发 gateway:pre-restart hook10s
3若是重启,排水待发送的回复调用方给的 drainTimeoutMs
4排水活跃会话2s
5关 WebSocket 客户端1s 宽限
6关 HTTP server1s 宽限 / 5s 强制

常量集中在 src/gateway/server-close.ts:45-52(GATEWAY_SHUTDOWN_HOOK_TIMEOUT_MS 等)。重启和停止走的是同一条路径,但只有重启会做回复排水——因为停止时没人在等回复,而重启时用户可能正等着一条消息发出去。


5. WS 帧级 RPC 协议

5.1 只有三种帧

整个协议的顶层只有三个信封,定义在 packages/gateway-protocol/src/schema/frames.ts:

type字段谁发定义位置
请求"req"id, method, params?客户端frames.ts:180(RequestFrameSchema)
响应"res"id, ok, payload?, error?服务端frames.ts:189(ResponseFrameSchema)
事件"event"event, payload?, seq?, stateVersion?服务端(节点也可反向收)frames.ts:198(EventFrameSchema)

三者用 type 作判别式合成一个 union(frames.ts:209,GatewayFrameSchema)。用判别式的理由是让下游 codegen(quicktype)生成更紧的类型,而不是一堆全可选字段的 blob。

所有 schema 都用 TypeBox 写,并且一律 additionalProperties: false(closedObject(...) 包装)——多一个字段就直接判非法。

5.2 握手:第一帧必须是 connect

这是协议里最硬的一条规则。看 src/gateway/server/ws-connection/message-handler.ts:268-275:

// Handshake must be a normal request:
// { type:"req", method:"connect", params: ConnectParams }.
const isRequestFrame = validateRequestFrame(parsed);
if (
!isRequestFrame ||
parsed.method !== "connect" ||
!validateConnectParams(parsed.params)
) {

不满足就把连接标记为 invalid-handshake 并关掉。握手还有一个超时(src/gateway/server/ws-connection.ts:303-323):到时间还没建立 client,直接 close 并记 handshake-timeout

ConnectParams(frames.ts:39,ConnectParamsSchema)里客户端要交代的东西:

字段组内容用途
协议区间minProtocol / maxProtocol版本协商;当前 PROTOCOL_VERSION = 4(packages/gateway-protocol/src/version.ts:2)
clientid / version / platform / mode / deviceFamily身份与形态
role / scopes"operator""node";操作员权限集决定能调哪些方法
caps / commands / permissions我能做什么节点能力声明,见第 7 节
deviceid / publicKey / signature / signedAt / nonce设备身份签名
authtoken / password / deviceToken / bootstrapToken凭据

5.3 hello-ok:服务器交底

握手通过后,服务器回一个 hello-ok(构造在 src/gateway/server/ws-connection/connect-hello.ts:123;schema 见 frames.ts:87HelloOkSchema),把这次会话的全部契约一次性交给客户端:

内容
protocol协商后的协议版本
server版本号 + 本次连接 id(connId)
features.methods / features.events本次可用的方法名与事件名清单(connect-hello.ts:132-133)
snapshot初始状态快照(在线设备、状态版本等)
auth分配到的 role、scopes,以及可能签发的 deviceToken
policymaxPayload / maxBufferedBytes / tickIntervalMs

features.methods 是动态的——核心方法 + 插件方法合并去重(组装闭包 listAttachedGatewayMethods,src/gateway/server-core-runtime.ts:438-443:注册表已广告方法 + 启动期通道方法)。所以客户端不需要硬编码方法表,连上就知道这台网关能干什么。

事件名清单来自 GATEWAY_EVENTS(src/gateway/server-methods-list.ts:43),包括 agentchatpresencetickshutdownnode.invoke.request 等。

5.4 惰性编译的 validate* 校验器

每个方法的参数都有对应的 validateXxxParams。这些校验器不是启动时全部编译好的——那样太贵:

export function lazyCompile<T>(schema:): ProtocolValidator<T> {
let compiled: TypeBoxValidator | undefined;
const getCompiled = () => {
compiled ??= Compile(schema as never);
return compiled;
};
...
}

packages/gateway-protocol/src/protocol-validator.ts:18 起。要点:

  • 首次调用才 Compile,之后缓存在闭包里。这个 protocol 包被 CLI 和测试大量 import,预编译几百个 schema 的启动成本白扔。
  • 返回的函数是 TypeScript 类型守卫 (data: unknown) => data is T,同时挂了 .errors.schema 两个属性。

然后是注册表式的一长串导出(packages/gateway-protocol/src/validator-registry.ts),命名刻意和 schema 常量一一对应:

export const validateConnectParams = compile(S.ConnectParamsSchema); // :15
export const validateRequestFrame = compile(S.RequestFrameSchema); // :78
export const validateNodeInvokeParams = compile(S.NodeInvokeParamsSchema); // :181

调用侧的用法很朴素(src/gateway/server-methods/nodes.invoke.ts:61-66):

if (!validateNodeInvokeParams(params)) {
respondInvalidParams({ respond, method: "node.invoke", validator: validateNodeInvokeParams });
return;
}

客户端也做本地预检。 GatewayProtocolClient.request() 发送前先检查方法与连接状态,不合法直接本地拒掉,不浪费一次往返(packages/gateway-client/src/protocol-client.ts:124-137)。

5.5 方法表:一张策略表 + 一张家族加载表

网关的核心方法不是"注册在哪个文件里就算数"。它们必须先出现在一张规范表里:

// This is the canonical core method policy table: every core handler must appear here so
// listing, authorization, startup availability, and write throttling stay in sync.
const CORE_GATEWAY_METHOD_SPECS = [
["health", "health", "operator.read", "<=2026.7"],
["config.apply", "config", "operator.admin", "<=2026.7", { controlPlaneWrite: true }],
...
];

src/gateway/methods/core-descriptors.ts:72-74。表每行是 [方法名, 家族, scope, since, 策略?];策略位最多四个:

属性含义
scope需要的操作员权限(见下)或 "node" / "dynamic"
advertise: false不出现在 hello-ok 的方法清单里(内部方法)
startup: true启动阶段就可用,不进 unavailableGatewayMethods
controlPlaneWrite: true属于控制平面写操作,受额外限流

权限是一个封闭集合(src/gateway/operator-scopes.ts:3-13):

scope典型方法
operator.readhealthstatusconfig.getnode.list
operator.writechat.sendsessions.sendtts.enable
operator.adminconfig.setchannels.startcron.addgateway.restart.request
operator.approvalsexec.approval.*plugin.approval.*
operator.questionsquestion.requestquestion.waitAnswer
operator.pairingdevice.pair.*node.rename
operator.talk(.secrets)语音相关方法与密钥读取

标了 controlPlaneWrite: true 的全是"会改变网关本身"的方法:config.applyconfig.patchmodels.authLogoutworktrees.create/remove/restore/gcupdate.rungateway.restart.request(core-descriptors.ts:102-103:129:170-173:216:324)。

handler 本身是惰性加载的。 lazyHandlerModule(src/gateway/server-methods/lazy-core-handlers.ts:4-11)给每个方法家族包一个只 import 一次的加载器:

// Cache the first import so concurrent calls to one family share its load.
return () => (handlersPromise ??= loadModule().then(selectHandlers));

家族到模块的映射是 CORE_GATEWAY_HANDLER_MODULES(src/gateway/server-methods.ts:66 起),列了 agent、chat、channels、artifacts、cron、nodes 等几十个家族,每个对应 src/gateway/server-methods/ 下一个文件;createLazyCoreHandlers(lazy-core-handlers.ts:13)按描述符表的 family 列把方法名接到对应家族上(调用点 server-methods.ts:327)。若表里声明了但模块里找不到,会大声抛错而不是静默跳过(lazy-core-handlers.ts:25,lazy gateway handler not found)——描述符漂移必须立刻暴露。

看几个代表家族的规模,就知道这层分家的必要性:

家族文件承担的方法
chatserver-methods/chat.tschat.send / chat.history / chat.abort / chat.inject
agentserver-methods/agent.tsagent / agent.wait / agent.identity.get
channelsserver-methods/channels.tschannels.status / .start / .stop / .logout
artifactsserver-methods/artifacts.tsartifacts.list / .get / .download
nodesserver-methods/nodes.ts(桶)→ nodes.pairing.ts / nodes.invoke.ts / …node.pair.* / node.invoke / node.event

5.6 一次请求要过几道闸门

handleGatewayRequest(src/gateway/server-methods.ts:581)是唯一入口。顺序如下(命中任一闸门就直接 respond 失败):

req 帧

├─▶ ⓪ 帧结构校验 (validateRequestFrame) → INVALID_REQUEST
│ ↑ 在 ws 层做, message-handler.ts:399

├─▶ ① role 闸门 node 方法只许 node,其余只许 operator → INVALID_REQUEST

├─▶ ② scope 闸门 有 operator.admin 直接放行,否则比对描述符 scope

├─▶ ③ 启动闸门 方法在 unavailableGatewayMethods 里 → UNAVAILABLE(可重试)

├─▶ ④ 写预算闸门 controlPlaneWrite 方法:60 秒 30 次 → UNAVAILABLE(可重试)

├─▶ ⑤ 查 handler 查不到 → INVALID_REQUEST

└─▶ ⑥ 在插件请求作用域里执行 handler

逐条对应源码:

  • ①② 在 authorizeGatewayMethod(server-methods.ts:240)。role 解析用封闭集合 parseGatewayRole(src/gateway/role-policy.ts:11),分流规则 isRoleAuthorizedForMethod(role-policy.ts:24)只有两行,但意图很硬:节点专属方法不许操作员调,操作员方法不许节点调。带 scope: "node" 的方法有 10 个,全是节点回报类:node.pluginSurface.refreshnode.pluginTools.updatenode.skills.updatenode.runnerInventory.updatenode.pending.drain/pull/acknode.invoke.progressnode.invoke.resultnode.event(core-descriptors.ts:327-339),外加 skills.bins(:194)。
  • ③ 在 server-methods.ts:426-438。返回的错误带 retryable: trueretryAfterMs(常量 GATEWAY_STARTUP_RETRY_AFTER_MS = 500,packages/gateway-protocol/src/startup-unavailable.ts:10),注释解释理由:启动期方法已经被列出来但运行时还没准备好,得让客户端退避重试,而不是误以为方法不存在。
  • ④ 在 server-methods.ts:462-483(rejectRateLimitedControlPlaneWrite),调 consumeControlPlaneWriteBudget(src/gateway/control-plane-rate-limit.ts:36)。预算是 60 秒 30 次(CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS / CONTROL_PLANE_RATE_LIMIT_WINDOW_MS,control-plane-rate-limit.ts:6-7),按 方法名|{deviceId, clientIp} 分桶(control-plane-rate-limit.ts:47)。被限流会写一条带 actor 的 warn 日志。
  • ⑥ 所有 handler 都跑在 withPluginRuntimeGatewayRequestScope 里(server-methods.ts:550),这是为了让插件运行时的子 agent 能反向调回网关,同时把调用方身份带进插件方法。

还有一条注册表选择的巧思(server-methods.ts:585-589 的注释):优先用调用方带来的方法注册表快照;如果那个快照不认识这个方法,就从进程根注册表重建一份——这样启动快照之后才注册的插件方法仍然可达(注释引 issue #94127)。

5.7 错误码:一个小封闭集

export const ErrorCodes = {
NOT_LINKED, NOT_PAIRED, AGENT_TIMEOUT,
INVALID_REQUEST, FORBIDDEN, APPROVAL_NOT_FOUND, UNAVAILABLE,
} as const;

packages/gateway-protocol/src/gateway-error-details.ts:4。其中 NOT_LINKEDAGENT_TIMEOUT 已标 @deprecated(保留仅为源码兼容,服务端不再发出);活跃使用的含义如下:

含义
NOT_PAIRED设备存在但还需要显式配对批准
INVALID_REQUEST参数校验或前置条件失败
FORBIDDEN已认证调用者缺少该操作所需权限
APPROVAL_NOT_FOUND审批请求缺失或过期
UNAVAILABLE服务或后端暂时不可用

配套的 errorShape(code, message, opts)(packages/gateway-protocol/src/schema/error-codes.ts:94)可以带 retryableretryAfterMs——"可重试"是协议里的一等公民,前面第 ③④ 道闸门都靠它区分"暂时不行"和"永远不行"。

5.8 客户端侧长什么样

GatewayClient(packages/gateway-client/src/client.ts:337)的 request()(client.ts:1308)就是这套协议的镜像:算好 expectFinal 与超时(expectFinal: true 的请求把超时设为 null,那是等 agent 跑完的长请求,client.ts:1313-1321),然后交给 GatewayProtocolClient.request()(protocol-client.ts:124)——那里先做本地预检(没连上、方法名为空都直接 reject),再按 id 挂一个 pending promise,超时/abort 由 AbortSignal 与定时器共同管理(pending-request.ts)。


6. 认证与暴露面

6.1 四种鉴权模式

模式由 resolveGatewayAuth(...)(src/gateway/auth-resolve.ts:93)从「配置 + 运行时覆盖 + 环境变量 + Tailscale 策略」合成:

模式客户端要出示什么备注
tokenauth.token 共享令牌默认模式
passwordauth.password与 token 二选一
trusted-proxy由反向代理注入身份头必须同时配 gateway.trustedProxies
none完全不鉴权

模式的选取顺序写得很直白(finalizeResolvedGatewayAuth,auth-resolve.ts:67-78):运行时覆盖 → 配置显式声明 → 有 password 就 password → 有 token 就 token → 兜底 token;modeSource 字段同时记下命中的是哪一档。兜底选 token 而不是 none 是有意的:这样配置断言能给出"缺 token"的明确诊断,而不是悄悄把鉴权关掉。

allowTailscale 的默认值也带策略(auth-resolve.ts:85-87):只有当 tailscaleMode === "serve" 模式不是 password/trusted-proxy 时才默认允许——Tailscale 提供的是网络层访问控制,但 password 和 trusted-proxy 这两种模式的用户显然想要更严的显式边界,不该被降级。

6.2 授权顺序

authorizeGatewayConnect(src/gateway/auth.ts:388)→ authorizeGatewayConnectCore(auth.ts:435)的判定链:

trusted-proxy 模式?
├─ 是 → 检查 trustedProxies 配置 → 校验代理头 → 校验浏览器 Origin
│ └─ 头不认?若是本机直连且配了 password → 退回 password 校验
└─ 否

├─ mode === "none" → 直接通过

├─ 先查限流(rejectIfRateLimited)

├─ 允许 Tailscale 头鉴权 且 非本机直连 且 客户端没显式给共享密钥
│ → 走 tailscale whois 验证,成功则重置限流计数

├─ mode === "token" → 常数时间比对 token
├─ mode === "password" → 常数时间比对 password
└─ 都不是 → 记一次失败,返回 unauthorized

三个细节:

  • "客户端没显式给共享密钥"才走 Tailscale(auth.ts:143 定义 hasExplicitSharedSecretAuth,使用点 :449)。你既然带了 token,就按 token 判——不会因为你在 tailnet 里就默认放行一个错的 token。
  • Tailscale 分支被序列化(auth.ts:425withSerializedRateLimitAttempt,实现 src/gateway/rate-limit-attempt-serialization.ts:25)。因为那是异步分支,不序列化的话「预检」和「记失败」之间会有窗口,限流就形同虚设——按 {scope, ip} 串起来。
  • HTTP 和 WS 控制 UI 是两个不同的 auth surface(authorizeHttpGatewayConnect,auth.ts:586 / authorizeWsControlUiGatewayConnect,auth.ts:606)。HTTP 面关闭 Tailscale 转发头鉴权;WS 控制 UI 面打开它,用于无令牌的可信主机登录。这个区分是安全边界,不是配置便利。

6.3 限流

限流器是纯内存滑动窗口(createAuthRateLimiter,src/gateway/auth-rate-limit.ts:180),默认参数:

参数默认常量位置
窗口内最大失败次数10auth-rate-limit.ts:136
窗口长度60 秒:137
触发后锁定300 秒:138
回环地址豁免是(失败仍累计一个递增延迟):184
最大跟踪条目10 000:140

计数按 {scope, clientIp} 分开,scope 是一个封闭清单(auth-rate-limit.ts:45-74):

scope保护什么
shared-secret共享 token / password
device-token设备令牌
node-pairing节点配对请求
node-reapproval已配对节点的审批面变更
bootstrap-token预鉴权的引导令牌校验
device-join公开加入码兑换(烧 SQLite 写)
watch-challengewatchOS 挑战签发
worker-admission / worker-transfer工作节点准入 / 传输
hook-authwebhook 鉴权

为什么要分这么细? 源码注释给的是攻击面推理,不是洁癖。例如 bootstrap-token 那条(auth-rate-limit.ts:55-61):verifyDeviceBootstrapToken 带锁串行,每次尝试都做文件读+写;没有专属 scope 的话,持有合法设备签名的攻击者可以用引导配对流程把锁队列塞满,阻塞正常的节点上线。device-join 的注释(:62-64)同理:公开加入码兑换会烧 SQLite 状态,必须在共享库锁之前先节流。

网关启动时建了两个限流器(src/gateway/server-runtime-state-prepare.ts:50-52):普通的一个尊重回环豁免;另一个专给浏览器来源的 WS 鉴权,强制 exemptLoopback: false——本机浏览器上的恶意页面也算攻击者。

6.4 暴露面清单

除了 WS,HTTP server 上按顺序挂了一串阶段(createGatewayHttpServer,src/gateway/server-http.ts:154;阶段数组从 :327 开始)。全表:

路径阶段鉴权开关
/health /healthz /ready /readyz探针(首阶段,:327-340)免鉴权,但细节要鉴权才给常开
(hook 路径)handleHooksRequest(:407)hook 专用配置
/v1/modelsmodels(:415)网关鉴权OpenAI 兼容任一开启
/v1/embeddingsembeddings(:418)网关鉴权同上
/tools/invoketools-invoke(:421)网关鉴权常开
/sessions/<id>/killsessions-kill(:424)网关鉴权常开
/sessions/<id>/historysessions-history(:427)网关鉴权常开
/__openclaw__/board/…board(:433)网关鉴权常开
/v1/responsesopenresponses(:448)网关鉴权openResponsesEnabled
/v1/chat/completionsopenai(:454)网关鉴权openAiChatCompletionsEnabled
插件注册的路径plugin-*(:532 注释起)按插件声明插件
控制 UI(含 SPA 兜底)control-ui-*(:636 起)网关鉴权controlUiEnabled
其余404

顺序有讲究: 注释写明 "Core and recovery routes run first, then plugin routes, then read-only Control UI"(server-http.ts:527)——显式注册的插件端点不会被 SPA 的 catch-all 吃掉;而核心内建路由又排在插件之前,在路径重叠时核心优先。控制 UI 里还有个例外:插件管理页必须永远可达,这样插件路由坏掉时操作员还能进去把它关掉(server-http.ts:500-501 的注释)。

探针的信息分级很值得学(handleGatewayProbeRequest,src/gateway/server-http-probes.ts:60):就绪细节会暴露子系统名字,所以只对本机直连证明了网关鉴权的调用者展开(server-http-probes.ts:91 的注释);未认证的远程探针只拿到一个布尔值。

控制 UI(handleControlUiHttpRequest,src/gateway/control-ui.ts:798) 服务浏览器端单页应用。它有独立的安全头(applyControlUiSecurityHeaders,control-ui.ts:194)和一个 bootstrap 配置端点(路径匹配 matchesControlUiBootstrapConfigPath,:1035;服务点 :1094),返回助手名字、头像、版本、嵌入沙箱策略等。启动时还有一次无条件迁移 maybeSeedControlUiAllowedOriginsAtStartup(src/gateway/startup-control-ui-origins.ts:14):给那些升级上来、绑了非回环地址却没配 allowedOrigins 的老装机补上必需的来源白名单。

OpenAI 兼容端点(handleOpenAiHttpRequest,src/gateway/openai-http.ts:866) 让任何 OpenAI SDK 直接把 OpenClaw 当模型用。它复用 chat.send 的操作员方法要求,但注释点明一处差异(openai-http.ts:875-876):兼容 HTTP 用的是另一套 scope 模型——共享密钥 bearer 在这里被当作完整操作员权限。这是刻意的取舍,因为 OpenAI 客户端没法表达细粒度 scope。

6.5 连接层的三道量化闸门

闸门常量位置作用
预鉴权单帧上限64 KiBserver-constants.ts:5(MAX_PREAUTH_PAYLOAD_BYTES)WebSocketServer 初始 maxPayload,握手前只允许小帧
鉴权后单帧上限25 MiBserver-constants.ts:3(MAX_PAYLOAD_BYTES)握手成功后才放开(选择点在 src/gateway/server/ws-connection.ts:396-398)
每连接发送缓冲上限50 MiBserver-constants.ts:4(MAX_BUFFERED_BYTES)慢消费者超限直接以 1008 关闭

外加预鉴权连接预算 createPreauthConnectionBudget(src/gateway/server/preauth-connection-budget.ts:30):升级阶段按客户端 IP 占一个未认证槽位,超了就回 "Too many unauthenticated sockets"。槽位的所有权转移写得很小心:socket 先持有(通过挂在 socket 上的 GATEWAY_WS_PREAUTH_BUDGET_PROPERTY,src/gateway/server/ws-connection.ts:216-218),直到 WS connection handler 认领;close/error 路径必须释放(ws-connection.ts:288),否则会泄漏未认证连接名额。

还有一个未授权洪泛守卫 UnauthorizedFloodGuard(src/gateway/server/ws-connection/unauthorized-flood-guard.ts,接入点 authenticated-request-dispatch.ts:51):同一连接反复触发"角色未授权"错误时,日志会被压制,累计到阈值直接以 1008 关闭连接(authenticated-request-dispatch.ts:126-144)。


7. 设备节点:配对、能力声明与反向调用

7.1 节点是什么

节点(node) 是本章第二个承重术语,固定含义:一个以 role: "node" 连上网关、并把自己的能力交给网关调用的伴侣端。三种典型:iOS App、Android App、macOS 伴侣应用,以及命令行的 openclaw node run(runNodeHost,src/node-host/runner.ts:234)。

节点和普通客户端的根本区别:

操作员客户端(CLI/控制 UI)节点(手机/Mac)
roleoperatornode
主要方向我调网关网关调我
可调方法全部 operator scope 方法只有约 10 个 node scope 回报方法
需要配对否(共享密钥即可),需要显式批准

7.2 连接时发生了什么

节点连接时在 connect 帧里交三样东西(见 5.2 的 ConnectParams):

  1. 设备身份 device:id + publicKey + 对 nonce/signedAtsignature
  2. 能力声明 caps(如 "system""camera")和 commands(如 "system.run""camera.snap")。
  3. 权限 permissions:一个 {权限名: 布尔} 映射,表示 App 侧是否拿到了系统授权。

握手通过后,NodeRegistry.register(...)(src/gateway/node-registry.ts:433)把这次连接登记成该 nodeId当前连接。注意它同时记了两组字段:declaredCaps/declaredCommands(声明的,:458-461)与 caps/commands(生效的)。

配对本身走 node.pair.request → 操作员 node.pair.approve / node.pair.reject(handlers 集中在 src/gateway/server-methods/nodes.pairing.ts),这些方法要 operator.pairing scope。配对相关请求还有专属限流 scope(见 6.3),因为它们会进入配对存储锁。

7.3 能力是"声明 ∩ 允许"的交集

这是节点安全模型的核心。一条命令能不能跑,要同时过两关(isNodeCommandAllowed,src/gateway/node-command-policy.ts:534):

command

├─ ① 在 allowlist 里吗? ← 网关按平台 + 配置算出来的
│ 否 → "command not allowlisted"

└─ ② 节点自己声明过吗? ← connect 帧里的 commands
否 → "command not declared by node"
节点一条都没声明 → "node did not declare commands"

allowlist 由 resolveNodeCommandAllowlist(cfg, node)(node-command-policy.ts:464)按节点平台分档算出。例如 iOS 节点根本不实现 system.run/system.which,它的系统类命令只有通知(node-command-policy.ts:78 的注释)。

命令还分普通与高危两档。高危集合 DEFAULT_DANGEROUS_NODE_COMMANDS(node-command-policy.ts:108)默认不在 allowlist 里,必须显式写进 gateway.nodes.allowCommands 才能启用:

类别普通高危(默认关)
相机camera.listcamera.snapcamera.clipcamera.ptz.control
屏幕screen.snapshotscreen.record
通讯录contacts.searchcontacts.add
日历calendar.eventscalendar.add
提醒reminders.listreminders.add
短信sms.sendsms.search

还有一条运行时收缩规则(node-registry.ts:1028sessionCapsCeiling):审批面变更只能收窄在 connect 时声明过的能力/命令/权限,不能扩张。

7.4 node.invoke:一次反向调用的完整时序

这是网关主动驱动节点的路径。怎么读:纵向是时间。

操作员 网关 节点(手机)
│ │ │
├─ req node.invoke ───────▶│ │
│ {nodeId, command, │ ① validateNodeInvokeParams │
│ params, idempotencyKey}│ ② 拒绝 system.execApprovals.* │
│ │ ③ 节点没连?→ APNs 唤醒 + 等重连 │
│ │ (两轮: wake1/wait1, wake2/wait2) │
│ │ ④ allowlist ∩ declared 检查 │
│ │ ⑤ 参数消毒 + 插件策略 │
│ │ │
│ ├─ event node.invoke.request ───────▶│
│ │ {id, command, paramsJSON, │ ⑥ handleInvoke()
│ │ timeoutMs, idempotencyKey} │ 本地执行白名单
│ │ │
│ │◀─ req node.invoke.result ──────────┤
│ │ {id, ok, payloadJSON, error} │
│◀─ res {ok, payload} ─────┤ ⑦ 按 id 找到 pending,resolve │

对应源码:

  • ①②④⑤ 都在 "node.invoke" handler 里(nodeInvokeHandlers,src/gateway/server-methods/nodes.invoke.ts:59-60)。
  • ② 两条硬禁令:system.execApprovals.get/set 不许通过 node.invoke 调,必须走 exec.approvals.node.*(nodes.invoke.ts:106-116);browser.proxy 不许用来改持久化浏览器配置(nodes.invoke.ts:119-130)。这是防止用一个宽松的通用通道绕开专门的审批方法。
  • ③ 唤醒流程很实在:节点不在线时先发 APNs 静默推送、等重连;失败再强制推一次、再等;还不行就发一个"轻推"通知,最后返回 UNAVAILABLE + code: "NOT_CONNECTED"。唤醒主循环在 nodes.invoke.ts:207-244,每一步都打了结构化日志(stage=wake1/wait1/…);三个助手分别是 maybeWakeNodeWithApns / maybeSendNodeWakeNudge / waitForNodeReconnect(src/gateway/server-methods/nodes.wake.ts:78:215:341)。
  • ⑥⑦ 在 NodeRegistry.invoke(...)(src/gateway/node-registry.ts:1104,实现委托 invokePublicNodeRegistry):生成 requestId,把 params 序列化成 paramsJSON 塞进 node.invoke.request 事件;默认 30 秒超时(resolveTimerTimeoutMs(params.timeoutMs, 30_000, 0),src/gateway/node-registry-private.ts:358),超时返回 {code: "TIMEOUT"}

unregister(connId)(node-registry.ts:582)在连接断开时会把绑在这条连接上的所有 pending invoke 全部拒掉(invokeStreams.handleDisconnect,:597),避免它们悬着等超时。

帧的 schema 分别是 NodeInvokeParamsSchema(packages/gateway-protocol/src/schema/nodes.ts:141,注意 idempotencyKey必填)与 NodeInvokeRequestEventSchema(schema/nodes.ts:228)。

7.5 节点端的执行白名单

命令到了节点,还要再过一遍节点自己的策略。handleInvoke(...)(src/node-host/invoke.ts:570)的分发顺序:

command
├─ system.execApprovals.get/set → 读写本地审批文件(invoke.ts:677 / :713;注意:网关侧已禁止经 node.invoke 到达)
├─ system.which → 查可执行文件(:774)
├─ 插件注册的 node-host 命令 → invokeRegisteredNodeHostCommand(:834)
├─ system.run.prepare → 只算计划 + 返回当前 exec 策略,不执行(:844)
├─ system.run → 真正执行
└─ 其余 → UNAVAILABLE "command not supported"(:898-899)

真正的允许/拒绝判定在 evaluateSystemRunPolicy(...)(src/node-host/exec-policy.ts:47),输入是「安全等级 + 询问策略 + 分析结果 + 允许清单命中 + 审批状态」,输出是一个带原因的判定:

拒绝原因码触发条件
security=deny安全等级直接是 deny
approval-required需要询问但还没拿到批准
allowlist-misssecurity=allowlist 且命令不在清单、也没有审批

其中一条平台差异写得很清楚(exec-policy.ts:60-63):

POSIX 侧刻意用 /bin/sh -lc 作为传输包装,所以按解析出的内层 shell 载荷判 allowlist;但 Windows 的 cmd.exe /c 仍然必须显式审批,因为它改变了内建命令和引号解析的执行语义。

拒绝消息也是给人看的,会告诉你"shell 包装需要审批,可以批准一次/永久,或用 --ask on-miss|always 运行"(formatSystemRunAllowlistMissMessage,exec-policy.ts:33)。

节点端的循环有多简单? runNodeHost(src/node-host/runner.ts:234)其实就是:建一个 GatewayClient,把 caps/commands 报上去,然后在 onEvent 里只认一个事件(runner.ts:579-594):

onEvent: (evt) => {
if (evt.event !== "node.invoke.request") return;
const payload = coerceNodeInvokePayload(evt.payload);
if (!payload) return;
void handleInvoke(payload, client, skillBins);
},

coerceNodeInvokePayloadsrc/node-host/invoke-payload.ts:6。整个"设备伴侣端"的骨架就这么点东西——复杂度全在两边的策略层,而不在传输层


8. 巧妙之处(可以拿走的技术)

① 用声明式表替代散落的分支。 无论是 CLI 的启动策略(cliCommandCatalog)、平台服务的适配(GATEWAY_SERVICE_REGISTRY)、还是方法的权限与限流策略(CORE_GATEWAY_METHOD_SPECS),都是一张可读表 + 一个通用执行器。core-descriptors.ts:72-73 的注释把这个原则说透了:所有核心方法必须出现在这张表里,列表、鉴权、启动可用性、写限流才可能保持同步

② "先接线,再开门"。 先建 WebSocketServer({noServer: true})、挂好 upgrade 处理器,最后才 listen()。这消灭了一整类"启动瞬间连接被静默 1006 拒绝"的竞态(server-runtime-state.ts:273-277)。

③ 惰性,但只惰性一次。 lazyCompile(schema 编译)和 lazyHandlerModule(handler 模块)是同一个模式:第一次用才做,做的结果缓存在闭包里,并发调用共享同一个 promise。启动只付"列出名字"的成本,不付"加载实现"的成本。

④ 可重试性写进协议。 errorShape 支持 retryable + retryAfterMs,于是"启动中"和"被限流"这两种暂时性失败能和"方法不存在"这种永久性失败清楚区分。客户端因此可以写出正确的退避逻辑,而不是靠猜错误消息。

⑤ 分 scope 的限流,理由都写在注释里。 限流 scope 每一个都附了一段攻击面推理(auth-rate-limit.ts:45-74),而不是"以防万一分开"。这让后来者知道哪些能合、哪些绝不能合。

⑥ 声明 ∩ 允许 的双向收窄。 节点的能力必须同时被节点声明被网关允许;运行时审批只能收窄不能扩张。任何一侧被攻破都不足以扩权。

⑦ 启动自检复用 agent,而不是另写一套。 BOOT.md 不引入新的"检查框架",它就是一次普通的 agent 运行,只是用了隔离会话、抑制回声、静默回复。功能上等价于"给自己写个开机备忘录"。

⑧ 探针的信息分级。 /readyz 对本机/已认证调用者给子系统细节,对匿名远程只给布尔——同一个端点,两种信息密度。


9. 边界与局限

  • 限流器是纯内存、单进程的。 模块开头写明只适用于单个网关进程(auth-rate-limit.ts:1-16)。多实例部署下计数不共享。
  • 回环地址默认豁免限流。 好处是本机 CLI 永远不会被锁死;代价是本机上的恶意进程不受节流(但失败尝试仍会累计一个递增延迟)。浏览器来源的 WS 鉴权专门关掉了这个豁免,但其他路径没有。
  • node.invoke 默认超时 30 秒。 长任务必须自己传 timeoutMs,否则会拿到 TIMEOUT 而节点那边可能还在跑。
  • 控制平面写预算很紧:60 秒 30 次、按方法分桶。 脚本化地批量改配置会撞上限流。
  • OpenAI 兼容端点的权限模型更粗。 共享密钥 bearer 在那里等价于完整操作员权限(openai-http.ts:875-876),不能做细粒度授权。
  • 协议版本是硬门槛。 MIN_CLIENT_PROTOCOL_VERSION = 4(version.ts:4),旧客户端连不上,没有兼容层。
  • Windows 的服务形态是 Scheduled Task,行为与 launchd/systemd 不完全对等;非上述三平台直接返回"不支持"服务(service.ts:324)。
  • BOOT.md 失败不阻塞启动。 runBootOnce 出错只返回 {status:"failed"} 并记日志(boot.ts:126:194),网关照常运行——自检是尽力而为,不是启动条件。

10. 代码地图

主题文件关键符号
启动器(不加载业务码)openclaw.mjsensureSupportedRuntimeVersiontryOutputLauncherVersiontryOutputBareRootHelp
TS 入口 + 主模块守卫src/entry.tsrunMainOrRootHelpENTRY_WRAPPER_PAIRS
库/程序双形态入口src/index.tsrunLegacyCliEntry
argv 解析src/cli/argv.tsgetCommandPathWithRootOptions
CLI 启动策略表src/cli/command-catalog.tscliCommandCatalogCliCommandPathPolicy
gateway * 子命令注册src/cli/gateway-cli/register.tsregisterGatewayCli
前台运行 + 进程内重启循环src/cli/gateway-cli/run-loop.tsrunGatewayLoop
服务子命令(install/start/…)src/cli/daemon-cli/register-service-commands.tsaddGatewayServiceCommands
平台服务适配层src/daemon/service.tsGatewayServiceGATEWAY_SERVICE_REGISTRYresolveGatewayServicestartGatewayServicewithGatewayServiceMutationGuards
systemd 单元文件生成src/daemon/systemd-unit.tsbuildSystemdUnit
systemd 安装/环境文件src/daemon/systemd.ts(桶)、src/daemon/systemd-install.tsstageSystemdServiceinstallSystemdService
launchd plistsrc/daemon/launchd.ts(桶)、src/daemon/launchd-service-files.tsresolveLaunchAgentPlistPathinstallLaunchAgent
服务标识常量src/daemon/constants.tsGATEWAY_LAUNCH_AGENT_LABELGATEWAY_SYSTEMD_SERVICE_NAME
服务器启动(壳/核心/内核/收尾)src/gateway/server.tsserver-start.tsserver-kernel.tsserver-startup-finish.tsstartGatewayServerstartGatewayServerCorecreateGatewayKernelfinishGatewayStartup
启动引导与内核状态src/gateway/server-startup-bootstrap.tssrc/gateway/server-runtime-state-prepare.tsprepareGatewayServerBootstrapprepareGatewayKernelState
服务器公共类型src/gateway/server-public.tsGatewayServerGatewayServerOptions
HTTP/WS 运行时状态构建src/gateway/server-runtime-state.tscreateGatewayHttpTransport
HTTP 路由阶段src/gateway/server-http.tscreateGatewayHttpServerrunGatewayHttpRequestStages
upgrade 处理src/gateway/server-http-upgrades.tsattachGatewayUpgradeHandler
端口绑定与重试src/gateway/server/http-listen.tslistenGatewayHttpServer
WS 连接处理器src/gateway/server/ws-connection.tsattachGatewayWsConnectionHandler
握手与帧循环src/gateway/server/ws-connection/message-handler.tsvalidateRequestFrame 使用点
hello-ok 构造src/gateway/server/ws-connection/connect-hello.tshello-ok payload
未授权洪泛守卫src/gateway/server/ws-connection/unauthorized-flood-guard.tsauthenticated-request-dispatch.tsUnauthorizedFloodGuard
启动后附加阶段src/gateway/server-startup-post-attach.tsstartGatewayPostAttachRuntimestartGatewaySidecars
就绪检查src/gateway/server/readiness.tscreateReadinessChecker
关闭编排src/gateway/server-shutdown.tssrc/gateway/server-close.tsrunGatewayShutdownStepsrunGatewayClosePreludecreateGatewayCloseHandler
请求分发与闸门src/gateway/server-methods.tshandleGatewayRequestauthorizeGatewayMethodCORE_GATEWAY_HANDLER_MODULES
惰性 handler 家族src/gateway/server-methods/lazy-core-handlers.tslazyHandlerModulecreateLazyCoreHandlers
核心方法策略表src/gateway/methods/core-descriptors.tsCORE_GATEWAY_METHOD_SPECSSTARTUP_UNAVAILABLE_GATEWAY_METHODS
方法注册表src/gateway/methods/registry.tscreateGatewayMethodRegistry
角色分流src/gateway/role-policy.tsparseGatewayRoleisRoleAuthorizedForMethod
操作员权限集合src/gateway/operator-scopes.tsOperatorScopeADMIN_SCOPE
控制平面写预算src/gateway/control-plane-rate-limit.tsconsumeControlPlaneWriteBudget
方法/事件目录src/gateway/server-methods-list.tsGATEWAY_EVENTSlistGatewayMethods
代表方法家族src/gateway/server-methods/{agent,chat,channels,artifacts}.tsnodes.ts + nodes.*.tsagentHandlerschatHandlerschannelsHandlersartifactsHandlersnodeHandlers
连接鉴权src/gateway/auth.tsauthorizeGatewayConnectauthorizeHttpGatewayConnectauthorizeWsControlUiGatewayConnect
鉴权模式解析src/gateway/auth-resolve.tsresolveGatewayAuthfinalizeResolvedGatewayAuth
鉴权限流src/gateway/auth-rate-limit.tscreateAuthRateLimiterAUTH_RATE_LIMIT_SCOPE_*
预鉴权连接预算src/gateway/server/preauth-connection-budget.tscreatePreauthConnectionBudget
连接层常量src/gateway/server-constants.tsMAX_PAYLOAD_BYTESMAX_PREAUTH_PAYLOAD_BYTESMAX_BUFFERED_BYTES
控制 UIsrc/gateway/control-ui.tshandleControlUiHttpRequesthandleControlUiAvatarRequest
OpenAI 兼容端点src/gateway/openai-http.tshandleOpenAiHttpRequest
节点会话注册表src/gateway/node-registry.tsnode-registry-private.tsNodeRegistry.registerNodeRegistry.invokeNodeSession
节点命令策略src/gateway/node-command-policy.tsresolveNodeCommandAllowlistisNodeCommandAllowedDEFAULT_DANGEROUS_NODE_COMMANDS
节点方法 handlerssrc/gateway/server-methods/nodes.{pairing,invoke,wake,read,pending,event}.tsnodePairingHandlersnodeInvokeHandlersmaybeWakeNodeWithApns
节点端命令执行src/node-host/invoke.tshandleInvoke
节点端执行策略src/node-host/exec-policy.tsevaluateSystemRunPolicyformatSystemRunAllowlistMissMessage
节点主机循环src/node-host/runner.tsrunNodeHost
BOOT.md 自检src/gateway/boot.tssrc/gateway/boot-echo-guard.tsrunBootOncebuildBootPrompt
BOOT.md 触发 hooksrc/hooks/bundled/boot-md/handler.tsrunBootChecklist
帧 schemapackages/gateway-protocol/src/schema/frames.tsRequestFrameSchemaResponseFrameSchemaEventFrameSchemaConnectParamsSchemaHelloOkSchema
节点 schemapackages/gateway-protocol/src/schema/nodes.tsNodeInvokeParamsSchemaNodeInvokeRequestEventSchema
校验器packages/gateway-protocol/src/protocol-validator.tsvalidator-registry.tslazyCompilevalidateConnectParamsvalidateNodeInvokeParams
错误码packages/gateway-protocol/src/gateway-error-details.tsschema/error-codes.tsErrorCodeserrorShape
协议版本packages/gateway-protocol/src/version.tsPROTOCOL_VERSIONMIN_CLIENT_PROTOCOL_VERSION
客户端 SDKpackages/gateway-client/src/client.tsprotocol-client.tspending-request.tsGatewayClientGatewayClient.request

下一步读哪章: 想知道网关启动时加载的那些"插件"到底是什么、通道/模型商/工具为什么共用一套扩展机制,去 02-plugin-kernel;想知道一条消息进来之后怎么被归一化和路由,去 03-inbound-and-sessions