mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
feat(cli): prepare session-host onboarding (#125879)
* feat(cli): prepare session-host onboarding * fix(cli): gate session-host installer capability
This commit is contained in:
committed by
GitHub
parent
a91f1202d9
commit
9924e2d7a7
+124
-19
@@ -4,6 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { NodeHostConfig } from "../node-host/config.js";
|
||||
import { encodePairingSetupCode } from "../pairing/setup-code.js";
|
||||
import { registerConnectCli } from "./connect-cli.js";
|
||||
@@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
runNodeDaemonInstall: vi.fn(),
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
loadNodeHostConfig: vi.fn<() => Promise<NodeHostConfig | null>>(async () => null),
|
||||
mutateConfigFileWithRetry: vi.fn(),
|
||||
runtime: {
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
@@ -21,7 +23,10 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../node-host/runner.js", () => ({ runNodeHost: mocks.runNodeHost }));
|
||||
vi.mock("../node-host/config.js", () => ({ loadNodeHostConfig: mocks.loadNodeHostConfig }));
|
||||
vi.mock("../config/config.js", () => ({ getRuntimeConfig: vi.fn(() => ({})) }));
|
||||
vi.mock("../config/config.js", () => ({
|
||||
getRuntimeConfig: vi.fn(() => ({})),
|
||||
mutateConfigFileWithRetry: mocks.mutateConfigFileWithRetry,
|
||||
}));
|
||||
vi.mock("./node-cli/daemon.js", () => ({
|
||||
runNodeDaemonInstall: mocks.runNodeDaemonInstall,
|
||||
}));
|
||||
@@ -54,9 +59,20 @@ describe("connect cli", () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.runNodeHost.mockResolvedValue(undefined);
|
||||
mocks.runNodeDaemonInstall.mockResolvedValue(undefined);
|
||||
mocks.mutateConfigFileWithRetry.mockResolvedValue(undefined);
|
||||
mocks.runtime.exit.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("advertises the wrapper-required connect options", () => {
|
||||
const program = new Command();
|
||||
registerConnectCli(program);
|
||||
const help = program.commands[0]?.helpInformation() ?? "";
|
||||
|
||||
expect(help).toMatch(/^[ \t]+--target-file <path>(?:[ \t]|$)/mu);
|
||||
expect(help).toMatch(/^[ \t]+--service(?:[ \t]|$)/mu);
|
||||
expect(help).toMatch(/^[ \t]+--session-host(?:[ \t]|$)/mu);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "bare setup code", target: () => setupCode(), fetched: false },
|
||||
{ name: "oc-pair wrapper", target: () => `oc-pair://${setupCode()}`, fetched: false },
|
||||
@@ -111,19 +127,36 @@ describe("connect cli", () => {
|
||||
expect(mocks.runNodeDaemonInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs environment-managed nodes as process-scoped session hosts", async () => {
|
||||
await runConnect([setupCode(), "--ephemeral", "--display-name", "Cloud Node"]);
|
||||
it.each([
|
||||
{
|
||||
name: "environment-managed node",
|
||||
flag: "--ephemeral",
|
||||
displayName: "Cloud Node",
|
||||
preferGatewayBootstrapToken: false,
|
||||
},
|
||||
{
|
||||
name: "operator-approved node",
|
||||
flag: "--session-host",
|
||||
displayName: "Build Node",
|
||||
preferGatewayBootstrapToken: true,
|
||||
},
|
||||
])(
|
||||
"runs a $name as a process-scoped session host",
|
||||
async ({ flag, displayName, preferGatewayBootstrapToken }) => {
|
||||
await runConnect([setupCode(), flag, "--display-name", displayName]);
|
||||
|
||||
expect(mocks.runNodeHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayBootstrapToken: "bootstrap-token",
|
||||
preferGatewayBootstrapToken: false,
|
||||
forceWorkerRuns: true,
|
||||
displayName: "Cloud Node",
|
||||
}),
|
||||
);
|
||||
expect(mocks.runNodeDaemonInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(mocks.runNodeHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gatewayBootstrapToken: "bootstrap-token",
|
||||
preferGatewayBootstrapToken,
|
||||
forceWorkerRuns: true,
|
||||
displayName,
|
||||
}),
|
||||
);
|
||||
expect(mocks.mutateConfigFileWithRetry).not.toHaveBeenCalled();
|
||||
expect(mocks.runNodeDaemonInstall).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("consumes an environment-managed target file before connecting", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-connect-target-"));
|
||||
@@ -141,18 +174,26 @@ describe("connect cli", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects service installation for environment-managed nodes", async () => {
|
||||
await runConnect([setupCode(), "--ephemeral", "--service"]);
|
||||
it.each([
|
||||
{
|
||||
args: ["--ephemeral", "--service"],
|
||||
message: "--ephemeral cannot be combined with --service.",
|
||||
},
|
||||
{
|
||||
args: ["--ephemeral", "--session-host"],
|
||||
message: "--ephemeral cannot be combined with --session-host.",
|
||||
},
|
||||
])("rejects incompatible flags: $args", async ({ args, message }) => {
|
||||
await runConnect([setupCode(), ...args]);
|
||||
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
"--ephemeral cannot be combined with --service.",
|
||||
);
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(message);
|
||||
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.runNodeHost).not.toHaveBeenCalled();
|
||||
expect(mocks.mutateConfigFileWithRetry).not.toHaveBeenCalled();
|
||||
expect(mocks.runNodeDaemonInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redeems before installing from the winning persisted endpoint", async () => {
|
||||
it("keeps the existing service path non-hosting", async () => {
|
||||
await runConnect([setupCode(), "--service", "--display-name", "Service Node"]);
|
||||
|
||||
expect(mocks.runNodeHost).toHaveBeenCalledWith(
|
||||
@@ -161,12 +202,76 @@ describe("connect cli", () => {
|
||||
stopAfterFirstConnect: true,
|
||||
}),
|
||||
);
|
||||
expect(mocks.runNodeHost.mock.calls[0]?.[0]).not.toHaveProperty("forceWorkerRuns");
|
||||
expect(mocks.mutateConfigFileWithRetry).not.toHaveBeenCalled();
|
||||
expect(mocks.runNodeDaemonInstall).toHaveBeenCalledWith({
|
||||
displayName: "Service Node",
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("authenticates before persisting hosting consent and installing the service", async () => {
|
||||
await runConnect([setupCode(), "--service", "--session-host", "--display-name", "Runner Node"]);
|
||||
|
||||
expect(mocks.runNodeHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stopAfterFirstConnect: true,
|
||||
}),
|
||||
);
|
||||
expect(mocks.runNodeHost.mock.calls[0]?.[0]).not.toHaveProperty("forceWorkerRuns");
|
||||
expect(mocks.mutateConfigFileWithRetry).toHaveBeenCalledWith({
|
||||
writeOptions: {
|
||||
auditOrigin: "cli",
|
||||
explicitSetPaths: [["nodeHost", "workerRuns", "enabled"]],
|
||||
},
|
||||
mutate: expect.any(Function),
|
||||
});
|
||||
const mutation = mocks.mutateConfigFileWithRetry.mock.calls[0]?.[0] as {
|
||||
mutate: (draft: OpenClawConfig) => void;
|
||||
};
|
||||
const draft: OpenClawConfig = {
|
||||
gateway: { port: 28443 },
|
||||
nodeHost: { skills: { enabled: false }, workerRuns: { enabled: false } },
|
||||
};
|
||||
mutation.mutate(draft);
|
||||
expect(draft).toEqual({
|
||||
gateway: { port: 28443 },
|
||||
nodeHost: { skills: { enabled: false }, workerRuns: { enabled: true } },
|
||||
});
|
||||
expect(mocks.runNodeDaemonInstall).toHaveBeenCalledWith({
|
||||
displayName: "Runner Node",
|
||||
force: true,
|
||||
});
|
||||
expect(mocks.runNodeHost.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.mutateConfigFileWithRetry.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(mocks.mutateConfigFileWithRetry.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.runNodeDaemonInstall.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ stage: "bootstrap connection", error: "bootstrap failed", mutationCalls: 0 },
|
||||
{ stage: "durable consent write", error: "config write failed", mutationCalls: 1 },
|
||||
])("does not install the service when the $stage fails", async ({ error, mutationCalls }) => {
|
||||
if (mutationCalls === 0) {
|
||||
mocks.runNodeHost.mockRejectedValueOnce(new Error(error));
|
||||
} else {
|
||||
mocks.mutateConfigFileWithRetry.mockRejectedValueOnce(new Error(error));
|
||||
}
|
||||
|
||||
await runConnect([setupCode(), "--service", "--session-host"]);
|
||||
|
||||
expect(mocks.runNodeHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stopAfterFirstConnect: true }),
|
||||
);
|
||||
expect(mocks.runNodeHost.mock.calls[0]?.[0]).not.toHaveProperty("forceWorkerRuns");
|
||||
expect(mocks.mutateConfigFileWithRetry).toHaveBeenCalledTimes(mutationCalls);
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(error);
|
||||
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(mocks.runNodeDaemonInstall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses plain HTTP join URLs for non-loopback gateways", async () => {
|
||||
await runConnect([`http://gateway.example/j/${"a".repeat(22)}`]);
|
||||
|
||||
|
||||
+32
-4
@@ -1,6 +1,6 @@
|
||||
// One-paste node onboarding from setup codes or single-use Gateway join URLs.
|
||||
import fs from "node:fs/promises";
|
||||
import { Option, type Command } from "commander";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
buildCloudflareAccessHeaders,
|
||||
CF_ACCESS_CLIENT_ID_HEADER,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../../packages/gateway-client/src/cloudflare-access.js";
|
||||
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { getRuntimeConfig, mutateConfigFileWithRetry } from "../config/config.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { cancelUnreadResponseBody, readResponseWithLimit } from "../infra/http-body.js";
|
||||
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
|
||||
@@ -33,6 +33,7 @@ import { resolveNodePairGatewayPayload } from "./node-cli/gateway-options.js";
|
||||
type ConnectCommandOptions = {
|
||||
service?: boolean;
|
||||
ephemeral?: boolean;
|
||||
sessionHost?: boolean;
|
||||
targetFile?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
@@ -165,6 +166,9 @@ async function runConnectCommand(
|
||||
target: string | undefined,
|
||||
opts: ConnectCommandOptions,
|
||||
): Promise<void> {
|
||||
if (opts.ephemeral && opts.sessionHost) {
|
||||
throw new Error("--ephemeral cannot be combined with --session-host.");
|
||||
}
|
||||
if (opts.ephemeral && opts.service) {
|
||||
throw new Error("--ephemeral cannot be combined with --service.");
|
||||
}
|
||||
@@ -202,6 +206,7 @@ async function runConnectCommand(
|
||||
: index === 0;
|
||||
return boundToAccessOrigin && cloudflareAccess ? { ...candidate, cloudflareAccess } : candidate;
|
||||
});
|
||||
const forceWorkerRuns = opts.ephemeral === true || (opts.sessionHost === true && !opts.service);
|
||||
const nodeRunOptions = {
|
||||
gatewayHost: pair.host,
|
||||
gatewayPort: pair.port,
|
||||
@@ -216,7 +221,7 @@ async function runConnectCommand(
|
||||
// Environment-managed nodes reuse their persisted device token when a provider
|
||||
// replays setup after the one-shot bootstrap credential has been consumed.
|
||||
preferGatewayBootstrapToken: opts.ephemeral !== true,
|
||||
...(opts.ephemeral === true ? { forceWorkerRuns: true } : {}),
|
||||
...(forceWorkerRuns ? { forceWorkerRuns: true } : {}),
|
||||
displayName: opts.displayName,
|
||||
};
|
||||
|
||||
@@ -228,6 +233,20 @@ async function runConnectCommand(
|
||||
// The first hello stores durable device auth and the winning endpoint before
|
||||
// installation, so the service never persists the one-shot bootstrap bearer.
|
||||
await runNodeHost({ ...nodeRunOptions, stopAfterFirstConnect: true });
|
||||
if (opts.sessionHost) {
|
||||
await mutateConfigFileWithRetry({
|
||||
writeOptions: {
|
||||
auditOrigin: "cli",
|
||||
explicitSetPaths: [["nodeHost", "workerRuns", "enabled"]],
|
||||
},
|
||||
mutate: (draft) => {
|
||||
draft.nodeHost = {
|
||||
...draft.nodeHost,
|
||||
workerRuns: { ...draft.nodeHost?.workerRuns, enabled: true },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
await runNodeDaemonInstall({ displayName: opts.displayName, force: true });
|
||||
}
|
||||
|
||||
@@ -238,7 +257,12 @@ export function registerConnectCli(program: Command): void {
|
||||
.argument("[target]", "oc-pair URL, setup code, or HTTPS Gateway join URL")
|
||||
.option("--service", "Install and run the node host as an OS service", false)
|
||||
.option("--ephemeral", "Run as an environment-managed disposable session host", false)
|
||||
.addOption(new Option("--target-file <path>").hideHelp())
|
||||
.option(
|
||||
"--session-host",
|
||||
"Host worker sessions (process-scoped unless installed as a service)",
|
||||
false,
|
||||
)
|
||||
.option("--target-file <path>", "Read the connect target from a private file and remove it")
|
||||
.option("--display-name <name>", "Override the node display name")
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -249,6 +273,10 @@ export function registerConnectCli(program: Command): void {
|
||||
"openclaw connect https://gateway.example/j/<code> --service",
|
||||
"Install the node host service.",
|
||||
],
|
||||
[
|
||||
"openclaw connect https://gateway.example/j/<code> --service --session-host",
|
||||
"Install a worker-session host service.",
|
||||
],
|
||||
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/connect", "docs.openclaw.ai/cli/connect")}\n`,
|
||||
)
|
||||
.action(async (target: string | undefined, opts: ConnectCommandOptions) => {
|
||||
|
||||
Reference in New Issue
Block a user