mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 11:54:46 +00:00
* fix: require approval for setup-code bootstrap pairing * addressing review-skill * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing codex review * addressing ci * addressing ci * docs: add changelog entry for PR merge
1862 lines
64 KiB
TypeScript
1862 lines
64 KiB
TypeScript
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expect, test, vi } from "vitest";
|
|
import { WebSocket } from "ws";
|
|
import {
|
|
BACKEND_GATEWAY_CLIENT,
|
|
connectReq,
|
|
configureTrustedProxyControlUiAuth,
|
|
CONTROL_UI_CLIENT,
|
|
ConnectErrorDetailCodes,
|
|
createSignedDevice,
|
|
ensurePairedDeviceTokenForCurrentIdentity,
|
|
GATEWAY_CLIENT_MODES,
|
|
GATEWAY_CLIENT_NAMES,
|
|
onceMessage,
|
|
openTailscaleWs,
|
|
openWs,
|
|
originForPort,
|
|
readConnectChallengeNonce,
|
|
restoreGatewayToken,
|
|
rpcReq,
|
|
startRateLimitedTokenServerWithPairedDeviceToken,
|
|
startGatewayServer,
|
|
startServer,
|
|
startServerWithClient,
|
|
TEST_OPERATOR_CLIENT,
|
|
testState,
|
|
TRUSTED_PROXY_CONTROL_UI_HEADERS,
|
|
waitForWsClose,
|
|
withGatewayServer,
|
|
writeTrustedProxyControlUiConfig,
|
|
} from "./server.auth.shared.js";
|
|
|
|
const operatorIdentityPathByPrefix = new Map<string, string>();
|
|
|
|
function expectArrayIncludes(actual: unknown, expectedValues: string[]): void {
|
|
expect(Array.isArray(actual)).toBe(true);
|
|
const values = actual as unknown[];
|
|
for (const expected of expectedValues) {
|
|
expect(values).toContain(expected);
|
|
}
|
|
}
|
|
|
|
export function registerControlUiAndPairingSuite(): void {
|
|
const trustedProxyControlUiCases: Array<{
|
|
name: string;
|
|
role: "operator" | "node";
|
|
withUnpairedNodeDevice: boolean;
|
|
expectedOk: boolean;
|
|
expectedErrorSubstring?: string;
|
|
expectedErrorCode?: string;
|
|
}> = [
|
|
{
|
|
name: "rejects loopback trusted-proxy control ui operator without device identity",
|
|
role: "operator",
|
|
withUnpairedNodeDevice: false,
|
|
expectedOk: false,
|
|
expectedErrorSubstring: "control ui requires device identity",
|
|
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
|
},
|
|
{
|
|
name: "rejects trusted-proxy control ui node role without device identity",
|
|
role: "node",
|
|
withUnpairedNodeDevice: false,
|
|
expectedOk: false,
|
|
expectedErrorSubstring: "control ui requires device identity",
|
|
expectedErrorCode: ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
|
},
|
|
{
|
|
name: "rejects loopback trusted-proxy control ui node role before pairing",
|
|
role: "node",
|
|
withUnpairedNodeDevice: true,
|
|
expectedOk: false,
|
|
expectedErrorSubstring: "unauthorized",
|
|
},
|
|
];
|
|
|
|
const buildSignedDeviceForIdentity = async (params: {
|
|
identityPath: string;
|
|
client: { id: string; mode: string };
|
|
nonce: string;
|
|
scopes: string[];
|
|
role?: "operator" | "node";
|
|
}) => {
|
|
const { device } = await createSignedDevice({
|
|
token: "secret",
|
|
scopes: params.scopes,
|
|
clientId: params.client.id,
|
|
clientMode: params.client.mode,
|
|
role: params.role ?? "operator",
|
|
identityPath: params.identityPath,
|
|
nonce: params.nonce,
|
|
});
|
|
return device;
|
|
};
|
|
|
|
const REMOTE_BOOTSTRAP_HEADERS = {
|
|
"x-forwarded-for": "10.0.0.14",
|
|
};
|
|
|
|
const expectStatusAndHealthOk = async (ws: WebSocket) => {
|
|
const status = await rpcReq(ws, "status");
|
|
expect(status.ok).toBe(true);
|
|
const health = await rpcReq(ws, "health");
|
|
expect(health.ok).toBe(true);
|
|
};
|
|
|
|
const expectAdminRpcOk = async (ws: WebSocket) => {
|
|
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
|
expect(admin.ok).toBe(true);
|
|
};
|
|
|
|
const connectControlUiWithoutDeviceAndExpectOk = async (params: {
|
|
ws: WebSocket;
|
|
token?: string;
|
|
password?: string;
|
|
client?: { id: string; version: string; platform: string; mode: string };
|
|
}) => {
|
|
const res = await connectReq(params.ws, {
|
|
...(params.token ? { token: params.token } : {}),
|
|
...(params.password ? { password: params.password } : {}),
|
|
device: null,
|
|
client: { ...(params.client ?? CONTROL_UI_CLIENT) },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
await expectStatusAndHealthOk(params.ws);
|
|
await expectAdminRpcOk(params.ws);
|
|
};
|
|
|
|
const createOperatorIdentityFixture = async (identityPrefix: string) => {
|
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
|
let identityPath = operatorIdentityPathByPrefix.get(identityPrefix);
|
|
if (!identityPath) {
|
|
const poolId = process.env.VITEST_POOL_ID ?? "0";
|
|
identityPath = path.join(os.tmpdir(), `${identityPrefix}${process.pid}-${poolId}.json`);
|
|
operatorIdentityPathByPrefix.set(identityPrefix, identityPath);
|
|
}
|
|
const identity = loadOrCreateDeviceIdentity(identityPath);
|
|
return {
|
|
identityPath,
|
|
identity,
|
|
client: { ...TEST_OPERATOR_CLIENT },
|
|
};
|
|
};
|
|
|
|
const startControlUiServerWithOperatorIdentity = async (
|
|
identityPrefix = "openclaw-device-scope-",
|
|
) => {
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
const { identityPath, identity, client } = await createOperatorIdentityFixture(identityPrefix);
|
|
return { server, port, prevToken, identityPath, identity, client };
|
|
};
|
|
|
|
const withControlUiGatewayServer = async <T>(
|
|
fn: (ctx: {
|
|
port: number;
|
|
server: Awaited<ReturnType<typeof startGatewayServer>>;
|
|
}) => Promise<T>,
|
|
): Promise<T> => {
|
|
return await withGatewayServer(fn, {
|
|
serverOptions: { controlUiEnabled: true },
|
|
});
|
|
};
|
|
|
|
const startControlUiServerWithClient = async (
|
|
token?: string,
|
|
opts?: Parameters<typeof startServerWithClient>[1],
|
|
) => {
|
|
return await startServerWithClient(token, {
|
|
...opts,
|
|
controlUiEnabled: true,
|
|
});
|
|
};
|
|
|
|
const startControlUiServer = async (token?: string, opts?: Parameters<typeof startServer>[1]) => {
|
|
return await startServer(token, {
|
|
...opts,
|
|
controlUiEnabled: true,
|
|
});
|
|
};
|
|
|
|
const getRequiredPairedMetadata = (
|
|
paired: Record<string, Record<string, unknown>>,
|
|
deviceId: string,
|
|
) => {
|
|
const metadata = paired[deviceId];
|
|
if (!metadata) {
|
|
throw new Error(`Expected paired metadata for deviceId=${deviceId}`);
|
|
}
|
|
return metadata;
|
|
};
|
|
|
|
const stripPairedMetadataRolesAndScopes = async (deviceId: string) => {
|
|
const { resolvePairingPaths, tryReadJson } = await import("../infra/pairing-files.js");
|
|
const { writeJson } = await import("../infra/json-files.js");
|
|
const { pairedPath } = resolvePairingPaths(undefined, "devices");
|
|
const paired = (await tryReadJson<Record<string, Record<string, unknown>>>(pairedPath)) ?? {};
|
|
const legacy = getRequiredPairedMetadata(paired, deviceId);
|
|
delete legacy.roles;
|
|
delete legacy.scopes;
|
|
await writeJson(pairedPath, paired);
|
|
};
|
|
|
|
const overwritePairedPublicKey = async (deviceId: string, publicKey: string) => {
|
|
const { resolvePairingPaths, tryReadJson } = await import("../infra/pairing-files.js");
|
|
const { writeJson } = await import("../infra/json-files.js");
|
|
const { pairedPath } = resolvePairingPaths(undefined, "devices");
|
|
const paired = (await tryReadJson<Record<string, Record<string, unknown>>>(pairedPath)) ?? {};
|
|
const metadata = getRequiredPairedMetadata(paired, deviceId);
|
|
metadata.publicKey = publicKey;
|
|
await writeJson(pairedPath, paired);
|
|
};
|
|
|
|
const seedApprovedOperatorReadPairing = async (params: {
|
|
identityPrefix: string;
|
|
clientId: string;
|
|
clientMode: string;
|
|
displayName: string;
|
|
platform: string;
|
|
scopes?: string[];
|
|
}): Promise<{ identityPath: string; identity: { deviceId: string } }> => {
|
|
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
|
const { approveDevicePairing, requestDevicePairing } =
|
|
await import("../infra/device-pairing.js");
|
|
const { identityPath, identity } = await createOperatorIdentityFixture(params.identityPrefix);
|
|
const scopes = params.scopes ?? ["operator.read"];
|
|
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
|
const seeded = await requestDevicePairing({
|
|
deviceId: identity.deviceId,
|
|
publicKey: devicePublicKey,
|
|
role: "operator",
|
|
scopes,
|
|
clientId: params.clientId,
|
|
clientMode: params.clientMode,
|
|
displayName: params.displayName,
|
|
platform: params.platform,
|
|
});
|
|
await approveDevicePairing(seeded.request.requestId, {
|
|
callerScopes: ["operator.admin"],
|
|
});
|
|
return { identityPath, identity: { deviceId: identity.deviceId } };
|
|
};
|
|
|
|
test("rejects untrusted trusted-proxy control ui device identity states", async () => {
|
|
await configureTrustedProxyControlUiAuth();
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
for (const tc of trustedProxyControlUiCases) {
|
|
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
|
try {
|
|
const scopes = tc.withUnpairedNodeDevice ? [] : undefined;
|
|
let device: Awaited<ReturnType<typeof createSignedDevice>>["device"] | null = null;
|
|
if (tc.withUnpairedNodeDevice) {
|
|
const challengeNonce = await readConnectChallengeNonce(ws);
|
|
if (!challengeNonce) {
|
|
throw new Error(`expected connect challenge nonce for ${tc.name}`);
|
|
}
|
|
({ device } = await createSignedDevice({
|
|
token: null,
|
|
role: "node",
|
|
scopes: [],
|
|
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
|
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
nonce: challengeNonce,
|
|
}));
|
|
}
|
|
const res = await connectReq(ws, {
|
|
skipDefaultAuth: true,
|
|
role: tc.role,
|
|
scopes,
|
|
device,
|
|
client: { ...CONTROL_UI_CLIENT },
|
|
});
|
|
expect(res.ok, tc.name).toBe(tc.expectedOk);
|
|
if (!tc.expectedOk) {
|
|
if (tc.expectedErrorSubstring) {
|
|
expect(res.error?.message ?? "", tc.name).toContain(tc.expectedErrorSubstring);
|
|
}
|
|
if (tc.expectedErrorCode) {
|
|
expect((res.error?.details as { code?: string } | undefined)?.code, tc.name).toBe(
|
|
tc.expectedErrorCode,
|
|
);
|
|
}
|
|
}
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("rejects trusted-proxy control ui without device identity even with self-declared scopes", async () => {
|
|
await configureTrustedProxyControlUiAuth();
|
|
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
|
const { rejectDevicePairing, requestDevicePairing } =
|
|
await import("../infra/device-pairing.js");
|
|
const { identity } = await createOperatorIdentityFixture("openclaw-control-ui-trusted-proxy-");
|
|
const pendingRequest = await requestDevicePairing({
|
|
deviceId: identity.deviceId,
|
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
role: "operator",
|
|
scopes: ["operator.admin"],
|
|
clientId: CONTROL_UI_CLIENT.id,
|
|
clientMode: CONTROL_UI_CLIENT.mode,
|
|
});
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
|
try {
|
|
const res = await connectReq(ws, {
|
|
skipDefaultAuth: true,
|
|
scopes: ["operator.admin"],
|
|
device: null,
|
|
client: { ...CONTROL_UI_CLIENT },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error?.message ?? "").toContain("control ui requires device identity");
|
|
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
|
);
|
|
} finally {
|
|
ws.close();
|
|
await rejectDevicePairing(pendingRequest.request.requestId);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("requires pairing for trusted-proxy control ui device identity", async () => {
|
|
const { replaceConfigFile } = await import("../config/config.js");
|
|
testState.gatewayAuth = undefined;
|
|
testState.gatewayControlUi = {
|
|
...testState.gatewayControlUi,
|
|
allowedOrigins: ["https://localhost"],
|
|
};
|
|
await replaceConfigFile({
|
|
nextConfig: {
|
|
gateway: {
|
|
auth: {
|
|
mode: "trusted-proxy",
|
|
trustedProxy: {
|
|
userHeader: "x-forwarded-user",
|
|
requiredHeaders: ["x-forwarded-proto"],
|
|
allowLoopback: true,
|
|
},
|
|
},
|
|
trustedProxies: ["127.0.0.1"],
|
|
controlUi: {
|
|
allowedOrigins: ["https://localhost"],
|
|
},
|
|
},
|
|
},
|
|
afterWrite: { mode: "auto" },
|
|
});
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
|
try {
|
|
const challengeNonce = await readConnectChallengeNonce(ws);
|
|
const { device } = await createSignedDevice({
|
|
token: null,
|
|
role: "operator",
|
|
scopes: ["operator.admin", "operator.read"],
|
|
clientId: CONTROL_UI_CLIENT.id,
|
|
clientMode: CONTROL_UI_CLIENT.mode,
|
|
nonce: challengeNonce,
|
|
});
|
|
const res = await connectReq(ws, {
|
|
skipDefaultAuth: true,
|
|
scopes: ["operator.admin", "operator.read"],
|
|
device,
|
|
client: { ...CONTROL_UI_CLIENT },
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error?.message ?? "").toContain("pairing required");
|
|
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
|
);
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test("clears trusted-proxy control ui scopes without device identity", async () => {
|
|
const { replaceConfigFile } = await import("../config/config.js");
|
|
testState.gatewayAuth = undefined;
|
|
testState.gatewayControlUi = {
|
|
...testState.gatewayControlUi,
|
|
allowedOrigins: ["https://localhost"],
|
|
};
|
|
await replaceConfigFile({
|
|
nextConfig: {
|
|
gateway: {
|
|
auth: {
|
|
mode: "trusted-proxy",
|
|
trustedProxy: {
|
|
userHeader: "x-forwarded-user",
|
|
requiredHeaders: ["x-forwarded-proto"],
|
|
allowLoopback: true,
|
|
},
|
|
},
|
|
trustedProxies: ["127.0.0.1"],
|
|
controlUi: {
|
|
allowedOrigins: ["https://localhost"],
|
|
},
|
|
},
|
|
},
|
|
afterWrite: { mode: "auto" },
|
|
});
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
const ws = await openWs(port, TRUSTED_PROXY_CONTROL_UI_HEADERS);
|
|
try {
|
|
const res = await connectReq(ws, {
|
|
skipDefaultAuth: true,
|
|
scopes: ["operator.admin", "operator.read"],
|
|
device: null,
|
|
client: { ...CONTROL_UI_CLIENT },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const payload = res.payload as
|
|
| {
|
|
auth?: { scopes?: string[]; deviceToken?: string };
|
|
}
|
|
| undefined;
|
|
expect(payload?.auth?.scopes).toEqual([]);
|
|
expect(payload?.auth?.deviceToken).toBeUndefined();
|
|
|
|
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
|
expect(admin.ok).toBe(false);
|
|
expect(admin.error?.message ?? "").toContain("missing scope");
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test("bounds trusted-proxy control ui scopes to proxy-declared scope header", async () => {
|
|
const { replaceConfigFile } = await import("../config/config.js");
|
|
testState.gatewayAuth = undefined;
|
|
testState.gatewayControlUi = {
|
|
...testState.gatewayControlUi,
|
|
allowedOrigins: ["https://localhost"],
|
|
};
|
|
await replaceConfigFile({
|
|
nextConfig: {
|
|
gateway: {
|
|
auth: {
|
|
mode: "trusted-proxy",
|
|
trustedProxy: {
|
|
userHeader: "x-forwarded-user",
|
|
requiredHeaders: ["x-forwarded-proto"],
|
|
allowLoopback: true,
|
|
},
|
|
},
|
|
trustedProxies: ["127.0.0.1"],
|
|
controlUi: {
|
|
allowedOrigins: ["https://localhost"],
|
|
},
|
|
},
|
|
},
|
|
afterWrite: { mode: "auto" },
|
|
});
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
const seeded = await seedApprovedOperatorReadPairing({
|
|
identityPrefix: "openclaw-control-ui-trusted-proxy-bounded-",
|
|
clientId: CONTROL_UI_CLIENT.id,
|
|
clientMode: CONTROL_UI_CLIENT.mode,
|
|
displayName: "Control UI",
|
|
platform: "web",
|
|
scopes: ["operator.admin", "operator.read"],
|
|
});
|
|
const ws = await openWs(port, {
|
|
...TRUSTED_PROXY_CONTROL_UI_HEADERS,
|
|
"x-openclaw-scopes": "operator.read",
|
|
});
|
|
try {
|
|
const challengeNonce = await readConnectChallengeNonce(ws);
|
|
const { device } = await createSignedDevice({
|
|
token: null,
|
|
role: "operator",
|
|
scopes: ["operator.admin", "operator.read"],
|
|
clientId: CONTROL_UI_CLIENT.id,
|
|
clientMode: CONTROL_UI_CLIENT.mode,
|
|
identityPath: seeded.identityPath,
|
|
nonce: challengeNonce,
|
|
});
|
|
const res = await connectReq(ws, {
|
|
skipDefaultAuth: true,
|
|
scopes: ["operator.admin", "operator.read"],
|
|
device,
|
|
client: { ...CONTROL_UI_CLIENT },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const payload = res.payload as
|
|
| {
|
|
auth?: { scopes?: string[]; deviceToken?: string };
|
|
}
|
|
| undefined;
|
|
expect(payload?.auth?.scopes).toEqual(["operator.read"]);
|
|
expect(payload?.auth?.deviceToken).toBeUndefined();
|
|
|
|
const admin = await rpcReq(ws, "set-heartbeats", { enabled: false });
|
|
expect(admin.ok).toBe(false);
|
|
expect(admin.error?.message ?? "").toContain("missing scope");
|
|
|
|
const health = await rpcReq(ws, "health");
|
|
expect(health.ok).toBe(true);
|
|
} finally {
|
|
ws.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test("allows localhost ui clients without device identity when insecure auth is enabled", async () => {
|
|
testState.gatewayControlUi = { allowInsecureAuth: true };
|
|
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret", {
|
|
wsHeaders: { origin: "http://127.0.0.1" },
|
|
});
|
|
let tuiWs: WebSocket | undefined;
|
|
try {
|
|
await connectControlUiWithoutDeviceAndExpectOk({ ws, token: "secret" });
|
|
|
|
tuiWs = await openWs(port);
|
|
await connectControlUiWithoutDeviceAndExpectOk({
|
|
ws: tuiWs,
|
|
token: "secret",
|
|
client: {
|
|
id: GATEWAY_CLIENT_NAMES.TUI,
|
|
version: "1.0.0",
|
|
platform: "darwin",
|
|
mode: GATEWAY_CLIENT_MODES.UI,
|
|
},
|
|
});
|
|
} finally {
|
|
ws.close();
|
|
tuiWs?.close();
|
|
await Promise.all([
|
|
waitForWsClose(ws, 1_000),
|
|
...(tuiWs ? [waitForWsClose(tuiWs, 1_000)] : []),
|
|
]);
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("allows control ui password-only auth on localhost when insecure auth is enabled", async () => {
|
|
testState.gatewayControlUi = { allowInsecureAuth: true };
|
|
testState.gatewayAuth = { mode: "password", password: "secret" }; // pragma: allowlist secret
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
const ws = await openWs(port, { origin: originForPort(port) });
|
|
await connectControlUiWithoutDeviceAndExpectOk({ ws, password: "secret" }); // pragma: allowlist secret
|
|
ws.close();
|
|
});
|
|
});
|
|
|
|
test("does not bypass pairing for control ui device identity when insecure auth is enabled", async () => {
|
|
testState.gatewayControlUi = {
|
|
allowInsecureAuth: true,
|
|
allowedOrigins: ["https://localhost"],
|
|
};
|
|
testState.gatewayAuth = { mode: "token", token: "secret" };
|
|
await writeTrustedProxyControlUiConfig({ allowInsecureAuth: true });
|
|
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
|
|
try {
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
|
headers: {
|
|
origin: "https://localhost",
|
|
"x-forwarded-for": "203.0.113.10",
|
|
},
|
|
});
|
|
const challengePromise = onceMessage(
|
|
ws,
|
|
(o) => o.type === "event" && o.event === "connect.challenge",
|
|
);
|
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
|
const challenge = await challengePromise;
|
|
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
|
|
expect(typeof nonce).toBe("string");
|
|
const { identityPath } = await createOperatorIdentityFixture("openclaw-controlui-device-");
|
|
const scopes = [
|
|
"operator.admin",
|
|
"operator.read",
|
|
"operator.write",
|
|
"operator.approvals",
|
|
"operator.pairing",
|
|
];
|
|
const { device } = await createSignedDevice({
|
|
token: "secret",
|
|
scopes,
|
|
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
|
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
identityPath,
|
|
nonce: String(nonce),
|
|
});
|
|
const res = await connectReq(ws, {
|
|
token: "secret",
|
|
scopes,
|
|
device,
|
|
client: {
|
|
...CONTROL_UI_CLIENT,
|
|
},
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error?.message ?? "").toContain("pairing required");
|
|
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
|
);
|
|
ws.close();
|
|
});
|
|
} finally {
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("allows control ui auth bypasses when device auth is disabled", async () => {
|
|
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
|
|
testState.gatewayAuth = { mode: "token", token: "secret" };
|
|
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
process.env.OPENCLAW_GATEWAY_TOKEN = "secret";
|
|
try {
|
|
await withControlUiGatewayServer(async ({ port }) => {
|
|
const staleDeviceWs = await openWs(port, { origin: originForPort(port) });
|
|
const challengeNonce = await readConnectChallengeNonce(staleDeviceWs);
|
|
if (!challengeNonce) {
|
|
throw new Error("expected stale device challenge nonce");
|
|
}
|
|
const { device } = await createSignedDevice({
|
|
token: "secret",
|
|
scopes: [],
|
|
clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
|
clientMode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
|
signedAtMs: Date.now() - 60 * 60 * 1000,
|
|
nonce: challengeNonce,
|
|
});
|
|
const res = await connectReq(staleDeviceWs, {
|
|
token: "secret",
|
|
scopes: ["operator.read"],
|
|
device,
|
|
client: {
|
|
...CONTROL_UI_CLIENT,
|
|
},
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const helloOk = res.payload as
|
|
| {
|
|
auth?: {
|
|
role?: unknown;
|
|
scopes?: unknown;
|
|
deviceToken?: unknown;
|
|
};
|
|
}
|
|
| undefined;
|
|
expect(helloOk?.auth?.role).toBe("operator");
|
|
expect(helloOk?.auth?.scopes).toEqual(["operator.read"]);
|
|
expect(helloOk?.auth?.deviceToken).toBeUndefined();
|
|
const health = await rpcReq(staleDeviceWs, "health");
|
|
expect(health.ok).toBe(true);
|
|
staleDeviceWs.close();
|
|
|
|
const scopedWs = await openWs(port, { origin: originForPort(port) });
|
|
const scopedRes = await connectReq(scopedWs, {
|
|
token: "secret",
|
|
scopes: ["operator.read"],
|
|
client: {
|
|
...CONTROL_UI_CLIENT,
|
|
},
|
|
});
|
|
expect(scopedRes.ok, "requested scope bypass").toBe(true);
|
|
const scopedHelloOk = scopedRes.payload as
|
|
| {
|
|
auth?: {
|
|
role?: unknown;
|
|
scopes?: unknown;
|
|
deviceToken?: unknown;
|
|
};
|
|
}
|
|
| undefined;
|
|
expect(scopedHelloOk?.auth?.role).toBe("operator");
|
|
expect(scopedHelloOk?.auth?.scopes).toEqual(["operator.read"]);
|
|
expect(scopedHelloOk?.auth?.deviceToken).toBeUndefined();
|
|
|
|
const scopedHealth = await rpcReq(scopedWs, "health");
|
|
expect(scopedHealth.ok).toBe(true);
|
|
scopedWs.close();
|
|
});
|
|
} finally {
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("device token auth matrix", async () => {
|
|
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret");
|
|
const { deviceToken, deviceIdentityPath } = await ensurePairedDeviceTokenForCurrentIdentity(ws);
|
|
ws.close();
|
|
|
|
const scenarios: Array<{
|
|
name: string;
|
|
opts: Parameters<typeof connectReq>[1];
|
|
assert: (res: Awaited<ReturnType<typeof connectReq>>) => void;
|
|
}> = [
|
|
{
|
|
name: "accepts device token auth for paired device",
|
|
opts: { token: deviceToken },
|
|
assert: (res) => {
|
|
expect(res.ok).toBe(true);
|
|
},
|
|
},
|
|
{
|
|
name: "accepts explicit auth.deviceToken when shared token is omitted",
|
|
opts: {
|
|
skipDefaultAuth: true,
|
|
deviceToken,
|
|
},
|
|
assert: (res) => {
|
|
expect(res.ok).toBe(true);
|
|
},
|
|
},
|
|
{
|
|
name: "uses explicit auth.deviceToken fallback when shared token is wrong",
|
|
opts: {
|
|
token: "wrong",
|
|
deviceToken,
|
|
},
|
|
assert: (res) => {
|
|
expect(res.ok).toBe(true);
|
|
},
|
|
},
|
|
{
|
|
name: "keeps shared token mismatch reason when fallback device-token check fails",
|
|
opts: { token: "wrong" },
|
|
assert: (res) => {
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error?.message ?? "").toContain("gateway token mismatch");
|
|
expect(res.error?.message ?? "").not.toContain("device token mismatch");
|
|
const details = res.error?.details as
|
|
| {
|
|
code?: string;
|
|
canRetryWithDeviceToken?: boolean;
|
|
recommendedNextStep?: string;
|
|
}
|
|
| undefined;
|
|
expect(details?.code).toBe(ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH);
|
|
expect(details?.canRetryWithDeviceToken).toBe(true);
|
|
expect(details?.recommendedNextStep).toBe("retry_with_device_token");
|
|
},
|
|
},
|
|
{
|
|
name: "reports device token mismatch when explicit auth.deviceToken is wrong",
|
|
opts: {
|
|
skipDefaultAuth: true,
|
|
deviceToken: "not-a-valid-device-token",
|
|
},
|
|
assert: (res) => {
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error?.message ?? "").toContain("device token mismatch");
|
|
expect((res.error?.details as { code?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
try {
|
|
for (const scenario of scenarios) {
|
|
const ws2 = await openWs(port);
|
|
try {
|
|
const res = await connectReq(ws2, {
|
|
...scenario.opts,
|
|
deviceIdentityPath,
|
|
});
|
|
scenario.assert(res);
|
|
} finally {
|
|
ws2.close();
|
|
}
|
|
}
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("keeps shared-secret lockout separate from device-token auth", async () => {
|
|
const { server, port, prevToken, deviceToken, deviceIdentityPath } =
|
|
await startRateLimitedTokenServerWithPairedDeviceToken();
|
|
try {
|
|
const wsBadShared = await openWs(port);
|
|
const badShared = await connectReq(wsBadShared, { token: "wrong", device: null });
|
|
expect(badShared.ok).toBe(false);
|
|
wsBadShared.close();
|
|
|
|
const wsSharedLocked = await openWs(port);
|
|
const sharedLocked = await connectReq(wsSharedLocked, { token: "secret", device: null });
|
|
expect(sharedLocked.ok).toBe(false);
|
|
expect(sharedLocked.error?.message ?? "").toContain("retry later");
|
|
wsSharedLocked.close();
|
|
|
|
const wsDevice = await openWs(port);
|
|
const deviceOk = await connectReq(wsDevice, { token: deviceToken, deviceIdentityPath });
|
|
expect(deviceOk.ok).toBe(true);
|
|
wsDevice.close();
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("keeps device-token lockout separate from shared-secret auth", async () => {
|
|
const { server, port, prevToken, deviceToken, deviceIdentityPath } =
|
|
await startRateLimitedTokenServerWithPairedDeviceToken();
|
|
try {
|
|
const wsBadDevice = await openWs(port);
|
|
const badDevice = await connectReq(wsBadDevice, {
|
|
skipDefaultAuth: true,
|
|
deviceToken: "wrong",
|
|
deviceIdentityPath,
|
|
});
|
|
expect(badDevice.ok).toBe(false);
|
|
wsBadDevice.close();
|
|
|
|
const wsDeviceLocked = await openWs(port);
|
|
const deviceLocked = await connectReq(wsDeviceLocked, {
|
|
skipDefaultAuth: true,
|
|
deviceToken: "wrong",
|
|
deviceIdentityPath,
|
|
});
|
|
expect(deviceLocked.ok).toBe(false);
|
|
expect(deviceLocked.error?.message ?? "").toContain("retry later");
|
|
wsDeviceLocked.close();
|
|
|
|
const wsShared = await openWs(port);
|
|
const sharedOk = await connectReq(wsShared, { token: "secret", device: null });
|
|
expect(sharedOk.ok).toBe(true);
|
|
wsShared.close();
|
|
|
|
const wsDeviceReal = await openWs(port);
|
|
const deviceStillLocked = await connectReq(wsDeviceReal, {
|
|
token: deviceToken,
|
|
deviceIdentityPath,
|
|
});
|
|
expect(deviceStillLocked.ok).toBe(false);
|
|
expect(deviceStillLocked.error?.message ?? "").toContain("retry later");
|
|
wsDeviceReal.close();
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => {
|
|
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { server, port, prevToken, identityPath, identity, client } =
|
|
await startControlUiServerWithOperatorIdentity();
|
|
|
|
const wsRemoteRead = await openWs(port, { host: "gateway.example" });
|
|
const initialNonce = await readConnectChallengeNonce(wsRemoteRead);
|
|
const initial = await connectReq(wsRemoteRead, {
|
|
token: "secret",
|
|
scopes: ["operator.read"],
|
|
client,
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client,
|
|
scopes: ["operator.read"],
|
|
nonce: initialNonce,
|
|
}),
|
|
});
|
|
expect(initial.ok).toBe(true);
|
|
let pairing = await listDevicePairing();
|
|
const pendingAfterRead = pairing.pending.filter(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
expect(pendingAfterRead).toHaveLength(0);
|
|
if (!(await getPairedDevice(identity.deviceId))) {
|
|
throw new Error(`expected paired device ${identity.deviceId}`);
|
|
}
|
|
wsRemoteRead.close();
|
|
|
|
const ws2 = await openWs(port, { host: "gateway.example" });
|
|
const nonce2 = await readConnectChallengeNonce(ws2);
|
|
const res = await connectReq(ws2, {
|
|
token: "secret",
|
|
scopes: ["operator.admin"],
|
|
client,
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client,
|
|
scopes: ["operator.admin"],
|
|
nonce: nonce2,
|
|
}),
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error?.message ?? "").toContain("pairing required");
|
|
pairing = await listDevicePairing();
|
|
const pendingAfterAdmin = pairing.pending.filter(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
expect(pendingAfterAdmin).toHaveLength(1);
|
|
expectArrayIncludes(pendingAfterAdmin[0]?.scopes, ["operator.admin"]);
|
|
if (!(await getPairedDevice(identity.deviceId))) {
|
|
throw new Error(`expected paired device ${identity.deviceId}`);
|
|
}
|
|
ws2.close();
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
});
|
|
|
|
test("requires approval for loopback scope upgrades for control ui clients", async () => {
|
|
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
|
identityPrefix: "openclaw-device-token-scope-",
|
|
clientId: CONTROL_UI_CLIENT.id,
|
|
clientMode: CONTROL_UI_CLIENT.mode,
|
|
displayName: "loopback-control-ui-upgrade",
|
|
platform: CONTROL_UI_CLIENT.platform,
|
|
});
|
|
|
|
const ws2 = await openWs(port, { origin: originForPort(port) });
|
|
const nonce2 = await readConnectChallengeNonce(ws2);
|
|
const upgraded = await connectReq(ws2, {
|
|
token: "secret",
|
|
scopes: ["operator.admin"],
|
|
client: { ...CONTROL_UI_CLIENT },
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client: CONTROL_UI_CLIENT,
|
|
scopes: ["operator.admin"],
|
|
nonce: nonce2,
|
|
}),
|
|
});
|
|
expect(upgraded.ok).toBe(false);
|
|
expect(upgraded.error?.message ?? "").toContain("pairing required");
|
|
const pending = await listDevicePairing();
|
|
const pendingUpgrade = pending.pending.filter((entry) => entry.deviceId === identity.deviceId);
|
|
expect(pendingUpgrade).toHaveLength(1);
|
|
expectArrayIncludes(pendingUpgrade[0]?.scopes, ["operator.admin"]);
|
|
const updated = await getPairedDevice(identity.deviceId);
|
|
expect(updated?.tokens?.operator?.scopes ?? []).not.toContain("operator.admin");
|
|
|
|
ws2.close();
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
});
|
|
|
|
test("does not expose approved access when a paired device id reconnects with a different key", async () => {
|
|
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
|
identityPrefix: "openclaw-device-key-mismatch-",
|
|
clientId: TEST_OPERATOR_CLIENT.id,
|
|
clientMode: TEST_OPERATOR_CLIENT.mode,
|
|
displayName: "remote-key-mismatch",
|
|
platform: TEST_OPERATOR_CLIENT.platform,
|
|
});
|
|
await overwritePairedPublicKey(identity.deviceId, "mismatched-public-key");
|
|
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
const ws2 = await openTailscaleWs(port);
|
|
try {
|
|
const nonce2 = await readConnectChallengeNonce(ws2);
|
|
const mismatched = await connectReq(ws2, {
|
|
token: "secret",
|
|
scopes: ["operator.admin"],
|
|
client: { ...TEST_OPERATOR_CLIENT },
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client: TEST_OPERATOR_CLIENT,
|
|
scopes: ["operator.admin"],
|
|
nonce: nonce2,
|
|
}),
|
|
});
|
|
expect(mismatched.ok).toBe(false);
|
|
expect(mismatched.error?.message ?? "").toContain("pairing required");
|
|
expect(
|
|
(
|
|
mismatched.error?.details as
|
|
| {
|
|
reason?: string;
|
|
requestedRole?: string;
|
|
requestedScopes?: string[];
|
|
approvedRoles?: string[];
|
|
approvedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.reason,
|
|
).toBe("not-paired");
|
|
expect(
|
|
(
|
|
mismatched.error?.details as
|
|
| {
|
|
requestedRole?: string;
|
|
requestedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.requestedRole,
|
|
).toBe("operator");
|
|
expect(
|
|
(
|
|
mismatched.error?.details as
|
|
| {
|
|
requestedRole?: string;
|
|
requestedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.requestedScopes,
|
|
).toEqual(["operator.admin"]);
|
|
expect(
|
|
(
|
|
mismatched.error?.details as
|
|
| {
|
|
approvedRoles?: string[];
|
|
approvedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.approvedRoles,
|
|
).toBeUndefined();
|
|
expect(
|
|
(
|
|
mismatched.error?.details as
|
|
| {
|
|
approvedRoles?: string[];
|
|
approvedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.approvedScopes,
|
|
).toBeUndefined();
|
|
} finally {
|
|
ws2.close();
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("requires approval before qr setup code returns a durable node token", async () => {
|
|
const { issueDeviceBootstrapToken, verifyDeviceBootstrapToken } =
|
|
await import("../infra/device-bootstrap.js");
|
|
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
|
const { approveDevicePairing, getPairedDevice, listDevicePairing, verifyDeviceToken } =
|
|
await import("../infra/device-pairing.js");
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
|
|
const { identityPath, identity } = await createOperatorIdentityFixture(
|
|
"openclaw-bootstrap-node-",
|
|
);
|
|
const client = {
|
|
id: "openclaw-ios",
|
|
version: "2026.3.30",
|
|
platform: "iOS 26.3.1",
|
|
mode: "node",
|
|
deviceFamily: "iPhone",
|
|
};
|
|
|
|
try {
|
|
const issued = await issueDeviceBootstrapToken();
|
|
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const initial = await connectReq(wsBootstrap, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(initial.ok).toBe(false);
|
|
expect(initial.error?.message ?? "").toContain("pairing required");
|
|
const initialDetails = initial.error?.details as
|
|
| {
|
|
code?: string;
|
|
pauseReconnect?: boolean;
|
|
recommendedNextStep?: string;
|
|
retryable?: boolean;
|
|
}
|
|
| undefined;
|
|
expect(initialDetails?.code).toBe(ConnectErrorDetailCodes.PAIRING_REQUIRED);
|
|
expect(initialDetails?.recommendedNextStep).toBe("wait_then_retry");
|
|
expect(initialDetails?.retryable).toBe(true);
|
|
expect(initialDetails?.pauseReconnect).toBe(false);
|
|
|
|
const pendingAfterInitial = await listDevicePairing();
|
|
const pendingForDevice = pendingAfterInitial.pending.filter(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
expect(pendingForDevice).toHaveLength(1);
|
|
expect(pendingForDevice[0]?.role).toBe("node");
|
|
expect(pendingForDevice[0]?.roles).toEqual(["node"]);
|
|
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
|
expect(
|
|
await approveDevicePairing(pendingForDevice[0]?.requestId ?? "", {
|
|
callerScopes: ["operator.pairing"],
|
|
}),
|
|
).toMatchObject({ status: "approved" });
|
|
wsBootstrap.close();
|
|
|
|
const wsApproved = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const approvedConnect = await connectReq(wsApproved, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(approvedConnect.ok).toBe(true);
|
|
const approvedPayload = approvedConnect.payload as
|
|
| {
|
|
type?: string;
|
|
auth?: {
|
|
deviceToken?: string;
|
|
role?: string;
|
|
scopes?: string[];
|
|
deviceTokens?: Array<{
|
|
deviceToken?: string;
|
|
role?: string;
|
|
scopes?: string[];
|
|
}>;
|
|
};
|
|
}
|
|
| undefined;
|
|
expect(approvedPayload?.type).toBe("hello-ok");
|
|
const issuedDeviceToken = approvedPayload?.auth?.deviceToken;
|
|
if (!issuedDeviceToken) {
|
|
throw new Error("expected issued device token");
|
|
}
|
|
expect(approvedPayload?.auth?.role).toBe("node");
|
|
expect(approvedPayload?.auth?.scopes ?? []).toEqual([]);
|
|
expect(approvedPayload?.auth?.deviceTokens ?? []).toEqual([]);
|
|
|
|
const afterBootstrap = await listDevicePairing();
|
|
expect(
|
|
afterBootstrap.pending.filter((entry) => entry.deviceId === identity.deviceId),
|
|
).toEqual([]);
|
|
const paired = await getPairedDevice(identity.deviceId);
|
|
expect(paired?.roles).toEqual(["node"]);
|
|
expect(paired?.approvedScopes).toEqual([]);
|
|
expect(paired?.tokens?.node?.token).toBe(issuedDeviceToken);
|
|
expect(paired?.tokens?.operator).toBeUndefined();
|
|
|
|
await new Promise<void>((resolve) => {
|
|
if (wsApproved.readyState === WebSocket.CLOSED) {
|
|
resolve();
|
|
return;
|
|
}
|
|
wsApproved.once("close", () => resolve());
|
|
wsApproved.close();
|
|
});
|
|
|
|
const wsReplay = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const replay = await connectReq(wsReplay, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(replay.ok).toBe(false);
|
|
expect((replay.error?.details as { code?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
|
);
|
|
wsReplay.close();
|
|
|
|
const wsReconnect = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const reconnect = await connectReq(wsReconnect, {
|
|
skipDefaultAuth: true,
|
|
deviceToken: issuedDeviceToken,
|
|
role: "node",
|
|
scopes: [],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(reconnect.ok).toBe(true);
|
|
wsReconnect.close();
|
|
|
|
await expect(
|
|
verifyDeviceBootstrapToken({
|
|
token: issued.token,
|
|
deviceId: identity.deviceId,
|
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
role: "node",
|
|
scopes: [],
|
|
}),
|
|
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
|
|
|
await expect(
|
|
verifyDeviceToken({
|
|
deviceId: identity.deviceId,
|
|
token: issuedDeviceToken,
|
|
role: "node",
|
|
scopes: [],
|
|
}),
|
|
).resolves.toEqual({ ok: true });
|
|
await expect(
|
|
verifyDeviceToken({
|
|
deviceId: identity.deviceId,
|
|
token: issuedDeviceToken,
|
|
role: "operator",
|
|
scopes: [
|
|
"operator.approvals",
|
|
"operator.read",
|
|
"operator.talk.secrets",
|
|
"operator.write",
|
|
],
|
|
}),
|
|
).resolves.toEqual({ ok: false, reason: "token-missing" });
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("rejected qr setup code cannot recreate pending node pairing", async () => {
|
|
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
|
const { listDevicePairing, rejectDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
const { identityPath, identity } = await createOperatorIdentityFixture(
|
|
"openclaw-bootstrap-node-reject-",
|
|
);
|
|
const client = {
|
|
id: "openclaw-ios",
|
|
version: "2026.3.30",
|
|
platform: "iOS 26.3.1",
|
|
mode: "node",
|
|
deviceFamily: "iPhone",
|
|
};
|
|
|
|
try {
|
|
const issued = await issueDeviceBootstrapToken();
|
|
const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const initial = await connectReq(wsInitial, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(initial.ok).toBe(false);
|
|
expect(
|
|
initial.error?.details as { code?: string; pauseReconnect?: boolean } | undefined,
|
|
).toMatchObject({
|
|
code: ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
|
pauseReconnect: false,
|
|
});
|
|
wsInitial.close();
|
|
|
|
const pending = (await listDevicePairing()).pending.find(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
if (!pending) {
|
|
throw new Error("expected pending bootstrap pairing request");
|
|
}
|
|
await expect(rejectDevicePairing(pending.requestId)).resolves.toEqual({
|
|
requestId: pending.requestId,
|
|
deviceId: identity.deviceId,
|
|
});
|
|
|
|
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const retry = await connectReq(wsRetry, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(retry.ok).toBe(false);
|
|
expect((retry.error?.details as { code?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.AUTH_BOOTSTRAP_TOKEN_INVALID,
|
|
);
|
|
wsRetry.close();
|
|
expect(
|
|
(await listDevicePairing()).pending.filter((entry) => entry.deviceId === identity.deviceId),
|
|
).toEqual([]);
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("does not consume bootstrap token when node reconcile fails before hello-ok", async () => {
|
|
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
|
const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const reconcileModule = await import("./node-connect-reconcile.js");
|
|
const reconcileSpy = vi
|
|
.spyOn(reconcileModule, "reconcileNodePairingOnConnect")
|
|
.mockRejectedValueOnce(new Error("boom"));
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
|
|
const { identityPath, client } = await createOperatorIdentityFixture(
|
|
"openclaw-bootstrap-reconcile-fail-",
|
|
);
|
|
const nodeClient = {
|
|
...client,
|
|
id: "openclaw-android",
|
|
mode: "node",
|
|
};
|
|
|
|
try {
|
|
const issued = await issueDeviceBootstrapToken({
|
|
profile: {
|
|
roles: ["node"],
|
|
scopes: [],
|
|
},
|
|
});
|
|
|
|
const wsInitial = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const initial = await connectReq(wsInitial, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client: nodeClient,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(initial.ok).toBe(false);
|
|
wsInitial.close();
|
|
const pending = (await listDevicePairing()).pending.find(
|
|
(entry) => entry.clientId === nodeClient.id,
|
|
);
|
|
if (!pending) {
|
|
throw new Error("expected pending bootstrap pairing request");
|
|
}
|
|
await approveDevicePairing(pending.requestId, { callerScopes: ["operator.pairing"] });
|
|
|
|
const wsFail = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
await expect(
|
|
connectReq(wsFail, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client: nodeClient,
|
|
deviceIdentityPath: identityPath,
|
|
timeoutMs: 500,
|
|
}),
|
|
).rejects.toThrow();
|
|
// The full agentic shard can saturate the event loop enough that the
|
|
// server-side close after a pre-hello failure arrives later than 1s.
|
|
await expect(waitForWsClose(wsFail, 5_000)).resolves.toBe(true);
|
|
|
|
const wsRetry = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const retry = await connectReq(wsRetry, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client: nodeClient,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(retry.ok).toBe(true);
|
|
wsRetry.close();
|
|
} finally {
|
|
reconcileSpy.mockRestore();
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("requires approval for bootstrap-auth role upgrades on already-paired devices", async () => {
|
|
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
|
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
|
await import("../infra/device-pairing.js");
|
|
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
|
|
const { identityPath, identity } = await createOperatorIdentityFixture(
|
|
"openclaw-bootstrap-role-upgrade-",
|
|
);
|
|
const client = {
|
|
id: "openclaw-ios",
|
|
version: "2026.3.30",
|
|
platform: "iOS 26.3.1",
|
|
mode: "node",
|
|
deviceFamily: "iPhone",
|
|
};
|
|
|
|
try {
|
|
const seededRequest = await requestDevicePairing({
|
|
deviceId: identity.deviceId,
|
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
|
role: "operator",
|
|
scopes: ["operator.read"],
|
|
clientId: client.id,
|
|
clientMode: client.mode,
|
|
platform: client.platform,
|
|
deviceFamily: client.deviceFamily,
|
|
});
|
|
await approveDevicePairing(seededRequest.request.requestId, {
|
|
callerScopes: ["operator.read"],
|
|
});
|
|
|
|
const issued = await issueDeviceBootstrapToken({
|
|
profile: {
|
|
roles: ["node"],
|
|
scopes: [],
|
|
},
|
|
});
|
|
const wsUpgrade = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const upgrade = await connectReq(wsUpgrade, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "node",
|
|
scopes: [],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(upgrade.ok).toBe(false);
|
|
expect(upgrade.error?.message ?? "").toContain("pairing required");
|
|
expect((upgrade.error?.details as { code?: string; reason?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
|
);
|
|
expect(
|
|
(upgrade.error?.details as { code?: string; reason?: string } | undefined)?.reason,
|
|
).toBe("role-upgrade");
|
|
expect(
|
|
(
|
|
upgrade.error?.details as
|
|
| {
|
|
requestedRole?: string;
|
|
approvedRoles?: string[];
|
|
}
|
|
| undefined
|
|
)?.requestedRole,
|
|
).toBe("node");
|
|
expect(
|
|
(
|
|
upgrade.error?.details as
|
|
| {
|
|
requestedRole?: string;
|
|
approvedRoles?: string[];
|
|
}
|
|
| undefined
|
|
)?.approvedRoles,
|
|
).toEqual(["operator"]);
|
|
|
|
const pending = (await listDevicePairing()).pending.filter(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
expect(pending).toHaveLength(1);
|
|
expect(pending[0]?.role).toBe("node");
|
|
expect(pending[0]?.roles).toEqual(["node"]);
|
|
const paired = await getPairedDevice(identity.deviceId);
|
|
expectArrayIncludes(paired?.roles, ["operator"]);
|
|
wsUpgrade.close();
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => {
|
|
const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js");
|
|
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
|
|
const { identityPath, identity, client } = await createOperatorIdentityFixture(
|
|
"openclaw-bootstrap-operator-",
|
|
);
|
|
|
|
try {
|
|
const issued = await issueDeviceBootstrapToken({
|
|
profile: {
|
|
roles: ["operator"],
|
|
scopes: ["operator.read"],
|
|
},
|
|
});
|
|
const wsBootstrap = await openWs(port, REMOTE_BOOTSTRAP_HEADERS);
|
|
const initial = await connectReq(wsBootstrap, {
|
|
skipDefaultAuth: true,
|
|
bootstrapToken: issued.token,
|
|
role: "operator",
|
|
scopes: ["operator.read"],
|
|
client,
|
|
deviceIdentityPath: identityPath,
|
|
});
|
|
expect(initial.ok).toBe(false);
|
|
expect(initial.error?.message ?? "").toContain("pairing required");
|
|
expect((initial.error?.details as { code?: string } | undefined)?.code).toBe(
|
|
ConnectErrorDetailCodes.PAIRING_REQUIRED,
|
|
);
|
|
|
|
const pending = (await listDevicePairing()).pending.filter(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
expect(pending).toHaveLength(1);
|
|
expect(pending[0]?.role).toBe("operator");
|
|
expectArrayIncludes(pending[0]?.scopes, ["operator.read"]);
|
|
expect(await getPairedDevice(identity.deviceId)).toBeNull();
|
|
wsBootstrap.close();
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("auto-approves local-direct node pairing, then queues operator scope approval", async () => {
|
|
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
const { identityPath, identity, client } =
|
|
await createOperatorIdentityFixture("openclaw-device-scope-");
|
|
const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => {
|
|
const socket = new WebSocket(`ws://127.0.0.1:${port}`, {
|
|
headers: { host: "gateway.example" },
|
|
});
|
|
const challengePromise = onceMessage(
|
|
socket,
|
|
(o) => o.type === "event" && o.event === "connect.challenge",
|
|
);
|
|
await new Promise<void>((resolve) => socket.once("open", resolve));
|
|
const challenge = await challengePromise;
|
|
const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce;
|
|
expect(typeof nonce).toBe("string");
|
|
const result = await connectReq(socket, {
|
|
token: "secret",
|
|
role,
|
|
scopes,
|
|
client,
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client,
|
|
role,
|
|
scopes,
|
|
nonce: String(nonce),
|
|
}),
|
|
});
|
|
socket.close();
|
|
return result;
|
|
};
|
|
|
|
const nodeConnect = await connectWithNonce("node", []);
|
|
expect(nodeConnect.ok).toBe(true);
|
|
|
|
const operatorConnect = await connectWithNonce("operator", ["operator.read", "operator.write"]);
|
|
expect(operatorConnect.ok).toBe(false);
|
|
expect(operatorConnect.error?.message ?? "").toContain("pairing required");
|
|
|
|
const pending = await listDevicePairing();
|
|
const pendingForTestDevice = pending.pending.filter(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
expect(pendingForTestDevice).toHaveLength(1);
|
|
expectArrayIncludes(pendingForTestDevice[0]?.scopes, ["operator.read", "operator.write"]);
|
|
|
|
const paired = await getPairedDevice(identity.deviceId);
|
|
expectArrayIncludes(paired?.roles, ["node", "operator"]);
|
|
expectArrayIncludes(paired?.approvedScopes, ["operator.read", "operator.write"]);
|
|
|
|
const approvedOperatorConnect = await connectWithNonce("operator", ["operator.read"]);
|
|
expect(approvedOperatorConnect.ok).toBe(true);
|
|
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
});
|
|
|
|
test("allows operator.read connect when device is paired with operator.admin", async () => {
|
|
const { listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { identityPath, identity } = await seedApprovedOperatorReadPairing({
|
|
identityPrefix: "openclaw-device-admin-superset-",
|
|
clientId: TEST_OPERATOR_CLIENT.id,
|
|
clientMode: TEST_OPERATOR_CLIENT.mode,
|
|
displayName: "operator-admin-superset",
|
|
platform: TEST_OPERATOR_CLIENT.platform,
|
|
scopes: ["operator.admin"],
|
|
});
|
|
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
|
|
const ws2 = await openWs(port);
|
|
const nonce2 = await readConnectChallengeNonce(ws2);
|
|
const res = await connectReq(ws2, {
|
|
token: "secret",
|
|
scopes: ["operator.read"],
|
|
client: TEST_OPERATOR_CLIENT,
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client: TEST_OPERATOR_CLIENT,
|
|
scopes: ["operator.read"],
|
|
nonce: nonce2,
|
|
}),
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
ws2.close();
|
|
|
|
const list = await listDevicePairing();
|
|
expect(list.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
|
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
});
|
|
|
|
test("allows operator shared auth with legacy paired metadata", async () => {
|
|
const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js");
|
|
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
|
await import("../infra/device-pairing.js");
|
|
const { identityPath, identity } = await createOperatorIdentityFixture(
|
|
"openclaw-device-legacy-meta-",
|
|
);
|
|
const deviceId = identity.deviceId;
|
|
const publicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
|
const pending = await requestDevicePairing({
|
|
deviceId,
|
|
publicKey,
|
|
role: "operator",
|
|
scopes: ["operator.read"],
|
|
clientId: TEST_OPERATOR_CLIENT.id,
|
|
clientMode: TEST_OPERATOR_CLIENT.mode,
|
|
displayName: "legacy-test",
|
|
platform: "test",
|
|
});
|
|
await approveDevicePairing(pending.request.requestId, {
|
|
callerScopes: pending.request.scopes ?? ["operator.admin"],
|
|
});
|
|
|
|
await stripPairedMetadataRolesAndScopes(deviceId);
|
|
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
let ws2: WebSocket | undefined;
|
|
try {
|
|
const wsReconnect = await openWs(port);
|
|
ws2 = wsReconnect;
|
|
const reconnectNonce = await readConnectChallengeNonce(wsReconnect);
|
|
const reconnect = await connectReq(wsReconnect, {
|
|
token: "secret",
|
|
scopes: ["operator.read"],
|
|
client: TEST_OPERATOR_CLIENT,
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client: TEST_OPERATOR_CLIENT,
|
|
scopes: ["operator.read"],
|
|
nonce: reconnectNonce,
|
|
}),
|
|
});
|
|
expect(reconnect.ok).toBe(true);
|
|
|
|
const repaired = await getPairedDevice(deviceId);
|
|
expect(repaired?.role).toBe("operator");
|
|
expect(repaired?.approvedScopes ?? []).toContain("operator.read");
|
|
expect(repaired?.tokens?.operator?.scopes ?? []).toContain("operator.read");
|
|
const list = await listDevicePairing();
|
|
expect(list.pending.filter((entry) => entry.deviceId === deviceId)).toEqual([]);
|
|
} finally {
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
ws2?.close();
|
|
}
|
|
});
|
|
|
|
test("requires approval for local scope upgrades even when paired metadata is legacy-shaped", async () => {
|
|
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
|
identityPrefix: "openclaw-device-legacy-",
|
|
clientId: TEST_OPERATOR_CLIENT.id,
|
|
clientMode: TEST_OPERATOR_CLIENT.mode,
|
|
displayName: "legacy-upgrade-test",
|
|
platform: "test",
|
|
});
|
|
|
|
await stripPairedMetadataRolesAndScopes(identity.deviceId);
|
|
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
let ws2: WebSocket | undefined;
|
|
try {
|
|
const client = { ...TEST_OPERATOR_CLIENT };
|
|
|
|
const wsUpgrade = await openWs(port);
|
|
ws2 = wsUpgrade;
|
|
const upgradeNonce = await readConnectChallengeNonce(wsUpgrade);
|
|
const upgraded = await connectReq(wsUpgrade, {
|
|
token: "secret",
|
|
scopes: ["operator.admin"],
|
|
client,
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client,
|
|
scopes: ["operator.admin"],
|
|
nonce: upgradeNonce,
|
|
}),
|
|
});
|
|
expect(upgraded.ok).toBe(false);
|
|
expect(upgraded.error?.message ?? "").toContain("pairing required");
|
|
expect(
|
|
(
|
|
upgraded.error?.details as
|
|
| {
|
|
reason?: string;
|
|
requestedRole?: string;
|
|
requestedScopes?: string[];
|
|
approvedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.reason,
|
|
).toBe("scope-upgrade");
|
|
expect(
|
|
(
|
|
upgraded.error?.details as
|
|
| {
|
|
reason?: string;
|
|
requestedRole?: string;
|
|
requestedScopes?: string[];
|
|
approvedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.requestedRole,
|
|
).toBe("operator");
|
|
expect(
|
|
(
|
|
upgraded.error?.details as
|
|
| {
|
|
reason?: string;
|
|
requestedRole?: string;
|
|
requestedScopes?: string[];
|
|
approvedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.requestedScopes,
|
|
).toEqual(["operator.admin"]);
|
|
expect(
|
|
(
|
|
upgraded.error?.details as
|
|
| {
|
|
reason?: string;
|
|
requestedRole?: string;
|
|
requestedScopes?: string[];
|
|
approvedScopes?: string[];
|
|
}
|
|
| undefined
|
|
)?.approvedScopes,
|
|
).toEqual(["operator.read"]);
|
|
wsUpgrade.close();
|
|
|
|
const pendingUpgrade = (await listDevicePairing()).pending.find(
|
|
(entry) => entry.deviceId === identity.deviceId,
|
|
);
|
|
if (!pendingUpgrade) {
|
|
throw new Error(`expected pending upgrade for device ${identity.deviceId}`);
|
|
}
|
|
expectArrayIncludes(pendingUpgrade.scopes, ["operator.admin"]);
|
|
const repaired = await getPairedDevice(identity.deviceId);
|
|
expect(repaired?.role).toBe("operator");
|
|
expectArrayIncludes(repaired?.approvedScopes, ["operator.read"]);
|
|
} finally {
|
|
ws2?.close();
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("rejects revoked device token", async () => {
|
|
const { revokeDeviceToken } = await import("../infra/device-pairing.js");
|
|
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret");
|
|
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, deviceIdentityPath });
|
|
expect(res2.ok).toBe(false);
|
|
|
|
ws2.close();
|
|
await server.close();
|
|
if (prevToken === undefined) {
|
|
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
} else {
|
|
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
|
}
|
|
});
|
|
|
|
test("allows gateway backend loopback shared-auth connections without device pairing", async () => {
|
|
const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret");
|
|
const sockets = [ws];
|
|
try {
|
|
const backendCases: Array<{
|
|
name: string;
|
|
headers?: Record<string, string>;
|
|
socket?: WebSocket;
|
|
}> = [
|
|
{ name: "default host", socket: ws },
|
|
{ name: "remote-looking host", headers: { host: "gateway.example" } },
|
|
{ name: "private host", headers: { host: "172.17.0.2:18789" } },
|
|
];
|
|
|
|
for (const backendCase of backendCases) {
|
|
const socket = backendCase.socket ?? (await openWs(port, backendCase.headers));
|
|
if (!backendCase.socket) {
|
|
sockets.push(socket);
|
|
}
|
|
const backendConnect = await connectReq(socket, {
|
|
token: "secret",
|
|
client: BACKEND_GATEWAY_CLIENT,
|
|
});
|
|
expect(backendConnect.ok, backendCase.name).toBe(true);
|
|
}
|
|
} finally {
|
|
for (const socket of sockets) {
|
|
socket.close();
|
|
}
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => {
|
|
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" });
|
|
try {
|
|
const { identity, identityPath } =
|
|
await createOperatorIdentityFixture("openclaw-cli-docker-");
|
|
const nonce = await readConnectChallengeNonce(wsDockerCli);
|
|
const dockerCli = await connectReq(wsDockerCli, {
|
|
token: "secret",
|
|
client: {
|
|
id: GATEWAY_CLIENT_NAMES.CLI,
|
|
version: "1.0.0",
|
|
platform: "linux",
|
|
mode: GATEWAY_CLIENT_MODES.CLI,
|
|
},
|
|
device: await buildSignedDeviceForIdentity({
|
|
identityPath,
|
|
client: {
|
|
id: GATEWAY_CLIENT_NAMES.CLI,
|
|
mode: GATEWAY_CLIENT_MODES.CLI,
|
|
},
|
|
scopes: ["operator.admin"],
|
|
nonce,
|
|
}),
|
|
});
|
|
expect(dockerCli.ok).toBe(true);
|
|
const pending = await listDevicePairing();
|
|
expect(pending.pending.filter((entry) => entry.deviceId === identity.deviceId)).toEqual([]);
|
|
if (!(await getPairedDevice(identity.deviceId))) {
|
|
throw new Error(`expected paired device ${identity.deviceId}`);
|
|
}
|
|
} finally {
|
|
wsDockerCli.close();
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
|
|
test("allows CLI clients on loopback even when the host header is not private-or-loopback", async () => {
|
|
const { server, port, prevToken } = await startControlUiServer("secret");
|
|
const wsRemoteLike = await openWs(port, { host: "gateway.example" });
|
|
try {
|
|
const remoteCli = await connectReq(wsRemoteLike, {
|
|
token: "secret",
|
|
client: {
|
|
id: GATEWAY_CLIENT_NAMES.CLI,
|
|
version: "1.0.0",
|
|
platform: "linux",
|
|
mode: GATEWAY_CLIENT_MODES.CLI,
|
|
},
|
|
});
|
|
expect(remoteCli.ok).toBe(true);
|
|
} finally {
|
|
wsRemoteLike.close();
|
|
await server.close();
|
|
restoreGatewayToken(prevToken);
|
|
}
|
|
});
|
|
}
|