场景:AI 助手的"边界问题"
把 OpenClaw 当作个人 AI 助手使用一段时间后,会遇到几个让单进程模型力不从心的场景:
- 远程执行:你想让 AI 帮你在家里的 Linux 服务器上跑一段脚本,但 OpenClaw Gateway 运行在 Mac 上------AI 怎么触达那台服务器的 Shell?
- 手机上的交互 UI:你想在手机上看到 AI 生成的实时仪表盘,并且能点击按钮触发下一步操作------AI 怎么向移动端 WebView 推送 UI,WebView 里的点击又怎么反馈给 AI?
- 并行任务:你让 AI 帮你整理 1000 封邮件------用一个 Agent 串行处理太慢了,能不能派出多个 AI 同时干?
这三个场景分别对应 OpenClaw 的三个核心扩展机制:Node Host(节点主机) 、Canvas + A2UI 、子 Agent(Sub-agent)。
一、Node Host:让 AI 触达远程机器
问题:Gateway 和执行目标不在同一台机器
Gateway 负责对话管理和 Agent 执行,但 system.run(执行 Shell 命令)这类工具需要在目标机器上运行------可能是远程服务器、NAS、Raspberry Pi,或者同一台 Mac 上受限环境的另一个进程。
Node 是解决这个问题的抽象:一个独立进程,连接到 Gateway,响应执行请求。Node 通过标准的 GatewayClient(WebSocket)注册自己:
typescript
// src/node-host/runner.ts
const client = new GatewayClient({
url: `wss://${gatewayHost}:${gatewayPort}`,
instanceId: nodeId, // 节点唯一标识(如机器 hostname)
clientName: "node-host",
role: "node", // 区别于 "agent"、"cli" 角色
caps: ["system", "browser"], // 声明此节点支持的能力
commands: NODE_SYSTEM_RUN_COMMANDS, // 支持的命令列表
onEvent: (evt) => {
if (evt.event !== "node.invoke.request") return;
const payload = coerceNodeInvokePayload(evt.payload);
void handleInvoke(payload, client, skillBins); // 处理执行请求
},
});
client.start();
数据流:
arduino
Agent 调用 system.run 工具
→ Gateway 路由到目标 nodeId 的 WebSocket 连接
→ 发送 node.invoke.request 事件
→ Node 执行命令 (spawn子进程)
→ node.invoke.result 返回结果
→ Gateway 把结果交给 Agent
执行安全:三级权限模型
Node 不是无限制的 Shell 执行器。exec-approvals.ts 实现了一个三级安全模型:
typescript
type ExecSecurity = "deny" | "allowlist" | "full";
deny:拒绝所有命令执行allowlist(默认):只允许exec-approvals.json白名单中的命令full:允许所有命令(高信任环境下使用)
白名单文件用哈希防止竞态修改------读取和更新都需要传入 baseHash,若文件已被其他进程改动,返回 "INVALID_REQUEST: exec approvals changed; reload and retry"。这防止了 TOCTOU 攻击。
还有一个特殊路径:在 macOS 上,preferMacAppExecHost 为 true 时,执行请求会优先通过 macOS 应用的 Exec Host(Unix socket),而不是直接 spawn。这是因为 macOS 沙盒限制了某些路径的访问权限,通过应用层代理绕过这个限制。
Node 的输出上限
命令的输出被硬性截断:
typescript
const OUTPUT_CAP = 200_000; // 累计输出上限(字节)
const OUTPUT_EVENT_TAIL = 20_000; // 单次事件的输出尾部(字节)
超出上限的内容被丢弃,结果中 truncated: true。这保证了大量输出(如日志文件)不会撑爆 Agent 的上下文窗口。
二、Canvas:在手机上渲染 AI 生成的 UI
问题:AI 的回复只能是文字吗?
AI 擅长生成代码,能不能生成 HTML 仪表盘、然后在用户手机上直接显示,并支持交互?
Canvas 是 Gateway 内置的一个轻量 HTTP 服务器,挂载在 /__openclaw__/canvas,专门伺服 AI 生成的 HTML/JS/CSS 文件:
typescript
// 目录结构
~/.openclaw/canvas/
index.html ← AI 生成后写到这里
app.js
style.css
Gateway 启动时,createCanvasHostHandler 会:
- 在
~/.openclaw/canvas/创建默认的index.html(如果不存在) - 用
chokidar监听目录变化 - 文件变化时通过 WebSocket(
/__openclaw__/ws)推送"reload"给所有已连接的客户端
被注入的实时重载脚本:
typescript
// 每个 HTML 页面末尾都会自动注入
const ws = new WebSocket("wss://host/__openclaw__/ws");
ws.onmessage = (ev) => {
if (String(ev.data || "") === "reload") location.reload();
};
这意味着:AI 重写 index.html → chokidar 检测到文件变化 → WebSocket 广播 "reload" → 用户手机上的 WebView 自动刷新。
文件系统边界
文件服务有安全约束。resolveFileWithinRoot 用 open(fd) + realpath 验证每个请求的文件路径都在 Canvas 根目录内------这与上一篇 Plugin SDK 里的 openBoundaryFileSync 是同一类防护,都是防止路径遍历攻击。
三、A2UI:WebView 与 Agent 的双向通信
问题:Canvas 页面里的按钮点击怎么回传给 Agent?
Canvas 里的 HTML 页面可以显示数据,但它本身没有办法与 OpenClaw 通信------它是运行在 iOS/Android WebView 里的孤立页面。
A2UI(Agent-to-UI) 解决了这个方向问题:提供一套跨平台的 JavaScript 桥接 API,让 WebView 里的代码可以触发 OpenClaw Agent 的动作。
A2UI 包(src/canvas-host/a2ui/)通过 /__openclaw__/a2ui/ 伺服,并在每个 Canvas 页面的 HTML 里自动注入一段引导脚本:
typescript
// 被注入到每个 Canvas HTML 页面的桥接脚本(简化)
function postToNode(payload) {
const raw = typeof payload === "string" ? payload : JSON.stringify(payload);
// iOS 桥接
const iosHandler = globalThis.webkit?.messageHandlers?.openclawCanvasA2UIAction;
if (iosHandler?.postMessage) {
iosHandler.postMessage(raw);
return true;
}
// Android 桥接
const androidHandler = globalThis.openclawCanvasA2UIAction;
if (androidHandler?.postMessage) {
androidHandler.postMessage(raw);
return true;
}
return false;
}
// 给 Canvas 页面的公开 API
globalThis.openclawSendUserAction = (userAction) => {
const id = userAction.id || crypto.randomUUID();
return postToNode({ userAction: { ...userAction, id } });
};
Canvas 页面里的代码可以这样触发动作:
javascript
// 用户点击了"执行备份"按钮
window.openclawSendUserAction({
name: "run_backup",
surfaceId: "main",
sourceComponentId: "backup.button",
context: { target: "nas-01", compress: true }
});
这条消息经由:
csharp
Canvas JS → native MessageHandler(iOS/Android)
→ OpenClaw Node Host 的 node.event
→ Gateway
→ 对应的 Agent 会话(作为用户输入)
→ Agent 决定下一步行动
动作结果回传
Agent 处理完动作后,可以通过 window.dispatchEvent(new CustomEvent("openclaw:a2ui-action-status", { detail: { id, ok, error } })) 通知 Canvas 页面操作已完成。这构成了一个完整的请求-响应循环。
四、ACP:标准化的 Agent 互操作协议
问题:外部工具如何调用 OpenClaw?
@agentclientprotocol/sdk 是 Agent Client Protocol 的实现,OpenClaw 通过它暴露一个标准接口,让任何兼容 ACP 的工具都能与 OpenClaw 的 Agent 会话交互。
typescript
// src/acp/server.ts
export async function serveAcpGateway(opts: AcpServerOptions): Promise<void> {
// 监听本地端口,将 ACP 请求翻译成 Gateway 操作
const agent = new AcpGatewayAgent(gateway);
// 每个 ACP 会话映射到一个 OpenClaw 会话(sessionKey)
}
ACP 会话有两种模式:
typescript
export const ACP_SPAWN_MODES = ["run", "session"] as const;
// "run" → 一次性任务:完成后关闭会话
// "session" → 持久会话:完成后保留,后续请求继续使用同一上下文
这让 CI/CD 系统、IDE 插件、或其他 AI 工具可以把 OpenClaw 当作一个可编程的 AI 后端调用,而不需要理解 OpenClaw 内部的协议细节。
五、子 Agent:并行任务分解
问题:串行处理大任务太慢
一个复杂任务(整理 1000 封邮件,分析 50 个代码文件)用单个 Agent 串行处理太慢。子 Agent 机制让 Agent 可以派生(spawn)独立的子 Agent 并行执行子任务。
typescript
// Agent 使用 sessions.spawn 工具派生子 Agent
export async function spawnSubagentDirect(
params: SpawnSubagentParams,
ctx: SpawnSubagentContext,
): Promise<SpawnSubagentResult>
SpawnSubagentParams 的核心字段:
typescript
type SpawnSubagentParams = {
task: string; // 子任务描述(注入为子 Agent 的第一条消息)
label?: string; // 可读标签(用于状态显示)
agentId?: string; // 指定使用哪个 agent 配置
model?: string; // 子 Agent 的模型(可以与父 Agent 不同)
thinking?: string; // 子 Agent 的思考等级
runTimeoutSeconds?: number; // 超时控制
thread?: boolean; // 是否绑定到聊天线程(结果直接发到聊天)
mode?: "run" | "session"; // 一次性 vs 持久会话
cleanup?: "delete" | "keep"; // 完成后是否清理会话
};
子 Agent 的生命周期
scss
父 Agent 调用 sessions.spawn
↓
spawnSubagentDirect()
→ 创建新 SessionKey(格式:agentId:session-xxxxxxxx)
→ 在 AGENT_LANE_SUBAGENT 通道里排队
→ 注册到 SubagentRegistry(追踪运行状态)
↓
子 Agent 在独立 Lane 里执行(与父 Agent 并行)
↓
完成后:subagent-announce 把结果发回给父 Agent
→ 作为父 Agent 会话里的一条用户消息
→ 父 Agent 继续处理
"不要轮询"注意事项 :子 Agent 完成后会主动宣告(announce)结果,而不是等父 Agent 来查询。父 Agent 在派生子任务后应该继续其他工作,而不是在循环里等待子任务完成。子任务的结果会自动以用户消息的形式重新注入父 Agent 的上下文。
arduino
// 子 Agent 结果宣告文案(src/agents/subagent-spawn.ts)
export const SUBAGENT_SPAWN_ACCEPTED_NOTE =
"auto-announces on completion, do not poll/sleep. The response will be sent back as an user message.";
深度限制
为了防止无限递归(子 Agent 再派生子 Agent 再派生......),系统维护一个深度计数器 (subagent-depth.ts),并且有默认上限:
typescript
// src/config/agent-limits.ts
export const DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH = 3;
超过这个深度的派生请求会被拒绝,返回 "forbidden" 状态。
子 Agent 绑定线程
thread: true 参数让子 Agent 的结果直接投递到当前聊天线程(而不是等父 Agent 转发):
arduino
父 Agent(在 Telegram 对话里)
→ 派生子 Agent,thread: true
→ 子 Agent 完成后,结果直接发送到 Telegram 对话
→ 用户在 Telegram 里直接看到子任务的输出
这个功能依赖 subagent_spawning 生命周期钩子------只有实现了该钩子的通道插件(能绑定线程的聊天平台)才能使用。
小结:三个"边界突破"机制
| 机制 | 突破的边界 | 核心数据流 |
|---|---|---|
| Node Host | 执行边界------让 AI 触达远程机器 | Gateway → WebSocket → Node → spawn → 结果返回 |
| Canvas + A2UI | UI 边界------让 AI 的输出变成交互式 UI | AI 写文件 → chokidar → WebSocket → WebView 重载;用户点击 → native bridge → node.event → Agent |
| 子 Agent | 并发边界------让 AI 并行分解任务 | 父 Agent spawn → 子 Agent 独立执行 → 完成后 announce 回注入父会话 |
三者共同扩展了"个人 AI 助手"的能力边界:不只是对话,而是可编程的执行引擎 + 交互式 UI 宿主 + 多 Agent 协作系统。
下一篇也是系列的最后一篇,我们将进入 OpenClaw 的安全模型与沙盒------系统性梳理 Gateway 认证、工具策略、沙盒隔离、API Key 保护、以及整个系统的信任边界设计。