openclaw gateway: persist device keys and smoke pairing flow

This commit is contained in:
Dotta
2026-03-07 17:05:36 -06:00
parent d52f1d4b44
commit 0abb6a1205
6 changed files with 197 additions and 30 deletions

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildJoinDefaultsPayloadForAccept } from "../routes/access.js";
import {
buildJoinDefaultsPayloadForAccept,
normalizeAgentDefaultsForJoin,
} from "../routes/access.js";
describe("buildJoinDefaultsPayloadForAccept", () => {
it("maps OpenClaw compatibility fields into agent defaults", () => {
@@ -245,4 +248,47 @@ describe("buildJoinDefaultsPayloadForAccept", () => {
},
});
});
it("generates persistent device key for openclaw_gateway when device auth is enabled", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: false,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(false);
expect(typeof normalized.normalized?.devicePrivateKeyPem).toBe("string");
expect((normalized.normalized?.devicePrivateKeyPem as string).length).toBeGreaterThan(64);
});
it("does not generate device key when openclaw_gateway has disableDeviceAuth=true", () => {
const normalized = normalizeAgentDefaultsForJoin({
adapterType: "openclaw_gateway",
defaultsPayload: {
url: "ws://127.0.0.1:18789",
headers: {
"x-openclaw-token": "gateway-token-1234567890",
},
disableDeviceAuth: true,
},
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
});
expect(normalized.fatalErrors).toEqual([]);
expect(normalized.normalized?.disableDeviceAuth).toBe(true);
expect(normalized.normalized?.devicePrivateKeyPem).toBeUndefined();
});
});

View File

@@ -1,4 +1,9 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import {
createHash,
generateKeyPairSync,
randomBytes,
timingSafeEqual
} from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -330,6 +335,13 @@ function parseBooleanLike(value: unknown): boolean | null {
return null;
}
function generateEd25519PrivateKeyPem(): string {
const generated = generateKeyPairSync("ed25519");
return generated.privateKey
.export({ type: "pkcs8", format: "pem" })
.toString();
}
export function buildJoinDefaultsPayloadForAccept(input: {
adapterType: string | null;
defaultsPayload: unknown;
@@ -611,10 +623,16 @@ function summarizeOpenClawGatewayDefaultsForLog(defaultsPayload: unknown) {
sessionKeyStrategy: defaults
? nonEmptyTrimmedString(defaults.sessionKeyStrategy)
: null,
disableDeviceAuth: defaults
? parseBooleanLike(defaults.disableDeviceAuth)
: null,
waitTimeoutMs:
defaults && typeof defaults.waitTimeoutMs === "number"
? defaults.waitTimeoutMs
: null,
devicePrivateKeyPem: defaults
? summarizeSecretForLog(defaults.devicePrivateKeyPem)
: null,
gatewayToken: summarizeSecretForLog(gatewayTokenValue)
};
}
@@ -692,7 +710,7 @@ function buildJoinConnectivityDiagnostics(input: {
return diagnostics;
}
function normalizeAgentDefaultsForJoin(input: {
export function normalizeAgentDefaultsForJoin(input: {
adapterType: string | null;
defaultsPayload: unknown;
deploymentMode: DeploymentMode;
@@ -828,10 +846,47 @@ function normalizeAgentDefaultsForJoin(input: {
}
const parsedDisableDeviceAuth = parseBooleanLike(defaults.disableDeviceAuth);
const disableDeviceAuth = parsedDisableDeviceAuth === true;
if (parsedDisableDeviceAuth !== null) {
normalized.disableDeviceAuth = parsedDisableDeviceAuth;
}
const configuredDevicePrivateKeyPem = nonEmptyTrimmedString(
defaults.devicePrivateKeyPem
);
if (configuredDevicePrivateKeyPem) {
normalized.devicePrivateKeyPem = configuredDevicePrivateKeyPem;
diagnostics.push({
code: "openclaw_gateway_device_key_configured",
level: "info",
message:
"Gateway device key configured. Pairing approvals should persist for this agent."
});
} else if (!disableDeviceAuth) {
try {
normalized.devicePrivateKeyPem = generateEd25519PrivateKeyPem();
diagnostics.push({
code: "openclaw_gateway_device_key_generated",
level: "info",
message:
"Generated persistent gateway device key for this join. Pairing approvals should persist for this agent."
});
} catch (err) {
diagnostics.push({
code: "openclaw_gateway_device_key_generate_failed",
level: "warn",
message: `Failed to generate gateway device key: ${
err instanceof Error ? err.message : String(err)
}`,
hint:
"Set agentDefaultsPayload.devicePrivateKeyPem explicitly or set disableDeviceAuth=true."
});
fatalErrors.push(
"Failed to generate gateway device key. Set devicePrivateKeyPem or disableDeviceAuth=true."
);
}
}
const waitTimeoutMs =
typeof defaults.waitTimeoutMs === "number" &&
Number.isFinite(defaults.waitTimeoutMs)
@@ -1293,7 +1348,7 @@ function buildInviteOnboardingManifest(
adapterType: "Use 'openclaw_gateway' for OpenClaw Gateway agents",
capabilities: "Optional capability summary",
agentDefaultsPayload:
"Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth."
"Adapter config for OpenClaw gateway. MUST include url (ws:// or wss://) and headers.x-openclaw-token (or legacy x-openclaw-auth). Optional fields: paperclipApiUrl, waitTimeoutMs, sessionKeyStrategy, sessionKey, role, scopes, disableDeviceAuth, devicePrivateKeyPem."
},
registrationEndpoint: {
method: "POST",
@@ -1430,7 +1485,6 @@ export function buildInviteOnboardingTextDocument(
waitTimeoutMs: 120000,
sessionKeyStrategy: "fixed",
sessionKey: "paperclip",
disableDeviceAuth: true,
role: "operator",
scopes: ["operator.admin"]
}
@@ -1447,8 +1501,9 @@ export function buildInviteOnboardingTextDocument(
Legacy x-openclaw-auth is also accepted, but x-openclaw-token is preferred.
Use adapterType "openclaw_gateway" and a ws:// or wss:// gateway URL.
Pairing mode requirement:
- For smoke/dev, set "disableDeviceAuth": true to avoid interactive pairing blocks.
- If device auth remains enabled, set a stable "devicePrivateKeyPem"; otherwise each run may generate a new device identity and trigger pairing again.
- Keep device auth enabled (recommended). If devicePrivateKeyPem is omitted, Paperclip generates and persists one during join so pairing approvals are stable.
- You may set disableDeviceAuth=true only for special environments that cannot support pairing.
- First run may return "pairing required" once; approve the pending pairing request in OpenClaw, then retry.
Do NOT use /v1/responses or /hooks/* in this gateway join flow.
Body (JSON):
@@ -1464,7 +1519,6 @@ export function buildInviteOnboardingTextDocument(
"waitTimeoutMs": 120000,
"sessionKeyStrategy": "fixed",
"sessionKey": "paperclip",
"disableDeviceAuth": true,
"role": "operator",
"scopes": ["operator.admin"]
}