agent-Specialization/cache_research/gateway/openclaw_study/openclaw_gateway.md
JOJO 6e043389b9 docs(research): Gateway 化改造工作计划与范围复杂度评估
Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
Co-authored-by: Codex powered by ChatGPT-6-Astra <codex@example.com>
2026-09-07 12:27:59 +08:00

34 KiB
Raw Blame History

openclaw Gateway 架构研究报告

研究对象openclaw 源码仓库(只读),用于为 Python Agent 项目 Astrion 的 "gateway 化" 改造提供借鉴。 研究方式:以文档要点为线索,直接在源码中定位实现并给出佐证(文件路径 + 关键代码)。 所有路径相对仓库根 <local-clones>/openclaw


1. Gateway 进程结构

结论

单一长驻守护进程:openclaw.mjs(二进制入口)→ CLI 路由 gateway 命令 → startGatewayServer() 拉起 HTTP/WS 服务。WS server 用 noServer: true 模式挂在 HTTP server 上,由统一的 upgrade 路由分配连接归属core WS / 插件 / worker / desktop 流)。每条连接由一个 ws-connection.ts handler 全权管理预认证预算、connect 握手门禁、消息分发、keepalive、慢消费者关闭连接注册进全局 GatewayClientRegistry(一个 Set<GatewayWsClient>)。方法分发通过 GatewayMethodRegistry(方法名 → handler/scope 的路由表)。

关键文件与代码证据

入口链:

  • openclaw.mjssrc/entry.tsrunCliWithExitFinalization)→ src/cli/run-main.tsarg !== "gateway" 分支L130import("./gateway-cli/run-command.js") L182src/cli/gateway-cli/run.tsL687 const { startGatewayServer } = await loadServerModule()L1131 return await startGatewayServer(port, {...}))。
  • src/gateway/server.ts:公开入口,默认端口 18789,动态 import 真正的实现:
    export async function startGatewayServer(port = 18789, opts: GatewayServerOptions = {}) {
      const mod = await loadServerStart();
      return await mod.startGatewayServerCore(port, { ...opts, startupStartedAt });
    }
    
  • src/gateway/server-start.tsstartGatewayServerCore()createGatewayKernel(port, opts)server-kernel.ts)→ createGatewayHttpTransport()server-runtime-state.ts)→ finishGatewayStartup()server-startup-finish.ts,在其中 attachGatewayWsHandlers(...)L147-170

WS server 组织:

  • src/gateway/server-runtime-state.ts L303-304const wss = new NpmWebSocketServer({ noServer: true, ... })HTTP server 的 upgrade 事件统一路由。
  • src/gateway/server-http-upgrades.tshttpServer.on("upgrade", ...)L221按路径/来源分派core 路径最终 wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); })L163-167
  • src/gateway/server/ws-connection.tsattachGatewayWsConnectionHandler()L121注册 wss.on("connection", ...),每连接:
    • 生成 connId = randomUUID();预认证预算 preauthConnectionBudget;握手超时 resolvePreauthHandshakeTimeoutMs
    • keepalivesrc/gateway/websocket-keepalive.ts —— 每 25s socket.ping(),错过 pong 判死;
    • send(obj) 检查 socket.bufferedAmount > MAX_BUFFERED_BYTES50MB拒绝/关闭;
    • 慢消费者:server-broadcast.tsslow && opts?.dropIfSlow 则丢弃但消耗 seq;否则 close(1008, "slow consumer")
  • 连接注册表:src/gateway/server/client-registry.ts —— class GatewayClientRegistry extends Set<GatewayWsClient>,支持按 connId 索引。
  • 连接状态/存在感:src/gateway/server/client-presence.tspresence-events.tspresence 快照广播)。

帧校验与分发路由表:

  • 首帧门禁:src/gateway/server/ws-connection/message-handler.tshandleMessage)——未认证连接只接受 {type:"req", method:"connect"}validateConnectParams 通过,否则 close(1008, "invalid handshake");成功后进入 authenticated-request-dispatch.ts
  • 方法路由表:src/gateway/methods/registry.ts —— createGatewayMethodRegistry(inputs) 把描述符(含 handlerbyName: Map<string, GatewayMethodDescriptor>,重复方法名直接抛错;getHandler(name) / getScope(name) 供分发使用。核心方法策略表在 src/gateway/methods/core-descriptors.tsCORE_GATEWAY_METHOD_SPECS,每行 [name, family, scope, since, policy])。
  • 分发执行:src/gateway/server-methods.tshandleGatewayRequest —— authorizeGatewayMethod()role/scope 校验L148 起)→ 注册表取 handler → 执行;请求进入时先 validateRequestFrame,非法即回 res {ok:false, error}
  • 方法组装:src/gateway/server-core-runtime.ts L407 createGatewayMethodRegistry(...) 汇聚 core handlers + extraHandlersserver-startup-finish.tsgetMethodRegistrygatewayMethods(方法名清单)、events(事件清单)注入 WS 层hello-ok 里下发给客户端。

对 Astrion 的借鉴意义

  • 用 "HTTP server + WebSocketServer(noServer) + 统一 upgrade 路由" 的组织方式,天然支持未来把插件/worker/控制面放在不同 WS 路径上且共享同一生命周期shutdown drain、连接预算
  • 方法路由表(名称 → handler → scope → since与 handlers 分离注册,是 Astrion 在 Python 侧可以照抄的骨架:一个 @method("sessions.send", scope="write") 式装饰器注册表,启动时查重。
  • 预认证预算 + 慢消费者策略dropIfSlow 或 close 1008值得直接抄单网关面对大量控制面客户端时这是稳健性的关键。

2. 协议实现connect / req / res / event / seq / stateVersion / 重连恢复 / 事件不重放)

结论

  • 线协议WS 文本帧 JSON首帧必须是 connect 请求;服务端在连接建立后先推送 connect.challenge 事件(含一次性 nonce客户端用设备 Ed25519 私钥签名 nonce 后再发 connect。
  • req/res 配对靠客户端生成的 idevent 帧带 seq(每连接单调,服务端生成)与可选 stateVersionpresence/health 两个单调计数器)。
  • 事件不重放:服务端不缓存事件;客户端靠 seq 间隙检测(seq > lastSeq+1)触发 onGapUI 的做法是直接重连,重连后通过 hello-ok 拿到全量 snapshot。重连期间 lastSeq 被重置为新 generation。
  • stateVersion 用于"有缺口就刷新状态"hello-ok 的 snapshot 带 stateVersion,事件帧可选携带,客户端可据此判断 presence/health 子树是否过期并定向刷新。

关键文件与代码证据

帧 schemaTypeBox packages/gateway-protocol/src/schema/frames.ts

export const RequestFrameSchema = closedObject({
  type: Type.Literal("req"), id: NonEmptyString, method: NonEmptyString,
  params: Type.Optional(Type.Unknown()), traceparent: Type.Optional(...),
});
export const ResponseFrameSchema = closedObject({
  type: Type.Literal("res"), id: NonEmptyString, ok: Type.Boolean(),
  payload: Type.Optional(Type.Unknown()), error: Type.Optional(ErrorShapeSchema),
});
export const EventFrameSchema = closedObject({
  type: Type.Literal("event"), event: NonEmptyString, payload: Type.Optional(Type.Unknown()),
  seq: Type.Optional(Type.Integer({ minimum: 0 })),
  stateVersion: Type.Optional(StateVersionSchema),
});

ConnectParamsSchema(同文件)含 client{id, version, mode, ...}device{id, publicKey, signature, signedAt, nonce}auth{token, bootstrapToken, deviceToken, password, ...}HelloOkSchemafeatures{methods, events}snapshot: SnapshotSchemaauth{deviceToken, role, scopes}policy{maxPayload, ...}

challenge 握手(服务端): src/gateway/server/ws-connection.ts L349-354 —— 连接建立即发:

const connectNonce = randomUUID();
if (connectionKind === "gateway") {
  send({ type: "event", event: "connect.challenge", payload: { nonce: connectNonce, ts: Date.now() } });
}

首帧门禁:message-handler.ts(见 §1

challenge 签名(客户端): packages/gateway-client/src/device-auth.ts

export function buildDeviceAuthPayload(params): string {
  return ["v2", params.deviceId, params.clientId, params.clientMode, params.role,
          scopes.join(","), String(params.signedAtMs), token ?? "", params.nonce].join("|");
}

客户端 protocol-client.ts handleMessage 里拦截 connect.challenge,取出 nonce/tssendConnect(socket, generation)buildConnectPlan({nonce, challengeTs}) 组装签名后的 connect 参数。

服务端验签: src/gateway/server/ws-connection/connect-device-proof.ts —— verifyGatewayConnectDeviceProof()

  • derivedId = deriveDeviceIdFromPublicKey(device.publicKey),必须等于 device.id
  • Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS (2min) → 拒绝;
  • device.nonce !== context.handler.connectNonce → 拒绝;
  • resolveDeviceSignaturePayloadVersion()handshake-auth-helpers.ts L297-340用 v3/v2 两种 payload 分别 verifyDeviceSignature(publicKey, payload, signature),全失败 → 拒绝。

seq 生成(服务端): src/gateway/server-broadcast.ts

const clientSeq = new WeakMap<GatewayWsClient, number>();
...
const nextSeq = (clientSeq.get(c) ?? 0) + 1;
frame = frameWithSequence(base, nextSeq, payloadFragment);   // {"type":"event","event":...,"seq":N,...}
clientSeq.set(c, nextSeq);

关键注释L490// Consume the seq for the dropped frame so the client's gap detector sees the loss instead of a silently thinner stream.dropIfSlow 时仍 clientSeq.set(c, nextSeq)。L596-599// Targeted frames ride the same per-client sequence as fanout frames...

stateVersion服务端 packages/gateway-protocol/src/schema/snapshot.ts

/** Monotonic version counters for snapshot subtrees. */
export const StateVersionSchema = closedObject({ presence: Type.Integer(), health: Type.Integer() });

src/gateway/server/health-state.ts:模块级 let presenceVersion = 1; let healthVersion = 1;incrementPresenceVersion() / health 刷新时 healthVersion += 1buildGatewaySnapshot()stateVersion: { presence: presenceVersion, health: healthVersion } 放进 snapshot。广播时 opts.stateVersion 可序列化进事件帧(server-broadcast.ts serializeFrameField("stateVersion", opts.stateVersion))。

客户端 seq 检测 + 事件不重放: packages/gateway-client/src/protocol-client.ts

private lastSeq: number | null = null;
private connect(): void {
  ...
  this.lastSeq = null; // Outer event sequences belong to one WebSocket generation.
  ...
}
const seq = typeof parsed.seq === "number" ? parsed.seq : null;
if (seq !== null) {
  if (this.lastSeq !== null && seq > this.lastSeq + 1) {
    const expected = this.lastSeq + 1;
    this.invoke("gap", () => this.opts.onGap?.({ expected, received: seq }));
    ...
  }
  this.lastSeq = seq;
}

ui/src/app/gateway-store.tsControl UI 消费端):onGap: ({ expected, received }) => { ...setSnapshot(...); if (isCurrentClient(nextClient)) connect(); } —— 检测到缺口就整体重连,重连的 hello-ok 带全量 snapshot完成"刷新状态"。

重连恢复: protocol-client.ts handleClose()opts.resolveClose(context)(客户端策略)→ 若 decision.retryscheduleReconnect(decision.reconnectDelayMs ?? connectFailure?.reconnectDelayMs, retryAfterMs)RetrySupervisor 初始 1s、×2、上限 30sclient.tsreconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 }),服务器可通过 retryable + retryAfterMs 施加更长的退避。generation 每次 connect() 递增,旧 socket 的迟到帧被 isActive(socket, generation) 丢弃(防止重连竞态混帧)。

对 Astrion 的借鉴意义

  • 协议要显式区分"握手期"与"认证后",并强制"首帧 connect"challenge→签名→hello 三步式认证能同时防重放nonce和防中间人签名绑定 deviceId+clientId+role+scopes+token+nonce
  • "事件不重放 + seq 间隙检测 + 断线重连全量 snapshot" 是无状态客户端投影范式的核心Astrion 的 gateway 可以完全照搬:服务端只做 seq = per-connection counter,客户端重连后向 snapshot 类方法全量拉取,避免服务端维护事件缓存与游标。
  • stateVersion 用"子树单调计数器"presence/health而非全局版本号客户端可精确知道哪个子树过期。Astrion 可扩展为 {sessions, agents, runs, presence} 多组计数器。

3. Session 状态归属

结论

真状态sessions/transcript/runs全部归 gateway 进程所有:默认存文件(每 agent 一个 sessions.json),也支持 SQLitesession.store 配置transcript 存 transcript_events共享状态设备身份、配对、tokenstate/openclaw.sqlite。多客户端只是"投影":通过 sessions.list/describe/catalog 读取、sessions.subscribe / sessions.messages.subscribe 订阅、sessions.changed / session.message 事件增量更新。存在 CAS/etag 机制session entry 的 lifecycleRevisionrandomUUID每次变更轮换+ 写操作的 expectedLifecycleRevision/expectedSessionId乐观锁transcript 层还有 expectedLeafEntryId 分支叶 CAS。

关键文件与代码证据

存储实现:

  • src/config/sessions/paths.tsresolveDefaultSessionStorePath(agentId) = <stateDir>/agents/<id>/sessions/sessions.jsonresolveSessionStorePathForScope()session-store-path.ts)支持 storePath 覆盖与 sqlite: 前缀。
  • src/config/sessions/session-accessor.sqlite-transcript-store.tsSQLite transcript 实现,createTranscriptEventInserter() 插入 transcript_events 表(含 seqeventJsoncreatedAt);配套 session-transcript-index.fs.ts(文件索引,seq 由 index+1 生成)。
  • 共享 SQLitesrc/state/openclaw-state-db.paths.ts —— <stateDir>/state/openclaw.sqlite,存设备身份/配对/token见 §5

会话行与 lifecycleRevisionetag

  • src/config/sessions/session-accessor.sqlite-entry-store.ts L442previousEntry.lifecycleRevision === normalizedEntry.lifecycleRevision(写前比对/冲突检测)。
  • session-accessor.sqlite-message-cut.ts L300currentEntry.lifecycleRevision !== params.expectedState.lifecycleRevision → 拒绝L577lifecycleRevision: params.forked ? randomUUID() : params.currentEntry.lifecycleRevision
  • session-accessor.sqlite-transcript-write-guard.ts L24entry.lifecycleRevision === scope.expectedLifecycleRevision && ... —— transcript 写入围栏。

CAS 参数(协议侧): packages/gateway-protocol/src/schema/sessions-patch.ts

export const SessionsPatchParamsSchema = closedObject({
  key: NonEmptyString, agentId: Type.Optional(NonEmptyString),
  /** Reject the mutation if the session was reset or replaced before it commits. */
  expectedSessionId: Type.Optional(NonEmptyString),
  expectedLifecycleRevision: Type.Optional(NonEmptyString),
  ...
});

logs-chat.ts ChatSendParamsSchemaexpectedLeafEntryIdTranscript-branch CAS ... the client's displayed branch leaf)、expectedSessionRoutingContract 等。

多客户端投影:

  • 读取:src/gateway/server-methods/sessions-read.ts L196 "sessions.list"(含 store_load / materialization / sharing / active_run_flags 等阶段,并把结果按客户端权限过滤)。
  • 订阅:sessions.subscribesessions.messages.subscribe/unsubscribecore-descriptors.ts 方法表:sessions.subscribe, sessions.messages.subscribe, sessions.messages.unsubscribe, sessions.viewers.set)。
  • 变更广播:src/gateway/server-methods/session-change-event.ts —— context.broadcastToConnIds("sessions.changed", eventPayload, connIds, ...),按 sessionKey/agentId 计算受众(resolvePrivateSessionEventBroadcastScope),只发给订阅了该 session 的连接。
  • 会话事件带 transcript 消息级 seqsrc/gateway/server-session-events.tsmessageSeq = stored.seq)→ session-transcript-message.tsparams.messageSeq 投射为事件 seq
  • 订阅注册表:src/gateway/server-chat-state.tsSessionMessageSubscriberRegistry),广播端用 sessionMessageSubscribers.get(sessionKey) 判断该连接是否订阅后才推 session.message 等(server-broadcast.ts requiresSessionSubscription 分支)。

对 Astrion 的借鉴意义

  • 采用"Gateway 单所有者 + 客户端投影"模型:客户端拿到的只是 hello snapshot + 增量事件拼接的镜像,写操作一律走 RPC 并在 gateway 内做冲突检测——Astrion 应把 session/run 状态收敛进 gateway 进程,控制面完全不落盘。
  • lifecycleRevision + expectedLifecycleRevision 的乐观锁范式非常轻量Astrion 可在 Python 侧实现为每个 session 一个 revision: UUID,所有 patch/send 带 expected_revision
  • 事件广播的"受众过滤"(按 sessionKey + 订阅者集合)值得借鉴,避免把私密 transcript 广播给未订阅连接。

4. 幂等与去重

结论

副作用方法sessions.create、sessions.send、channels.send、node.invoke 等)在参数 schema 层强制 idempotencyKey;服务端实现分两类:

  1. sessions.create内存级去重缓存(按 principal/device 分组的 MapTTL 5 分钟,容量上限),同时去重并发 inflight(同 key 共享同一个 Promise并校验"同 key 同参数"params 的 sha256
  2. 消息类chat send / source reply / channel 消息):幂等键由 runId + 投递指纹 + 操作 id 组合,去重靠扫描已持久化 transcript 中同 idempotencyKey 的 message + FIFO 租约互斥。

关键文件与代码证据

sessions.create内存去重缓存 src/gateway/server-methods/session-create-idempotency.ts

const sessionCreatesByContext = new WeakMap<GatewayRequestContext, Map<string, Map<string, SessionCreateEntry>>>();
// owner = principal ? `principal:${principal}` : `device:${deviceId}`
// requestIdentity = sha256(stableStringify(request.params))
const existing = entries?.get(idempotencyKey);
if (existing) {
  if (existing.requestIdentity !== requestIdentity)  INVALID_REQUEST "idempotency key was reused with different parameters"
  ... scope/authorization 变化也拒绝
  const result = existing.state.kind === "completed" ? existing.state.result : await existing.state.work;  // inflight 去重
  request.respond(result.ok, ..., { ...result.meta, cached: true });
  return;
}
// 容量DEDUPE_MAX=1000server-constants.ts超限返回 UNAVAILABLE

TTLpackages/gateway-protocol/src/schema/sessions-create.ts —— export const SESSION_CREATE_IDEMPOTENCY_RETENTION_MS = 5 * 60_000;(仅 ok 结果保留缓存,失败即释放)。

消息类幂等键构造: src/agents/tools/message-tool-idempotency.ts

export function buildMessageToolDeliveryFingerprint(params) {
  const canonical = JSON.stringify(canonicalizeMessageToolIdempotencyValue({ action, params: stripEnvelope(params.params) }));
  return sha256Base64UrlPrefix(canonical, 24);
}
export function buildMessageToolAutogeneratedIdempotencyKey({ runId, deliveryFingerprint, operationId }) {
  return `${runId}:message-tool:${deliveryFingerprint}:${operationId}`;
}

消息去重(持久化扫描 + 租约): src/gateway/internal-source-reply-persistence.ts

const lease = leaseKey ? internalSourceReplyPersistenceLeases.reserve([leaseKey]) : undefined;  // FIFO 租约,并发互斥
await lease?.wait();
// 先扫描completePersistedInternalSourceReply() → findTranscriptEvent(scope, event =>
//   message?.idempotencyKey === params.idempotencyKey && isOpenClawDeliveryMirrorAssistantMessage(message))

transcript 写入层支持显式去重模式:session-accessor.sqlite-transcript-store.tsidempotencyKeyMode?: "dedupe" | "preserve-owner" | "relocate-owner",以及 idempotencyLookup: "scan"

node.invoke节点侧队列去重 src/gateway/node-runtime-state.ts L83 const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey);——执行节点侧用内存队列按 key 去重。

对 Astrion 的借鉴意义

  • 协议 schema 层把 idempotencyKey 设为必填(非空字符串),客户端重试时必须复用同一 key这是网关化系统在网络抖动下不产生重复副作用的根基。
  • "内存 Map + TTL + 容量上限 + inflight 共享 Promise" 是极简且正确的服务端去重缓存实现Astrion 在 Python 可用 dict + asyncio.Future 复刻,注意同样要校验"同 key 同参数"hash 参数)与授权元数据不变。
  • 消息类侧采用"幂等键进 transcript + 落盘扫描"的持久化去重比纯内存缓存更抗网关重启Astrion 若把 transcript 落盘,可把 idempotencyKey 作为消息的唯一索引来做幂等。

5. 设备配对与认证

结论

  • 每次 gateway WS 连接建立后,服务器先发 connect.challenge(一次性 nonce + ts客户端用设备 Ed25519 私钥对 v2/v3 拼接载荷签名后放进 connect 的 device{id, publicKey, signature, signedAt, nonce}
  • 服务端验签:device.id 必须由 publicKey 派生、签名时间在 2 分钟窗口内、nonce 必须等于该连接下发的 nonce、Ed25519 签名必须通过。
  • 配对流程:device.pair.requested/resolved 事件 + device.pair.approve/reject 方法(PAIRING_SCOPE 权限配对记录pending/paired与 bootstrap token 都持久化在共享 SQLitestate/openclaw.sqlite)。
  • 设备 token连接成功授权ensureDeviceToken 颁发/轮换hello-ok 返回 deviceToken;支持 device.token.rotate/revoke 主动吊销,吊销会 invalidate 已连接客户端close 4001
  • bootstrap token 是短时效一次性凭据(首次配对用),兑换后才有完整 profile。

关键文件与代码证据

challenge 下发: src/gateway/server/ws-connection.ts L349-354见 §2

验签: src/gateway/server/ws-connection/connect-device-proof.ts(见 §2

配对存储: src/infra/device-pairing-store.ts —— SQLite 表 device_pairing_paired / device_pairing_pending / device_bootstrap_tokensopenOpenClawStateDatabasestate/openclaw-state-db.jsDevicePairingStoreState = { pendingById, pairedByDeviceId },写事务持锁(withDevicePairingLock)。

  • 路径:state/openclaw-state-db.paths.ts —— resolveOpenClawStateSqlitePath() = <stateDir>/state/openclaw.sqlite

配对请求/审批: src/infra/device-pairing.tsrequestDevicePairing,事件 device.pair.requestedsrc/infra/device-pairing-approval.tsapproveDevicePairing,支持 auto-approve、并发协调、scope 基线校验);src/gateway/server/ws-connection/connect-device-pairing.tsauthorizeGatewayConnectDevice,把 pending 转 paired 并下发 scopes

token 颁发/轮换/吊销: src/infra/device-pairing-tokens.ts

export type RotateDeviceTokenDenyReason = "unknown-device-or-role" | "missing-approved-scope-baseline"
  | "scope-outside-approved-baseline" | "caller-missing-scope";
// ensureDeviceToken / rotateDeviceToken / revokeDeviceToken 均在配对锁内读写 paired record 的 tokens

token 生成:src/infra/pairing-token.ts —— randomBytes(32).toString("base64url")verifyPairingToken 用常数时间比较(safeEqualSecret)。

bootstrap token src/infra/device-bootstrap.ts —— generatePairingToken() 颁发TTL DEVICE_BOOTSTRAP_TOKEN_TTL_MS),一次性兑换 redeemDeviceBootstrapTokenProfile;兑换入口在 sendGatewayHelloconnect-hello.ts)的 authMethod === "bootstrap-token" 分支。

吊销联动: src/gateway/server/ws-connection/authenticated-request-dispatch.ts

const DEVICE_CREDENTIAL_INVALIDATING_METHODS = new Set([
  "device.pair.remove", "device.token.rotate", "device.token.revoke", "node.pair.remove",
]);

这些方法成功后,携带旧凭据的连接被 invalidateGatewayPolicyClient(...close(4001, "client invalidated: ...")) 踢下线。

设备身份gateway 自身): src/infra/device-identity.ts —— Ed25519 身份存共享 SQLitedevice_identity 表),deriveDeviceIdFromPublicKeysignEd25519PayloadverifyEd25519Signatureed25519-signature.ts)。

对 Astrion 的借鉴意义

  • "服务器先发 challenge 再收 connect" 的次序很关键nonce 必须由服务器生成,客户端签名后回传,才能防重放。
  • "deviceId 由公钥派生"(无注册中心也能识别同一物理设备)+"配对审批+token 轮换/吊销+踢线" 是完整闭环Astrion 可先在本地做"首台设备自动 approved"autoApproveNewDeviceScopes),再扩展审批流。
  • token 落 SQLite + 常数时间比较是低成本高安全性的做法Python 端secrets.compare_digest同样适用。

6. 协议 codegenTypeBox → JSON Schema → Swift

结论

TypeBox schema 本身就是 JSON Schema 兼容对象;每 feature 一个 protocol-schema-fragment-*.ts,合成唯一注册表 ProtocolSchemas;运行时校验用 TypeBox/compile 惰性编译出 validatorSwift 模型由脚本直接吃 TypeBox schema 对象(当作 JSON Schema 遍历)生成 GatewayModels.swiftCI 用 --check 比对防止漂移。closedObjectadditionalProperties:false 强化封闭性,并打隐藏 symbol 标记以保留名义身份。

关键文件与代码证据

TypeBox 定义: packages/gateway-protocol/src/schema/frames.ts / snapshot.ts / 各 feature fragment原始类型在 primitives.tsNonEmptyString = Type.String({minLength:1}));封闭对象工厂 closed-object.ts

export function closedObject<Properties extends TProperties>(properties: Properties) {
  const schema = Type.Object(properties, { additionalProperties: false });
  Object.defineProperty(schema, identityKey, { value: Symbol("closedObject") });  // 隐藏 symbol不入 JSON
  return schema;
}

注册表合成: schema/protocol-schemas.ts —— ProtocolSchemas = composeProtocolSchemaFragments([...16 个 fragment])schema/protocol-schema-composer.ts 遍历合成并拒绝重复 key

JSON Schema → 运行时 validator protocol-validator.ts

import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
compiled ??= Compile(schema as never);   // 惰性编译

validator-registry.ts 给每个协议类型导出 validateXxx = compile(S.XxxSchema),服务端/客户端共用。

Swift 生成链路: scripts/protocol-gen-swift.ts

  • 导入 ProtocolSchemasErrorCodespackages/gateway-protocol/src/schema/error-codes.js
  • 把每个 TypeBox/JSON Schema 对象经 stableJson() / schemaSignature() 归一化后直接当作 JSON Schema 遍历(读 properties/required/items/enum/anyOf/oneOf/patternProperties 等),生成 Swift struct/class
  • 输出写到 apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swiftheader 注明 "Generated by scripts/protocol-gen-swift.ts — do not edit by hand"
  • --check 模式用于 CI 校验产物与当前 schema 一致;配套 scripts/format-swift.sh
  • Schema 对象在 JS 侧还用于 GatewayFrameSchema 判别联合(discriminator: "type"),供 quicktype/codegen 产出更紧的类型。

协议版本: packages/gateway-protocol/src/version.ts —— PROTOCOL_VERSION = 4, MIN_CLIENT_PROTOCOL_VERSION = 4, MIN_NODE_PROTOCOL_VERSION = 3connect 请求带 minProtocol/maxProtocol 区间hello-ok 回显服务器当前版本(connect-hello.ts)。

对 Astrion 的借鉴意义

  • "单一 schema 源 →(服务端校验器 / 客户端模型 / 文档)" 的单一事实源值得照搬。Astrion 若用 Python可选择 Pydantic v2model_json_schema() 直接产 JSON Schema+ JS 客户端侧校验或生成 TS 类型;关键是禁止手工再写一遍协议类型
  • "封闭对象additionalProperties:false"强制了前向兼容纪律:客户端发未知字段直接被拒绝,服务器加字段必须走 optional + 版本协商。
  • 一对协议版本区间min/max+ hello-ok 回显版本,让新旧客户端/节点共存变得可管理Astrion 的 gateway 协议应从一开始就带版本协商。

7. 方法与事件目录

结论

方法以"名称.类别"扁平命名,核心方法在 CORE_GATEWAY_METHOD_SPECS 一张策略表里集中声明名称、family、scope、since 版本、是否 control-plane write / advertise / startup插件方法由插件注册表附加。事件由 GATEWAY_EVENTS 清单声明,广播时按 EVENT_SCOPE_GUARDS 做 scope 过滤,部分事件要求 session 订阅。

关键文件

方法: src/gateway/methods/core-descriptors.tsCORE_GATEWAY_METHOD_SPECS,核心方法约 400+ 个),src/gateway/methods/registry.ts(注册表),src/gateway/server-methods/core-handlers.tshandlers 挂载)。

事件: src/gateway/server-methods-list.tsGATEWAY_EVENTSsrc/gateway/server-broadcast.tsEVENT_SCOPE_GUARDS + SESSION_SUBSCRIPTION_EVENTS)。

方法类别(按 family 聚合统计)

  • 会话与运行(最大族):sessions.* 57+list/get/describe/create/send/abort/patch/reset/delete/compact/recover/fork/rewind/subscribe/viewers/goal/groups/branches/usage/diff/files/compaction…runs 相关融入 send/abort/subscribe。
  • 节点/workernode.* 19pair.list/approve/reject/remove、list/describe、invoke、pending.pull/ack/enqueue/drain、runnerInventory.update、pluginTools/skills.update、event…
  • 设备device.* 9pair.list/approve/reject/remove/rename、token.rotate/revoke、scopes.requestUpgrade/waitUpgrade
  • agent/模型/技能agents.*list/create/update/delete/files/workspacemodels.*skills.*36 项,含建议接受)。
  • 配置与运维config.get/set/apply/patch/schemahealthstatusdiagnostics.*doctor.*logs.tailupdate.*migrations.*gateway.*(重启/suspendcron.*10channels.*7secrets.*worktrees.*projects.*desktop.*
  • 人机交互/审批exec.approval.* / exec.approvals.*11question.*5plugin.approval.*openclaw.approval.*talk.*16语音
  • 系统 agent 引导openclaw.chat/history/setup.*/changes.listwizard.*
  • 工具/媒体/周边tools.*7terminal.*7board.*9canvas.*portal.*progressCard.*tts.*9push.*7users.*20voicewake.*mentions.*messages.*assistant.*attach.*artifacts.*audit.*mcp.*7conversations.*4等。

事件类别GATEWAY_EVENTS约 90 个)

  • 握手:connect.challenge
  • 会话/聊天:sessions.changedchatchat.metadata.changedui.commandsession.message/observer/operation/sharing/typing/tool/suggestion/approval
  • 存在与健康:presencehealthheartbeattickshutdowngateway.suspension
  • 节点/设备:node.pair.requested/resolvednode.presencenode.hostStatsnode.invoke.*cancel/input/requestdevice.pair.*changed/requested/resolved/setup.completed/deliveryUncertain
  • 审批/提问:exec.approval.requested/resolvedquestion.requested/resolvedplugin.approval.*openclaw.approval.*
  • 系统:crontasktask.suggestionupdate.availableupdate run changedconfig.changedskills.changedusers.prefs.changedmentions.changedvoicewake.*talk.mode/eventterminal.data/exitportal.changedprogressCard.changedcontrolUi.sessionPullRequests.changedplugins.controlUi.changedsessions.catalog.host

对 Astrion 的借鉴意义

  • "一张集中策略表(方法名 + scope + since + 属性)+ 注册表查重 + hello-ok 下发 methods/events 白名单" 让能力发现feature discovery成为协议一等公民。Astrion 可在 hello 响应里下发 features.methods/events,客户端据此决定 UI 能力。
  • 事件统一走 EVENT_SCOPE_GUARDS 过滤矩阵event → 需要的 scope 列表),比在 handler 里各自检查权限更不易漏Python 侧可做一个装饰器 @event("session.message", requires="read") 并集中注册。

openclaw 设计要点速查表≤10 行)

  1. 单 Gateway 长驻HTTP+WS(noServer) 统一 upgrade 路由;每连接一个 handler认证门禁/keepalive/慢消费/预算),连接注册表 = Set<GatewayWsClient>
  2. 协议WS 文本 JSON首帧必须 connect{type:req,id,method,params}{type:res,id,ok,payload|error};事件 {type:event,event,payload,seq?,stateVersion?};协议版本 = 4min/max 协商)。
  3. 认证:连接先收 connect.challenge{nonce,ts},客户端用设备 Ed25519 私钥签名 v2|deviceId|clientId|mode|role|scopes|signedAt|token|nonce 回传;服务端验 id 派生、2min 时间窗、nonce、签名。
  4. 事件不重放seq 为每连接单调计数(被丢弃的帧也消耗 seq客户端 seq > lastSeq+1onGap → 整体重连;重连 hello-ok 带全量 snapshotstateVersion{presence,health} 定子树版本。
  5. 会话状态归 Gatewaysessions.json每 agent或 SQLite transcripttranscript_events 带 seq共享状态在 state/openclaw.sqlite客户端只投影sessions.list/subscribe + sessions.changed 事件)。
  6. CAS/乐观锁session lifecycleRevision=UUID 每次变更轮换;写操作带 expectedLifecycleRevision/expectedSessionId/expectedLeafEntryId
  7. 幂等:副作用方法必填 idempotencyKeysessions.create 用内存 MapTTL 5min、容量 1000、inflight 同 key 共享 Promise、sha256 参数校验);消息类用"RunId:指纹:opId"键 + transcript 扫描去重 + FIFO 租约。
  8. 配对/租约吊销bootstrap token32B base64url、短时效一次性→ 配对审批pending/paired 表)→ ensureDeviceToken 轮换rotate/revoke/pair.remove 使旧连接 close(4001)。
  9. Schema 单一源TypeBox schemaclosedObject=additionalProperties:false→ 惰性编译 validator + scripts/protocol-gen-swift.ts 直读 schema 生成 GatewayModels.swiftCI --check 防漂移)。
  10. 方法/事件目录:核心方法集中在 CORE_GATEWAY_METHOD_SPECSname/family/scope/since一张表事件在 GATEWAY_EVENTS + EVENT_SCOPE_GUARDS 过滤矩阵hello-ok 下发 features.methods/events 供客户端能力发现。