Files
openclaw/src/gateway/server.node-version-mismatch.test.ts
Jason (Json) 65b460f234 fix(nodes): surface pending reapproval diagnostics (#92547)
* fix(nodes): surface pending reapproval diagnostics

* fix(nodes): harden reapproval diagnostics

* fix(nodes): scope pending diagnostics

* fix(nodes): request pairing diagnostics in cli

* fix(nodes): reuse stored auth for diagnostics

* fix(nodes): preserve selected diagnostics credentials

* fix(nodes): prefer approved diagnostics auth

* fix(nodes): narrow diagnostics fallbacks

* fix(nodes): recover from stale diagnostics auth

* fix(gateway): preserve connect error narrowing

* fix(nodes): isolate privileged diagnostics auth

* fix(nodes): constrain privileged diagnostics auth

* fix(nodes): close diagnostics review gaps

* fix(nodes): guard reapproval cleanup races

* fix(nodes): defer stale pairing cleanup

* fix(nodes): preserve reapproval on hello failure

* test(nodes): await post-handshake reapproval cleanup

* test(nodes): avoid unbound websocket send capture

* fix(nodes): allow local auth-none diagnostics

* fix(nodes): preserve overlapping reapproval

* fix(nodes): preserve pending node metadata

* fix(nodes): keep connection age with status

* fix(nodes): preserve reapproval during reconnect races

* fix(nodes): serialize reapproval cleanup

* fix(nodes): bound reapproval reconnect races

* test(nodes): satisfy cleanup claim lint

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-13 17:40:18 -07:00

216 lines
7.8 KiB
TypeScript

// Node version mismatch tests protect local node identity/version checks so the
// gateway accepts matching node hosts and rejects incompatible local runtimes.
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { pairDeviceIdentity } from "./device-authz.test-helpers.js";
import { connectGatewayClient } from "./test-helpers.e2e.js";
import { installGatewayTestHooks, startServer } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const gatewayVersion = resolveRuntimeServiceVersion(process.env);
const TEST_LOCAL_NODE_ID = "test-local-node-version-mismatch";
describe("node host version mismatch guard", () => {
let port: number;
let server: Awaited<ReturnType<typeof startServer>>["server"];
beforeAll(async () => {
// Write a node.json so the gateway's resolveLocalNodeId() finds it in the test state dir.
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (stateDir) {
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(
path.join(stateDir, "node.json"),
JSON.stringify({ version: 1, nodeId: TEST_LOCAL_NODE_ID }),
);
}
const started = await startServer("secret");
port = started.port;
server = started.server;
});
afterAll(async () => {
await server?.close();
});
test("local node with matching released version connects successfully", async () => {
// Use the actual gateway version so versions match
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "test-node-match",
clientVersion: gatewayVersion,
instanceId: TEST_LOCAL_NODE_ID,
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: [],
});
expect(client).toBeDefined();
await client.stopAndWait({ timeoutMs: 2_000 });
});
test("local node with mismatched released version is rejected", async () => {
const staleVersion = "2020.1.1";
await expect(
connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "test-node-stale",
clientVersion: staleVersion,
instanceId: TEST_LOCAL_NODE_ID,
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: [],
timeoutMs: 5_000,
timeoutMessage: "expected version mismatch rejection",
}),
).rejects.toThrow(/client version mismatch|version mismatch/i);
});
test("rejected local reconnects preserve the active node pending reapproval", async () => {
const pairedNode = await pairDeviceIdentity({
name: "node-version-mismatch-pending-reapproval",
role: "node",
scopes: [],
clientId: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientMode: GATEWAY_CLIENT_MODES.NODE,
});
const initial = await requestNodePairing({
nodeId: pairedNode.identity.deviceId,
platform: "macos",
deviceFamily: "Mac",
commands: ["screen.snapshot"],
});
await approveNodePairing(initial.request.requestId, {
callerScopes: ["operator.pairing", "operator.write"],
});
const upgraded = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "test-node-upgraded",
clientVersion: gatewayVersion,
instanceId: TEST_LOCAL_NODE_ID,
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: ["screen.snapshot", "system.run"],
deviceIdentity: pairedNode.identity,
});
try {
const connectReverted = async (clientVersion: string, clientDisplayName: string) =>
await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName,
clientVersion,
instanceId: TEST_LOCAL_NODE_ID,
platform: "macos",
deviceFamily: "Mac",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: ["screen.snapshot"],
deviceIdentity: pairedNode.identity,
timeoutMs: 5_000,
timeoutMessage: "expected rejected reconnect",
});
const pendingBefore = (await listNodePairing()).pending.find(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
);
expect(pendingBefore?.commands).toEqual(["screen.snapshot", "system.run"]);
await expect(connectReverted("2020.1.1", "test-node-reverted-stale")).rejects.toThrow(
/client version mismatch|version mismatch/i,
);
const pendingAfterVersionMismatch = (await listNodePairing()).pending.find(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
);
expect(pendingAfterVersionMismatch?.requestId).toBe(pendingBefore?.requestId);
expect(pendingAfterVersionMismatch?.commands).toEqual(["screen.snapshot", "system.run"]);
const originalSend = Reflect.get(WebSocket.prototype, "send");
let failNextHelloOk = true;
const sendSpy = vi.spyOn(WebSocket.prototype, "send").mockImplementation(function (
this: WebSocket,
...args: Parameters<WebSocket["send"]>
) {
if (failNextHelloOk && typeof args[0] === "string" && args[0].includes('"hello-ok"')) {
failNextHelloOk = false;
const callback = args.findLast((arg) => typeof arg === "function");
if (typeof callback === "function") {
callback(new Error("test hello-ok send failure"));
}
return;
}
Reflect.apply(originalSend, this, args);
});
try {
await expect(
connectReverted(gatewayVersion, "test-node-reverted-hello-failure"),
).rejects.toThrow(/gateway closed during connect/i);
expect(failNextHelloOk).toBe(false);
} finally {
sendSpy.mockRestore();
}
const pendingAfterHelloFailure = (await listNodePairing()).pending.find(
(entry) => entry.nodeId === pairedNode.identity.deviceId,
);
expect(pendingAfterHelloFailure?.requestId).toBe(pendingBefore?.requestId);
expect(pendingAfterHelloFailure?.commands).toEqual(["screen.snapshot", "system.run"]);
} finally {
await upgraded.stopAndWait({ timeoutMs: 2_000 });
}
});
test("local node with dev/test version is allowed (not a released version)", async () => {
// "dev" does not match YYYY.M.PATCH, so the guard skips
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "test-node-dev",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: [],
});
expect(client).toBeDefined();
await client.stopAndWait({ timeoutMs: 2_000 });
});
test("local node with non-date version '1.0.0' is allowed", async () => {
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: "secret",
role: "node",
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: "test-node-semver",
clientVersion: "1.0.0",
mode: GATEWAY_CLIENT_MODES.NODE,
scopes: [],
commands: [],
});
expect(client).toBeDefined();
await client.stopAndWait({ timeoutMs: 2_000 });
});
});