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
+32 -2
View File
@@ -47,6 +47,16 @@ npx openclaw connect https://gateway.example/j/<shortcode> --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/<shortcode> --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 <setup-code> --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/<shortcode> --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 <target>` accepts:
@@ -82,13 +106,19 @@ installed service.
- an `oc-pair://<setup-code>` URL;
- a bare base64url setup code.
`--target-file <path>` 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/<shortcode>`. 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
+11
View File
@@ -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
+216
View File
@@ -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 <exact-version> [--prefix <path>] [--display-name <name>] <join-target>
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 <exact-version> Required exact version, for example 2026.8.1
--prefix <path> Install prefix (default: ~/.openclaw or $OPENCLAW_PREFIX)
--display-name <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'
+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) => {
+273
View File
@@ -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 <path>' ' --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<typeof createFixture>,
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 <path>\n --session-host" },
},
{
name: "help omits --session-host",
env: { FAKE_CONNECT_HELP: " --target-file <path>\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);
},
);
});