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:
Peter Steinberger
2026-08-18 16:08:48 -07:00
committed by GitHub
parent a91f1202d9
commit 9924e2d7a7
6 changed files with 688 additions and 25 deletions
+124 -19
View File
@@ -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
View File
@@ -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) => {