fix(gateway): pin paired reconnect metadata for node policy

This commit is contained in:
Peter Steinberger
2026-02-26 14:10:00 +01:00
parent cf311978ea
commit 7d8aeaaf06
13 changed files with 282 additions and 39 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth.
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
- Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.
- Onboarding/Gateway: seed default Control UI `allowedOrigins` for non-loopback binds during onboarding (`localhost`/`127.0.0.1` plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.
- CLI/Gateway status: force local `gateway status` probe host to `127.0.0.1` for `bind=lan` so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.
- Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.

View File

@@ -98,6 +98,9 @@ sequenceDiagram
- **Local** connects (loopback or the gateway hosts own tailnet address) can be
autoapproved to keep samehost UX smooth.
- All connects must sign the `connect.challenge` nonce.
- Signature payload `v3` also binds `platform` + `deviceFamily`; the gateway
pins paired metadata on reconnect and requires repair pairing for metadata
changes.
- **Nonlocal** connects still require explicit approval.
- Gateway auth (`gateway.auth.*`) still applies to **all** connections, local or
remote.

View File

@@ -215,6 +215,10 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
Control UI can omit it **only** when `gateway.controlUi.dangerouslyDisableDeviceAuth`
is enabled for break-glass use.
- All connections must sign the server-provided `connect.challenge` nonce.
- Preferred signature payload is `v3`, which binds `platform` and `deviceFamily`
in addition to device/client/role/scopes/token/nonce fields.
- Legacy `v2` signatures remain accepted for compatibility, but paired-device
metadata pinning still controls command policy on reconnect.
## TLS + pinning

View File

@@ -21,7 +21,7 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { isSecureWebSocketUrl } from "./net.js";
import {
type ConnectParams,
@@ -52,6 +52,7 @@ export type GatewayClientOptions = {
clientDisplayName?: string;
clientVersion?: string;
platform?: string;
deviceFamily?: string;
mode?: GatewayClientMode;
role?: string;
scopes?: string[];
@@ -265,11 +266,12 @@ export class GatewayClient {
: undefined;
const signedAtMs = Date.now();
const scopes = this.opts.scopes ?? ["operator.admin"];
const platform = this.opts.platform ?? process.platform;
const device = (() => {
if (!this.opts.deviceIdentity) {
return undefined;
}
const payload = buildDeviceAuthPayload({
const payload = buildDeviceAuthPayloadV3({
deviceId: this.opts.deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
@@ -278,6 +280,8 @@ export class GatewayClient {
signedAtMs,
token: authToken ?? null,
nonce,
platform,
deviceFamily: this.opts.deviceFamily,
});
const signature = signDevicePayload(this.opts.deviceIdentity.privateKeyPem, payload);
return {
@@ -295,7 +299,8 @@ export class GatewayClient {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
displayName: this.opts.clientDisplayName,
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? process.platform,
platform,
deviceFamily: this.opts.deviceFamily,
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
instanceId: this.opts.instanceId,
},

View File

@@ -9,6 +9,18 @@ export type DeviceAuthPayloadParams = {
nonce: string;
};
export type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & {
platform?: string | null;
deviceFamily?: string | null;
};
function normalizeMetadataField(value?: string | null): string {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
}
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
@@ -24,3 +36,23 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
params.nonce,
].join("|");
}
export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
const platform = normalizeMetadataField(params.platform);
const deviceFamily = normalizeMetadataField(params.deviceFamily);
return [
"v3",
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
params.nonce,
platform,
deviceFamily,
].join("|");
}

View File

@@ -42,6 +42,7 @@ export const DevicePairRequestedEventSchema = Type.Object(
publicKey: NonEmptyString,
displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
clientId: Type.Optional(NonEmptyString),
clientMode: Type.Optional(NonEmptyString),
role: Type.Optional(NonEmptyString),

View File

@@ -1,3 +1,5 @@
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { withEnvAsync } from "../test-utils/env.js";
@@ -267,19 +269,24 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
} as any;
const { server, ws, port, prevToken } = await startServerWithClient();
const deviceIdentityPath = path.join(
os.tmpdir(),
`openclaw-auth-rate-limit-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
try {
const initial = await connectReq(ws, { token: "secret" });
const initial = await connectReq(ws, { token: "secret", deviceIdentityPath });
if (!initial.ok) {
await approvePendingPairingIfNeeded();
}
const identity = loadOrCreateDeviceIdentity();
const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token;
expect(paired?.deviceId).toBe(identity.deviceId);
expect(deviceToken).toBeDefined();
ws.close();
return { server, port, prevToken, deviceToken: String(deviceToken ?? "") };
return { server, port, prevToken, deviceToken: String(deviceToken ?? ""), deviceIdentityPath };
} catch (err) {
ws.close();
await server.close();
@@ -291,20 +298,31 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise<{
identity: { deviceId: string };
deviceToken: string;
deviceIdentityPath: string;
}> {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { getPairedDevice } = await import("../infra/device-pairing.js");
const res = await connectReq(ws, { token: "secret" });
const deviceIdentityPath = path.join(
os.tmpdir(),
`openclaw-auth-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
const res = await connectReq(ws, { token: "secret", deviceIdentityPath });
if (!res.ok) {
await approvePendingPairingIfNeeded();
}
const identity = loadOrCreateDeviceIdentity();
const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
const paired = await getPairedDevice(identity.deviceId);
const deviceToken = paired?.tokens?.operator?.token;
expect(paired?.deviceId).toBe(identity.deviceId);
expect(deviceToken).toBeDefined();
return { identity: { deviceId: identity.deviceId }, deviceToken: String(deviceToken ?? "") };
return {
identity: { deviceId: identity.deviceId },
deviceToken: String(deviceToken ?? ""),
deviceIdentityPath,
};
}
describe("gateway server auth/connect", () => {
@@ -328,7 +346,7 @@ describe("gateway server auth/connect", () => {
try {
const ws = await openWs(port);
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 60);
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 500);
expect(closed).toBe(true);
} finally {
if (prevHandshakeTimeout === undefined) {
@@ -1042,7 +1060,7 @@ describe("gateway server auth/connect", () => {
test("device token auth matrix", async () => {
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const { deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
const { deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
ws.close();
const scenarios: Array<{
@@ -1109,7 +1127,10 @@ describe("gateway server auth/connect", () => {
for (const scenario of scenarios) {
const ws2 = await openWs(port);
try {
const res = await connectReq(ws2, scenario.opts);
const res = await connectReq(ws2, {
...scenario.opts,
deviceIdentityPath,
});
scenario.assert(res);
} finally {
ws2.close();
@@ -1122,7 +1143,7 @@ describe("gateway server auth/connect", () => {
});
test("keeps shared-secret lockout separate from device-token auth", async () => {
const { server, port, prevToken, deviceToken } =
const { server, port, prevToken, deviceToken, deviceIdentityPath } =
await startRateLimitedTokenServerWithPairedDeviceToken();
try {
const wsBadShared = await openWs(port);
@@ -1137,7 +1158,7 @@ describe("gateway server auth/connect", () => {
wsSharedLocked.close();
const wsDevice = await openWs(port);
const deviceOk = await connectReq(wsDevice, { token: deviceToken });
const deviceOk = await connectReq(wsDevice, { token: deviceToken, deviceIdentityPath });
expect(deviceOk.ok).toBe(true);
wsDevice.close();
} finally {
@@ -1147,16 +1168,16 @@ describe("gateway server auth/connect", () => {
});
test("keeps device-token lockout separate from shared-secret auth", async () => {
const { server, port, prevToken, deviceToken } =
const { server, port, prevToken, deviceToken, deviceIdentityPath } =
await startRateLimitedTokenServerWithPairedDeviceToken();
try {
const wsBadDevice = await openWs(port);
const badDevice = await connectReq(wsBadDevice, { token: "wrong" });
const badDevice = await connectReq(wsBadDevice, { token: "wrong", deviceIdentityPath });
expect(badDevice.ok).toBe(false);
wsBadDevice.close();
const wsDeviceLocked = await openWs(port);
const deviceLocked = await connectReq(wsDeviceLocked, { token: "wrong" });
const deviceLocked = await connectReq(wsDeviceLocked, { token: "wrong", deviceIdentityPath });
expect(deviceLocked.ok).toBe(false);
expect(deviceLocked.error?.message ?? "").toContain("retry later");
wsDeviceLocked.close();
@@ -1167,7 +1188,10 @@ describe("gateway server auth/connect", () => {
wsShared.close();
const wsDeviceReal = await openWs(port);
const deviceStillLocked = await connectReq(wsDeviceReal, { token: deviceToken });
const deviceStillLocked = await connectReq(wsDeviceReal, {
token: deviceToken,
deviceIdentityPath,
});
expect(deviceStillLocked.ok).toBe(false);
expect(deviceStillLocked.error?.message ?? "").toContain("retry later");
wsDeviceReal.close();
@@ -1686,14 +1710,15 @@ describe("gateway server auth/connect", () => {
test("rejects revoked device token", async () => {
const { revokeDeviceToken } = await import("../infra/device-pairing.js");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
const { identity, deviceToken } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
const { identity, deviceToken, deviceIdentityPath } =
await ensurePairedDeviceTokenForCurrentIdentity(ws);
await revokeDeviceToken({ deviceId: identity.deviceId, role: "operator" });
ws.close();
const ws2 = await openWs(port);
const res2 = await connectReq(ws2, { token: deviceToken });
const res2 = await connectReq(ws2, { token: deviceToken, deviceIdentityPath });
expect(res2.ok).toBe(false);
ws2.close();

View File

@@ -202,6 +202,7 @@ describe("node.invoke approval bypass", () => {
readyResolve = resolve;
});
const resolvedDeviceIdentity = deviceIdentity ?? createDeviceIdentity();
const client = new GatewayClient({
url: `ws://127.0.0.1:${port}`,
// Keep challenge timeout realistic in tests; 0 maps to a 250ms timeout and can
@@ -215,7 +216,7 @@ describe("node.invoke approval bypass", () => {
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: ["system.run"],
deviceIdentity,
deviceIdentity: resolvedDeviceIdentity,
onHelloOk: () => readyResolve?.(),
onEvent: (evt) => {
if (evt.event !== "node.invoke.request") {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { CONFIG_PATH } from "../config/config.js";
import type { DeviceIdentity } from "../infra/device-identity.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import type { GatewayClient } from "./client.js";
@@ -36,6 +37,9 @@ installConnectedControlUiServerSuite((started) => {
const connectNodeClient = async (params: {
port: number;
commands: string[];
platform?: string;
deviceFamily?: string;
deviceIdentity?: DeviceIdentity;
instanceId?: string;
displayName?: string;
onEvent?: (evt: { event?: string; payload?: unknown }) => void;
@@ -51,11 +55,13 @@ const connectNodeClient = async (params: {
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientVersion: "1.0.0",
clientDisplayName: params.displayName,
platform: "ios",
platform: params.platform ?? "ios",
deviceFamily: params.deviceFamily,
mode: GATEWAY_CLIENT_MODES.NODE,
instanceId: params.instanceId,
scopes: [],
commands: params.commands,
deviceIdentity: params.deviceIdentity,
onEvent: params.onEvent,
timeoutMessage: "timeout waiting for node to connect",
});
@@ -313,4 +319,51 @@ describe("gateway node command allowlist", () => {
allowedClient?.stop();
}
});
test("rejects reconnect metadata spoof for paired node devices", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const deviceIdentityPath = path.join(
os.tmpdir(),
`openclaw-spoof-test-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath);
let iosClient: GatewayClient | undefined;
try {
iosClient = await connectNodeClientWithPairing({
port,
commands: ["canvas.snapshot"],
platform: "ios",
deviceFamily: "iPhone",
instanceId: "node-platform-pin",
displayName: "node-platform-pin",
deviceIdentity,
});
iosClient.stop();
await expect
.poll(async () => {
const listRes = await rpcReq<{ nodes?: Array<{ connected?: boolean }> }>(
ws,
"node.list",
{},
);
return (listRes.payload?.nodes ?? []).filter((node) => node.connected).length;
}, FAST_WAIT_OPTS)
.toBe(0);
await expect(
connectNodeClient({
port,
commands: ["system.run"],
platform: "linux",
deviceFamily: "linux",
instanceId: "node-platform-pin",
displayName: "node-platform-pin",
deviceIdentity,
}),
).rejects.toThrow(/pairing required/i);
} finally {
iosClient?.stop();
}
});
});

View File

@@ -32,7 +32,7 @@ import {
CANVAS_CAPABILITY_TTL_MS,
mintCanvasCapabilityToken,
} from "../../canvas-capability.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { buildDeviceAuthPayload, buildDeviceAuthPayloadV3 } from "../../device-auth.js";
import {
isLocalishHost,
isLoopbackAddress,
@@ -122,7 +122,7 @@ function shouldAllowSilentLocalPairing(params: {
hasBrowserOriginHeader: boolean;
isControlUi: boolean;
isWebchat: boolean;
reason: "not-paired" | "role-upgrade" | "scope-upgrade";
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade";
}): boolean {
return (
params.isLocalClient &&
@@ -131,6 +131,10 @@ function shouldAllowSilentLocalPairing(params: {
);
}
function normalizeClientMetadataForComparison(value: string | undefined): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function attachGatewayWsMessageHandler(params: {
socket: WebSocket;
upgradeReq: IncomingMessage;
@@ -416,6 +420,7 @@ export function attachGatewayWsMessageHandler(params: {
const deviceRaw = connectParams.device;
let devicePublicKey: string | null = null;
let deviceAuthPayloadVersion: "v2" | "v3" | null = null;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
const hasSharedAuth = hasTokenAuth || hasPasswordAuth;
@@ -583,7 +588,19 @@ export function attachGatewayWsMessageHandler(params: {
rejectDeviceAuthInvalid("device-nonce-mismatch", "device nonce mismatch");
return;
}
const payload = buildDeviceAuthPayload({
const payloadV3 = buildDeviceAuthPayloadV3({
deviceId: device.id,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
signedAtMs: signedAt,
token: connectParams.auth?.token ?? connectParams.auth?.deviceToken ?? null,
nonce: providedNonce,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
});
const payloadV2 = buildDeviceAuthPayload({
deviceId: device.id,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
@@ -595,11 +612,18 @@ export function attachGatewayWsMessageHandler(params: {
});
const rejectDeviceSignatureInvalid = () =>
rejectDeviceAuthInvalid("device-signature", "device signature invalid");
const signatureOk = verifyDeviceSignature(device.publicKey, payload, device.signature);
if (!signatureOk) {
const signatureOkV3 = verifyDeviceSignature(
device.publicKey,
payloadV3,
device.signature,
);
const signatureOkV2 =
!signatureOkV3 && verifyDeviceSignature(device.publicKey, payloadV2, device.signature);
if (!signatureOkV3 && !signatureOkV2) {
rejectDeviceSignatureInvalid();
return;
}
deviceAuthPayloadVersion = signatureOkV3 ? "v3" : "v2";
devicePublicKey = normalizeDevicePublicKeyBase64Url(device.publicKey);
if (!devicePublicKey) {
rejectDeviceAuthInvalid("device-public-key", "device public key invalid");
@@ -668,9 +692,18 @@ export function attachGatewayWsMessageHandler(params: {
`security audit: device access upgrade requested reason=${reason} device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} roleFrom=${formatAuditList(currentRoles)} roleTo=${role} scopesFrom=${formatAuditList(currentScopes)} scopesTo=${formatAuditList(scopes)} client=${connectParams.client.id} conn=${connId}`,
);
};
const clientAccessMetadata = {
const clientPairingMetadata = {
displayName: connectParams.client.displayName,
platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
scopes,
remoteIp: reportedClientIp,
};
const clientAccessMetadata = {
displayName: connectParams.client.displayName,
clientId: connectParams.client.id,
clientMode: connectParams.client.mode,
role,
@@ -678,7 +711,7 @@ export function attachGatewayWsMessageHandler(params: {
remoteIp: reportedClientIp,
};
const requirePairing = async (
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
reason: "not-paired" | "role-upgrade" | "scope-upgrade" | "metadata-upgrade",
) => {
const allowSilentLocalPairing = shouldAllowSilentLocalPairing({
isLocalClient,
@@ -690,7 +723,7 @@ export function attachGatewayWsMessageHandler(params: {
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientAccessMetadata,
...clientPairingMetadata,
silent: allowSilentLocalPairing,
});
const context = buildRequestContext();
@@ -747,6 +780,37 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
} else {
const claimedPlatform = connectParams.client.platform;
const pairedPlatform = paired.platform;
const claimedDeviceFamily = connectParams.client.deviceFamily;
const pairedDeviceFamily = paired.deviceFamily;
const hasPinnedPlatform = normalizeClientMetadataForComparison(pairedPlatform) !== "";
const hasPinnedDeviceFamily =
normalizeClientMetadataForComparison(pairedDeviceFamily) !== "";
const platformMismatch =
hasPinnedPlatform &&
normalizeClientMetadataForComparison(claimedPlatform) !==
normalizeClientMetadataForComparison(pairedPlatform);
const deviceFamilyMismatch =
hasPinnedDeviceFamily &&
normalizeClientMetadataForComparison(claimedDeviceFamily) !==
normalizeClientMetadataForComparison(pairedDeviceFamily);
if (platformMismatch || deviceFamilyMismatch) {
logGateway.warn(
`security audit: device metadata upgrade requested reason=metadata-upgrade device=${device.id} ip=${reportedClientIp ?? "unknown-ip"} auth=${authMethod} payload=${deviceAuthPayloadVersion ?? "unknown"} claimedPlatform=${claimedPlatform ?? "<none>"} pinnedPlatform=${pairedPlatform ?? "<none>"} claimedDeviceFamily=${claimedDeviceFamily ?? "<none>"} pinnedDeviceFamily=${pairedDeviceFamily ?? "<none>"} client=${connectParams.client.id} conn=${connId}`,
);
const ok = await requirePairing("metadata-upgrade");
if (!ok) {
return;
}
} else {
if (hasPinnedPlatform && pairedPlatform) {
connectParams.client.platform = pairedPlatform;
}
if (hasPinnedDeviceFamily) {
connectParams.client.deviceFamily = pairedDeviceFamily;
}
}
const pairedRoles = Array.isArray(paired.roles)
? paired.roles
: paired.role
@@ -795,6 +859,8 @@ export function attachGatewayWsMessageHandler(params: {
}
}
// Metadata pinning is approval-bound. Reconnects can update access metadata,
// but platform/device family must stay on the approved pairing record.
await updatePairedDeviceMetadata(device.id, clientAccessMetadata);
}
}

View File

@@ -1,4 +1,6 @@
import { writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { WebSocket } from "ws";
import {
type DeviceIdentity,
@@ -15,7 +17,7 @@ import {
type GatewayClientName,
} from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { startGatewayServer } from "./server.js";
@@ -31,6 +33,7 @@ export async function connectGatewayClient(params: {
clientVersion?: string;
mode?: GatewayClientMode;
platform?: string;
deviceFamily?: string;
role?: "operator" | "node";
scopes?: string[];
caps?: string[];
@@ -42,6 +45,20 @@ export async function connectGatewayClient(params: {
timeoutMs?: number;
timeoutMessage?: string;
}) {
const role = params.role ?? "operator";
const platform = params.platform ?? process.platform;
const identityRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
const deviceIdentity =
params.deviceIdentity ??
loadOrCreateDeviceIdentity(
(() => {
const safe =
`${params.clientName ?? GATEWAY_CLIENT_NAMES.TEST}-${params.mode ?? GATEWAY_CLIENT_MODES.TEST}-${platform}-${params.deviceFamily ?? "none"}-${role}`
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.toLowerCase();
return path.join(identityRoot, "test-device-identities", `${safe}.json`);
})(),
);
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
@@ -63,14 +80,15 @@ export async function connectGatewayClient(params: {
clientName: params.clientName ?? GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: params.clientDisplayName ?? "vitest",
clientVersion: params.clientVersion ?? "dev",
platform: params.platform,
platform,
deviceFamily: params.deviceFamily,
mode: params.mode ?? GATEWAY_CLIENT_MODES.TEST,
role: params.role,
role,
scopes: params.scopes,
caps: params.caps,
commands: params.commands,
instanceId: params.instanceId,
deviceIdentity: params.deviceIdentity,
deviceIdentity,
onEvent: params.onEvent,
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
@@ -127,7 +145,8 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
const connectNonce = await connectNoncePromise;
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
const platform = process.platform;
const payload = buildDeviceAuthPayloadV3({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
@@ -136,6 +155,7 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
signedAtMs,
token: params.token ?? null,
nonce: connectNonce,
platform,
});
const device = {
id: identity.deviceId,
@@ -156,7 +176,7 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
id: GATEWAY_CLIENT_NAMES.TEST,
displayName: "vitest",
version: "dev",
platform: process.platform,
platform,
mode: GATEWAY_CLIENT_MODES.TEST,
},
caps: [],

View File

@@ -18,7 +18,7 @@ import { DEFAULT_AGENT_ID, toAgentStoreSessionKey } from "../routing/session-key
import { captureEnv } from "../test-utils/env.js";
import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import type { GatewayServerOptions } from "./server.js";
import {
@@ -421,6 +421,21 @@ type ConnectResponse = {
error?: { message?: string; code?: string; details?: unknown };
};
function resolveDefaultTestDeviceIdentityPath(params: {
clientId: string;
clientMode: string;
platform: string;
deviceFamily?: string;
role: string;
}) {
const safe =
`${params.clientId}-${params.clientMode}-${params.platform}-${params.deviceFamily ?? "none"}-${params.role}`
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.toLowerCase();
const suiteRoot = process.env.OPENCLAW_STATE_DIR ?? process.env.HOME ?? os.tmpdir();
return path.join(suiteRoot, "test-device-identities", `${safe}.json`);
}
export async function readConnectChallengeNonce(
ws: WebSocket,
timeoutMs = 2_000,
@@ -478,6 +493,7 @@ export async function connectReq(
signedAt: number;
nonce?: string;
} | null;
deviceIdentityPath?: string;
skipConnectChallengeNonce?: boolean;
timeoutMs?: number;
},
@@ -527,9 +543,18 @@ export async function connectReq(
if (!connectChallengeNonce) {
throw new Error("missing connect.challenge nonce");
}
const identity = loadOrCreateDeviceIdentity();
const identityPath =
opts?.deviceIdentityPath ??
resolveDefaultTestDeviceIdentityPath({
clientId: client.id,
clientMode: client.mode,
platform: client.platform,
deviceFamily: client.deviceFamily,
role,
});
const identity = loadOrCreateDeviceIdentity(identityPath);
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
const payload = buildDeviceAuthPayloadV3({
deviceId: identity.deviceId,
clientId: client.id,
clientMode: client.mode,
@@ -538,6 +563,8 @@ export async function connectReq(
signedAtMs,
token: authTokenForSignature ?? null,
nonce: connectChallengeNonce,
platform: client.platform,
deviceFamily: client.deviceFamily,
});
return {
id: identity.deviceId,

View File

@@ -17,6 +17,7 @@ export type DevicePairingPendingRequest = {
publicKey: string;
displayName?: string;
platform?: string;
deviceFamily?: string;
clientId?: string;
clientMode?: string;
role?: string;
@@ -52,6 +53,7 @@ export type PairedDevice = {
publicKey: string;
displayName?: string;
platform?: string;
deviceFamily?: string;
clientId?: string;
clientMode?: string;
role?: string;
@@ -165,6 +167,7 @@ function mergePendingDevicePairingRequest(
...existing,
displayName: incoming.displayName ?? existing.displayName,
platform: incoming.platform ?? existing.platform,
deviceFamily: incoming.deviceFamily ?? existing.deviceFamily,
clientId: incoming.clientId ?? existing.clientId,
clientMode: incoming.clientMode ?? existing.clientMode,
role: existingRole ?? incomingRole ?? undefined,
@@ -297,6 +300,7 @@ export async function requestDevicePairing(
publicKey: req.publicKey,
displayName: req.displayName,
platform: req.platform,
deviceFamily: req.deviceFamily,
clientId: req.clientId,
clientMode: req.clientMode,
role: req.role,
@@ -360,6 +364,7 @@ export async function approveDevicePairing(
publicKey: pending.publicKey,
displayName: pending.displayName,
platform: pending.platform,
deviceFamily: pending.deviceFamily,
clientId: pending.clientId,
clientMode: pending.clientMode,
role: pending.role,