OpenClaw 设计与实现

第11章 Node 系统与设备连接

作者 杨艺韬 · 14,393 字 · 发布于 · 更新于

第11章 Node 系统与设备连接

Agent 的计算基座和操作目标注定是物理分离的——服务器在弗吉尼亚,相机在北京,屏幕在东京。这不是架构缺陷,而是使用场景的本质。一切设计都必须从"设备随时会消失"这个假设出发。

本章要点

  • 理解 Node 系统如何让 Agent 从数字世界延伸到物理世界
  • 掌握配对协议:从零信任到建立安全连接的完整流程
  • 深入基于能力的工具路由:如何将工具调用分派到正确的设备
  • 理解重连韧性设计与分布式信任模型

上一章,我们给 Agent 装上了"双手"——浏览器、Shell、搜索引擎等软件层面的工具。但这双手只能触及数字世界。如果 Agent 需要拍一张照片、读一条短信、截一个屏幕呢?

本章的 Node 系统,将 Agent 的触角从虚拟世界延伸到物理世界。

11.1 物理触达的问题

你的 Agent 运行在弗吉尼亚的 VPS 上。你需要它拍摄北京家门口的照片。或者读取 Android 手机上的短信验证码。或者在 MacBook 上截取应用截图。

传统 Agent 框架面对这个需求只能摊手——Agent 的世界止步于服务器的网络边界,就像困在房间里的人,再聪明也出不了门。

11.1.1 为什么这是一个根本性问题

这不只是"功能缺失"——它反映了一个更深层的架构假设问题。几乎所有 Agent 框架都隐含假设 Agent 运行在它需要操作的设备上。LangChain 的工具调用在本地进程中执行。AutoGPT 操作本地文件系统。这个假设在"单机 Agent"场景下是合理的,但它与现实的使用模式根本冲突。

现实是:Agent 需要运行在一个稳定、24/7 在线的环境中(VPS、云服务器)以保持持久连接和定时任务的可靠性。但它需要操作的设备——手机、笔记本、智能家居——分布在不同的物理位置,随时可能离线。Agent 的计算基座和操作目标是物理分离的。

OpenClaw 采取了截然不同的立场:Agent 的世界应该延伸到运营者信任的每一台设备。 Node 系统通过安全配对的 Android、iOS 和 macOS 伴侣应用(以及无界面的节点宿主 openclaw node run)实现这一点,将手机和笔记本变成 Agent 的远程传感器与执行器——眼睛、耳朵、双手,一应俱全。

11.1.2 三个核心工程挑战

但"连接手机到 Agent"听起来简单,直到你面对工程现实。三个问题接踵而至:

信任建立问题:网关运行在 VPS 上,手机在用户口袋里,两者素未谋面。在没有共享信任根的前提下,如何确保"连接的确实是主人的手机"而不是攻击者的设备?传统的证书基础设施(CA、TLS 证书)需要预先配置,对消费者设备不可行。

工具路由问题:三台设备同时连接——Android 手机、iPhone 和 MacBook。Agent 调用 camera 时,发给谁?如果首选设备断线了呢?如果两台设备都有相机但分辨率不同呢?路由决策不能简单地"有就发"。

韧性问题:手机进电梯信号中断、切换 Wi-Fi 网络、进入飞行模式。这些不是异常情况——它们是移动设备的常态。Node 系统必须在"设备随时可能消失"的假设下正确工作。

Node 系统的本质,是一个伪装成移动应用功能的分布式系统问题。揭开友好的 QR 码外衣,里面藏着的是每个分布式系统都要面对的老龙:共识、分区容忍和网络的永恒不可靠。

🔥 深度洞察:从数字世界到物理世界的鸿沟

Node 系统解决的问题,本质上是控制论(Cybernetics)的核心命题——如何让一个信息处理系统影响物理世界?借用 Norbert Wiener 1948 年《控制论》开创的框架,控制系统可以拆成传感器(感知)、处理器(决策)和执行器(行动),以及连接它们的反馈回路。OpenClaw 的 Node 系统是这个理论的完美实例:手机相机是传感器,Gateway 中的 Agent 是处理器,手机的通知推送、Mac 上的命令执行是执行器,WebSocket 是反馈回路。这不是"连接手机"这么简单——这是赋予数字 Agent 物理存在感的哲学跨越。一个只存在于服务器上的 Agent 是一个"纯精神存在";通过 Node 系统,它获得了"身体"——虽然这个身体分散在多台设备上,但它能看(相机)、能听(麦克风)、能触(通知推送)。

11.2 架构设计:网关作为编排中心

11.2.1 架构概览

graph TB
    subgraph "图 11-1:Node 系统架构"
        GW["网关<br/>(编排中心)"] --> Channels["通道<br/>(Telegram, Discord...)"]
        GW --> Providers["LLM 提供商<br/>(OpenAI, Anthropic...)"]
        GW --> NodeHost["节点注册表<br/>(NodeRegistry)"]
        NodeHost --> Android["Android<br/>📷 相机 📍 定位 🔔 通知"]
        NodeHost --> iOS["iOS<br/>📷 相机 🗺️ 定位 🖥️ 屏幕录制"]
        NodeHost --> macOS["macOS<br/>🖥️ 屏幕录制 📷 相机 ⚙️ system.run"]
    end
    style GW fill:#c0392b,color:#fff
    style NodeHost fill:#f39c12,color:#000

每台连接的设备申报其能力(caps,如 camera、location、screen、canvas)和可执行的命令(commands,如 camera.snap、location.get)。网关按申报的命令(再与放行清单取交集)决定一台节点能收到哪些 node.invoke 命令;Agent 侧的 nodes 工具(action: "camera_snap" 等)则要求显式给出目标节点,只有 canvas、exec host=node、浏览器代理等少数调用在没指定节点时才自动挑选(见 11.5.1)。

关键概念:基于能力的工具路由(Capability-Based Tool Routing) 设备声明的"能力"(如相机、定位、画布)决定它能承接哪类调用。需要自动挑选设备的调用(如 canvas)先按能力过滤、再优先在线节点;相机、录屏、定位这类涉及隐私的动作则要求调用方点名设备(node=<id/名称/IP>),不做自动猜测。

关键设计决策:Agent 从不直接与设备交互。它通过与浏览器自动化或命令执行相同的接口调用工具。Node 系统在工具级别是透明的——完美应用的适配器模式。这意味着所有工具系统的安全策略(第10章的七层管线)自动适用于设备工具,无需任何额外配置。

11.2.2 为什么不用 P2P 架构?

一个自然的替代方案是 P2P(点对点)架构——让设备之间直接通信,网关只负责协调发现。这种方案有明显的优势:降低网关负载、减少单点故障。

但 OpenClaw 选择了网关中心化架构,原因是三个关键约束:

  1. NAT 穿透:大多数移动设备在运营商级 NAT 之后,P2P 连接需要打洞(STUN/TURN),增加了延迟和复杂性,且不保证成功。网关作为公网可达的中介自然解决了这个问题。

  2. 安全审计:所有设备调用都经过网关(node.invoke),形成单一的观测与审计点——v2026.3.31 并没有专门的设备操作审计日志,但要补也只需在网关一处补。P2P 架构中,审计日志分散在各设备上,难以集中管理。

  3. 策略执行:工具策略管线在网关中执行。设备调用一旦绕过网关,策略管线就形同虚设。P2P 架构需要将策略逻辑复制到每台设备上,增加了攻击面。

⚠️ 注意:有时效的只是配对过程中的凭证——bootstrap 令牌 10 分钟、待批的配对请求 5 分钟。配对完成后签发的设备令牌不会自动过期,除非被 openclaw devices rotate/revoke 轮换或撤销、或配对记录被移除;设备长时间断线后通常直接重连即可。建议在部署多设备场景时,记录每台设备的配对状态,并配置断线告警。

💡 最佳实践:在多设备场景中,为每台设备起一个明确的显示名(如 office-camera、home-macbook,可用 openclaw nodes rename 修改),而非依赖自动路由。这样 Agent 可以通过 node=office-camera 精确指定目标设备,避免工具调用被路由到意外的设备。

权衡:中心化架构意味着网关是单点瓶颈——所有设备通信都经过它。对于个人或小团队使用场景(OpenClaw 的主要目标用户),这个瓶颈不是问题。但如果未来需要支持企业级规模(数千台设备),可能需要引入网关集群或混合架构。

11.3 配对协议:从无到信任

Node 系统中最有趣的工程挑战不是设备能力本身——而是在没有先验关系的服务器和手机之间建立信任。

11.3.1 威胁模型

在设计配对协议之前,必须明确防什么:

  • 中间人攻击:攻击者拦截配对流程,连接自己的设备到目标网关。
  • 重放攻击:攻击者捕获并重放合法的配对请求。
  • 暴力猜测:攻击者枚举配对码直到找到有效的。
  • 长期密钥泄露:配对完成后,第三方窃取信任令牌。

11.3.2 质询-响应协议

先澄清一个容易混淆的地方:仓库里的 src/pairing/ 主体不是设备配对,而是通道 DM 配对——陌生人给 bot 发私信、系统生成一个码、运营者批准后才放行(issuePairingChallenge(),src/pairing/pairing-challenge.ts)。设备配对只借用了其中的 src/pairing/setup-code.ts,主体在别处:src/infra/device-bootstrap.ts(引导令牌)、src/infra/device-pairing.ts(设备配对,device.pair.*)和 src/infra/node-pairing.ts(节点配对,node.pair.*)。

设备侧的流程分两段:bootstrap 令牌把设备带进门,节点配对审批决定它能做什么。

第一段:bootstrap 令牌

resolvePairingSetupFromConfig()(src/pairing/setup-code.ts)产出的载荷只有两个字段:

export type PairingSetupPayload = {
  url: string;            // 网关的 ws(s):// 地址,来自 device-pair 插件的 publicUrl / Tailscale / gateway.remote.url / bind 解析
  bootstrapToken: string;
};

令牌本身是纯随机的,不是由 deviceId 派生的 HMAC:

// src/infra/pairing-token.ts:4
export const PAIRING_TOKEN_BYTES = 32;
export function generatePairingToken(): string {
  return randomBytes(PAIRING_TOKEN_BYTES).toString("base64url");
}
export function verifyPairingToken(provided: string, expected: string): boolean {
  if (provided.trim().length === 0 || expected.trim().length === 0) return false;
  return safeEqualSecret(provided, expected);   // 常量时间比较,防时序侧信道
}

有效期是 10 分钟(DEVICE_BOOTSTRAP_TOKEN_TTL_MS = 10 * 60 * 1000),过期条目在每次读状态时由 pruneExpiredPending() 清掉。签发时会带上一份角色/权限档案,默认是:

// src/shared/device-bootstrap-profile.ts:13
export const PAIRING_SETUP_BOOTSTRAP_PROFILE: DeviceBootstrapProfile = {
  roles: ["node", "operator"],
  scopes: ["operator.read", "operator.talk.secrets", "operator.write"],
};

兑换逻辑(verifyDeviceBootstrapToken)有两处值得细看。其一是先验权限再绑身份,源码注释写得很直白:

Fail closed for any attempt to redeem the token outside the issued role/scope allowlist before binding it to a concrete device identity.

也就是说,设备申请的 role / scopes 必须落在签发时那份档案之内,否则连绑定都不给做——失败一律返回同一个模糊原因 bootstrap_token_invalid,不泄露"是令牌错了还是权限超了"。

其二是首次使用即绑定(TOFU)而非"一次性作废":

const boundDeviceId = record.deviceId?.trim();
const boundPublicKey = record.publicKey?.trim();
if (boundDeviceId || boundPublicKey) {
  // 已绑定过:deviceId 与 publicKey 必须完全一致,否则拒绝
  if (boundDeviceId !== deviceId || boundPublicKey !== publicKey) {
    return { ok: false, reason: "bootstrap_token_invalid" };
  }
  // ... 更新 lastUsedAtMs
}
// 首次:把 deviceId + publicKey 写进记录

这个选择很实际:移动端在配对期间可能因为网络抖动重试好几次,严格的一次性作废会让用户反复扫码;而绑定到 (deviceId, publicKey) 之后,重放这枚令牌的攻击者除非同时掌握设备的私钥,否则拿不到任何东西。注意设备提交的是公钥——后续认证是公钥体系,不是共享密钥。

兑换成功之后还有一步设备配对(src/infra/device-pairing.ts,device.pair.*)。在最常见的扫码路径上——全新设备、以 node 角色、不申请任何 operator 权限、凭 bootstrap 令牌连接——网关会在同一次握手里静默批准这次设备配对,并随即吊销这枚 bootstrap 令牌(src/gateway/server/ws-connection/message-handler.ts,源码注释:QR bootstrap onboarding is node-only and single-use)。所以在这条路径上,令牌实际是"用一次就作废"的;其他情况(例如申请了 operator 权限)则要运营者用 openclaw devices approve 批准。

第二段:节点配对审批

进门之后,节点还要走 src/infra/node-pairing.ts 的审批流:以 node 角色连接而尚未配对的节点,会带着一份元数据(nodeId / displayName / platform / caps / commands 等)进入 pending 队列(TTL 5 分钟,PENDING_TTL_MS),由运营者通过 node.pair.approve 批准,批准后才写入 pairedByNodeId 并发放节点令牌。在批准之前,这台节点连得上网关,但它申报的命令一条都不会被放行——连接时网关只保留"已配对记录里也有"的命令。

审批需要的权限档次由设备申请的命令决定:

function resolveNodeApprovalRequiredScope(pending: NodePairingPendingRequest): string | null {
  const commands = Array.isArray(pending.commands) ? pending.commands : [];
  // 申请了 system.run.prepare / system.run / system.which → 必须 operator.admin
  if (commands.some((command) => NODE_SYSTEM_RUN_COMMANDS.some((allowed) => allowed === command))) {
    return OPERATOR_ADMIN_SCOPE;
  }
  if (commands.length > 0) return OPERATOR_WRITE_SCOPE;
  return null;
}

这是一个很干净的设计:批准者需要的权限,随被批准者索要的能力水涨船高。一台只想推通知的手机,operator.write 就能批;一台想要 system.run(等于在这台设备上远程执行命令)的节点,必须 operator.admin 才批得动。

注意几个关键的安全属性:

  • bootstrap 令牌 10 分钟过期:暴露窗口很短
  • 常量时间比较:safeEqualSecret 避免通过响应时间逐字节猜令牌
  • 权限先验、身份后绑:越权申请在绑定之前就被挡掉
  • 首次使用绑定 deviceId + 公钥:后续重放必须同时拥有设备私钥;扫码路径上设备配对完成后令牌即被吊销
  • 审批权限随能力升级:system.run 这类命令必须 operator.admin 才能批准

11.3.3 为什么选择短时码而不是证书?

替代方案 A:预共享密钥(PSK)。运营者将密钥手动输入到手机配置中。简单但痛苦——输入一个 32 字符的密钥在手机上是折磨。

替代方案 B:证书基础设施。网关作为 CA 签发客户端证书。安全性强,但需要理解 PKI 的运营者才能配置——这不是大多数 OpenClaw 用户的画像。

替代方案 C:配对码(最终选择)。类似蓝牙配对——临时、通过物理渠道传递:以二维码为主(屏幕到摄像头),文本码只作备用,用户不需要手输长串。安全性适中(依赖物理渠道安全和码的短时有效期),但极其简单。

选择配对码的根本原因是受众。OpenClaw 的目标用户是技术人员(他们理解安全概念),但不一定是安全专家(他们不想管理证书链)。配对码在"足够安全"和"足够简单"之间取得了正确的平衡。

11.3.4 逐设备权限模型

权限不是"网关按设备类型分配",而是设备自己申报、运营者批准:节点在 pending 请求里带上 caps(能力)和 commands(想调用的命令),运营者在 node.pair.approve 时看到这份清单再决定批不批。网关侧还有一道按平台的命令放行清单(src/gateway/node-command-policy.ts 的 PLATFORM_DEFAULTS),可用 gateway.nodes.allowCommands / denyCommands(GatewayNodesConfig)增减,其中 deny 优先级最高,源码注释写明是"即便出现在默认列表或节点自称的清单里也要拒绝"。节点连接时申报的命令先按这份清单过滤,再进入配对请求;camera.snap、camera.clip、screen.record、sms.send 等高风险命令(DEFAULT_DANGEROUS_NODE_COMMANDS)不在任何平台的默认清单里,必须显式加入 allowCommands 才可用。

这种能力白名单模型(而非默认允许一切)确保:

  1. 即使设备遭到入侵,攻击者也只能访问该设备明确获授的能力。
  2. 运营者对"这台设备能做什么"有清晰的、可审计的认知。
  3. 新能力默认不可用——必须显式授予。

11.4 传输层:为什么选择 WebSocket?

11.4.1 候选方案分析

在选择网关与设备之间的传输协议时,可以对比四种候选方案:

HTTP 轮询:设备定期向网关请求"有没有给我的指令?"。实现最简单,但延迟高(取决于轮询间隔)、浪费带宽(大多数轮询返回空)、耗电(对移动设备致命)。

HTTP 长轮询:请求挂起直到有数据或超时。比普通轮询好,但每次都需要重新建立连接,TCP 握手和 TLS 协商的开销在移动网络上显著。

Server-Sent Events(SSE):单向流——服务器推送事件到客户端。只能单向,设备不能主动向网关发送消息(如能力变更通知)。

WebSocket:全双工连接。建立一次后,双方可以随时发送消息,无需重新握手。

11.4.2 WebSocket 的决定性优势

OpenClaw 选择 WebSocket,基于四个决定性因素:

双向性:网关和节点都需要主动发起消息。网关需要向设备下发工具调用指令;设备需要向网关上报能力变更、传感器数据、操作结果。WebSocket 的全双工特性天然匹配。

NAT 穿透:运营商级 NAT 后的移动设备无法接受入站连接。WebSocket 连接由节点发起(出站),无需端口转发即可穿越 NAT 和防火墙。一旦连接建立,双向通信自动工作。

跨平台兼容:相同的 WebSocket 协议适用于 Node.js(网关端)、Android(OkHttp)、iOS(URLSessionWebSocketTask)和 macOS(Foundation)。不需要为每个平台维护不同的通信栈。

存活检测有现成积木:WebSocket 自带 ping/pong 控制帧,客户端据此保活 NAT 映射(Android 端 OkHttp 每 30 秒 ping 一次,Apple 端定期 sendPing);网关还在应用层每 30 秒广播一次 tick 事件(TICK_INTERVAL_MS),src/gateway/client.ts 超过两个周期没收到 tick 就判定连接停滞。按这个节奏,断连通常要几十秒才会被发现,而不是秒级。

11.4.3 WebSocket 的局限与应对

局限1:设备离线时消息无处可去。WebSocket 断开后,网关手里的指令没有任何缓冲位置。

应对:网关侧有两个很窄的按节点缓冲。其一是待办工作队列(src/gateway/node-pending-work.ts),配套 node.pending.enqueue(入队)和 node.pending.drain(节点取走)两个 Gateway 方法,CHANGELOG 称之为给休眠节点投递工作的"窄的内存队列原语":

export const NODE_PENDING_WORK_TYPES = ["status.request", "location.request"] as const;
export const NODE_PENDING_WORK_PRIORITIES = ["default", "normal", "high"] as const;
const DEFAULT_MAX_ITEMS = 4;
const MAX_ITEMS = 10;

只支持两种工作类型(状态请求、定位请求),同类型重复入队会被去重,所以每个节点实际最多挂两条;MAX_ITEMS = 10 是单次 drain 最多取走的条数(默认 4)。队列由进程内存持有(stateByNodeId 是个模块级 Map),入队时可指定过期时间(expiresAtMs)。v2026.3.31 仓库内还没有客户端调用 node.pending.drain,它目前只是网关侧的基础设施。

其二是 iOS 前台动作队列(src/gateway/server-methods/nodes.ts):iOS 应用在后台时无法执行 camera.*、screen.*、canvas.*、talk.* 命令,节点会回报 NODE_BACKGROUND_UNAVAILABLE;网关把这条命令暂存(每节点最多 64 条、10 分钟过期),本次调用以可重试的 QUEUED_UNTIL_FOREGROUND 错误返回,应用回到前台后用 node.pending.pull 取回执行、node.pending.ack 确认。两者都不是通用的"工具调用重放队列":Android 或 macOS 节点断线时,发给它的命令不会被暂存。

局限2:设备可能睡着而不是断开。移动端进后台后系统会冻结连接,从网关看和断线难以区分。

应对:主动唤醒 + 分级等待(src/gateway/server-methods/nodes.ts),只在 node.invoke 发出时目标节点不在线才启动:

export const NODE_WAKE_RECONNECT_WAIT_MS = 3_000;
export const NODE_WAKE_RECONNECT_RETRY_WAIT_MS = 12_000;

先用 maybeWakeNodeWithApns 发一次 APNs 后台唤醒推送,然后 waitForNodeReconnect() 等 3 秒;没回来就强制再推一次、等 12 秒;仍未回来,再用 maybeSendNodeWakeNudge 发一条可见的提醒通知("OpenClaw needs a quick reopen",10 分钟内只发一次),本次调用以 node not connected 失败。唤醒依赖节点登记过的 APNs 推送,实际只对 iOS 节点有效;没有推送登记的节点(如 Android)会直接失败。源码没有解释 3 秒和 12 秒的来由,可以理解为先给"应用还活着、只是连接要重建"一个短窗口,再给"需要被推送拉起来"一个长窗口。

局限3:重连是一条全新的连接。WebSocket 没有"恢复会话"的概念。

应对:客户端自己退避重连。TypeScript 客户端(CLI 与无界面节点宿主 openclaw node run 使用)在 src/gateway/client.ts:

private backoffMs = 1000;
// ...
private scheduleReconnect() {
  // ...(省略 closed 检查与 tick 定时器清理)
  const delay = this.backoffMs;
  this.backoffMs = Math.min(this.backoffMs * 2, 30_000);   // 1s → 2s → 4s → … → 30s 封顶
  setTimeout(() => this.start(), delay).unref();
}

连接成功后 backoffMs 复位为 1000。注意上限是 30 秒。伴侣应用各有实现:iOS/macOS 共用的 apps/shared/OpenClawKit 从 500 毫秒起翻倍、同样 30 秒封顶;Android(GatewaySession.kt)按 350 毫秒 × 1.7^n 增长、8 秒封顶。三处都没有随机抖动——对个人部署的设备数量来说,惊群效应不是现实威胁,省掉抖动换来的是行为可预测、好调试。

11.5 基于能力的工具路由

当 Agent 调用需要设备能力的工具时,路由决策比简单的"找到有这个能力的设备就发"要复杂得多。

11.5.1 显式指定优先,默认选择兜底

真实的路由分两条路,先看显式指定:nodes 工具接受一个 node 参数,值可以是节点 id、显示名,甚至是 IP——而且 camera_snap、camera_clip、screen_record、location_get、notify 等动作必须带这个参数,不带就直接报 node required。它由 resolveNodeIdFromCandidates()(src/shared/node-match.ts)做打分匹配——精确匹配 nodeId 得最高分(4_000),其次是 IP 精确匹配(3_000)、显示名归一化后匹配(2_000)、nodeId 前缀匹配(至少 6 个字符,1_000)。归一化规则很宽松:

export function normalizeNodeKey(value: string) {
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
}

所以显示名为 "Office MacBook" 的设备,用 office-macbook、OFFICE_MACBOOK 都能命中。同分的多个候选还会按在线与否、新旧客户端再比一轮;仍有歧义时,错误信息会把候选列表连同 node=<id> 一起打出来,让用户改成精确写法。

没有显式指定、且调用方允许默认选择时,才走 selectDefaultNodeFromList(src/agents/tools/nodes-utils.ts)。v2026.3.31 里这样的调用方只有三处:canvas 工具和 exec host=node(经 pickDefaultNode,按 canvas 能力过滤、fallback: "first"),以及浏览器代理(extensions/browser/src/browser-tool.ts,不按能力过滤、fallback: "none"):

export function selectDefaultNodeFromList(
  nodes: NodeListNode[],
  options: DefaultNodeSelectionOptions = {},
): NodeListNode | null {
  const capability = options.capability?.trim();
  // 1. 能力过滤:没有 caps 字段的节点视为"通过"(向后兼容旧客户端)
  const withCapability = capability
    ? nodes.filter((n) => (Array.isArray(n.caps) ? n.caps.includes(capability) : true))
    : nodes;
  if (withCapability.length === 0) return null;

  // 2. 在线优先:有在线节点就只在在线里挑;一个都没在线才退回全集
  const connected = withCapability.filter((n) => n.connected);
  const candidates = connected.length > 0 ? connected : withCapability;
  if (candidates.length === 1) return candidates[0];

  // 3. 本机 Mac 优先(且必须唯一才算数)
  const preferLocalMac = options.preferLocalMac ?? true;
  if (preferLocalMac) {
    const local = candidates.filter(isLocalMacNode);
    if (local.length === 1) return local[0];
  }

  // 4. 仍然歧义:fallback 决定是"报错让用户指定"还是"挑一个"
  const fallback = options.fallback ?? "none";
  if (fallback === "none") return null;
  const ordered = [...candidates].toSorted(compareDefaultNodeOrder);  // 最近连接优先,同刻按 nodeId
  return ordered[0] ?? null;
}

四个决策点,每一个都值得停一下:

能力过滤对旧客户端网开一面。Array.isArray(n.caps) ? n.caps.includes(capability) : true —— 没上报 caps 的节点默认放行。这是典型的向后兼容取舍:宁可把请求发给一台可能不支持的老设备(它会自己报错),也不要因为新增了能力字段就让所有未升级的设备集体消失。

fallback: "none" 是默认值。也就是说,默认情况下歧义不会被自动消解,而是返回 null 让上层报错。这和很多系统"随便挑一个"的做法相反——在涉及物理设备的场景里,猜错的代价(把家里的照片拍成办公室的)远高于让用户多打几个字;相机这类动作干脆不进默认选择,必须点名。只有认为"随便哪台都行"的调用方才传 fallback: "first",比如 canvas 相关操作(源码注释:多节点部署会把画面广播到所有设备,挑谁都一样);exec host=node 也复用了同一个 pickDefaultNode。

"本机 Mac 优先"要求唯一命中。local.length === 1 才返回——有两台本机 Mac 时这条规则直接放弃,继续往下走。规则只在能给出确定答案时才发言。

最后的排序按"最近连接优先"(connectedAtMs 降序,相同则按 nodeId 字典序保证确定性)。直觉是:用户刚拿起来用的那台设备,多半就是他此刻想用的那台。

需要明确指出的是:仓库里没有 nodes.preferences 这样的能力偏好配置。GatewayNodesConfig 的全部内容是:

export type GatewayNodesConfig = {
  browser?: { mode?: "auto" | "manual" | "off"; node?: string };  // 仅浏览器代理的节点固定
  allowCommands?: string[];
  denyCommands?: string[];
};

想稳定地把某类操作钉在某台设备上,正确做法是在调用时传 node=<名字>,而不是指望一份全局偏好表。

11.5.2 路由失败时会发生什么

该点名却没点名,或默认选择的候选集为空(或有歧义且 fallback: "none")时,resolveNodeIdFromNodeList 抛出 node required——错误信息直达 Agent,由 Agent 决定下一步(先用 action: "status" 列出节点再点名、追问用户、或者直接告知设备不可用)。

设备"睡着"是另一回事,走的是 11.4.3 讲的唤醒路径(仅对登记了 APNs 推送的 iOS 节点有效):先推唤醒、等 3 秒,不行再重试、等 12 秒。这条路径只在设备已配对且被认为存在时才有意义——它解决的是"叫不醒",不是"找不到"。

11.6 重连与韧性

移动设备频繁离线——网络切换、睡眠模式、隧道过渡。这不是异常——这是常态。Node 系统的韧性设计以"设备必然会断线"为前提。

11.6.1 多阶段重连策略

图 11-2:多阶段重连策略

stateDiagram-v2
    [*] --> Connected
    Connected --> Disconnected: 连接断开
    Disconnected --> RetryImmediate: 检测到断开
    RetryImmediate --> Connected: 成功
    RetryImmediate --> RetryBackoff: 失败
    RetryBackoff --> Connected: 成功
    RetryBackoff --> RetryBackoff: 失败(增加延迟)
    note right of RetryBackoff
        指数退避(src/gateway/client.ts)
        backoffMs = min(backoffMs * 2, 30_000)
        1s → 2s → 4s → … → 30s 封顶
        连上后复位为 1s
    end note
  1. 首次重试:TypeScript 客户端第一次退避就是初值 1 秒(Apple 端 500 毫秒、Android 端约 0.6 秒)——足以覆盖常见的网络闪断。
  2. 指数退避:Math.min(this.backoffMs * 2, 30_000),30 秒封顶(Android 端 8 秒封顶);连接成功即复位。
  3. 能力重新申报:每次重连都在 connect 里重新提交 caps / commands(命令仍要与放行清单、已配对记录取交集)。设备在断线期间可能变了——用户关掉了相机权限、卸载了某个组件。
  4. 待办拉取:iOS 应用回到前台后用 node.pending.pull 取回被暂存的前台命令,执行后用 node.pending.ack 确认;状态/定位类待办则由 node.pending.drain 取走。

11.6.2 为什么不做通用的命令重放

一个自然的想法是:设备断线时把所有工具调用排队,重连后统统重放。OpenClaw 没有这么做——待办工作队列只收 status.request 和 location.request 两类,iOS 前台动作队列只在"应用在线但处于后台"时暂存少数前台命令,两者都带过期时间。

源码没有写明理由,但从设计上可以读出时效性的考量。工具调用的价值高度依赖执行时刻:三分钟前排队的"拍一张门口的照片",等设备重连时拍到的已经不是当时那一刻的门口;"读一下当前位置"倒是可以晚点执行,因为用户要的是"最新位置"而不是"三分钟前的位置"。前者重放会给出看似成功实则错误的结果,比干脆失败更糟糕。iOS 前台队列是个有意的例外:用户把应用切回前台,本身就是"现在可以拍了"的信号,而且原调用已经以 QUEUED_UNTIL_FOREGROUND 错误告知了调用方。

这也解释了队列为什么支持过期:入队的条目本来就假定"过了这个村就不必再有这个店"。

容量上限同样是这个逻辑的延伸:待办工作队列按类型去重,每个节点最多挂一条状态请求、一条定位请求;iOS 前台队列每节点最多 64 条,超出时丢弃最旧的。

11.6.3 重连对 Agent 的透明性

唤醒与重连的过程对 Agent 是透明的。Agent 发出 camera_snap 调用后,它只会看到两种结果:照片(成功)或一条说明原因的错误(如 node not connected、node disconnected,iOS 在后台时是 QUEUED_UNTIL_FOREGROUND)。它不知道中间经历了推送唤醒、等待重连这些步骤。

这种透明性不是"锦上添花"——它是必要的。如果 Agent 需要处理设备连接状态的细节("如果设备断线就等待重连,重连后检查能力是否还在,然后重试..."),它的提示词会被基础设施逻辑淹没,留给实际任务的上下文窗口空间所剩无几。

11.7 分布式信任的深层思考

Node 系统实质上是一个分布式信任系统——它需要在去中心化的、可能不稳定的参与者之间建立和维护信任关系。这引出了几个深层设计问题。

11.7.1 信任的动态性

配对时建立的信任不是永恒的。设备可能丢失、遭到入侵或转手。信任令牌的管理需要考虑:

  • 令牌撤销:openclaw devices revoke --device <id> --role <role>(底层调用 Gateway 方法 device.token.revoke)。注意撤销是按 (设备, 角色) 维度的——同一台设备可以持有多个角色的令牌,撤掉 node 角色不影响它的 operator 角色,反之亦然。
  • 令牌轮换:openclaw devices rotate --device <id> --role <role>(device.token.rotate),可选 --scope 在轮换的同时收窄权限。令牌用得越久泄露风险越高,轮换缩短暴露窗口。
  • 配对清理:openclaw devices remove / clear 移除配对记录;openclaw nodes pending / approve / reject / rename 管理节点侧的配对队列与显示名。
  • 设备元数据上报:节点在配对与重连时申报 platform / version / coreVersion / uiVersion / deviceFamily / modelIdentifier 等字段(NodePairingNodeMetadata),运营者据此判断"这台设备是不是我认识的那台"。需要说明的是,这些字段是设备自称的,能帮运营者发现异常,但不构成密码学意义上的完整性证明。

11.7.2 与微服务架构的类比

Node 系统的很多设计模式在微服务架构中有直接对应:

Node 系统概念 微服务对应
bootstrap 令牌 + 审批 服务注册与准入控制
caps / commands 申报 元数据注册
selectDefaultNodeFromList 服务发现(能力过滤 + 健康优先)
resolveNodeIdFromCandidates 打分匹配 按名字/标签选实例
退避重连 + 唤醒等待 重试策略 + 超时预算
node.pending.* 队列 有界的离线消息缓冲
denyCommands 网关侧的统一策略下发

这不是巧合——两者本质上解决同一类问题:在不可靠的网络上协调分布式组件。OpenClaw 的做法是把这些企业级模式削薄后用到消费级设备场景:没有一致性协议,没有粘性会话,没有通用重放——每一处"少做"都对应一个"设备本来就会消失、猜错比失败更糟"的判断。

11.7.3 信任传递问题

一个更微妙的安全问题:如果 Agent 通过设备 A(手机)拍摄了一张照片,然后指示设备 B(笔记本)将照片发送到外部服务器。在这个操作中,数据经过了网关——网关是否应该记录(或审查)跨设备数据流?

v2026.3.31 的做法是"信任已配对设备"——数据经网关在设备间流动,网关只做命令放行与参数校验,不解析内容,也没有专门记录跨设备数据流的审计日志(能看到的只有常规的网关请求日志)。这个选择省去了在网关解析所有数据的开销,但在合规性要求高的场景中需要自行增强。

11.8 跨平台伴侣应用

11.8.1 共享核心的分层架构

伴侣应用(apps/android、apps/ios、apps/macos)中,iOS 与 macOS 共用 Swift 包 apps/shared/OpenClawKit(网关连接、协议、命令定义);Android 是独立的 Kotlin 实现,只通过构建配置复用 OpenClawKit 里的资源文件(画布脚手架、tool-display.json):

graph TB
    subgraph "图 11-3:伴侣应用分层架构"
        Shared["共享核心 (apps/shared/OpenClawKit)<br/>连接管理、协议、命令定义(Swift)"]
        Android["Android 层(Kotlin 独立实现)<br/>CameraX, LocationManager<br/>Jetpack Compose UI"]
        iOS["iOS 层<br/>AVFoundation, CoreLocation<br/>SwiftUI"]
        macOS["macOS 层<br/>ScreenCaptureKit, AVFoundation<br/>AppKit"]
    end
    Shared -.->|仅资源文件| Android
    Shared --> iOS
    Shared --> macOS
    style Shared fill:#1f618d,color:#fff
平台 关键能力 平台特定 API
Android 相机、定位、运动传感器、通知、短信、通话记录 CameraX、LocationManager
iOS 相机、定位、屏幕录制、照片、通讯录、日历 AVFoundation、CoreLocation
macOS 屏幕录制、相机、命令执行(system.run) ScreenCaptureKit、AVFoundation

11.8.2 平台特性与限制

每个平台有独特的限制需要适配:

Android:后台位置访问需要 ACCESS_BACKGROUND_LOCATION 权限(Android 10+),上架 Google Play 还要过专门的审核(v2026.3.31 的 Android 应用清单只申请了前台定位权限)。应用在后台时需要前台服务保活以维持 WebSocket 连接。

iOS:后台任务执行时间有严格限制(约 30 秒)。应用进入后台后,系统可能终止 WebSocket 连接。需要利用 BGTaskScheduler 定期唤醒应用重连。

macOS:屏幕录制需要用户在系统设置中显式授权。用 AppleScript 控制其他应用需要用户在"自动化"中授权(应用用一段发给"终端"的无害脚本探测这项权限),模拟键鼠等界面操作还需要"辅助功能"权限。

这些限制不是 Bug——它们是操作系统对隐私和安全的保护。OpenClaw 的伴侣应用需要在这些限制内工作,同时向 Agent 提供尽可能丰富的能力。这是"理想的功能规格"与"现实的平台约束"之间的持续协商。

11.9 实战推演:手机远程控制家庭设备

让我们用一个贴近生活的场景,将 Node 系统的所有设计决策串联成一个完整的故事。

11.9.1 场景:出差在外,远程查看家门口

你在北京出差,VPS 上的 OpenClaw 运行着你的个人助手 Agent。你通过 Telegram 对它说:"帮我看看成都家门口有没有快递。"

11.9.2 幕后发生了什么

1. [Telegram 通道] 消息到达 Gateway
2. [Agent 推理] 需要拍照 → 调用 nodes 工具,action="camera_snap",node="android-phone"
   (camera_snap 必须点名节点,不带 node 参数直接报 "node required";
     不确定有哪些设备时,Agent 可先用 action="status" 列出节点)
3. [节点解析] resolveNodeIdFromCandidates("android-phone") → 按 nodeId / IP / 显示名打分命中
4. [命令放行] camera.snap 须已加入 gateway.nodes.allowCommands 且在节点配对记录里,否则直接拒绝
5. [WebSocket 传输] Gateway → android-phone: node.invoke.request { command: "camera.snap", params: { facing: "back", ... } }
6. [Android 伴侣] CameraX 拍照 → 压缩 → 经 node.invoke.result 回传
7. [Agent 推理] 模型直接查看 camera_snap 返回的图片 → "门口有一个京东快递箱,看起来已放置较长时间"
8. [Telegram 通道] 回复用户,附带照片和分析结果

11.9.3 断线场景

假设手机在拍照过程中进入了电梯(WiFi 断开):

5.5. [连接中断] 网关的节点注册表注销这条连接,挂起中的调用立即失败:
     "node disconnected (camera.snap)" —— 错误返回给 Agent
     (唤醒 + 等待重连只在调用发出时节点已不在线才启动,且只对登记了 APNs 的 iOS 节点有效)

6.1. [手机出电梯] Android 客户端退避重连: 约 0.6s → 1s → 1.7s …(×1.7 增长,上限 8s)
6.2. [重新申报] 节点在 connect 里重新提交 caps / commands
6.3. [结果] 相机快照不会被暂存到设备回来补拍(Android 节点没有这类队列),
     Agent 可以在设备重连后重新发起调用

11.9.4 降级场景

如果手机完全不在线(关机了):

3. [调用失败] android-phone 不在线,且没有 APNs 推送登记、无法唤醒
   → node.invoke 立即以 "node not connected" 失败,错误直接返回给 Agent
   注意: 系统并不知道"MacBook 在北京、拍不到成都的门口"——
   节点元数据里没有地理位置字段。是否换设备是 Agent(和用户)的判断,不是路由层的。

4. [Agent 自适应] 回复用户: "你成都家里的手机似乎离线了,无法拍照。
   我可以帮你查看是否有京东/顺丰的配送记录,看最近是否有快递送达?"

Node 系统最深刻的设计洞察是:失败不是异常——它是移动设备的常态。系统不是在"正常运行"和"异常处理"之间切换,而是在"设备可用"和"设备不可用"这两个同样正常的状态之间平滑流转。就像人类不会因为左手暂时麻了就惊慌失措——你只是自然地用右手,等左手恢复。

11.10 远程节点的多元应用场景

前文的"出差查看家门口快递"只是 Node 系统最直觉的用法。实际上,分布式设备网络打开了大量传统 Agent 无法触及的使用场景。以下按领域分类,展示 Node 系统在不同场景中的独特价值。

11.10.1 开发者工作流:跨机器的统一 Agent

场景:一名工程师有三台设备——办公室台式机(高性能编译)、通勤笔记本(轻量编辑)和家中服务器(CI/CD)。她希望一个 Agent 统一管理所有三台机器的开发环境。

需要提醒的是:v2026.3.31 没有按能力配置首选设备的配置项。tools.exec.node 可以把 exec 绑定到一台节点,但绑定后调用就不能再改派到别的节点(会报 exec node not allowed),不适合带降级的约定。下面这类偏好要靠"在调用时显式指定 node"或在 Agent 的系统提示里写清约定来实现:

约定(写进 Agent 提示词或技能文档,而非配置):
  编译/测试 → node=office-desktop,离线则改用 home-server
  截屏      → node=laptop

Agent 收到"帮我在 staging 环境跑一下集成测试"的指令时:

  1. Agent 依据上述约定,带 node=office-desktop 发起调用
  2. 工作日白天该节点在线 → 正常执行
  3. 周末该节点离线 → 调用失败,Agent 依约定改带 node=home-server 重试
  4. 测试完成后,结果通过 Telegram 通道发送给工程师

把降级逻辑放在 Agent 而不是路由层,代价是要写清提示词,收益是降级规则可以带条件、带解释——"台式机没开,我改用家里的服务器跑了"这句话,路由层是说不出来的。

这种模式让 Agent 成为开发者的跨设备统一入口——无论你在哪里、用哪台设备,一条消息即可触发正确设备上的操作。

11.10.2 家庭自动化:不依赖云服务的智能管家

场景:家中有一台旧 Android 手机作为"家庭哨兵",一台 MacBook 作为媒体中心,Agent 运行在家庭 NAS 上。

用户 (Telegram): "家里现在什么温度?暖气是不是开了?"
Agent → nodes(action: "camera_snap", node: "android-sentinel")
  ├─ 拍摄暖气控制面板照片(节点命令里没有读取温度传感器的命令,温度只能从面板读数看)
  └─ 返回: "面板显示室温 22°C,暖气运行中,设定温度 23°C"

与云端智能家居平台(Alexa、HomeKit)相比,这种方案的特点在于:

  • 复用旧设备——不需要额外购买智能家居硬件
  • 隐私更可控——设备数据只经过你自己的网关(但发给模型分析的照片仍会到达所选的模型服务商,经 Telegram 回复的内容也会经过 Telegram)
  • 高度可定制——Agent 的行为由你的 AGENTS.md 决定,不受厂商限制

11.10.3 远程监控:服务器巡检与告警

场景:运营者在多地部署了 3 台 VPS,每台运行不同的服务。一台 macOS 伴侣连接到本地的网络交换机,用于监控内网。

这里同样没有"在配置里声明设备能力"这回事——能力(caps)由每台设备在配对时自己申报,运营者在批准时审阅。网关侧能配的只有命令层面的闸门:

// ~/.openclaw/openclaw.json
{
  "gateway": {
    "nodes": {
      "allowCommands": ["screen.record"],  // 额外放行的 node.invoke 命令(高风险命令默认不放行)
      "denyCommands": ["camera.snap", "camera.clip"],  // 一律拒绝,优先级最高
    },
  },
}

注意 system.run 这类命令在配对审批时就需要 operator.admin(见 11.3.2);denyCommands 是在此之上的第二道保险——即使某台节点已经被批准过,也能在网关侧统一关掉。

心跳任务可以轮询所有节点:

<!-- HEARTBEAT.md -->
- [ ] 节点健康: 逐一检查 vps-tokyo/vps-frankfurt/vps-virginia 的磁盘、内存、进程
- [ ] 网络监控: 通过 office-mac 执行 ping/traceroute 检查跨节点连通性
- [ ] 如任一节点离线超过 5 分钟 → 立即告警

Agent 不只是检查单台服务器——它通过 Node 网络获得了全局视野,能发现"从东京到法兰克福的延迟异常升高"这类跨节点问题。

11.10.4 内容创作:多设备素材采集

场景:自媒体创作者通过 Agent 协调多设备拍摄素材。手机拍视频,MacBook 截取网页素材,Agent 统一整理。

用户: "帮我准备明天的视频素材:用手机拍一段书桌的视频,再从 Mac 上截取三个竞品网站的首页"
Agent → 分解为两个并行任务:
  ├─ android-phone: nodes(action: "camera_clip", durationMs: 15000)
  └─ macbook: 
      ├─ browser(navigate: "competitor1.com") → screenshot
      ├─ browser(navigate: "competitor2.com") → screenshot
      └─ browser(navigate: "competitor3.com") → screenshot
Agent → 整理素材清单,返回预览缩略图

11.10.5 应用场景总结

场景类别 关键能力 典型设备组合 独特价值
跨机器开发 exec, filesystem 台式机 + 笔记本 + 服务器 统一入口,按约定显式指定设备
家庭自动化 camera 旧手机 + NAS 复用旧设备、隐私更可控
多节点监控 exec, screenshot 多台 VPS + 本地 Mac 跨节点全局视野
内容创作 camera, browser 手机 + 笔记本 多设备并行采集
远程协助 screenshot, clipboard 家人手机 + 你的 Agent 远程帮家人解决设备问题

Node 系统的应用场景远不止"拍照"——它让 Agent 从一个局限于单台服务器的软件进程,进化为一个跨越物理空间的分布式感知-执行网络。每多连接一台设备,Agent 的能力就多一个维度。

11.11 框架对比与行业分析

特性 OpenClaw LangChain AutoGPT CrewAI Semantic Kernel
移动设备集成 ✅ 原生应用 ❌ ❌ ❌ ❌
安全配对协议 ✅ bootstrap 令牌 + 运营者审批 — — — —
基于能力的路由 ✅ 能力过滤 + 在线优先 — — — —
离线韧性 ✅ 退避重连 + 唤醒等待 + 有限待办队列 — — — —
跨平台共享核心 ✅ iOS/macOS 共享 OpenClawKit — — — —

Node 系统是 OpenClaw 最突出的差异化因素之一。上表所列框架的核心都没有内建原生移动设备集成(按各自公开文档的定性概括,未逐项实测),它们的 Agent 基本停留在服务器的网络边界之内。

11.11.1 为什么其他框架不做?

不是因为技术不可行,而是因为定位不同。LangChain 和 Semantic Kernel 定位为开发框架——它们提供构建块,开发者自己组装。设备连接需要完整的端到端方案(协议、应用、安全),不适合以"构建块"形式提供。

AutoGPT 和 CrewAI 定位为自治 Agent——它们关注 Agent 的"思考"能力而非"感知"能力。设备连接本质上是"感知扩展",不在它们的架构假设中。

OpenClaw 作为运行时系统(而非框架),有动机和能力提供完整的端到端方案。运行时负责整个 Agent 的生命周期,自然包括它与物理世界的交互。

11.12 快速上手:从零配置 Node 连接

对于想要实际接入设备的读者,以下是从零开始的完整步骤。

11.12.1 步骤一:确认 Gateway 配置

确保 Gateway 的绑定地址允许外部连接(手机需要通过网络访问 Gateway):

// ~/.openclaw/openclaw.json(按 JSON5 解析)
{
  "gateway": {
    // 枚举值,不是 IP:auto | lan | loopback | tailnet | custom(默认 loopback)
    "bind": "lan",
    "port": 18789,            // WS + HTTP 复用的单一端口,默认 18789
    "remote": {
      // 写进配对码的地址来源之一:优先级低于 device-pair 插件的 publicUrl 和
      // Tailscale serve/funnel,高于按 bind 推断的地址;openclaw qr --remote 时优先用它。
      // wss:// 需要网关启用 TLS(gateway.tls)或前面有 TLS 反向代理
      "url": "wss://your-vps.example.com:18789",
    },
    "nodes": {
      // 拍照属于高风险命令,默认不放行;最好在配对前就配好,
      // 因为节点连接和配对时申报的命令会先按当时的放行清单过滤
      "allowCommands": ["camera.snap"],
    },
  },
}

bind 的取值是枚举而非 IP 地址(要指定具体 IP 得用 bind: custom 配合 customBindHost)。各档语义(源码注释):auto 优先回环、不可用则全接口;lan 直接 0.0.0.0;loopback 只本机;tailnet 绑 Tailscale 的 100.64.0.0/10 地址、拿不到则退回回环。默认是 loopback——开箱状态外部设备连不上,这是刻意的安全默认值。

另外,用新装向导初始化的网关会把高风险节点命令整批写进 gateway.nodes.denyCommands(src/wizard/setup.gateway-config.ts),而 deny 优先于 allow。遇到这种情况,要么把 camera.snap 从 deny 列表里删掉,要么在聊天里用默认启用的 phone-control 插件临时放开:/phone arm camera(需要 operator.admin)。

⚠️ 如果 Gateway 在 NAT 后面(如家庭路由器),需要配置端口转发,或者把 gateway.tailscale.mode 设为 "serve" / "funnel"——配了之后配对码里的 URL 会自动解析成 MagicDNS 主机名(wss://<host>),省掉手工填公网地址。

11.12.2 步骤二:生成配对码

openclaw qr

(命令描述:"Generate an iOS pairing QR code and setup code"。)终端会显示一个 QR 码和一个文本码(备用)。文本码就是把 { url, bootstrapToken } 这个 JSON 做 base64url 编码的结果:

// src/pairing/setup-code.ts:347
export function encodePairingSetupCode(payload: PairingSetupPayload): string {
  const json = JSON.stringify(payload);
  const base64 = Buffer.from(json, "utf8").toString("base64");
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

——所以它不是加密的,只是编码;这个码等同于一枚临时凭证,别贴到聊天群里。bootstrap 令牌有效期 10 分钟。

11.12.3 步骤三:安装伴侣应用并扫码

在手机上安装 OpenClaw 伴侣应用,打开后点击"扫码配对"。扫描 QR 码后,应用会:

  1. 从码里解出 url 与 bootstrapToken
  2. 用 bootstrap 令牌连上 Gateway,兑换时提交 deviceId + 公钥 + 申请的 role/scopes;全新设备以 node 角色扫码时,设备配对在这次握手里自动完成,令牌随即作废
  3. 网关为这台节点创建节点配对请求(带 caps / commands 元数据),进入 pending 队列
  4. 运营者批准后,节点申报的命令才被放行,开始工作

11.12.4 步骤四:批准并验证

节点配对请求需要运营者点头:

openclaw nodes pending            # 列出待批的节点配对请求
openclaw nodes approve <requestId>
openclaw nodes list               # 查看已配对节点

批准所需权限随设备索要的命令升级——只要通知类能力 operator.write 即可,索要 system.run 则必须 operator.admin(见 11.3.2)。

设备配对与设备令牌层面另有一组命令:openclaw devices list / approve / reject / rotate / revoke / remove / clear。openclaw qr 打印的提示就是 openclaw devices list / openclaw devices approve <requestId>——没有走上面那条自动完成的扫码路径(例如设备申请了 operator 权限)时,先要在这里批准设备配对,设备才连得上。

11.12.5 步骤五:测试设备工具

通过任何已连接的通道(如 Telegram)向 Agent 发送:

帮我用手机拍一张照片

Agent 会调用 nodes 工具的 camera_snap 动作。这个动作必须点名节点,Agent 通常会先用 action: "status" 查看有哪些节点,再带上 node 参数;指令里直接点名设备("用客厅那台手机拍")更稳妥。也可以直接用 CLI 验证:openclaw nodes camera snap --node <idOrNameOrIp>。如果返回 not in the allowlist,回到步骤一检查 allowCommands / denyCommands。

11.12.6 常见问题排查

问题 可能原因 解决方案
扫码后连接失败 Gateway 不可达 检查 gateway.bind 是否还是默认的 loopback、gateway.remote.url 是否正确、手机能否访问该地址
设备频繁断线 手机后台限制 Android:保持应用的前台服务运行;iOS:系统会挂起后台应用,尽量保持前台,网关会对登记了 APNs 推送的 iOS 节点尝试唤醒
配对码过期 超过 10 分钟(DEVICE_BOOTSTRAP_TOKEN_TTL_MS) 重新执行 openclaw qr
能力不显示 / 命令被拒 权限未授予、请求还卡在 pending,或命令不在放行清单 在手机系统设置中授予相机/位置等权限;用 openclaw nodes pending 确认请求是否已批准;检查 gateway.nodes.allowCommands / denyCommands

11.13 历史演进

仓库里没有一份完整的 Node 子系统演进记录,但代码本身留下了不少地层痕迹,比逐条编年更值得读:

兼容旧客户端的痕迹。nodes-utils.ts 的能力过滤写成 Array.isArray(n.caps) ? n.caps.includes(capability) : true——说明 caps 字段是后加的,早期节点不上报能力,因此必须默认放行。同一文件里 loadNodes() 在 node.list 报"unknown method / method not found / not implemented / unsupported"时,会回退到更老的 node.pair.list,也是同一个故事的另一面。

客户端标识的迁移痕迹。src/shared/node-match.ts 里有 isCurrentOpenClawClient()(前缀 openclaw-)和 isLegacyClawdbotClient()(前缀 clawdbot- / moldbot-),以及一段 pickPreferredLegacyMigrationMatch():当同一个查询同时命中一个新客户端和若干旧客户端时,自动选新的那个。这是项目改名留下的疤——用户升级 App 后,旧记录还躺在配对表里。

安全收紧的痕迹。verifyDeviceBootstrapToken 里那句注释 "Fail closed for any attempt to redeem the token outside the issued role/scope allowlist before binding it to a concrete device identity",特意强调了顺序。按 git 历史,它是 2026-03-31 随 #58382(恢复扫码 bootstrap 引导流程)一起加入的,同一个 PR 的子提交里有一条 "narrow qr bootstrap handoff scope"——收窄引导令牌的权限范围时,顺手把"先校验、后绑定"钉死在了代码里。同一时期还有 #57777("require node pairing before enabling node commands"),让未经节点配对的节点申报的命令一律不放行。

读源码时留意这类"多余的兼容分支"和"强调顺序的注释"——它们往往比 CHANGELOG 更诚实地记录了系统踩过的坑。

11.14 关键源码文件

目录/文件 用途
src/node-host/ 无界面节点宿主(openclaw node run):以 node 角色连接网关、执行 system.run 等命令
src/node-host/config.ts 节点宿主本地配置(状态目录下的 node.json:nodeId、令牌、网关地址)
src/node-host/invoke.ts 处理网关下发的 node.invoke 请求并回传结果
src/node-host/runner.ts 节点宿主主流程:连接网关、处理调用
src/node-host/exec-policy.ts system.run 的审批与 allowlist 策略判定
src/gateway/node-registry.ts 网关侧在线节点登记、node.invoke 转发与超时
src/gateway/node-command-policy.ts 按平台的节点命令放行清单与 allow/deny 合并
src/gateway/server-methods/nodes.ts node.* Gateway 方法、唤醒等待、iOS 前台动作队列
src/infra/device-pairing.ts 设备配对(device.pair.*)与设备令牌
src/pairing/setup-code.ts 配对码({ url, bootstrapToken })生成与编码
src/infra/device-bootstrap.ts bootstrap 令牌签发/兑换、TTL 与角色档案
src/infra/node-pairing.ts 节点配对 pending/approve 状态机与审批权限
src/infra/pairing-token.ts 令牌生成与常量时间校验
src/gateway/node-pending-work.ts 节点离线待办队列
src/shared/node-match.ts / node-resolve.ts 节点查询的打分匹配与解析
src/agents/tools/nodes-tool.ts / nodes-utils.ts nodes 工具与默认节点选择
src/cli/nodes-cli/ / src/cli/devices-cli.ts / src/cli/qr-cli.ts 节点/设备/配对码 CLI
apps/shared/ iOS/macOS 共享的 Swift 包(OpenClawKit 等)
apps/android/、apps/ios/、apps/macos/ 平台伴侣应用

11.15 本章小结

Node 系统将 OpenClaw 的触达从云端延伸到物理世界。通过 bootstrap 令牌 + 运营者审批的两段式配对、按平台放行的命令清单、显式点名为主(能力过滤加在线优先兜底)的节点选择,以及带退避与唤醒的 WebSocket 通信,Agent 像使用 Web 搜索或命令执行一样自然地获取相机、GPS、屏幕和设备控制的访问。

核心洞察:设备连接是分布式系统问题,不是移动开发问题。配对审批对应服务注册与准入,能力申报对应元数据注册,节点选择对应服务发现,退避重连与唤醒等待对应重试策略。移动应用是用户看到的表面;底层的分布式系统工程才是使它们可靠的关键。

同样值得记住的是这套系统没有做什么:没有能力偏好配置表,没有会话亲和性,没有通用的命令重放队列。它把"歧义"(多台设备都能干这件事)交还给调用方去消解(默认 fallback: "none" 直接报错),把"过期"(离线期间积压的请求)当作正常结果而不是需要补偿的失败。在一个所有参与者都可能随时消失的系统里,少猜一点,往往比多兜一层更可靠。

OpenClaw 的独特洞察是:Agent 的边界不应该是服务器的网络边界——它应该延伸到运营者信任的每一台设备。这个洞察将一个"不错的功能"提升为一种架构范式:Agent 作为分布式感知-执行网络的编排者,而非局限于单台机器的软件进程。

Agent 的边界不应该是服务器的网络边界——它应该延伸到运营者信任的每一台设备。 大脑不在手上,但手听大脑的话。Agent 不在手机上,但手机是 Agent 的眼睛和手指。Node 系统的本质,就是把神经网络从大脑延伸到四肢。

Node 系统体现了运行时而非框架(Runtime over Framework)的核心哲学——设备登记、配对校验、命令放行、唤醒等待都由 Gateway 统一处理,而不是留给开发者手动实现。同时,渐进式复杂度(Progressive Disclosure)确保不需要设备连接的用户完全感知不到这个子系统的存在。

工具系统和 Node 系统分别赋予了 Agent 在数字世界和物理世界的行动能力,但它们都有一个共同的前提:需要有人先发起请求。下一章将打破这层限制——定时任务与自动化让 Agent 从"被动应答"进化为"主动行动"。

思考题

  1. 概念理解:Node 系统的设备配对为什么采用"随机令牌 + 时间窗口"而非简单的密码认证?这种设计如何防止中间人攻击?
  2. 实践应用:如果要将一个智能家居设备(如智能灯泡)接入 OpenClaw Node 系统,你需要在设备端实现哪些通信协议?WebSocket 长连接在弱网环境下如何保证可靠性?
  3. 开放讨论:Agent 控制物理设备带来了独特的安全挑战——数字世界的错误可以撤销,物理世界的操作往往不可逆。你认为 Node 系统应该如何平衡"自动化便利"和"物理安全"?

📚 推荐阅读