From ee258beba66a7aa4ef7a56cc53eadfc27e5de846 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 22:47:53 -0700 Subject: [PATCH] fix(reef): diagnose relay protocol skew (#123498) --- docs/channels/reef.md | 1 + extensions/reef/api.ts | 6 +- extensions/reef/src/transport.test.ts | 117 +++++++++++++++++++++++--- extensions/reef/src/transport.ts | 84 ++++++++++++++---- 4 files changed, 181 insertions(+), 27 deletions(-) diff --git a/docs/channels/reef.md b/docs/channels/reef.md index d29894b21f3d..61df7bf1ec56 100644 --- a/docs/channels/reef.md +++ b/docs/channels/reef.md @@ -158,5 +158,6 @@ When a peer's inbound guard rejects a delivered message, Reef verifies the signe - `channels status` shows `running` but not `connected`: the relay WebSocket is reconnecting; check network reachability of the relay URL. - Every inbound message denied with `guard_failure`: the guard provider call is failing — most commonly `apiKeyEnv` is unset in the Gateway environment or the key has no credits. - Pairing request never appears: the recipient's channel reconciles with the relay every 30 seconds; check `openclaw pairing list reef` after that, and confirm the requester used a fresh code (codes expire after 15 minutes). +- Pairing fails with a Reef protocol compatibility error: update OpenClaw and the Reef relay together, then approve the fresh pairing challenge again. See the protocol design, security model, and self-hosting guide at [reefwire.ai/docs](https://reefwire.ai/docs/). diff --git a/extensions/reef/api.ts b/extensions/reef/api.ts index 660772f384de..d1fc23a0075a 100644 --- a/extensions/reef/api.ts +++ b/extensions/reef/api.ts @@ -1,6 +1,10 @@ export { reefPlugin } from "./src/channel.js"; export { reefMessageAdapter, reefOutboundAdapter } from "./src/outbound.js"; -export { ReefTransportClient, ReefInboxConnection } from "./src/transport.js"; +export { + ReefInboxConnection, + ReefProtocolCompatibilityError, + ReefTransportClient, +} from "./src/transport.js"; export type { WebSocketLike } from "./src/transport.js"; export { ReefFriendManager } from "./src/friends.js"; export { ReefMessageFlow, createConfiguredGuard } from "./src/flow.js"; diff --git a/extensions/reef/src/transport.test.ts b/extensions/reef/src/transport.test.ts index 0167a63b5686..cfa3e3a98221 100644 --- a/extensions/reef/src/transport.test.ts +++ b/extensions/reef/src/transport.test.ts @@ -7,6 +7,7 @@ import WebSocket, { WebSocketServer } from "ws"; import { canonicalBytes, fromBase64url, sha256Hex } from "../protocol/index.js"; import { ReefInboxConnection, + ReefProtocolCompatibilityError, ReefRelayError, ReefTransportClient, createReefWebSocket, @@ -39,6 +40,18 @@ function createClient( return new ReefTransportClient(baseUrl, "alice", keys, fetcher, clock); } +function pendingFriend(peer = "bob"): RelayFriend { + return { + peer, + status: "pending", + initiated_by: peer, + vouching_mutual: null, + ed25519_pub: "B".repeat(43), + x25519_pub: "C".repeat(43), + key_epoch: 2, + }; +} + afterEach(() => { vi.useRealTimers(); }); @@ -172,18 +185,10 @@ describe("ReefTransportClient device authentication", () => { const calls: RequestInit[] = []; const fetcher: typeof fetch = async (_input, init) => { calls.push(init ?? {}); - return Response.json({ peer: "bob", status: "active" }); + return Response.json({ peer: "bob", status: "active", future: "ignored" }); }; const client = createClient(fetcher); - const friend: RelayFriend = { - peer: "bob", - status: "pending", - initiated_by: "bob", - vouching_mutual: null, - ed25519_pub: "B".repeat(43), - x25519_pub: "C".repeat(43), - key_epoch: 2, - }; + const friend = pendingFriend(); await expect(client.respondFriend(friend, true)).resolves.toEqual({ peer: "bob", @@ -198,6 +203,83 @@ describe("ReefTransportClient device authentication", () => { }); }); + it("diagnoses an outdated relay without retrying or downgrading the signed request", async () => { + const calls: RequestInit[] = []; + const fetcher: typeof fetch = async (_input, init) => { + calls.push(init ?? {}); + return Response.json({ error: "invalid_request" }, { status: 400 }); + }; + const client = createClient(fetcher); + const friend = pendingFriend(); + + const error = await client.respondFriend(friend, true).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(ReefRelayError); + expect(error).toBeInstanceOf(ReefProtocolCompatibilityError); + expect(error).toMatchObject({ + status: 400, + code: "invalid_request", + upgradeRequired: "reef-relay", + message: + "The Reef relay is likely incompatible or outdated. Update OpenClaw and the Reef relay together, then approve the fresh pairing challenge again.", + }); + expect(calls).toHaveLength(1); + expect(JSON.parse(new TextDecoder().decode(calls[0]?.body as Uint8Array))).toEqual({ + peer: "bob", + accept: true, + expected_key_epoch: 2, + expected_ed25519_pub: "B".repeat(43), + expected_x25519_pub: "C".repeat(43), + }); + }); + + it("diagnoses an outdated OpenClaw client from the current relay response", async () => { + const client = createClient(async () => + Response.json({ error: "client_upgrade_required" }, { status: 409 }), + ); + + const error = await client + .respondFriend(pendingFriend(), true) + .catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(ReefRelayError); + expect(error).toBeInstanceOf(ReefProtocolCompatibilityError); + expect(error).toMatchObject({ + status: 409, + code: "client_upgrade_required", + upgradeRequired: "openclaw-client", + message: + "OpenClaw is outdated for this Reef relay. Update OpenClaw, then approve the fresh pairing challenge again.", + }); + }); + + it.each([ + { name: "an empty 204", response: () => new Response(null, { status: 204 }), accept: true }, + { name: "a primitive", response: () => Response.json("active"), accept: true }, + { name: "a malformed object", response: () => Response.json({ peer: "bob" }), accept: true }, + { + name: "a different peer", + response: () => Response.json({ peer: "mallory", status: "active" }), + accept: true, + }, + { + name: "the wrong accepted status", + response: () => Response.json({ peer: "bob", status: "blocked" }), + accept: true, + }, + { + name: "the wrong rejected status", + response: () => Response.json({ peer: "bob", status: "active" }), + accept: false, + }, + ])("rejects $name before friendship trust can be committed", async ({ response, accept }) => { + const client = createClient(async () => response()); + + await expect(client.respondFriend(pendingFriend(), accept)).rejects.toThrow( + "invalid Reef relay friendship response", + ); + }); + it("bumps ts monotonically so identical same-second requests never share a replay key", async () => { const seenTs: string[] = []; const fetcher: typeof fetch = async (_input, init) => { @@ -306,7 +388,7 @@ describe("ReefTransportClient response body bounds", () => { const error = await client.requestFriend("bob", "code").catch((cause: unknown) => cause); expect(error).toBeInstanceOf(ReefRelayError); - expect(error).toMatchObject({ status: 400 }); + expect(error).toMatchObject({ status: 400, code: undefined }); expect((error as Error).message).toHaveLength(ERROR_RESPONSE_MAX_BYTES - 12); expect(Buffer.byteLength(body)).toBe(ERROR_RESPONSE_MAX_BYTES); }); @@ -322,6 +404,7 @@ describe("ReefTransportClient response body bounds", () => { name: "ReefRelayError", status: 503, message: "relay HTTP 503", + code: undefined, }); expect(offered.state.cancelled).toBe(true); expect(offered.state.emittedBytes).toBeGreaterThan(64 * 1024); @@ -335,6 +418,18 @@ describe("ReefTransportClient response body bounds", () => { name: "ReefRelayError", status: 502, message: "relay HTTP 502", + code: undefined, + }); + }); + + it("keeps the status fallback when parsed error JSON has no relay code", async () => { + const client = createClient(async () => Response.json({ detail: "ignored" }, { status: 400 })); + + await expect(client.requestFriend("bob")).rejects.toMatchObject({ + name: "ReefRelayError", + status: 400, + message: "relay HTTP 400", + code: undefined, }); }); }); diff --git a/extensions/reef/src/transport.ts b/extensions/reef/src/transport.ts index 283c094fc859..3d3aead71510 100644 --- a/extensions/reef/src/transport.ts +++ b/extensions/reef/src/transport.ts @@ -2,6 +2,7 @@ import { toStringifiedError as asError } from "openclaw/plugin-sdk/error-runtime import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared"; import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import WebSocket from "ws"; import { sha256Hex, signDeviceRequest, utf8 } from "../protocol/index.js"; import type { Envelope, SignedReceipt } from "../protocol/index.js"; @@ -14,6 +15,7 @@ type FetchLike = typeof fetch; // force unbounded allocation through response.json(). const REEF_RELAY_JSON_MAX_BYTES = 16 * 1024 * 1024; const REEF_RELAY_ERROR_JSON_MAX_BYTES = 64 * 1024; +const REEF_RELAY_ERROR_CODE_PATTERN = /^[a-z][a-z0-9_]{0,127}$/; // Relay envelopes are capped at 48 KiB. Leave room for inbox metadata while // rejecting oversized or compressed frames before ws materializes the message. const REEF_RELAY_WEBSOCKET_MAX_PAYLOAD_BYTES = 64 * 1024; @@ -42,12 +44,25 @@ export class ReefRelayError extends Error { constructor( readonly status: number, message: string, + readonly code?: string, ) { super(message); this.name = "ReefRelayError"; } } +export class ReefProtocolCompatibilityError extends ReefRelayError { + constructor( + status: 400 | 409, + code: "invalid_request" | "client_upgrade_required", + readonly upgradeRequired: "reef-relay" | "openclaw-client", + message: string, + ) { + super(status, message, code); + this.name = "ReefProtocolCompatibilityError"; + } +} + class ReefRelayUnavailableError extends Error { constructor(cause: unknown) { super(cause instanceof Error ? cause.message : String(cause), { cause }); @@ -158,14 +173,51 @@ export class ReefTransportClient { code ? [code] : [], ); } - respondFriend(friend: RelayFriend, accept: boolean): Promise<{ peer: string; status: string }> { - return this.signed("POST", "/v1/friends/respond", { - peer: friend.peer, - accept, - expected_key_epoch: friend.key_epoch, - expected_ed25519_pub: friend.ed25519_pub, - expected_x25519_pub: friend.x25519_pub, - }); + async respondFriend( + friend: RelayFriend, + accept: boolean, + ): Promise<{ peer: string; status: "active" | "blocked" }> { + let result: unknown; + try { + result = await this.signed("POST", "/v1/friends/respond", { + peer: friend.peer, + accept, + expected_key_epoch: friend.key_epoch, + expected_ed25519_pub: friend.ed25519_pub, + expected_x25519_pub: friend.x25519_pub, + }); + } catch (error) { + if ( + error instanceof ReefRelayError && + error.status === 400 && + error.code === "invalid_request" + ) { + throw new ReefProtocolCompatibilityError( + 400, + error.code, + "reef-relay", + "The Reef relay is likely incompatible or outdated. Update OpenClaw and the Reef relay together, then approve the fresh pairing challenge again.", + ); + } + if ( + error instanceof ReefRelayError && + error.status === 409 && + error.code === "client_upgrade_required" + ) { + throw new ReefProtocolCompatibilityError( + 409, + error.code, + "openclaw-client", + "OpenClaw is outdated for this Reef relay. Update OpenClaw, then approve the fresh pairing challenge again.", + ); + } + throw error; + } + const status = accept ? "active" : "blocked"; + if (!isRecord(result) || result.peer !== friend.peer || result.status !== status) { + throw new Error("invalid Reef relay friendship response"); + } + return { peer: friend.peer, status }; } listFriends(): Promise<{ friendships: RelayFriend[] }> { return this.signed("GET", "/v1/friends"); @@ -275,14 +327,16 @@ export class ReefTransportClient { } if (!response.ok) { let message = `relay HTTP ${response.status}`; + let code: string | undefined; try { - const parsed = await readProviderJsonResponse<{ error?: string }>( - response, - "reef.relay.error", - { maxBytes: REEF_RELAY_ERROR_JSON_MAX_BYTES }, - ); - if (typeof parsed.error === "string" && parsed.error) { + const parsed = await readProviderJsonResponse(response, "reef.relay.error", { + maxBytes: REEF_RELAY_ERROR_JSON_MAX_BYTES, + }); + if (isRecord(parsed) && typeof parsed.error === "string" && parsed.error) { message = redactReefRelayErrorMessage(parsed.error, secrets); + if (REEF_RELAY_ERROR_CODE_PATTERN.test(parsed.error)) { + code = parsed.error; + } } } catch { if (timeout.signal?.aborted) { @@ -291,7 +345,7 @@ export class ReefTransportClient { // Keep the status fallback when the error body is missing, malformed, // or oversized; callers still get a typed ReefRelayError. } - throw new ReefRelayError(response.status, message); + throw new ReefRelayError(response.status, message, code); } if (response.status === 204) { return undefined as T;