OpenClaw 设计解析(三):Gateway 核心——WebSocket 服务与会话管理

系列第 3 篇。内容:Gateway 的 WebSocket 服务、认证、会话管理、并发控制。


1. Gateway 的角色

Gateway 是 OpenClaw 的运行时核心。无论你用的是 macOS 菜单栏应用、iOS/Android 客户端、Web 界面、终端 TUI,还是 Telegram/Discord/Slack 等消息渠道,所有请求最终都汇聚到同一个 Gateway 进程。

graph TD A[macOS App] --> G B[iOS App] --> G C[Android] --> G D[Web UI] --> G E[TUI] --> G F[CLI] --> G G["Gateway 核心 (Node.js 单进程)"] G --- H["HTTP REST<br/>健康检查 / OpenAI 兼容 / Webhook"] G --- I["WebSocket RPC<br/>聊天 / 事件推送 / 命令审批"] H & I --> J["28+ Handler 模块"]

它同时暴露 HTTP 和 WebSocket 两种协议:HTTP 服务静态资源和 REST 端点(如 OpenAI 兼容的 /v1/chat/completions、健康检查、Slack webhook 回调等),WebSocket 承载主要的双向 RPC 通信(聊天流式输出、事件推送、命令审批等需要服务端主动推送的场景)。30 多个 RPC handler 模块覆盖了聊天、会话、模型管理、渠道控制、定时任务等全部能力。

这是一个”单进程、双协议、多客户端”的架构——一个 Gateway 进程就能服务所有客户端和所有协议。部署上,Gateway 通过绑定模式(--bind)控制网络可达性:默认绑定 loopback(仅本机访问),也可以通过 Tailscale 或反向代理安全地暴露给远程设备(详见 3.4 节)。


2. 启动流程

命令行入口:

1
openclaw gateway run --port 18789 --bind loopback

主要启动参数:

参数 说明 示例值
--bind 网络绑定模式 loopback / lan / tailnet / auto / custom
--port 监听端口 18789(默认)
--auth 认证模式 none / token / password / trusted-proxy
--token 共享认证令牌
--tailscale Tailscale 集成模式 off / serve / funnel
--force 强制抢占端口

启动链分为四步:

flowchart LR A["① 端口解析<br/>校验合法性<br/>--force 时抢占端口"] --> B["② 认证配置<br/>合并 CLI + 配置文件<br/>非 loopback 必须配认证"] B --> C["③ 绑定模式<br/>解析网络绑定策略<br/>默认 loopback"] C --> D["④ 启动服务<br/>进入主循环<br/>端口冲突时报错"]

其中有一条硬性安全规则:非 loopback 绑定必须配置 token/password/trusted-proxy 之一,否则拒绝启动。


3. WebSocket 服务实现

3.1 服务器初始化

Gateway 的网络层初始化核心逻辑(src/gateway/server-runtime-state.ts):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 伪代码:Gateway 网络层初始化

// 1. 解析需要绑定的地址列表
// loopback 模式下可能是 ["127.0.0.1", "::1"](同时覆盖 IPv4 和 IPv6)
// 其他模式下通常是单个地址,如 ["0.0.0.0"]
const bindHosts = resolveListenHosts(bindHost)

// 2. 为每个地址创建独立的 HTTP 服务器并监听
const httpServers = []
for (const host of bindHosts) {
const server = createHttpServer(/* 路由、认证、插件等配置 */)
server.listen(port, host)
httpServers.push(server)
}

// 3. 创建一个共享的 WebSocket 服务(noServer 模式,不自行监听端口)
const wss = new WebSocketServer({ noServer: true })

// 4. 将每个 HTTP 服务器的 upgrade 事件都挂载到同一个 wss
for (const server of httpServers) {
server.on("upgrade", (req, socket, head) => {
// 在这里可以做鉴权、限流等检查
wss.handleUpgrade(req, socket, head, (ws) => {
clients.add(ws) // 加入共享的客户端列表
})
})
}

关键设计点:noServer: true 让 WebSocket 不自行监听端口,而是复用 HTTP 服务的 upgrade 事件,HTTP API 和 WebSocket 共享同一端口。

3.2 HTTP 与 WebSocket 的关系

很多人会疑惑:既然 Gateway 主要用 WebSocket 通信,为什么还需要 HTTP 服务?

首先,WebSocket 连接的建立依赖 HTTP。 这是协议规范决定的,不是设计选择:

sequenceDiagram participant C as 客户端 participant H as HTTP Server participant W as WebSocket Server C->>H: ① TCP 连接 C->>H: ② HTTP GET (Upgrade: websocket) H->>W: ③ 转交 upgrade 请求 W->>C: ④ HTTP 101 Switching Protocols Note over C,W: ⑤ 此后同一条 TCP 连接切换为 WebSocket 协议<br/>HTTP 不再参与 C<<->>W: 双向 WebSocket 通信

没有 HTTP 服务器,就没有人接收这个初始的 upgrade 请求,WebSocket 握手无法完成。

其次,Gateway 本身需要大量纯 HTTP 接口。 WebSocket 适合双向、持续的通信(如聊天流式输出、事件推送),但很多场景天然是单次请求-响应模式,或者对端只支持 HTTP:

端点 用途 为什么是 HTTP
GET /health, /healthz 容器健康检查 Docker/K8s 探活不支持 WebSocket
POST /v1/chat/completions OpenAI 兼容接口 IDE(Cursor 等)只认 HTTP API 格式
POST /v1/responses OpenResponses 接口 同上,行业标准 REST/SSE 格式
POST /tools/invoke 工具调用 一次性请求-响应,不需要长连接
POST /slack/events Slack 事件回调 Slack 只发 HTTP POST
POST /hooks/wake 外部触发唤醒 cron/GitHub webhook 只会发 HTTP
GET /ui/** Control UI 静态资源 浏览器加载页面用 HTTP GET

为什么不用 SSE 替代 WebSocket? SSE(Server-Sent Events)能做服务端推送,但它是单向的。OpenClaw 的核心场景是双向高频交互——用户发消息、AI 流式回复、中途弹出审批请求、用户批准、AI 继续执行——这些消息在同一条连接上交错进行。SSE 做不到客户端随时发消息,每次都得单独发 HTTP 请求,状态关联和流控都会变复杂。

对于不需要双向的场景(如 /v1/chat/completions),OpenClaw 确实用了 HTTP + SSE 流式返回。

3.3 为什么需要多个 HTTP 服务器

你可能注意到 3.1 的代码里用 for 循环创建了多个 HTTP 服务器。通常写法是一个 server 就够了:

1
2
3
4
// 常见写法:一个 server,一对一绑定
const server = http.createServer()
const wss = new WebSocketServer({ server }) // wss 自动绑定到这个 server
server.listen(3000)

但 OpenClaw 不能这么做。Loopback 模式下,Gateway 需要同时绑定 127.0.0.1(IPv4)和 ::1(IPv6)。这是因为 localhost 在不同环境下解析结果不同:

  • Node.js 18+ 默认优先解析 IPv6,localhost::1
  • 有些 curl 版本优先 IPv4,localhost127.0.0.1
  • 浏览器行为因操作系统而异

如果只绑定 127.0.0.1,那些解析到 ::1 的客户端就会 connection refused。

server.listen(port, host)host 只接受一个地址,这是 Node.js/操作系统层面的限制,所以需要两个 HTTP 服务器分别绑定。

为什么不用 server.listen(18789) 省事? 不指定 host 时,Node.js 绑定到 :: (所有接口),IPv4 和 IPv6 都能连。但 :: 意味着局域网里的其他机器也能连进来,失去了 loopback 的安全保护。

两个 HTTP 服务器不会导致消息重复。 它们只是两扇门,进来之后是同一个房间:

graph LR A["127.0.0.1:18789<br/>(IPv4 入口)"] --> C["共享的 WebSocketServer<br/>共享的 clients 列表<br/>共享的请求处理逻辑"] B["[::1]:18789<br/>(IPv6 入口)"] --> C

一个客户端只会选其中一个地址连接(由操作系统 DNS 解析决定),广播时遍历的是 clients 列表,跟有几个 HTTP 服务器无关。

这也是为什么代码用 noServer: true + 手动挂载 upgrade handler,而不是 new WebSocketServer({ server })——后者只能绑定一个 HTTP 服务器。

容错方面:127.0.0.1 绑定失败则 Gateway 启动失败;::1 绑不上只是警告,降级为单 HTTP 服务器继续运行。

3.4 Loopback 模式与部署方案

Loopback 模式是 Gateway 的默认绑定模式,安全保证来自操作系统:绑定 127.0.0.1 后,内核的 TCP/IP 协议栈会直接丢弃不是来自本机的数据包,不需要防火墙规则。

但如果 Gateway 部署在 VPS 上,需要从外部访问,就有几种方案:

方案一:SSH 隧道(最简单)

Gateway 保持 loopback 模式,通过 SSH 端口转发访问:

1
2
3
4
5
6
# VPS 上
openclaw gateway run --bind loopback --port 18789

# 本地电脑
ssh -N -L 18789:127.0.0.1:18789 user@your-vps
# 然后本地 CLI 直接连 localhost:18789
graph LR A["你的电脑<br/>localhost:18789"] -- "SSH 加密隧道" --> B["VPS<br/>127.0.0.1:18789<br/>(Gateway)"]

适合个人 CLI 使用,不适合手机访问(手机不好开 SSH)。

方案二:Tailscale Serve(推荐多设备方案)

Tailscale 是一个组网工具,安装后所有设备加入一个私有虚拟网络。Gateway 保持 loopback,Tailscale Serve 提供 HTTPS 代理:

1
2
3
4
openclaw config set gateway.bind loopback
openclaw config set gateway.tailscale.mode serve
# 可选:免 token 认证
openclaw config set gateway.auth.allowTailscale true
graph LR A["手机 / 电脑 / 平板<br/>(Tailnet 内)"] -- "Tailscale 加密" --> B["Tailscale Serve<br/>https://my-vps.ts.net"] B --> C["Gateway<br/>127.0.0.1:18789"] D["外部网络"] -. "不可达" .-> C style D fill:#fee,stroke:#f66

各设备装 Tailscale 并登录同一账号即可访问,不需要开防火墙端口、不需要配 Nginx、不需要买域名证书。Tailscale 自动提供 HTTPS 和身份认证。

方案三:反向代理(需要公网访问时)

当需要接收 Slack webhook 等外部回调时,必须有公网可达的 URL:

1
2
openclaw config set gateway.bind lan
openclaw config set gateway.auth.token "your-secret-token"
graph LR A["公网用户"] --> B["Nginx<br/>0.0.0.0:443 (TLS)"] B --> C["Gateway<br/>0.0.0.0:18789"]

这种方案需要注意一个安全陷阱:OpenClaw 默认信任来自 localhost 的连接(跳过认证),而反向代理转发的请求在 TCP 层显示为 127.0.0.1,会导致认证被绕过。必须正确配置 trustedProxies,让 Gateway 从 X-Forwarded-For 头读取真实客户端 IP。

方案 bind 模式 安全性 复杂度 适合场景
SSH 隧道 loopback 最高 个人,仅 CLI
Tailscale Serve loopback 个人,多设备
反向代理 lan 取决于配置 需要公网访问

3.5 连接生命周期

每个 WebSocket 连接经历三个阶段:握手挑战 → 认证 → 已认证 RPC 通道。

sequenceDiagram participant C as Client participant G as Gateway C->>G: TCP + HTTP Upgrade G->>C: connect.challenge {nonce, ts} Note right of G: 发送随机挑战值 C->>G: connect 请求 {auth, device, caps, protocol} Note right of G: 认证检查 (6种模式)<br/>速率限制检查<br/>角色 + 作用域授权 G->>C: hello-ok {protocol, methods[], snapshot, policy} rect rgb(230, 245, 230) Note over C,G: 已认证 RPC 通道 C->>G: RPC 请求 (如 chat.send) G->>C: RPC 响应 G->>C: 事件推送 (chat delta, agent 状态...) G->>C: tick 心跳 (定时 keepalive) end C->>G: 断开 Note right of G: 清理: 注销节点, 广播离线状态

握手挑战:连接建立后,服务端发送一个包含随机 nonce 和时间戳的 challenge。客户端必须在超时时间内用 connect 请求响应,否则连接被关闭。

认证与注册:认证通过后,客户端被加入全局 clients 集合,后续所有 RPC 调用和事件推送都通过这个集合管理。

断开清理:连接关闭时,清理已注册的节点、广播存在状态变更、记录最后消息元数据。

3.6 消息解析与路由

WebSocket 收到的每一帧消息都经过统一处理:

  1. 握手阶段:第一条消息必须是 connect 请求,参数格式不对则直接断开(关闭码 1008
  2. 已认证阶段:后续消息走统一的 RPC 路由,根据 method 字段分发到对应 handler
  3. 洪泛防护:如果检测到客户端反复发送未授权请求,会抑制日志并主动断开连接,防止日志被刷爆

4. 协议定义

OpenClaw 使用 TypeBox(@sinclair/typebox)定义运行时 schema,兼顾 TypeScript 类型推导和运行时校验。

连接参数(客户端 → 服务端)

客户端连接时需要携带的信息:

字段 说明
minProtocol / maxProtocol 协议版本协商
client.id / mode / version 客户端身份(如 cliopenclaw-macoswebchat
caps / commands / permissions 能力声明(客户端支持哪些特性)
device 设备认证(publicKey + ECDSA 签名)
auth 认证凭据(token / deviceToken / password)

握手响应(服务端 → 客户端)

认证成功后,服务端返回 hello-ok

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// hello-ok 响应结构概览
{
type: "hello-ok",
protocol: 42, // 协商后的协议版本
server: { version, connId }, // 服务端版本和连接 ID
features: {
methods: string[], // 可用 RPC 方法列表
events: string[], // 可用事件类型列表
},
snapshot: { ... }, // 当前状态快照(presence、health 等)
policy: {
maxPayload: 1048576, // 最大载荷字节数
maxBufferedBytes: 4194304,
tickIntervalMs: 30000, // 心跳间隔
},
auth?: {
deviceToken, // 颁发的设备令牌
role, // 授予的角色
scopes, // 授予的作用域
},
}

所有 schema 都设置了 additionalProperties: false,确保严格校验——客户端不能偷塞未知字段。

跨平台一致性:协议 schema 导出为 JSON Schema 文件,Swift 端通过 codegen 生成对应的模型代码,保证 iOS/macOS 客户端与服务端的协议定义始终同步。


5. RPC 方法分发

5.1 Handler 聚合

Gateway 将 28 个 handler 模块聚合为一个统一的方法表,覆盖全部功能域:

模块 功能
chat 聊天(发送、中止、流式输出)
sessions 会话管理(创建、切换、删除)
models 模型配置
channels 渠道控制(Telegram、Discord 等)
cron 定时任务
devices 设备管理和配对
tools-catalog 工具目录
browser 浏览器集成
health 健康检查
voicewake 语音唤醒
其余 handler 模块

5.2 方法路由

路由逻辑很简单——根据请求的 method 字段在方法表中查找对应 handler:

1
2
3
4
5
6
7
8
9
// 伪代码:RPC 路由
const handler = pluginHandlers[method] ?? coreHandlers[method]

if (!handler) {
respond({ error: "unknown method" })
return
}

await handler(req, client, context)

先检查插件注入的 handler(插件可以扩展 RPC 方法),再回退到核心方法表。找不到就返回错误。

5.3 方法授权

每个 RPC 调用在分发前都要经过角色 + 作用域双层授权检查:

flowchart LR A["RPC 请求进入"] --> B{"角色检查\n该角色能调\n这个方法吗?"} B -- 不允许 --> X["拒绝:\nunauthorized role"] B -- 允许 --> C{"作用域检查"} C -- "node 角色" --> D["放行,执行 handler"] C -- "admin scope" --> D C -- "普通 operator" --> E{"有所需\nscope 吗?"} E -- 缺少 --> Y["拒绝:\nmissing scope"] E -- 有 --> D
  • operator(默认角色):拥有大部分读写权限
  • node:远程节点角色,有特殊的方法白名单
  • admin scope:万能钥匙,全权通过

这套机制让移动端可以使用受限作用域连接,降低凭据泄露风险。


6. 认证机制

6.1 六种认证模式

Gateway 支持六种认证方式,适应从本地开发到远程部署的不同场景:

flowchart LR A["认证请求进入"] --> B{"按优先级\n依次尝试"} B --> C["① none\n仅 loopback"] B --> D["② token\n共享密钥"] B --> E["③ password\n密码认证"] B --> F["④ tailscale\nwhois 验证"] B --> G["⑤ device-token\n设备专属令牌"] B --> H["⑥ trusted-proxy\n信任代理头"] C & D & E & F & G & H --> I["返回结果"]
模式 适用场景 说明
none 仅 loopback 部署 不做任何认证
token 远程访问 共享密钥,常量时间比较防时序攻击
password 简单远程访问 密码认证,常量时间比较
tailscale Tailscale 网络 通过 Tailscale whois 验证身份
device-token 移动设备 设备专属令牌(绑定 deviceId + clientId)
trusted-proxy 反向代理部署 从代理头提取身份

认证结果中的 rateLimitedretryAfterMs 字段让客户端知道何时可以重试——这比简单返回 403 要友好得多。

6.2 认证速率限制

Gateway 实现了 per-IP 的认证速率限制,防止暴力破解:

  • 滑动窗口:1 分钟内最多 10 次失败尝试
  • 锁定:超过阈值后锁定 5 分钟
  • 重置:认证成功后清除计数
  • 豁免:Loopback 地址(127.0.0.1、::1)默认不受限制
flowchart LR A["认证请求"] --> B{"IP 被锁定?"} B -- 是 --> C["拒绝 + retryAfterMs"] B -- 否 --> D{"认证是否成功?"} D -- 成功 --> E["清除计数,放行"] D -- 失败 --> F["记录失败<br/>窗口内累计"] F --> G{"累计 >= 10 次?"} G -- 是 --> H["锁定 5 分钟"] G -- 否 --> I["返回失败"]

token 和 password 认证都使用常量时间比较(safeEqualSecret),防止时序攻击——攻击者无法通过响应时间差异逐字符猜测密钥。


7. 会话管理

会话(Session)是 Gateway 的核心状态单元,每个对话都对应一个 SessionEntry。会话数据以 JSON 文件持久化在磁盘上,Gateway 在其上做了三层处理来保证性能、一致性和兼容性。

7.1 TTL 缓存 + mtime 校验

Gateway 频繁读取会话数据(每次聊天、心跳、事件推送都可能触发),如果每次都读磁盘,IO 压力很大。解决方案是内存缓存 + 双重失效机制

flowchart LR A["读取会话"] --> B{"内存缓存\n有数据?"} B -- 没有 --> D["从磁盘读取\nJSON 文件"] B -- 有 --> T{"TTL 过期?\n默认 45 秒"} T -- 过期 --> D T -- 未过期 --> M{"文件 mtime\n变了?"} M -- 变了 --> D M -- 没变 --> C["返回深拷贝"] D --> E["写入缓存"] --> C

为什么需要 mtime 校验? 单靠 TTL 有个问题:如果另一个进程(比如同时开了两个 CLI)修改了 sessions.json,你要等 45 秒才能看到最新数据。mtime 校验在每次缓存命中时快速 stat 一下文件修改时间(只读元数据,不读内容),发现变化就立即重新读磁盘。

为什么需要深拷贝? 缓存返回的是 structuredClone() 深拷贝,防止调用方修改对象后污染缓存:

1
2
3
4
5
6
7
8
// 没有深拷贝的问题:
const session = cache["session-1"] // 返回引用
session.model = "gpt-4" // 调用方修改
// 缓存也被污染了!下次读缓存拿到的是 "gpt-4",但磁盘上还是 "claude-3"

// 有深拷贝:
const session = structuredClone(cache["session-1"]) // 返回独立拷贝
session.model = "gpt-4" // 只改了拷贝,缓存不受影响

7.2 数据迁移

OpenClaw 经过多次迭代,会话的路由字段有过好几种写法。加载会话时自动归一化,让上层代码不用关心历史格式:

1
2
3
4
5
6
7
8
9
10
11
// 老格式:字段散落在顶层,命名不统一
{ "provider": "telegram", "room": "group-456" }

// ↓ 加载时自动迁移

// 新格式:统一收归到 deliveryContext,字段名规范化
{
"channel": "telegram",
"groupChannel": "group-456",
"deliveryContext": { "channel": "telegram", "to": "group-456" }
}

这样上层代码只需要读 deliveryContext,不用到处判断”这个字段是叫 provider 还是 channel?是 room 还是 groupChannel?”

7.3 文件锁与并发安全

多个进程可能同时读写同一个 sessions.json(比如 Gateway 进程 + CLI 进程),不加锁会导致写入丢失:

sequenceDiagram participant A as 进程 A participant F as sessions.json participant B as 进程 B Note over A,B: 没有文件锁 — 写入丢失 A->>F: 读取 {tokens: 100} B->>F: 读取 {tokens: 100} A->>F: 写入 {tokens: 150} B->>F: 写入 {tokens: 200} Note over F: A 的修改被覆盖了! Note over A,B: 有文件锁 — 串行写入 A->>F: 获取锁 → 读取 → 写入 {tokens: 150} → 释放锁 B->>F: 等待锁... → 获取锁 → 读取 {tokens: 150} → 写入 {tokens: 200} → 释放锁 Note over F: 两次修改都保留了

锁的实现是创建 .lock 文件(用 fs.open("wx") 原子创建),并带有防死锁机制:锁文件记录了持有者的 PID 和进程启动时间,超时 30 分钟或持有者进程已退出时自动回收。

会话生命周期:创建 → 活跃(有运行中的 agent)→ 持久化


8. 并发控制:Lane-based 队列

Gateway 同时处理来自多个客户端和渠道的请求。如果不加控制,多个 agent 同时运行会导致资源竞争。OpenClaw 的解决方案是 Lane-based 队列——按任务类型分 lane,每个 lane 独立控制并发度。

8.1 四种 Lane

graph LR subgraph Main["Main Lane — 用户对话"] direction TB M1["活跃 ①"] ~~~ M2["活跃 ②"] ~~~ M3["活跃 ③"] M4["等待 ④"] ~~~ M5["等待 ⑤"] end subgraph Cron["Cron Lane — 定时任务 (并发=1)"] direction TB C1["活跃 ①"] C2["等待 ②"] ~~~ C3["等待 ③"] end subgraph Sub["Subagent Lane — 子 agent"] direction TB S1["独立并发池"] end subgraph Nested["Nested Lane — 嵌套调用"] direction TB N1["独立并发池"] end Main ~~~ Cron ~~~ Sub ~~~ Nested
Lane 用途 默认并发度
Main 用户对话 来自配置
Cron 定时任务 1(防止互踩)
Subagent 子 agent 任务 独立上限
Nested 嵌套调用 独立上限

8.2 队列核心机制

每个 Lane 维护一个队列和活跃任务集合:

机制 说明
maxConcurrent 活跃任务数达到上限时,新任务排队等待
draining 优雅关闭时,拒绝新任务入队,已在执行的任务继续完成
generation 每次重置时递增,旧任务自动失效,保证重启后状态一致
自动推进 任务完成后自动从队列取出下一个执行

8.3 配置热重载

用户可以通过配置文件调整各 lane 的并发度,不需要重启 Gateway——配置变更时自动生效。


9. 小结

回顾 Gateway 的核心设计:

双协议共享端口:HTTP 和 WebSocket 共享同一端口,HTTP 服务外部集成(健康检查、OpenAI 兼容接口、webhook),WebSocket 承载内部 RPC 通信。loopback 模式下通过创建多个 HTTP 服务器同时覆盖 IPv4 和 IPv6。

TypeBox schema 驱动:一份定义同时服务于 TypeScript 类型推导和运行时校验,再通过 JSON Schema 导出驱动 Swift codegen。协议一致性通过自动化保证。

Lane-based 队列:按任务类型分 lane、按配置控并发。用户对话不被定时任务阻塞,子 agent 有独立的并发池。draining 机制支持优雅关闭。

多认证模式:从零认证(loopback 开发)到 Tailscale 身份验证(远程部署),六种模式覆盖不同部署场景。速率限制 + 常量时间比较防止暴力破解和时序攻击。

会话缓存:45 秒 TTL + mtime 校验,平衡性能和一致性。