openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
192 lines (191 loc) • 9.14 kB
JavaScript
import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js";
import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js";
import { r as readRegularFile } from "./regular-file-Equ4rrfE.js";
import "./http-body-D3IMwTJJ.js";
import { i as readResponseWithLimit, t as cancelUnreadResponseBody } from "./http-response-body-CwT_cCNz.js";
import { n as mutateConfigFileWithRetry } from "./mutate-ZNN4iFCn.js";
import "./config-Cs0XXL3x.js";
import { t as formatDocsLink } from "./links-ClIwBcy4.js";
import { r as theme } from "./theme-vjDs9tao.js";
import { s as isLoopbackHost } from "./net-DbNPs6Xm.js";
import { I as CF_ACCESS_CLIENT_ID_HEADER, L as CF_ACCESS_CLIENT_SECRET_HEADER, R as buildCloudflareAccessHeaders } from "./worker-process-protocol-COVu2ppZ.js";
import { t as normalizeHostname } from "./hostname-_16721Le.js";
import { i as fetchWithSsrFGuard } from "./fetch-guard-BMdGQhbb.js";
import { t as formatHelpExamples } from "./help-format-CAcwboTs.js";
import { n as encodePairingSetupCode, t as decodePairingSetupCode } from "./setup-code-Dv85EyDz.js";
import { a as loadNodeHostConfig, c as nodeHostGatewayMatchesUrl, d as resolveNodeHostCloudflareAccess, l as nodeHostGatewaysShareOrigin, s as nodeHostCloudflareAccessConfigFromEnv } from "./config-o0eKhywR.js";
import { l as resolveNodePairGatewayPayload, t as runNodeDaemonInstall, u as runNodeHost } from "./daemon-Ck0pm4A2.js";
import { t as isDevicePairingJoinCode } from "./join-code-B_OfdZ-j.js";
import fs from "node:fs/promises";
//#region src/cli/connect-cli.ts
const MAX_JOIN_PAYLOAD_BYTES = 24576;
const MAX_TARGET_FILE_BYTES = 65536;
const JOIN_FETCH_TIMEOUT_MS = 15e3;
function parseJoinTarget(target) {
let parsed;
try {
parsed = new URL(target);
} catch {
return null;
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
const shortcode = /(?:^|\/)j\/([^/]+)$/u.exec(parsed.pathname)?.[1] ?? "";
if (parsed.username || parsed.password || parsed.search || parsed.hash || !isDevicePairingJoinCode(shortcode)) throw new Error("Join URL must end with the exact /j/<shortcode> form.");
if (parsed.protocol === "http:" && !isLoopbackHost(parsed.hostname)) throw new Error("Plain HTTP join URLs are allowed only for loopback gateways.");
return parsed;
}
async function fetchJoinPayload(target, cloudflareAccess) {
const expectedHost = normalizeHostname(target.hostname);
let release = async () => {};
try {
const guarded = await fetchWithSsrFGuard({
url: target.toString(),
auditContext: "openclaw-connect-join",
maxRedirects: 0,
requireHttps: target.protocol === "https:",
timeoutMs: JOIN_FETCH_TIMEOUT_MS,
...cloudflareAccess ? {
init: { headers: buildCloudflareAccessHeaders(cloudflareAccess) },
capture: { sensitiveRequestHeaderNames: [CF_ACCESS_CLIENT_ID_HEADER, CF_ACCESS_CLIENT_SECRET_HEADER] }
} : {},
policy: {
allowPrivateNetwork: true,
allowedHostnames: [expectedHost],
hostnameAllowlist: [expectedHost]
}
});
release = guarded.release;
const response = guarded.response;
if (!response.ok || !response.headers.get("content-type")?.startsWith("application/json")) {
await cancelUnreadResponseBody(response);
throw new Error("Gateway join code was not found or has expired.");
}
const body = await readResponseWithLimit(response, MAX_JOIN_PAYLOAD_BYTES);
let decoded;
try {
decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body));
} catch {
throw new Error("Gateway returned an invalid pairing payload.");
}
return decodePairingSetupCode(encodePairingSetupCode(decoded));
} catch (error) {
if (error instanceof Error && error.message.startsWith("Gateway ")) throw error;
throw new Error("Could not fetch the Gateway join payload securely.", { cause: error });
} finally {
await release();
}
}
function selectCloudflareAccessConfig(params) {
const saved = params.savedGateway;
return (saved && nodeHostGatewayMatchesUrl(saved, params.target) ? saved.cloudflareAccess : void 0) ?? nodeHostCloudflareAccessConfigFromEnv(params.env);
}
async function resolveConnectTarget(target, targetFile) {
if (target && targetFile) throw new Error("Provide the connect target or --target-file, not both.");
if (target) return target;
const filePath = targetFile?.trim();
if (!filePath) throw new Error("Connect target is required.");
let buffer;
try {
const resolvedFilePath = await fs.realpath(filePath);
({buffer} = await readRegularFile({
filePath: resolvedFilePath,
maxBytes: MAX_TARGET_FILE_BYTES
}));
} catch (error) {
const cause = error instanceof Error ? error.message : String(error);
throw new Error(`Could not read --target-file ${filePath} (max ${MAX_TARGET_FILE_BYTES} bytes): ${cause}`, { cause: error });
}
const value = buffer.toString("utf8").trim();
if (!value) throw new Error("Connect target file is empty.");
await fs.rm(filePath, { force: true });
return value;
}
async function runConnectCommand(target, opts) {
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.");
const resolvedTarget = await resolveConnectTarget(target, opts.targetFile);
const joinTarget = parseJoinTarget(resolvedTarget);
const saved = await loadNodeHostConfig();
const initialCloudflareAccess = joinTarget ? selectCloudflareAccessConfig({
savedGateway: saved?.gateway,
target: joinTarget,
env: process.env
}) : void 0;
if (initialCloudflareAccess && joinTarget?.protocol !== "https:") throw new Error("Cloudflare Access credentials require an HTTPS join URL.");
const joinCredentials = await resolveNodeHostCloudflareAccess({
value: initialCloudflareAccess,
config: getRuntimeConfig(),
env: process.env
});
const payload = joinTarget ? await fetchJoinPayload(joinTarget, joinCredentials) : decodePairingSetupCode(resolvedTarget);
const pair = resolveNodePairGatewayPayload(payload);
const cloudflareAccess = initialCloudflareAccess ?? (saved?.gateway && nodeHostGatewaysShareOrigin(saved.gateway, pair.candidates[0]) ? saved.gateway.cloudflareAccess : void 0) ?? nodeHostCloudflareAccessConfigFromEnv(process.env);
const gatewayCandidates = pair.candidates.map((candidate, index) => {
return (joinTarget ? nodeHostGatewayMatchesUrl(candidate, joinTarget) : index === 0) && cloudflareAccess ? {
...candidate,
cloudflareAccess
} : candidate;
});
const forceWorkerRuns = opts.ephemeral === true || opts.sessionHost === true && !opts.service;
const nodeRunOptions = {
gatewayHost: pair.host,
gatewayPort: pair.port,
gatewayTls: pair.tls,
gatewayTlsFingerprint: pair.tlsFingerprint,
gatewayContextPath: pair.contextPath,
...gatewayCandidates[0]?.cloudflareAccess ? { gatewayCloudflareAccess: gatewayCandidates[0].cloudflareAccess } : {},
gatewayCandidates,
gatewayBootstrapToken: pair.bootstrapToken,
preferGatewayBootstrapToken: opts.ephemeral !== true,
...forceWorkerRuns ? { forceWorkerRuns: true } : {},
...opts.ephemeral === true ? { ephemeral: true } : {},
displayName: opts.displayName
};
if (!opts.service) {
await runNodeHost(nodeRunOptions);
return;
}
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
});
}
function registerConnectCli(program) {
program.command("connect").description("Connect this machine to an OpenClaw Gateway as a node").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).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", () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw connect oc-pair://<setup-code>", "Connect in the foreground."],
["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, opts) => {
try {
await runConnectCommand(target, opts);
} catch (error) {
defaultRuntime.error(error instanceof Error ? error.message : String(error));
defaultRuntime.exit(1);
}
});
}
//#endregion
export { registerConnectCli };