OpenClaw源码解读系列:Gateway核心
今天继续OpenClaw源码解读——Gateway核心。Gateway是整个OpenClaw项目的重中之重,所以今天的篇幅也会长一些。

-
使用的分支是main
-
commit版本是:f5160ca6becaeeb6a4dfd892fffd2130a696f766
讲解计划如下:
1. CLI 框架与进程模型
2. 配置系统
3. Gateway 核心(今日讲解)
4. 通道与路由
5. Agent 引擎
6. 自动回复管线
7. 插件系统
8. 记忆系统
9. Web 控制台
10. 原生客户端
11. 浏览器自动化
12. 运维与测试
概述
Gateway 是 OpenClaw 的神经中枢——一个运行在本地的单进程服务器,通过一个端口同时提供 HTTP 和 WebSocket 服务。所有客户端(CLI、Web 控制台、macOS 菜单栏 App、iOS/Android 客户端、通道节点)都通过 WebSocket 连接到 Gateway,以 JSON-RPC 风格的帧进行通信。HTTP 端口同时承载 Control UI 静态文件、Webhook 入口、OpenAI 兼容 API、Canvas Host 等功能。
本文按照 Gateway 从启动到运行的真实代码路径,逐层拆解其架构。

一、启动流程:从 CLI 命令到运行循环
当用户执行 `openclaw gateway run –port 18789` 时,Commander 解析参数后调用 `src/cli/gateway-cli/run.ts` 中的 `runGatewayCommand`。它做了三件事:校验配置、解析认证、启动运行循环。
真正的精华在运行循环 `runGatewayLoop`(`src/cli/gateway-cli/run-loop.ts`)。这个函数只有 128 行,但设计非常精巧:
export async function runGatewayLoop(params: {start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;runtime: typeof defaultRuntime;}) {const lock = await acquireGatewayLock();let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;let shuttingDown = false;let restartResolver: (() => void) | null = null;// ... 信号处理逻辑 ...try {while (true) {server = await params.start();await new Promise<void>((resolve) => {restartResolver = resolve;});}} finally {await lock?.release();}}
这里有一个巧妙的无限循环 + Promise 挂起模式。每次 `params.start()` 启动一个 Gateway 服务器实例后,循环立刻用一个 `new Promise` 挂起自己。这个 Promise 的 `resolve` 函数被存到 `restartResolver`。循环不会继续——直到外部调用 `restartResolver()` 将它唤醒。
谁来唤醒?答案是信号处理器。`runGatewayLoop` 注册了三个 POSIX 信号监听:
-
SIGTERM / SIGINT:触发 `request(“stop”, …)`,关闭服务器后 `process.exit(0)` 退出
-
SIGUSR1:触发 `request(“restart”, …)`,关闭服务器后调用 `restartResolver()`,唤醒循环,重新执行 `params.start()`
这意味着 Gateway 支持进程内热重启——不需要外部进程监控器(如 pm2 或 systemd restart),发一个 `SIGUSR1` 就能让服务器在同一个进程内重新初始化。重启前还会等待进行中的 Agent 会话执行完毕(drain),避免丢消息:
if (isRestart) {const activeTasks = getActiveTaskCount();if (activeTasks > 0) {gatewayLog.info(`draining ${activeTasks} active task(s) before restart (timeout ${DRAIN_TIMEOUT_MS}ms)`,);const { drained } = await waitForActiveTasks(DRAIN_TIMEOUT_MS);}}await server?.close({ reason: "gateway restarting", restartExpectedMs: 1500 });
30 秒 drain 超时 + 5 秒关闭超时,再加一个强制退出的 `setTimeout` 兜底,确保进程不会永远挂住。
二、实例锁:防止多 Gateway 同时运行
在运行循环开始的第一行就是 `acquireGatewayLock()`。OpenClaw 用双重锁机制确保同一配置只有一个 Gateway 实例在运行。
### 文件锁
实现在 `src/infra/gateway-lock.ts`。锁路径是对配置文件路径取 SHA1 后生成的:
function resolveGatewayLockPath(env: NodeJS.ProcessEnv) {const stateDir = resolveStateDir(env);const configPath = resolveConfigPath(env, stateDir);const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);const lockDir = resolveGatewayLockDir();const lockPath = path.join(lockDir, `gateway.${hash}.lock`);return { lockPath, configPath };}
这样不同配置文件(比如不同 `–profile`)不会互相冲突,各自有独立的锁文件。
获取锁的核心逻辑用的是 `fs.open(lockPath, “wx”)`——`wx` 标志表示独占创建,如果文件已存在就抛 `EEXIST`。这是操作系统级的原子操作,不会出现竞态条件。
锁文件的内容是一个 JSON payload:
{"pid": 12345,"createdAt": "2026-02-07T10:00:00.000Z","configPath": "/Users/me/.openclaw/openclaw.json","startTime": 98765432}
当检测到锁文件已存在时,系统需要判断持有锁的进程是否还活着。这里有一套多层验活策略:
1. `process.kill(pid, 0)` 试探——signal 0 不发送实际信号,只检查进程是否存在
2. 在 Linux 上,还会读 `/proc/<pid>/stat` 获取进程启动时间(`startTime` 字段),与锁文件中记录的对比。这是为了处理 PID 重用的问题——操作系统可能把一个已死进程的 PID 分配给新进程
3. 如果无法确认进程状态,则检查锁文件的 mtime 是否超过 30 秒阈值(`DEFAULT_STALE_MS`),超时即视为陈旧锁
如果判定持有者已死,删除锁文件并重新尝试。如果超过 5 秒(`DEFAULT_TIMEOUT_MS`)仍然无法获取锁,抛出 `GatewayLockError`。
端口锁
第二重锁是 HTTP 服务器绑定端口。在 `src/gateway/server/http-listen.ts` 中,如果 `httpServer.listen` 因为 `EADDRINUSE` 失败,同样会抛出 `GatewayLockError`。这是操作系统层面的天然互斥——同一个端口只能被一个进程占用。
两重锁互补:文件锁是主动的(能提前检测),端口锁是被动的(最后兜底)。
三、服务器初始化:`startGatewayServer`
`src/gateway/server.impl.ts` 中的 `startGatewayServer` 是整个 Gateway 的初始化入口,663 行代码(含关闭逻辑)。它是一个编排函数——自身不实现具体功能,而是按顺序调用各子系统的创建函数,把它们组装在一起。
按执行顺序,初始化分为以下几个阶段。
阶段 1:配置校验与迁移
let configSnapshot = await readConfigFileSnapshot();if (configSnapshot.legacyIssues.length > 0) {const { config: migrated, changes } = migrateLegacyConfig(configSnapshot.parsed);await writeConfigFile(migrated);}configSnapshot = await readConfigFileSnapshot();if (configSnapshot.exists && !configSnapshot.valid) {throw new Error(`Invalid config at ${configSnapshot.path}.\n${issues}\n...`);}
先读配置快照,如果有历史遗留问题就自动迁移;迁移后再重新读取一次并校验。校验不通过直接报错退出,不会带着坏配置启动。
阶段 2:插件与通道注册
const { pluginRegistry, gatewayMethods: baseGatewayMethods } = loadGatewayPlugins({cfg: cfgAtStart,workspaceDir: defaultWorkspaceDir,coreGatewayHandlers,baseMethods,});const channelMethods = listChannelPlugins().flatMap((p) => p.gatewayMethods ?? []);const gatewayMethods = Array.from(new Set([...baseGatewayMethods, ...channelMethods]));
加载所有启用的插件,收集它们注册的额外 Gateway 方法。通道插件也可以注册自己的 RPC 方法,最终合并到一个去重的方法列表里。这个列表会在 WebSocket 握手时发给客户端,让客户端知道服务器支持哪些方法。
阶段 3:运行时状态创建
const { httpServer, wss, clients, broadcast, ... } = await createGatewayRuntimeState({cfg: cfgAtStart,bindHost,port,resolvedAuth,// ... 十几个参数});
`createGatewayRuntimeState`(`src/gateway/server-runtime-state.ts`)是 Gateway 的”基础设施工厂”,它创建了:
1. Canvas Host(可选):用于 AI 画布 (A2UI) 的本地 HTTP 服务
2. HTTP 服务器:Node.js 原生 `http.createServer`(或 `https`),不使用 Express/Hono 等框架
3. WebSocket 服务器:`ws` 库的 `WebSocketServer`,`noServer: true` 模式——不自己监听端口,而是通过 HTTP 服务器的 `upgrade` 事件接入
4. 客户端注册表:`Set<GatewayWsClient>`,维护所有活跃的 WebSocket 连接
5. 广播器:`createGatewayBroadcaster` 创建的 `broadcast` 和 `broadcastToConnIds` 函数,用于向所有或特定客户端推送事件
6. Chat 运行状态:管理进行中的 Agent 对话、流式缓冲、abort 控制器等
WebSocket 使用 `noServer` 模式是一个关键设计选择。HTTP 和 WebSocket 复用同一个端口,通过 `upgrade` 事件来区分:
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES });for(const server of httpServers) {attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost, clients, resolvedAuth });}
`attachGatewayUpgradeHandler`(`src/gateway/server-http.ts`)监听 HTTP 服务器的 `upgrade` 事件。如果请求路径是 Canvas WebSocket 路径,转给 Canvas Host 处理;否则交给主 WebSocket 服务器:
httpServer.on("upgrade", (req, socket, head) => {if (canvasHost && url.pathname === CANVAS_WS_PATH) {// Canvas WebSocket 鉴权 + 转发canvasHost.handleUpgrade(req, socket, head);return;}wss.handleUpgrade(req, socket, head, (ws) => {wss.emit("connection", ws, req);});});
阶段 4:连接各子系统
创建完基础设施后,`startGatewayServer` 继续初始化上层子系统:
-
NodeRegistry:追踪远程节点(如 iOS/Android 客户端)的连接、命令调用、事件订阅
-
Discovery**:mDNS/Bonjour 广播 + 广域发现,让局域网内的客户端能自动找到 Gateway
-
ChannelManager:管理消息通道(Telegram、Discord 等)的启动/停止/状态快照
-
CronService:定时任务调度
-
HeartbeatRunner:心跳发送器
-
ExecApprovalManager:工具执行审批流
-
ConfigReloader:配置文件热重载——监视文件变化,安全的改动走热更新,关键改动触发 SIGUSR1 重启
最后把所有东西组装起来,调用 `attachGatewayWsHandlers` 把 WebSocket 消息处理逻辑挂载上去,构建一个包含所有子系统引用的 `context` 对象传进去。这个 context 就是 Gateway 的”依赖注入容器”——任何 RPC handler 都可以通过它访问所需的子系统。
四、HTTP 请求路由:链式分发
`createGatewayHttpServer`(`src/gateway/server-http.ts`)创建的 HTTP 服务器没有使用任何 Web 框架,而是用一个 `handleRequest` 函数做链式分发:
async function handleRequest(req: IncomingMessage, res: ServerResponse) {if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;if (await handleHooksRequest(req, res)) return;if (await handleToolsInvokeHttpRequest(req, res, { auth, trustedProxies })) return;if (await handleSlackHttpRequest(req, res)) return;if (handlePluginRequest && ...) return;if (openResponsesEnabled && await handleOpenResponsesHttpRequest(...)) return;if (openAiChatCompletionsEnabled && await handleOpenAiHttpRequest(...)) return;if (canvasHost && ...) return;if (controlUiEnabled && ...) return;res.statusCode = 404;res.end("Not Found");}
每个 handler 返回 `true` 表示”我处理了这个请求”,返回 `false` 表示”跳过,交给下一个”。这种责任链模式非常清晰——想增加新的 HTTP 端点,只需在链上合适的位置插入一个新 handler。
请求路径匹配的优先级从上到下依次是:Webhook 入口 → 工具调用 API → Slack HTTP → 插件路由 → OpenResponses API → OpenAI 兼容 API → Canvas Host → Control UI → 404。
值得注意的是,Channel 的 HTTP 端点(`/api/channels/…`)走的是 plugin handler 路径,并且会做 Gateway 级别的鉴权——从 `Authorization: Bearer` 头提取 token,调用 `authorizeGatewayConnect` 验证。这确保了即使插件忘记做鉴权,敏感的通道操作也不会被未授权访问。
五、WebSocket 连接生命周期
当客户端发起 WebSocket 连接后,`attachGatewayWsConnectionHandler`(`src/gateway/server/ws-connection.ts`)接管。每个连接的生命周期如下。
连接建立
服务器为每个连接生成一个唯一的 `connId`(UUID),创建一个 `GatewayWsClient` 对象,记录远程地址、User-Agent 等元信息。此时连接处于 `pending` 状态——还没通过认证。
wss.on("connection", (socket, upgradeReq) => {const connId = randomUUID();let handshakeState: "pending" | "connected" | "failed" = "pending";// ...});
握手超时
服务器设置一个超时计时器(可配置,默认值来自 `getHandshakeTimeoutMs()`)。如果客户端在超时窗口内没有发送 `connect` 帧完成认证,连接会被强制关闭。这防止了恶意连接占用资源。
认证握手
客户端需要发送一个 `connect` 类型的帧,包含认证凭据。消息处理逻辑在 `attachGatewayWsMessageHandler`(超过 1000 行的大文件)中,它解析 JSON 帧,验证帧格式(必须有 `type`、`id`、`method`),然后对 `connect` 方法做特殊处理——调用 `authorizeGatewayConnect` 进行认证。
消息处理
认证通过后,`handshakeState` 变为 `connected`。后续消息按帧类型路由:
-
`req` 帧(请求):调用 `handleGatewayRequest` 分发到对应的 handler
-
`res` 帧(响应):通常是节点对服务器发起的命令调用的回复
-
`event` 帧(事件):节点主动推送的事件
六、认证系统
认证逻辑集中在 `src/gateway/auth.ts`,支持三种模式。
Token 模式(默认)
服务器生成或配置一个 token,客户端在 `connect` 帧中携带相同的 token。对比使用 `safeEqualSecret`——一个**常量时间比较**函数,防止计时攻击:
if (auth.mode === "token") {if (!safeEqualSecret(connectAuth.token, auth.token)) {return { ok: false, reason: "token_mismatch" };}return { ok: true, method: "token" };}
Password 模式
类似 token,但语义是”人类记忆的密码”,同样用常量时间比较。
Tailscale 模式
当 Gateway 通过 Tailscale Serve 暴露时,Tailscale 代理会在请求头中注入 `Tailscale-User-Login`、`Tailscale-User-Name` 等身份信息。Gateway 的验证流程分三步:
1. 检查请求是否确实来自 Tailscale 代理(`isTailscaleProxyRequest`:loopback 地址 + forwarded 头)
2. 从 `X-Forwarded-For` 提取客户端 IP
3. 调用 `tailscale whois <ip>` 获取认证身份,与请求头中的 login 做规范化比较(全部转小写后对比)
这种双重验证确保了即使有人伪造 Tailscale 头,只要不是从 Tailscale 代理过来的请求,都不会通过认证。
还有一个关键概念:本地直接请求(`isLocalDirectRequest`)。如果请求来自 loopback 地址,Host 是 `localhost`/`127.0.0.1`/`::1`,且没有 forwarded 头,则被认为是本地进程发起的连接。这在某些内部通信场景中有特殊的信任逻辑。
七、RPC 分发与权限控制
通过认证后的 WebSocket 请求帧最终进入 `handleGatewayRequest`(`src/gateway/server-methods.ts`)。这个函数只做两件事:鉴权和分发。
方法鉴权
`authorizeGatewayMethod` 实现了一套**基于角色 + 作用域**的权限模型。每个连接有一个角色(`operator` 或 `node`)和一组作用域(scope)。
角色决定了可访问的方法集合。`node` 角色(远程设备节点)只能调用 `node.invoke.result`、`node.event`、`skills.bins` 这三个方法。`operator` 角色的权限则由 scope 细分:
-
`operator.admin`:所有方法,包括 config 修改、插件管理、会话删除
-
`operator.read`:`health`、`channels.status`、`sessions.list` 等只读方法
-
`operator.write`:`send`、`agent`、`chat.send` 等写操作
-
`operator.approvals`:工具执行审批相关
-
`operator.pairing`:设备配对相关
admin scope 是超集——拥有它就可以调用任何方法。对于不在任何显式列表中的方法,默认需要 admin scope。这遵循了**最小权限原则**。
方法注册与分发
所有核心 handler 通过展开合并注册到 `coreGatewayHandlers` 对象中:
export const coreGatewayHandlers: GatewayRequestHandlers = {...connectHandlers,...healthHandlers,...channelsHandlers,...chatHandlers,...agentHandlers,...sendHandlers,// ... 20+ 个 handler 模块};
分发逻辑非常直接——用方法名作为 key 查找 handler:
const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];if (!handler) {respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`));return;}await handler({ req, params, client, respond, context });
注意这里先查 `extraHandlers`,再查 `coreGatewayHandlers`。`extraHandlers` 包含插件注册的和 Exec Approval 的 handler,这意味着插件可以覆盖核心方法。
每个 handler 模块(如 `server-methods/health.ts`、`server-methods/agent.ts`)是一个独立文件,导出一个 `{ [method: string]: HandlerFn }` 对象。这种按领域拆分的方式让 handler 代码保持在合理的规模内,每个文件专注一个功能域。
八、配置热重载
Gateway 运行过程中,用户可能通过 `openclaw config set` 或直接编辑配置文件来修改配置。`startGatewayConfigReloader`(`src/gateway/config-reload.ts`)负责处理这种情况。
它监视配置文件的变化,检测到修改后读取新快照,与当前配置做 diff。根据变化的严重程度,采取不同策略:
-
安全变更(hooks 配置、heartbeat 间隔、cron 列表等):走热更新路径,不中断服务,直接更新内存中的配置和相关子系统
-
关键变更(端口、绑定地址、认证方式、TLS 等):触发 SIGUSR1 重启,通过前面讲的运行循环实现进程内重启
这种分级策略在运行稳定性和配置即时生效之间取得了很好的平衡。
九、优雅关闭
`createGatewayCloseHandler`(`src/gateway/server-close.ts`)创建关闭函数时接收了所有子系统的引用。关闭时按相反顺序逐一清理:
1. 触发 `gateway_stop` 插件钩子
2. 停止配置监视器
3. 关闭浏览器控制服务
4. 关闭所有 WebSocket 客户端连接(发送带 `restartExpectedMs` 的关闭帧,让客户端知道这是重启而非永久关闭)
5. 停止所有消息通道
6. 停止 cron、heartbeat、维护定时器
7. 取消事件订阅
8. 停止 Tailscale Serve 暴露
9. 停止 mDNS/Bonjour 广播
10. 关闭 HTTP 服务器
每一步都有错误处理——单个子系统关闭失败不会阻止其他子系统的清理。
十、架构要点回顾
-
单端口复用:HTTP 和 WebSocket 共用一个端口,通过 `upgrade` 事件区分,简化了网络配置。
-
双重锁机制:文件锁(主动检测)+ 端口锁(被动兜底),确保单实例运行。
-
进程内热重启:SIGUSR1 信号触发,drain 进行中的任务后重建服务器,无需外部监控器。
-
链式 HTTP 路由:每个 handler 返回 bool 表示是否处理,简单且可扩展。
-
角色 + 作用域鉴权:operator/node 角色 × admin/read/write/approvals/pairing 作用域,细粒度控制方法访问。
-
编排式初始化:`startGatewayServer` 不实现具体逻辑,只负责按顺序创建和组装子系统,每个子系统独立可测。
-
配置变更分级:安全改动热更新,关键改动触发重启,保证稳定性。
掌握了这些核心机制,你就理解了 OpenClaw 所有网络通信、客户端管理和子系统协调的基础。后续的通道系统、Agent 引擎、记忆系统等,都是挂载在这个 Gateway 骨架上运行的。
夜雨聆风
