数据截至 (上游 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() 到「就绪」,再到关闭时的排水顺序 |
| 3 | WS 帧级 RPC | 三种帧、TypeBox schema、惰性编译校验器、错误码 |
| 4 | 认证与暴露面 | 令牌怎么解析、有几种鉴权模式、限流打在哪、除了 WS 还开了哪些 HTTP 口 |
| 5 | 设备节点 | 手机/Mac 伴侣端如何配对、声明能力,以及网关如何反向调用它们 |
明确不覆盖(在同组其他章):
- 入站消息怎么被归一化、会话怎么路由 → 03-inbound-and-sessions
- 回复怎么编排、分块投递 → 04-reply-pipeline
- 智能体循环、模型调用与失败转移 → 05-agent-runtime
- 通道/模型商/工具为什么都是插件 → 02-plugin-kernel
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:65 的 runLegacyCliEntry、: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 | 跳过配置校验(修配置的命令必须能跑) | configure、doctor、secrets |
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 起)写着:只有 gateway 和 gateway run 用默认网络代理,gateway status 等其余子命令一律 bypass(:218 起)——因为它们只是本机 RPC,不该被公司代理劫持。
3.3 前台模式 vs 守护进程模式
这是网关的两种运行形态,区别很清楚:
openclaw gateway run | openclaw 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) | LaunchAgent | ai.openclaw.gateway | loaded |
| Linux | systemd user unit | openclaw-gateway | enabled |
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 |
|---|