mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 02:51:38 +00:00
feat(gateway): auto-approve node pairing via SSH device-key verification (#104180)
This commit is contained in:
committed by
GitHub
parent
dc42c893c3
commit
58b0ec9e50
71
src/cli/node-cli/identity.test.ts
Normal file
71
src/cli/node-cli/identity.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Node identity CLI tests: read-only output of the node host device identity.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
} from "../../infra/device-identity.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { runNodeIdentityShow } from "./identity.js";
|
||||
|
||||
describe("runNodeIdentityShow", () => {
|
||||
let stateDir: string;
|
||||
let prevStateDir: string | undefined;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-identity-"));
|
||||
prevStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
logSpy = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = prevStateDir;
|
||||
}
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("fails closed when no identity exists (never mints one)", () => {
|
||||
runNodeIdentityShow({});
|
||||
expect(errorSpy).toHaveBeenCalledOnce();
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(fs.existsSync(path.join(stateDir, "identity", "device.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("prints deviceId and raw public key as JSON", () => {
|
||||
const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json"));
|
||||
runNodeIdentityShow({ json: true });
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledOnce();
|
||||
const parsed = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
};
|
||||
expect(parsed).toEqual({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
});
|
||||
});
|
||||
|
||||
it("prints human-readable lines without --json", () => {
|
||||
const identity = loadOrCreateDeviceIdentity(path.join(stateDir, "identity", "device.json"));
|
||||
runNodeIdentityShow({});
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
const output = logSpy.mock.calls.map((call: unknown[]) => String(call[0])).join("\n");
|
||||
expect(output).toContain(identity.deviceId);
|
||||
expect(output).toContain(publicKeyRawBase64UrlFromPem(identity.publicKeyPem));
|
||||
});
|
||||
});
|
||||
31
src/cli/node-cli/identity.ts
Normal file
31
src/cli/node-cli/identity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Prints the local node host device identity for pairing verification.
|
||||
import {
|
||||
loadDeviceIdentityIfPresent,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
} from "../../infra/device-identity.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
|
||||
/**
|
||||
* Read-only by design: the SSH-verified pairing probe calls this remotely and
|
||||
* must never mint a fresh identity on a host that has not run the node host.
|
||||
*/
|
||||
export function runNodeIdentityShow(opts: { json?: boolean }) {
|
||||
const identity = loadDeviceIdentityIfPresent();
|
||||
if (!identity) {
|
||||
defaultRuntime.error(
|
||||
"no node device identity found (start the node host once with `openclaw node run` or `openclaw node install`)",
|
||||
);
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
};
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(payload));
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(`deviceId: ${payload.deviceId}`);
|
||||
defaultRuntime.log(`publicKey: ${payload.publicKey}`);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ type LoadNodeHostConfig = typeof import("../../node-host/config.js").loadNodeHos
|
||||
|
||||
const daemonMocks = vi.hoisted(() => ({
|
||||
defaultRuntime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
runNodeDaemonStop,
|
||||
runNodeDaemonUninstall,
|
||||
} from "./daemon.js";
|
||||
import { runNodeIdentityShow } from "./identity.js";
|
||||
|
||||
function parsePortOption(value: unknown, fallback: number): number | null {
|
||||
// Undefined keeps config/default port; invalid explicit input returns null for CLI errors.
|
||||
@@ -103,6 +104,14 @@ export function registerNodeCli(program: Command) {
|
||||
await runNodeDaemonStatus(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("identity")
|
||||
.description("Print the node host device identity (device id + public key)")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action((opts) => {
|
||||
runNodeIdentityShow(opts);
|
||||
});
|
||||
|
||||
node
|
||||
.command("install")
|
||||
.description("Install the node host service (launchd/systemd/schtasks)")
|
||||
|
||||
Reference in New Issue
Block a user