From 9924e2d7a72dc14c91ba2cb4ee2c697b2e107a46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 18 Aug 2026 16:08:48 -0700 Subject: [PATCH] feat(cli): prepare session-host onboarding (#125879) * feat(cli): prepare session-host onboarding * fix(cli): gate session-host installer capability --- docs/cli/connect.md | 34 ++- docs/plan/runners.md | 11 + scripts/connect.sh | 216 +++++++++++++++++++ src/cli/connect-cli.test.ts | 143 +++++++++++-- src/cli/connect-cli.ts | 36 +++- test/scripts/connect-installer.test.ts | 273 +++++++++++++++++++++++++ 6 files changed, 688 insertions(+), 25 deletions(-) create mode 100755 scripts/connect.sh create mode 100644 test/scripts/connect-installer.test.ts diff --git a/docs/cli/connect.md b/docs/cli/connect.md index 840f8c24110f..f2391e1ff38c 100644 --- a/docs/cli/connect.md +++ b/docs/cli/connect.md @@ -47,6 +47,16 @@ npx openclaw connect https://gateway.example/j/ --display-name "Build The node stays in the foreground until you stop it. +To let that foreground process host full worker sessions, give explicit local +consent with `--session-host`: + +```bash +npx openclaw connect https://gateway.example/j/ --session-host +``` + +Foreground consent applies only to that process. It does not change +`openclaw.json`, so the next normal node-host start remains non-hosting. + ## Environment-managed cloud nodes Worker providers use `--ephemeral` for disposable cloud machines: @@ -55,7 +65,7 @@ Worker providers use `--ephemeral` for disposable cloud machines: npx openclaw connect --ephemeral ``` -This process hosts worker sessions even when the machine's durable node config has worker hosting disabled. It does not install a service and cannot be combined with `--service`. The Gateway owns the setup identity and paired-node lifetime: provider replay resumes the persisted device token after the one-shot setup credential is consumed, and environment teardown removes the node role after releasing the cloud lease. +This process hosts worker sessions even when the machine's durable node config has worker hosting disabled. It does not install a service and cannot be combined with `--service` or `--session-host`. The Gateway owns the setup identity and paired-node lifetime: provider replay resumes the persisted device token after the one-shot setup credential is consumed, and environment teardown removes the node role after releasing the cloud lease. `--ephemeral` is intended for provider-managed state directories on throwaway machines, not as a shortcut for enrolling a personal device. @@ -74,6 +84,20 @@ or node-host configuration; later starts use the durable paired-device token. Use [`openclaw node status`](/cli/node#service-background) to inspect the installed service. +The service does not host worker sessions by default. To consent to full +worker-session hosting, add `--session-host`: + +```bash +npx openclaw connect https://gateway.example/j/ --service --session-host +``` + +The one-shot bootstrap connection authenticates and saves the durable device +identity without advertising worker hosting. Only after that connection +succeeds does OpenClaw persist `nodeHost.workerRuns.enabled=true`, preserving +the rest of the config, and install the service. If the config write fails, +service installation does not start. The installed service advertises worker +hosting and exact capacity from this durable consent when it starts. + ## Accepted targets `openclaw connect ` accepts: @@ -82,13 +106,19 @@ installed service. - an `oc-pair://` URL; - a bare base64url setup code. +`--target-file ` reads the target from a private file and removes that file +after reading it. The dormant installer wrapper uses this handoff to keep the +single-use target out of child-process arguments. + Join URLs must use HTTPS. Plain HTTP is accepted only for loopback Gateway URLs such as `http://127.0.0.1/j/`. Direct setup codes can carry the Gateway TLS certificate fingerprint, which lets the node host pin a self-signed Gateway certificate after decoding the payload. The payload determines the saved host, port, TLS mode, WebSocket context path, -and ordered fallback endpoints. No additional `openclaw.json` keys are created. +and ordered fallback endpoints. Normal and foreground connections do not add +`openclaw.json` keys; `--service --session-host` explicitly persists the worker +hosting consent described above. ## Revocation behavior diff --git a/docs/plan/runners.md b/docs/plan/runners.md index 169b7a1dc1ae..cad8f0374baf 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -299,6 +299,17 @@ get here, Tailscale's key/device revocation split is the documented model): fences in-flight placements. Node auto-cleanup after a long dead period mirrors runner-industry practice. +Repository preparation now includes a dormant `scripts/connect.sh` wrapper. +It requires an exact OpenClaw version, installs that version into the dedicated +CLI prefix, and hands the join target to `openclaw connect --service +--session-host` through a private temporary file. Before creating that file, it +verifies the installed exact CLI's `connect --help` advertises `--target-file`, +`--service`, and `--session-host`; unsupported versions fail before the +single-use target is handed off. The wrapper is not hosted, website-synced, or +emitted by the UI or devices CLI in this slice. Public activation still requires +an explicitly authorized stable release and publish, followed by a separate +activation change that hosts and emits the released wrapper. + ### Bundle and updates (milestone 7) Exact-hash admission stays. The pinned, content-hashed bundle is pushed to diff --git a/scripts/connect.sh b/scripts/connect.sh new file mode 100755 index 000000000000..20f3d82a0da6 --- /dev/null +++ b/scripts/connect.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +set -eEuo pipefail + +# Dormant fresh-machine session-host bootstrap. Public website activation is a +# separate release/publish step; this repository wrapper is safe to invoke directly. + +umask 077 + +VERSION="" +PREFIX="${OPENCLAW_PREFIX:-}" +DISPLAY_NAME="" +JOIN_TARGET="" +TEMP_DIR="" +FAILURE_CONTEXT="Connect setup failed. Review the preceding output and retry." +ERROR_REPORTED=0 + +print_usage() { + cat <<'EOF' +Usage: connect.sh --version [--prefix ] [--display-name ] + +Installs an exact OpenClaw CLI version, connects the machine as a worker-session +host, and installs the node service. The join target is handed to OpenClaw through +a private temporary file, never as a child-process argument. + +Options: + --version Required exact version, for example 2026.8.1 + --prefix Install prefix (default: ~/.openclaw or $OPENCLAW_PREFIX) + --display-name Override the node display name + -h, --help Show this help + +Environment: + OPENCLAW_INSTALL_CLI_URL HTTPS installer URL, file:// URL, or local installer path +EOF +} + +cleanup() { + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + rm -rf -- "$TEMP_DIR" + fi +} + +on_exit() { + local status=$? + cleanup + if [[ $status -ne 0 && $ERROR_REPORTED -eq 0 ]]; then + printf 'ERROR: %s\n' "$FAILURE_CONTEXT" >&2 + fi +} +trap on_exit EXIT + +fail() { + ERROR_REPORTED=1 + printf 'ERROR: %s\n' "$1" >&2 + exit 1 +} + +require_value() { + local option="$1" + local value="${2:-}" + if [[ -z "$value" || "$value" == --* ]]; then + fail "${option} requires a value. Run connect.sh --help for usage." + fi +} + +is_exact_version() { + local value="$1" + local number='(0|[1-9][0-9]*)' + local prerelease='(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)' + local pattern="^${number}\\.${number}\\.${number}(-${prerelease}(\\.${prerelease})*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$" + [[ "$value" =~ $pattern ]] +} + +require_home_for_prefix() { + local message="$1" + local home="${HOME:-}" + if [[ -z "$home" || "$home" == "/" || ! -d "$home" ]]; then + fail "$message" + fi +} + +resolve_prefix() { + case "$PREFIX" in + \~) + require_home_for_prefix "Cannot expand prefix '~': HOME is unavailable. Pass an absolute --prefix or set OPENCLAW_PREFIX." + PREFIX="$HOME" + ;; + \~/*) + require_home_for_prefix "Cannot expand prefix '${PREFIX}': HOME is unavailable. Pass an absolute --prefix or set OPENCLAW_PREFIX." + PREFIX="${HOME}${PREFIX:1}" + ;; + /*) ;; + *) PREFIX="${PWD}/${PREFIX}" ;; + esac +} + +download_installer() { + local source="$1" + local destination="$2" + local local_path="" + + if [[ -f "$source" ]]; then + cp -- "$source" "$destination" + return + fi + case "$source" in + file://*) + local_path="${source#file://}" + [[ -f "$local_path" ]] || fail "Installer override does not exist: ${local_path}" + cp -- "$local_path" "$destination" + ;; + https://*) + if command -v curl >/dev/null 2>&1; then + curl -fsSL --proto '=https' --tlsv1.2 \ + --speed-limit 1 --speed-time 30 \ + --retry 3 --retry-delay 1 --retry-connrefused \ + -o "$destination" -- "$source" + elif command -v wget >/dev/null 2>&1; then + wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 \ + -O "$destination" -- "$source" + else + fail "Missing downloader. Install curl or wget, then retry." + fi + ;; + *) + fail "OPENCLAW_INSTALL_CLI_URL must be HTTPS or a readable local installer path." + ;; + esac +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + require_value "$1" "${2:-}" + VERSION="$2" + shift 2 + ;; + --prefix) + require_value "$1" "${2:-}" + PREFIX="$2" + shift 2 + ;; + --display-name) + require_value "$1" "${2:-}" + DISPLAY_NAME="$2" + shift 2 + ;; + -h|--help) + print_usage + exit 0 + ;; + --*) + fail "Unknown option: $1. Run connect.sh --help for usage." + ;; + *) + if [[ -n "$JOIN_TARGET" ]]; then + fail "Exactly one join target is required. Run connect.sh --help for usage." + fi + JOIN_TARGET="$1" + shift + ;; + esac +done + +[[ -n "$VERSION" ]] || fail "--version is required and must name an exact published version." +if ! is_exact_version "$VERSION"; then + fail "Invalid --version '${VERSION}'. Use an exact registry version such as 2026.8.1; leading v, moving tags, ranges, and wildcards are not allowed." +fi +[[ -n "$JOIN_TARGET" ]] || fail "A join target is required. Mint one with 'openclaw devices join-code' and retry." +if [[ -z "$PREFIX" ]]; then + require_home_for_prefix "Cannot resolve the default install prefix: pass --prefix, set OPENCLAW_PREFIX, or provide an existing HOME directory." + PREFIX="${HOME}/.openclaw" +fi + +resolve_prefix +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-connect.XXXXXX")" +chmod 0700 "$TEMP_DIR" +INSTALLER_PATH="${TEMP_DIR}/install-cli.sh" +TARGET_FILE="${TEMP_DIR}/join-target" +INSTALLER_SOURCE="${OPENCLAW_INSTALL_CLI_URL:-https://openclaw.ai/install-cli.sh}" + +FAILURE_CONTEXT="Could not obtain the OpenClaw CLI installer. Check network access or OPENCLAW_INSTALL_CLI_URL, then retry." +download_installer "$INSTALLER_SOURCE" "$INSTALLER_PATH" +[[ -s "$INSTALLER_PATH" ]] || fail "The OpenClaw CLI installer was empty. Check the installer source and retry." +chmod 0700 "$INSTALLER_PATH" + +FAILURE_CONTEXT="OpenClaw CLI installation failed. Verify the exact version and install prefix, then retry." +bash "$INSTALLER_PATH" --version "$VERSION" --prefix "$PREFIX" --no-onboard + +OPENCLAW_BIN="${PREFIX}/bin/openclaw" +[[ -x "$OPENCLAW_BIN" ]] || fail "Installed OpenClaw CLI is missing at ${OPENCLAW_BIN}. Check the installer output and retry." + +CAPABILITY_ERROR="The selected exact version ${VERSION} does not support session-host onboarding. Choose a newer supporting exact version and retry." +if ! CONNECT_HELP="$("$OPENCLAW_BIN" connect --help 2>&1)"; then + fail "$CAPABILITY_ERROR" +fi +for required_flag in --target-file --service --session-host; do + if ! grep -Eq -- "^[[:space:]]+${required_flag}([[:space:]=<]|$)" <<<"$CONNECT_HELP"; then + fail "$CAPABILITY_ERROR" + fi +done +unset CONNECT_HELP + +: >"$TARGET_FILE" +chmod 0600 "$TARGET_FILE" +printf '%s\n' "$JOIN_TARGET" >"$TARGET_FILE" +JOIN_TARGET="" + +CONNECT_ARGS=(connect --target-file "$TARGET_FILE" --service --session-host) +if [[ -n "$DISPLAY_NAME" ]]; then + CONNECT_ARGS+=(--display-name "$DISPLAY_NAME") +fi + +FAILURE_CONTEXT="OpenClaw could not connect or install the session-host service. Mint a fresh join target, verify Gateway reachability, and retry." +"$OPENCLAW_BIN" "${CONNECT_ARGS[@]}" + +printf 'OpenClaw session-host service installed.\n' diff --git a/src/cli/connect-cli.test.ts b/src/cli/connect-cli.test.ts index 3e42dab38d5d..51ee1e16e18b 100644 --- a/src/cli/connect-cli.test.ts +++ b/src/cli/connect-cli.test.ts @@ -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>(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 (?:[ \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)}`]); diff --git a/src/cli/connect-cli.ts b/src/cli/connect-cli.ts index f10dd7b290d2..24840989d542 100644 --- a/src/cli/connect-cli.ts +++ b/src/cli/connect-cli.ts @@ -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 { + 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 ").hideHelp()) + .option( + "--session-host", + "Host worker sessions (process-scoped unless installed as a service)", + false, + ) + .option("--target-file ", "Read the connect target from a private file and remove it") .option("--display-name ", "Override the node display name") .addHelpText( "after", @@ -249,6 +273,10 @@ export function registerConnectCli(program: Command): void { "openclaw connect https://gateway.example/j/ --service", "Install the node host service.", ], + [ + "openclaw connect https://gateway.example/j/ --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) => { diff --git a/test/scripts/connect-installer.test.ts b/test/scripts/connect-installer.test.ts new file mode 100644 index 000000000000..d6063a750fe3 --- /dev/null +++ b/test/scripts/connect-installer.test.ts @@ -0,0 +1,273 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const SCRIPT_PATH = "scripts/connect.sh"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function createFixture() { + const root = tempDirs.make("openclaw-connect-installer-"); + const installer = join(root, "install-cli.sh"); + const installArgs = join(root, "install-args"); + const helpArgs = join(root, "help-args"); + const connectArgs = join(root, "connect-args"); + const targetContent = join(root, "target-content"); + const targetCreatedBeforeHelp = join(root, "target-created-before-help"); + const targetMode = join(root, "target-mode"); + const targetPath = join(root, "target-path"); + writeFileSync( + installer, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" >"$FAKE_INSTALL_ARGS" +prefix="" +while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) prefix="$2"; shift 2 ;; + *) shift ;; + esac +done +[[ -n "$prefix" ]] +mkdir -p "$prefix/bin" +cat >"$prefix/bin/openclaw" <<'OPENCLAW' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$#" -eq 2 && "$1" == "connect" && "$2" == "--help" ]]; then + printf '%s\n' "$@" >"$FAKE_HELP_ARGS" + target_files=("\${TMPDIR:-/tmp}"/openclaw-connect.*/join-target) + if [[ -e "\${target_files[0]}" ]]; then + : >"$FAKE_TARGET_CREATED_BEFORE_HELP" + fi + if [[ -n "\${FAKE_CONNECT_HELP:-}" ]]; then + printf '%s\n' "$FAKE_CONNECT_HELP" + else + printf '%s\n' ' --target-file ' ' --service' ' --session-host' + fi + exit "\${FAKE_HELP_EXIT:-0}" +fi +printf '%s\n' "$@" >"$FAKE_CONNECT_ARGS" +target_file="" +while [[ $# -gt 0 ]]; do + case "$1" in + --target-file) target_file="$2"; shift 2 ;; + *) shift ;; + esac +done +[[ -n "$target_file" ]] +printf '%s\n' "$target_file" >"$FAKE_TARGET_PATH" +if mode="$(stat -f '%Lp' "$target_file" 2>/dev/null)"; then + printf '%s\n' "$mode" >"$FAKE_TARGET_MODE" +else + stat -c '%a' "$target_file" >"$FAKE_TARGET_MODE" +fi +cat "$target_file" >"$FAKE_TARGET_CONTENT" +exit "\${FAKE_CLI_EXIT:-0}" +OPENCLAW +chmod 0755 "$prefix/bin/openclaw" +`, + ); + chmodSync(installer, 0o755); + return { + root, + installer, + installArgs, + helpArgs, + connectArgs, + targetContent, + targetCreatedBeforeHelp, + targetMode, + targetPath, + }; +} + +function runWrapper( + fixture: ReturnType, + args: string[], + env: NodeJS.ProcessEnv = {}, +) { + return spawnSync("/bin/bash", [SCRIPT_PATH, ...args], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_INSTALL_CLI_URL: fixture.installer, + FAKE_INSTALL_ARGS: fixture.installArgs, + FAKE_HELP_ARGS: fixture.helpArgs, + FAKE_CONNECT_ARGS: fixture.connectArgs, + FAKE_TARGET_CONTENT: fixture.targetContent, + FAKE_TARGET_CREATED_BEFORE_HELP: fixture.targetCreatedBeforeHelp, + FAKE_TARGET_MODE: fixture.targetMode, + FAKE_TARGET_PATH: fixture.targetPath, + TMPDIR: fixture.root, + ...env, + }, + }); +} + +function readArgs(path: string): string[] { + return readFileSync(path, "utf8").trim().split("\n"); +} + +describe("scripts/connect.sh", () => { + it("installs an exact version and hands the private target to a session-host service", () => { + const fixture = createFixture(); + const prefix = join(fixture.root, "prefix"); + const target = "oc-pair://private-join-target"; + + const result = runWrapper( + fixture, + ["--version", "2026.8.1", "--prefix", prefix, "--display-name", "Runner Node", target], + { HOME: undefined }, + ); + + expect(result.status, result.stderr).toBe(0); + expect(readArgs(fixture.installArgs)).toEqual([ + "--version", + "2026.8.1", + "--prefix", + prefix, + "--no-onboard", + ]); + expect(readArgs(fixture.helpArgs)).toEqual(["connect", "--help"]); + expect(existsSync(fixture.targetCreatedBeforeHelp)).toBe(false); + const connectArgs = readArgs(fixture.connectArgs); + const privateTargetPath = readFileSync(fixture.targetPath, "utf8").trim(); + expect(connectArgs).toEqual([ + "connect", + "--target-file", + privateTargetPath, + "--service", + "--session-host", + "--display-name", + "Runner Node", + ]); + expect(readFileSync(fixture.targetContent, "utf8")).toBe(`${target}\n`); + expect(readFileSync(fixture.targetMode, "utf8").trim()).toBe("600"); + expect(readFileSync(fixture.installArgs, "utf8")).not.toContain(target); + expect(readFileSync(fixture.connectArgs, "utf8")).not.toContain(target); + expect(`${result.stdout}\n${result.stderr}`).not.toContain(target); + expect(existsSync(privateTargetPath)).toBe(false); + expect(existsSync(dirname(privateTargetPath))).toBe(false); + }); + + it.each([ + { name: "the help probe fails", env: { FAKE_HELP_EXIT: "2" } }, + { + name: "help omits --target-file", + env: { FAKE_CONNECT_HELP: " --service\n --session-host" }, + }, + { + name: "help omits --service", + env: { FAKE_CONNECT_HELP: " --target-file \n --session-host" }, + }, + { + name: "help omits --session-host", + env: { FAKE_CONNECT_HELP: " --target-file \n --service" }, + }, + ])("rejects an installed CLI when $name", ({ env }) => { + const fixture = createFixture(); + const prefix = join(fixture.root, "prefix"); + const target = "oc-pair://private-join-target"; + + const result = runWrapper( + fixture, + ["--version", "2026.7.1-2", "--prefix", prefix, target], + env, + ); + + expect(result.status).toBe(1); + expect(readArgs(fixture.installArgs)).toContain("2026.7.1-2"); + expect(readArgs(fixture.helpArgs)).toEqual(["connect", "--help"]); + expect(result.stderr).toContain( + "selected exact version 2026.7.1-2 does not support session-host onboarding", + ); + expect(result.stderr).toContain("Choose a newer supporting exact version"); + expect(result.stderr.match(/ERROR:/gu)).toHaveLength(1); + expect(existsSync(fixture.targetCreatedBeforeHelp)).toBe(false); + expect(existsSync(fixture.connectArgs)).toBe(false); + expect(existsSync(fixture.targetPath)).toBe(false); + expect(existsSync(fixture.targetContent)).toBe(false); + expect(`${result.stdout}\n${result.stderr}`).not.toContain(target); + }); + + it("respects OPENCLAW_PREFIX when --prefix is omitted", () => { + const fixture = createFixture(); + const prefix = join(fixture.root, "env-prefix"); + + const result = runWrapper(fixture, ["--version", "2026.8.1", "setup-code"], { + OPENCLAW_PREFIX: prefix, + }); + + expect(result.status, result.stderr).toBe(0); + expect(readArgs(fixture.installArgs)).toContain(prefix); + }); + + it("cleans the private target after an installed CLI failure", () => { + const fixture = createFixture(); + const prefix = join(fixture.root, "prefix"); + + const result = runWrapper( + fixture, + ["--version", "2026.8.1", "--prefix", prefix, "setup-code"], + { FAKE_CLI_EXIT: "23" }, + ); + + const privateTargetPath = readFileSync(fixture.targetPath, "utf8").trim(); + expect(result.status).toBe(23); + expect(result.stderr).toContain( + "OpenClaw could not connect or install the session-host service.", + ); + expect(existsSync(privateTargetPath)).toBe(false); + expect(existsSync(dirname(privateTargetPath))).toBe(false); + }); + + it("requires an explicit version", () => { + const fixture = createFixture(); + + const result = runWrapper(fixture, ["setup-code"]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("--version is required"); + expect(existsSync(fixture.installArgs)).toBe(false); + }); + + it.each([ + { + name: "default prefix", + args: ["--version", "2026.8.1", "setup-code"], + message: "Cannot resolve the default install prefix", + }, + { + name: "tilde prefix", + args: ["--version", "2026.8.1", "--prefix", "~/.openclaw", "setup-code"], + message: "Cannot expand prefix '~/.openclaw'", + }, + ])("fails cleanly without HOME for the $name", ({ args, message }) => { + const fixture = createFixture(); + + const result = runWrapper(fixture, args, { + HOME: undefined, + OPENCLAW_PREFIX: undefined, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(message); + expect(result.stderr.match(/ERROR:/gu)).toHaveLength(1); + expect(existsSync(fixture.installArgs)).toBe(false); + }); + + it.each(["latest", "next", "beta", "v2026.8.1", "2026.8", "2026.8.x", "^2026.8.1", "2026.8.*"])( + "rejects non-exact version %s", + (version) => { + const fixture = createFixture(); + + const result = runWrapper(fixture, ["--version", version, "setup-code"]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("leading v, moving tags, ranges, and wildcards"); + expect(existsSync(fixture.installArgs)).toBe(false); + }, + ); +});