refactor(gateway): consolidate client contracts (#101012)

This commit is contained in:
Dallin Romney
2026-07-08 09:56:31 -07:00
committed by GitHub
parent 534ace4d8a
commit 8fa83d2742
11 changed files with 208 additions and 239 deletions
+25 -71
View File
@@ -2,10 +2,10 @@
import { randomUUID } from "node:crypto";
import type {
ConnectParams,
ErrorShape,
EventFrame,
HelloOk,
RequestFrame,
ResponseFrame,
} from "@openclaw/gateway-protocol";
import {
GATEWAY_CLIENT_MODES,
@@ -21,6 +21,10 @@ import {
readPairingConnectErrorDetails,
type ConnectErrorRecoveryAdvice,
} from "@openclaw/gateway-protocol/connect-error-details";
import {
isGatewayEventFrame,
isGatewayResponseFrame,
} from "@openclaw/gateway-protocol/frame-guards";
import { resolveGatewayStartupRetryAfterMs } from "@openclaw/gateway-protocol/startup-unavailable";
import { MIN_CLIENT_PROTOCOL_VERSION, PROTOCOL_VERSION } from "@openclaw/gateway-protocol/version";
import ipaddr from "ipaddr.js";
@@ -78,63 +82,6 @@ function normalizeOptionalString(value: unknown): string | undefined {
return trimmed || undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
function isGatewayClientErrorShape(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
if (!isNonEmptyString(value.code) || !isNonEmptyString(value.message)) {
return false;
}
if (value.retryable !== undefined && typeof value.retryable !== "boolean") {
return false;
}
if (value.retryAfterMs !== undefined && !isNonNegativeInteger(value.retryAfterMs)) {
return false;
}
return true;
}
function isGatewayEventFrame(value: unknown): value is EventFrame {
if (!isRecord(value) || value.type !== "event" || !isNonEmptyString(value.event)) {
return false;
}
return value.seq === undefined || isNonNegativeInteger(value.seq);
}
function isGatewayResponseFrame(value: unknown): value is ResponseFrame {
if (
!isRecord(value) ||
value.type !== "res" ||
!isNonEmptyString(value.id) ||
typeof value.ok !== "boolean"
) {
return false;
}
return value.error === undefined || isGatewayClientErrorShape(value.error);
}
function validateClientRequestFrame(frame: RequestFrame): string | null {
if (!isNonEmptyString(frame.id)) {
return "id must be a non-empty string";
}
if (!isNonEmptyString(frame.method)) {
return "method must be a non-empty string";
}
return null;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
@@ -314,14 +261,6 @@ export type GatewayClientRequestOptions = {
onAccepted?: (payload: unknown) => void;
};
type GatewayClientErrorShape = {
code?: string;
message?: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
type SelectedConnectAuth = {
authToken?: string;
authBootstrapToken?: string;
@@ -376,7 +315,7 @@ export class GatewayClientRequestError extends Error {
readonly retryable: boolean;
readonly retryAfterMs?: number;
constructor(error: GatewayClientErrorShape) {
constructor(error: Partial<ErrorShape>) {
super(formatConnectErrorMessage({ message: error.message, details: error.details }));
this.name = "GatewayClientRequestError";
this.gatewayCode = error.code ?? "UNAVAILABLE";
@@ -461,6 +400,13 @@ export type GatewayClientOptions = {
onGap?: (info: { expected: number; received: number }) => void;
};
export type GatewayClientConnectionMetadata = {
clientName?: GatewayClientName;
hasDeviceIdentity: boolean;
mode?: GatewayClientMode;
preauthHandshakeTimeoutMs?: number;
};
export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
1000: "normal closure",
1006: "abnormal closure (no close frame)",
@@ -595,6 +541,15 @@ export class GatewayClient {
: 30_000;
}
getConnectionMetadata(): GatewayClientConnectionMetadata {
return {
clientName: this.opts.clientName,
hasDeviceIdentity: Boolean(this.opts.deviceIdentity),
mode: this.opts.mode,
preauthHandshakeTimeoutMs: this.opts.preauthHandshakeTimeoutMs,
};
}
start() {
if (this.closed) {
return;
@@ -1639,12 +1594,11 @@ export class GatewayClient {
if (opts?.signal?.aborted) {
throw createGatewayRequestAbortError(method);
}
if (typeof method !== "string" || method.length === 0) {
throw new Error("invalid request frame: method must be a non-empty string");
}
const id = randomUUID();
const frame: RequestFrame = { type: "req", id, method, params };
const requestFrameError = validateClientRequestFrame(frame);
if (requestFrameError) {
throw new Error(`invalid request frame: ${requestFrameError}`);
}
const expectFinal = opts?.expectFinal === true;
const timeoutMs =
opts?.timeoutMs === null
@@ -149,6 +149,26 @@ describe("GatewayClient", () => {
).toBe(6_000);
});
test("returns non-sensitive connection metadata", () => {
const client = new GatewayClient({
clientName: "cli",
mode: "backend",
preauthHandshakeTimeoutMs: 30_000,
deviceIdentity: {
deviceId: "device-1",
privateKeyPem: "private-key",
publicKeyPem: "public-key",
},
});
expect(client.getConnectionMetadata()).toEqual({
clientName: "cli",
hasDeviceIdentity: true,
mode: "backend",
preauthHandshakeTimeoutMs: 30_000,
});
});
test("closes on missing ticks", async () => {
const port = await getFreePort();
wss = new WebSocketServer({ port, host: "127.0.0.1" });
+6 -1
View File
@@ -24,6 +24,11 @@
"import": "./dist/connect-error-details.mjs",
"default": "./dist/connect-error-details.mjs"
},
"./frame-guards": {
"types": "./dist/frame-guards.d.mts",
"import": "./dist/frame-guards.mjs",
"default": "./dist/frame-guards.mjs"
},
"./schema": {
"types": "./dist/schema.d.mts",
"import": "./dist/schema.mjs",
@@ -41,7 +46,7 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/client-info.ts src/connect-error-details.ts src/frame-guards.ts src/schema.ts src/startup-unavailable.ts src/version.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"typebox": "1.3.3"
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { isGatewayEventFrame, isGatewayResponseFrame } from "./frame-guards.js";
describe("gateway frame guards", () => {
it("accepts additive event fields while validating dispatch fields", () => {
expect(
isGatewayEventFrame({
type: "event",
event: "tick",
seq: 0,
payload: { future: true },
futureEnvelopeField: true,
}),
).toBe(true);
expect(isGatewayEventFrame({ type: "event", event: "", seq: 0 })).toBe(false);
expect(isGatewayEventFrame({ type: "event", event: "tick", seq: -1 })).toBe(false);
});
it("accepts additive response fields while validating errors", () => {
expect(
isGatewayResponseFrame({
type: "res",
id: "request-1",
ok: false,
error: {
code: "UNAVAILABLE",
message: "try later",
retryable: true,
retryAfterMs: 10,
},
futureEnvelopeField: true,
}),
).toBe(true);
expect(
isGatewayResponseFrame({
type: "res",
id: "request-1",
ok: false,
error: { code: "UNAVAILABLE", message: "", retryAfterMs: 10 },
}),
).toBe(false);
expect(
isGatewayResponseFrame({
type: "res",
id: "request-1",
ok: false,
error: { code: "UNAVAILABLE", message: "try later", retryAfterMs: -1 },
}),
).toBe(false);
});
});
@@ -0,0 +1,47 @@
import type { EventFrame, ResponseFrame } from "./schema/types.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
function isGatewayErrorShape(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
if (!isNonEmptyString(value.code) || !isNonEmptyString(value.message)) {
return false;
}
if (value.retryable !== undefined && typeof value.retryable !== "boolean") {
return false;
}
return value.retryAfterMs === undefined || isNonNegativeInteger(value.retryAfterMs);
}
// These lightweight guards validate dispatch-critical envelope fields without
// compiling the full schemas or rejecting additive payload fields.
export function isGatewayEventFrame(value: unknown): value is EventFrame {
if (!isRecord(value) || value.type !== "event" || !isNonEmptyString(value.event)) {
return false;
}
return value.seq === undefined || isNonNegativeInteger(value.seq);
}
export function isGatewayResponseFrame(value: unknown): value is ResponseFrame {
if (
!isRecord(value) ||
value.type !== "res" ||
!isNonEmptyString(value.id) ||
typeof value.ok !== "boolean"
) {
return false;
}
return value.error === undefined || isGatewayErrorShape(value.error);
}
+25 -167
View File
@@ -1,24 +1,17 @@
// OpenClaw Gateway client facade.
// Wraps the shared gateway-client package with OpenClaw host dependencies.
import {
GatewayClient as BaseGatewayClient,
GATEWAY_CLOSE_CODE_HINTS as BASE_GATEWAY_CLOSE_CODE_HINTS,
GatewayClientRequestError as BaseGatewayClientRequestError,
describeGatewayCloseCode as baseDescribeGatewayCloseCode,
isGatewayConnectAssemblyError as baseIsGatewayConnectAssemblyError,
resolveGatewayClientConnectChallengeTimeoutMs as baseResolveGatewayClientConnectChallengeTimeoutMs,
} from "../../packages/gateway-client/src/index.js";
// Injects OpenClaw host dependencies into the shared gateway-client package.
import { GatewayClient as BaseGatewayClient } from "../../packages/gateway-client/src/index.js";
import type {
GatewayClientMode,
GatewayClientName,
} from "../../packages/gateway-protocol/src/client-info.js";
import type { EventFrame, HelloOk } from "../../packages/gateway-protocol/src/index.js";
GatewayClientConnectionMetadata,
GatewayClientHostDeps,
GatewayClientOptions,
GatewayClientRequestOptions,
} from "../../packages/gateway-client/src/index.js";
import {
clearDeviceAuthToken,
loadDeviceAuthToken,
storeDeviceAuthToken,
} from "../infra/device-auth-store.js";
import type { DeviceIdentity } from "../infra/device-identity.js";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
@@ -33,139 +26,23 @@ import { logDebug, logError } from "../logger.js";
import { redactToolPayloadText } from "../logging/redact.js";
import { VERSION } from "../version.js";
export type DeviceAuthTokenRecord = {
token?: string;
scopes?: string[];
};
export type GatewayClientHostDeps = {
loadOrCreateDeviceIdentity?: () => DeviceIdentity | undefined;
signDevicePayload?: (privateKeyPem: string, payload: string) => string;
publicKeyRawBase64UrlFromPem?: (publicKeyPem: string) => string;
loadDeviceAuthToken?: (params: {
deviceId: string;
role: string;
env?: NodeJS.ProcessEnv;
}) => DeviceAuthTokenRecord | null;
storeDeviceAuthToken?: (params: {
deviceId: string;
role: string;
token: string;
scopes: string[];
env?: NodeJS.ProcessEnv;
}) => void;
clearDeviceAuthToken?: (params: {
deviceId: string;
role: string;
env?: NodeJS.ProcessEnv;
}) => void;
beforeConnect?: () => void;
registerGatewayLoopbackBypass?: (url: string) => (() => void) | undefined;
logDebug?: (message: string) => void;
logError?: (message: string) => void;
redactForLog?: (message: string) => string;
normalizeTlsFingerprint?: (fingerprint: string | undefined) => string;
};
export type GatewayClientRequestOptions = {
expectFinal?: boolean;
timeoutMs?: number | null;
signal?: AbortSignal;
onAccepted?: (payload: unknown) => void;
};
export type GatewayReconnectPausedInfo = {
code: number;
reason: string;
detailCode: string | null;
};
export type GatewayClientCloseInfo = {
phase: "pre-hello" | "post-hello";
socketOpened: boolean;
transportValidated: boolean;
transientPreHelloCleanClose: boolean;
};
type GatewayClientErrorShape = {
message: string;
code?: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> =
BASE_GATEWAY_CLOSE_CODE_HINTS;
export const GatewayClientRequestError = BaseGatewayClientRequestError as unknown as {
new (error: GatewayClientErrorShape): Error & {
readonly gatewayCode: string;
readonly details?: unknown;
readonly retryable: boolean;
readonly retryAfterMs?: number;
};
};
export type GatewayClientRequestError = InstanceType<typeof GatewayClientRequestError>;
export function describeGatewayCloseCode(code: number): string | undefined {
return baseDescribeGatewayCloseCode(code);
}
export function isGatewayConnectAssemblyError(value: unknown): value is Error {
return baseIsGatewayConnectAssemblyError(value);
}
export type GatewayClientOptions = {
url?: string;
origin?: string;
connectChallengeTimeoutMs?: number;
/** @deprecated Use connectChallengeTimeoutMs. */
connectDelayMs?: number;
preauthHandshakeTimeoutMs?: number;
tickWatchMinIntervalMs?: number;
tickWatchTimeoutMs?: number;
requestTimeoutMs?: number;
token?: string;
bootstrapToken?: string;
deviceToken?: string;
password?: string;
approvalRuntimeToken?: string;
agentRuntimeIdentityToken?: string;
instanceId?: string;
clientName?: GatewayClientName;
clientDisplayName?: string;
clientVersion?: string;
platform?: string;
deviceFamily?: string;
mode?: GatewayClientMode;
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
pathEnv?: string;
env?: NodeJS.ProcessEnv;
deviceIdentity?: DeviceIdentity | null;
hostDeps?: GatewayClientHostDeps;
minProtocol?: number;
maxProtocol?: number;
tlsFingerprint?: string;
onEvent?: (evt: EventFrame) => void;
onHelloOk?: (hello: HelloOk) => void;
onConnectError?: (err: Error) => void;
onReconnectPaused?: (info: GatewayReconnectPausedInfo) => void;
onClose?: (code: number, reason: string, info?: GatewayClientCloseInfo) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
export type GatewayClientConnectionMetadata = {
clientName?: GatewayClientName;
hasDeviceIdentity: boolean;
mode?: GatewayClientMode;
preauthHandshakeTimeoutMs?: number;
};
export {
GATEWAY_CLOSE_CODE_HINTS,
GatewayClientRequestError,
describeGatewayCloseCode,
isGatewayConnectAssemblyError,
resolveGatewayClientConnectChallengeTimeoutMs,
} from "../../packages/gateway-client/src/index.js";
export type {
DeviceAuthTokenRecord,
DeviceIdentity,
GatewayClientCloseInfo,
GatewayClientConnectionMetadata,
GatewayClientHostDeps,
GatewayClientOptions,
GatewayClientRequestOptions,
GatewayReconnectPausedInfo,
} from "../../packages/gateway-client/src/index.js";
function createOpenClawGatewayClientHostDeps(
overrides?: GatewayClientHostDeps,
@@ -189,21 +66,10 @@ function createOpenClawGatewayClientHostDeps(
};
}
export function resolveGatewayClientConnectChallengeTimeoutMs(
opts: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
>,
): number {
return baseResolveGatewayClientConnectChallengeTimeoutMs(opts);
}
export class GatewayClient {
#client: BaseGatewayClient;
constructor(opts: GatewayClientOptions) {
// Inject host deps here so the reusable package stays decoupled from
// OpenClaw device identity, token storage, proxy routing, and logging.
this.#client = new BaseGatewayClient({
...opts,
clientVersion: opts.clientVersion ?? VERSION,
@@ -232,14 +98,6 @@ export class GatewayClient {
}
getConnectionMetadata(): GatewayClientConnectionMetadata {
const opts = (this.#client as unknown as { opts: GatewayClientOptions }).opts;
return {
clientName: opts.clientName,
hasDeviceIdentity: Boolean(opts.deviceIdentity),
mode: opts.mode,
preauthHandshakeTimeoutMs: opts.preauthHandshakeTimeoutMs,
};
return this.#client.getConnectionMetadata();
}
}
export type { DeviceIdentity };
+19
View File
@@ -1509,6 +1509,12 @@ describe("plugin sdk alias helpers", () => {
srcFile: "schema.ts",
distFile: "schema.mjs",
});
const gatewayProtocolFrameGuards = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "gateway-protocol",
srcFile: "frame-guards.ts",
distFile: "frame-guards.mjs",
});
const netPolicy = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "net-policy",
@@ -1615,6 +1621,7 @@ describe("plugin sdk alias helpers", () => {
fs.rmSync(gatewayClientTimeouts.distFile);
fs.rmSync(gatewayProtocol.distFile);
fs.rmSync(gatewayProtocolSchema.distFile);
fs.rmSync(gatewayProtocolFrameGuards.distFile);
fs.rmSync(markdownCore.distFile);
fs.rmSync(markdownCoreTables.distFile);
fs.rmSync(mediaGenerationCore.distFile);
@@ -1653,6 +1660,9 @@ describe("plugin sdk alias helpers", () => {
expect(fs.realpathSync(aliases["@openclaw/gateway-protocol/schema"] ?? "")).toBe(
fs.realpathSync(gatewayProtocolSchema.srcFile),
);
expect(fs.realpathSync(aliases["@openclaw/gateway-protocol/frame-guards"] ?? "")).toBe(
fs.realpathSync(gatewayProtocolFrameGuards.srcFile),
);
expect(fs.realpathSync(aliases["@openclaw/markdown-core"] ?? "")).toBe(
fs.realpathSync(markdownCore.srcFile),
);
@@ -1720,6 +1730,12 @@ describe("plugin sdk alias helpers", () => {
srcFile: "connect-error-details.ts",
distFile: "connect-error-details.mjs",
});
const gatewayProtocolFrameGuards = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "gateway-protocol",
srcFile: "frame-guards.ts",
distFile: "frame-guards.mjs",
});
const mediaGenerationCore = writeWorkspacePackageEntry({
root: fixture.root,
packageDir: "media-generation-core",
@@ -1792,6 +1808,9 @@ describe("plugin sdk alias helpers", () => {
expect(fs.realpathSync(aliases["@openclaw/gateway-protocol/connect-error-details"] ?? "")).toBe(
fs.realpathSync(gatewayProtocol.distFile),
);
expect(fs.realpathSync(aliases["@openclaw/gateway-protocol/frame-guards"] ?? "")).toBe(
fs.realpathSync(gatewayProtocolFrameGuards.distFile),
);
expect(fs.realpathSync(aliases["@openclaw/markdown-core/render"] ?? "")).toBe(
fs.realpathSync(markdownCore.distFile),
);
+7
View File
@@ -572,6 +572,13 @@ const WORKSPACE_PACKAGE_ALIAS_ENTRIES: WorkspacePackageAliasEntry[] = [
srcFile: "connect-error-details.ts",
distFile: "connect-error-details.mjs",
},
{
packageName: "@openclaw/gateway-protocol",
packageDir: "gateway-protocol",
subpath: "frame-guards",
srcFile: "frame-guards.ts",
distFile: "frame-guards.mjs",
},
{
packageName: "@openclaw/gateway-protocol",
packageDir: "gateway-protocol",
+4
View File
@@ -213,6 +213,10 @@ export const sharedVitestConfig = {
"connect-error-details.ts",
),
},
{
find: "@openclaw/gateway-protocol/frame-guards",
replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "frame-guards.ts"),
},
{
find: "@openclaw/gateway-protocol/schema",
replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "schema.ts"),
+3
View File
@@ -73,6 +73,9 @@
"@openclaw/gateway-protocol/connect-error-details": [
"./packages/gateway-protocol/src/connect-error-details.ts"
],
"@openclaw/gateway-protocol/frame-guards": [
"./packages/gateway-protocol/src/frame-guards.ts"
],
"@openclaw/gateway-protocol/schema": ["./packages/gateway-protocol/src/schema.ts"],
"@openclaw/gateway-protocol/startup-unavailable": [
"./packages/gateway-protocol/src/startup-unavailable.ts"
+1
View File
@@ -367,6 +367,7 @@ function buildGatewayProtocolDistEntries(): Record<string, string> {
index: "packages/gateway-protocol/src/index.ts",
"client-info": "packages/gateway-protocol/src/client-info.ts",
"connect-error-details": "packages/gateway-protocol/src/connect-error-details.ts",
"frame-guards": "packages/gateway-protocol/src/frame-guards.ts",
schema: "packages/gateway-protocol/src/schema.ts",
"startup-unavailable": "packages/gateway-protocol/src/startup-unavailable.ts",
version: "packages/gateway-protocol/src/version.ts",