// 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>["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 ) { 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 }); }); });