mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 19:31:37 +00:00
189 lines
7.3 KiB
TypeScript
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();
|
|
},
|
|
});
|
|
});
|
|
});
|