diff --git a/packages/gateway-client/src/client.ts b/packages/gateway-client/src/client.ts index 177777be7463..90fed8911326 100644 --- a/packages/gateway-client/src/client.ts +++ b/packages/gateway-client/src/client.ts @@ -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 { - 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) { 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> = { 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 diff --git a/packages/gateway-client/src/client.watchdog.test.ts b/packages/gateway-client/src/client.watchdog.test.ts index b20de4436329..fd67a6461ce5 100644 --- a/packages/gateway-client/src/client.watchdog.test.ts +++ b/packages/gateway-client/src/client.watchdog.test.ts @@ -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" }); diff --git a/packages/gateway-protocol/package.json b/packages/gateway-protocol/package.json index 2476736f966e..fcda431948d7 100644 --- a/packages/gateway-protocol/package.json +++ b/packages/gateway-protocol/package.json @@ -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" diff --git a/packages/gateway-protocol/src/frame-guards.test.ts b/packages/gateway-protocol/src/frame-guards.test.ts new file mode 100644 index 000000000000..3e6d14ee8b64 --- /dev/null +++ b/packages/gateway-protocol/src/frame-guards.test.ts @@ -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); + }); +}); diff --git a/packages/gateway-protocol/src/frame-guards.ts b/packages/gateway-protocol/src/frame-guards.ts new file mode 100644 index 000000000000..8df10e77dcc4 --- /dev/null +++ b/packages/gateway-protocol/src/frame-guards.ts @@ -0,0 +1,47 @@ +import type { EventFrame, ResponseFrame } from "./schema/types.js"; + +function isRecord(value: unknown): value is Record { + 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); +} diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 3810a0584b00..be839cb1651b 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -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> = - 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; - -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; - 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 }; diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index f87bd2aee96f..2b22e467137f 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -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), ); diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index fb0fc581e856..bcf0812c2e3c 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -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", diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 4644ee364f0d..8d8b7ab2f54b 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -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"), diff --git a/tsconfig.json b/tsconfig.json index 69aa173de5b1..f99239ac90f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" diff --git a/tsdown.config.ts b/tsdown.config.ts index 88abe4e5e4f5..9b762362a9a3 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -367,6 +367,7 @@ function buildGatewayProtocolDistEntries(): Record { 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",