mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 09:31:54 -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
+32
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+216
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user