【#3】 OpenClaw を読み解く — 制御プレーン Gateway プロトコル
本記事のコード参照は OpenClaw
mainのcee2aca409(version2026.6.10)時点。行番号は更新でズレ得ます。
連載「OpenClaw を読み解く」
OpenClaw の中心には Gateway(制御プレーン)があり、CLI・Web UI・モバイルノードといった多様なクライアントが、ひとつの Gateway プロトコルで会話します。今回は packages/gateway-protocol/ と src/gateway/ を読み、「制御プレーンの通信規約」を解剖します。
会話を成す三つの型 — req / res / event
プロトコルの土台は3つのフレーム型です(packages/gateway-protocol/src/schema/frames.ts)。
// RequestFrame: クライアント→サーバの要求
{ type: "req", id: string, method: string, params?: unknown }
// ResponseFrame: id で対応づくサーバ応答
{ type: "res", id: string, ok: boolean, payload?: unknown, error?: ErrorShape }
// EventFrame: サーバからのブロードキャスト(順序番号つき)
{ type: "event", event: string, payload?: unknown, seq?: number, stateVersion?: StateVersion }
形としては WebSocket 上の JSON-RPC 風です。req に一意な id を付け、res が同じ id で返る。サーバからの非同期通知は event で流れ、seq/stateVersion によってクライアントは状態の追従ができます。心拍は TickEventSchema({ ts })、停止予告は ShutdownEventSchema({ reason, restartExpectedMs? })として定義されています。
スキーマ定義には TypeBox(Type.Object / Type.Literal など)が使われ、packages/gateway-protocol/src/schema/protocol-schemas.ts が約 270 のプロトコルスキーマ名を一元登録しています。型と実行時バリデーションが同じ定義から導かれる、AGENTS.md の「外部境界は zod / 既存スキーマヘルパで固める」方針の体現です。
名乗りと擦り合わせ — ハンドシェイクとバージョン交渉
接続時、Gateway はまず connect.challenge イベントで nonce を発行し、クライアントはそれを使った認証情報とともに ConnectParams(frames.ts:30 付近)で自分の能力・対応プロトコル範囲を申告します。
export const ConnectParamsSchema = Type.Object({
minProtocol: Type.Integer({ minimum: 1 }),
maxProtocol: Type.Integer({ minimum: 1 }),
client: Type.Object({
id, version, platform, mode, // ... device auth, token, role, scopes
}),
});
サーバは対応可能なバージョンを選んで HelloOk で返します。現在のバージョン定数は明快です(packages/gateway-protocol/src/version.ts)。
export const PROTOCOL_VERSION = 4 as const;
export const MIN_CLIENT_PROTOCOL_VERSION = 4 as const;
export const MIN_PROBE_PROTOCOL_VERSION = 4 as const;
min/maxProtocol を交換してから合意バージョンを確定する設計なので、新旧クライアントが混在しても破綻しません。
バージョニングの規律
AGENTS.md のアーキテクチャ規約はプロトコル変更について厳しい線を引いています。
Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
Protocol version bumps: explicit owner confirmation only; never automatic/generated.
つまり「まず後方互換な追加で済ませる。非互換変更はバージョニング+ドキュメント+クライアント追従が必須。バージョン番号の引き上げはオーナー確認必須で、自動・生成では絶対にやらない」。PROTOCOL_VERSION = 4 という一行が重い意味を持つのはこのためです。
失敗を型で語る — 閉じたコードと再試行の手がかり
エラーは自由文字列ではなく、閉じたコードと再試行情報を持ちます(schema/error-codes.ts)。
export const ErrorCodes = {
NOT_LINKED, NOT_PAIRED, AGENT_TIMEOUT,
INVALID_REQUEST, APPROVAL_NOT_FOUND, UNAVAILABLE,
};
export function errorShape(code, message, opts?: {
details?: unknown; retryable?: boolean; retryAfterMs?: number;
}): ErrorShape { return { code, message, ...opts }; }
retryable / retryAfterMs を構造として持つので、クライアントは「リトライしてよいか・いつか」を推測ではなく型から判断できます。これは AGENTS.md の「runtime branching は discriminated union / closed code で。freeform string のセマンティックセンチネルを避ける」という Code 規約そのものです。
受け手の流儀 — メソッドの登録とディスパッチ
src/gateway/ 側では、メソッドはレジストリで管理されます(methods/registry.ts)。createGatewayMethodRegistry() がメソッド記述子を正規化・検証し、名前の一意性・スコープ割当・所有者追跡を強制します。レジストリは getHandler() / listMethods() / getScope() / isControlPlaneWrite() などのビューを返します。
リクエスト処理の骨格はこうです(src/gateway/server-methods.ts:638 付近)。
export async function handleGatewayRequest(opts) {
// 1. 認可チェック
const authError = authorizeGatewayMethod(req.method, client, req.params, methodRegistry);
// 2. 起動中で未準備のメソッドは retryable な UNAVAILABLE で退避
if (context.unavailableGatewayMethods?.has(req.method)) { /* respond UNAVAILABLE */ return; }
// 3. 制御プレーン書き込みのメソッドだけレート制限
if (methodRegistry.isControlPlaneWrite(req.method)) {
const budget = consumeControlPlaneWriteBudget({ client });
// ...予算切れなら UNAVAILABLE で応答して return
}
// 4. ハンドラ探索と実行
const handler = methodRegistry.getHandler(req.method);
if (!handler) {
respond(false, undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`));
return;
}
await withPluginRuntimeGatewayRequestScope(/* ... */,
() => handler({ req, params, client, respond, context }));
}
注目点は2つ。第一に 「制御プレーン書き込み(control-plane write)」だけにレート制限を課すこと。状態を変えるメソッドと読み取り専用メソッドをレジストリが区別しているので、書き込みだけ守れます。第二に、ハンドラ群(agentHandlers, channelsHandlers, cronHandlers …)が遅延ロードされること。起動時にすべてのメソッド実装を読み込まない、#02 で見た「遅延初期化」がここでも一貫しています。
boot — 隔離された部屋での起動時の自己点検
src/gateway/boot.ts の runBootOnce() は、ワークスペースの BOOT.md を読み込み、内部ランタイムコンテキストの区切りで包んで、隔離セッション内で起動チェックを走らせます。成功・失敗に応じてセッションマッピングをスナップショット/復元する作りで、本番のセッションを汚さずに「起動時の健全性確認」を行えます。
契約を使う側 — packages/gateway-client
クライアント実装は packages/gateway-client/src/client.ts。WebSocket クライアントで、gateway-protocol の RequestFrame/ResponseFrame/EventFrame 型をそのまま使います。デバイス認証(device-auth.ts)、トークンローテーション、ペアリングフローを内包し、OpenClaw 固有の状態(デバイス鍵・トークン保存・ログ)は GatewayClientHostDeps として外から注入されます。
プロトコル定義(packages/gateway-protocol)と通信実装(packages/gateway-client)が別パッケージに分かれているのがポイントです。プロトコルは「契約」、クライアントは「契約を使う実装」。AGENTS.md の「再利用される純ロジックは packages/*-core に切り出す」という分担が、ここでも効いています。
ディレクトリごとの掟 — scoped AGENTS.md が守らせるもの
src/gateway/AGENTS.md は hot path のガードレールを定めます。「テストや起動で不要にバンドルプラグインのランタイムを実体化しない」「フルチャネルプラグインの前に軽量アーティファクトリゾルバを優先する」など。さらに src/gateway/server-methods/AGENTS.md はセッション書き込みの安全則を明文化します。
Never append raw
type: "message"entries via JSONL writes (breaks compaction/history).
Always useSessionManager.appendMessage(...).
トランスクリプトは parentId チェーン/DAG 構造であり、生 JSONL 書き込みは圧縮・履歴を壊す——この invariant は #08 のセッション回で深掘りします。
まとめ — 契約と実装を分けるということ
- プロトコルは WebSocket 上の JSON-RPC 風、
req/res/eventの3フレーム+ TypeBox スキーマ。 -
バージョン交渉(
min/maxProtocol→HelloOk)と厳格な bump 規律(PROTOCOL_VERSION = 4、オーナー確認必須)。 - エラーは閉じたコード+再試行メタデータで、推測を排除。
- サーバはスコープ別レジストリ+遅延ロードハンドラで、書き込みだけにレート制限。
- 「契約(gateway-protocol)」と「実装(gateway-client)」をパッケージで分離。
次回予告 — コアに能力を注ぐ唯一の窓口へ
#04 は、この Gateway に機能を足し込む唯一の窓口、プラグイン SDK とローダーです。130 以上のプラグインがどうやって「コアを汚さずに」能力を注入するのか。マニフェスト・capability レジストリ・多数の細い SDK エントリポイントの謎に踏み込みます。
参考: packages/gateway-protocol/src/schema/frames.ts, .../version.ts, .../schema/error-codes.ts, src/gateway/server-methods.ts:638, src/gateway/methods/registry.ts, src/gateway/boot.ts, packages/gateway-client/src/client.ts, src/gateway/AGENTS.md
