mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
fix(reef): diagnose relay protocol skew (#123498)
This commit is contained in:
committed by
GitHub
parent
d9646ad5d5
commit
ee258beba6
@@ -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/).
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user