fix(reef): diagnose relay protocol skew (#123498)

This commit is contained in:
Peter Steinberger
2026-08-13 22:47:53 -07:00
committed by GitHub
parent d9646ad5d5
commit ee258beba6
4 changed files with 181 additions and 27 deletions
+1
View File
@@ -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/).
+5 -1
View File
@@ -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";
+106 -11
View File
@@ -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,
});
});
});
+69 -15
View File
@@ -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<unknown>(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;