Files
openclaw/src/gateway/server.node-pairing-ssh-verify.test.ts
2026-07-15 13:14:35 -07:00

189 lines
7.3 KiB
TypeScript

// SSH-verified node pairing e2e: real gateway server on the LAN self-connect
// harness, with the SSH probe runtime mocked at the module boundary.
import { beforeEach, describe, expect, test, vi } from "vitest";
import { writeConfigFile } from "../config/config.js";
import {
getPairedDevice,
listDevicePairing,
requestDevicePairing,
} from "../infra/device-pairing.js";
import type {
NodeIdentityProbeParams,
NodeIdentityProbeResult,
} from "./node-pairing-ssh-verify.runtime.js";
import { installGatewayTestHooks } from "./test-helpers.js";
import { withLanNodePairingAttempt } from "./test-helpers.lan-pairing.js";
const probeMock = vi.hoisted(() =>
vi.fn<(params: NodeIdentityProbeParams) => Promise<NodeIdentityProbeResult>>(),
);
vi.mock("./node-pairing-ssh-verify.runtime.js", () => ({
runNodeIdentityProbe: (params: NodeIdentityProbeParams) => probeMock(params),
}));
vi.mock("../skills/runtime/remote.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../skills/runtime/remote.js")>()),
// Pairing coverage does not need the unrelated 5s connect-time bin refresh.
refreshRemoteNodeBins: vi.fn(async () => {}),
}));
installGatewayTestHooks({ scope: "suite" });
async function waitFor<T>(
fn: () => Promise<T | null | undefined>,
what: string,
timeoutMs = 8_000,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await fn();
if (value !== null && value !== undefined) {
return value;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
throw new Error(`timed out waiting for ${what}`);
}
type PairingRequiredDetails = {
code?: string;
recommendedNextStep?: string;
pauseReconnect?: boolean;
};
describe("gateway ssh-verified node pairing auto-approve", () => {
beforeEach(() => {
// Each case uses a distinct identityName, matching the host+device cooldown key.
probeMock.mockReset();
});
test("approves device pairing and the first capability surface on a key match", async () => {
await withLanNodePairingAttempt({
identityName: "ssh-verify-key-match",
run: async ({ lanIp, loaded, connectNode }) => {
probeMock.mockImplementation(async () => ({
status: "ok",
stdout: `motd noise\n{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`,
}));
const first = await connectNode();
expect(first.ok).toBe(false);
const details = first.error?.details as PairingRequiredDetails | undefined;
// The node must keep retrying while the detached probe can still land.
expect(details?.recommendedNextStep).toBe("wait_then_retry");
expect(details?.pauseReconnect).toBe(false);
const paired = await waitFor(async () => {
const record = await getPairedDevice(loaded.identity.deviceId);
// Wait for the ssh-verified provenance specifically: the approval
// and its read-back must survive the SQLite round-trip.
return record?.approvedVia === "ssh-verified" ? record : null;
}, "ssh-verified device approval");
expect(paired.approvedVia).toBe("ssh-verified");
expect(paired.publicKey).toBe(loaded.publicKey);
expect(probeMock).toHaveBeenCalledWith(expect.objectContaining({ host: lanIp }));
const second = await connectNode();
expect(second.ok).toBe(true);
expect((second.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok");
// The first capability surface rides on the same machine-ownership
// proof: approved without a node.pair prompt.
const record = await getPairedDevice(loaded.identity.deviceId);
expect(record?.nodeSurface).toBeDefined();
expect(record?.pendingNodeSurface).toBeUndefined();
},
});
});
test("does not ssh-approve a pending request that carries scopes from an earlier attempt", async () => {
await withLanNodePairingAttempt({
identityName: "ssh-verify-scoped-refresh",
run: async ({ loaded, connectNode }) => {
// Seed a scoped pending request (as an earlier interactive attempt
// would). The scopeless reconnect below refreshes this same request in
// place, so approving it would smuggle the scope past the fresh
// scopeless boundary. A matching probe would approve if reached.
await requestDevicePairing({
deviceId: loaded.identity.deviceId,
publicKey: loaded.publicKey,
role: "node",
roles: ["node"],
scopes: ["node.exec"],
});
probeMock.mockImplementation(async () => ({
status: "ok",
stdout: `{"deviceId":"${loaded.identity.deviceId}","publicKey":"${loaded.publicKey}"}\n`,
}));
const res = await connectNode();
expect(res.ok).toBe(false);
// The scoped pending request disqualifies ssh-verify entirely: no probe
// runs and the device is never auto-approved.
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
expect(probeMock).not.toHaveBeenCalled();
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
},
});
});
test("leaves the pairing pending when the remote identity does not match", async () => {
await withLanNodePairingAttempt({
identityName: "ssh-verify-key-mismatch",
run: async ({ loaded, connectNode }) => {
// A different key than the pending request: assembled from words so the
// fixture is not a high-entropy blob (keeps review bundlers happy).
const wrongKey = ["not", "the", "expected", "device", "key"].join("-");
probeMock.mockImplementation(async () => ({
status: "ok",
stdout: `{"deviceId":"${loaded.identity.deviceId}","publicKey":"${wrongKey}"}\n`,
}));
const res = await connectNode();
expect(res.ok).toBe(false);
await waitFor(
async () => (probeMock.mock.calls.length > 0 ? true : undefined),
"probe execution",
);
// Give the detached approval path time to (incorrectly) land before
// asserting it did not.
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
const pending = (await listDevicePairing()).pending.filter(
(entry) => entry.deviceId === loaded.identity.deviceId,
);
expect(pending).toHaveLength(1);
},
});
});
test("sshVerify: false disables the probe and keeps default reconnect pause behavior", async () => {
await withLanNodePairingAttempt({
identityName: "ssh-verify-disabled",
beforeStart: async () => {
await writeConfigFile({
gateway: { nodes: { pairing: { sshVerify: false } } },
});
},
run: async ({ loaded, connectNode }) => {
const res = await connectNode();
expect(res.ok).toBe(false);
const details = res.error?.details as PairingRequiredDetails | undefined;
expect(details?.recommendedNextStep).toBeUndefined();
expect(details?.pauseReconnect).toBeUndefined();
expect(probeMock).not.toHaveBeenCalled();
expect(await getPairedDevice(loaded.identity.deviceId)).toBeNull();
},
});
});
});