openclaw / src /cli /daemon-cli /install.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
21.3 kB
// Gateway service installer: writes config defaults, resolves credentials, and installs service definitions.
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { SUPPORTED_NODE_VERSIONS } from "../../../node-version.mjs";
import { resolveNodeStartupTlsEnvironment } from "../../bootstrap/node-startup-env.js";
import { buildGatewayInstallPlan } from "../../commands/daemon-install-helpers.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
isGatewayDaemonRuntime,
type GatewayDaemonRuntime,
} from "../../commands/daemon-runtime.js";
import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js";
import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js";
import { readConfigFileSnapshotForWrite } from "../../config/io.js";
import { replaceConfigFile } from "../../config/mutate.js";
import { resolveGatewayPort } from "../../config/paths.js";
import type { GatewayBindMode } from "../../config/types.gateway.js";
import type { OpenClawConfig } from "../../config/types.js";
import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js";
import { isNodeRuntime } from "../../daemon/runtime-binary.js";
import { resolveNodeRuntimeInfo, resolvePreferredNodePath } from "../../daemon/runtime-paths.js";
import { readEmbeddedGatewayToken } from "../../daemon/service-audit.js";
import { mergeGatewayServiceEnv } from "../../daemon/service-env-merge.js";
import { sanitizeServiceInspectionError } from "../../daemon/service-inspection-error.js";
import {
assertServiceDefinitionWritable,
resolveManagedGatewayServiceCommand,
} from "../../daemon/service-types.js";
import {
assertGatewayServiceUpdateCurrent,
isUpdateOwnedGatewayServiceCommand,
} from "../../daemon/service-update-authority.js";
import { resolveGatewayService, type GatewayServiceCommandConfig } from "../../daemon/service.js";
import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd-exec.js";
import { resolveGatewayAuth } from "../../gateway/auth.js";
import {
defaultGatewayBindMode,
isLoopbackHost,
resolveGatewayBindHost,
} from "../../gateway/net.js";
import { hasErrnoCode, isMissingPathError } from "../../infra/errno.js";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
normalizeEnvVarKey,
} from "../../infra/host-env-security.js";
import { defaultRuntime } from "../../runtime.js";
import { createLazyPromise } from "../../shared/lazy-promise.js";
import { formatCliCommand } from "../command-format.js";
import { formatInvalidConfigPort, formatInvalidPortOption } from "../error-format.js";
import { waitForGatewayServiceLoad } from "./install-load.js";
import { buildDaemonServiceSnapshot, installDaemonServiceAndEmit } from "./response.js";
import {
createDaemonInstallActionContext,
resolveDaemonInstallBlockMessage,
parsePort,
} from "./shared.js";
import type { DaemonInstallOptions } from "./types.js";
function resolveGatewayInstallBindMode(cfg: OpenClawConfig): GatewayBindMode {
return cfg.gateway?.bind ?? defaultGatewayBindMode(cfg.gateway?.tailscale?.mode ?? "off");
}
function formatNoAuthNonLoopbackInstallBlock(params: {
bind: GatewayBindMode;
bindHost: string;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): string | undefined {
const auth = resolveGatewayAuth({
authConfig: params.config.gateway?.auth,
env: params.env,
tailscaleMode: params.config.gateway?.tailscale?.mode ?? "off",
});
const bindCanExposeNetwork = params.bind === "tailnet" || !isLoopbackHost(params.bindHost);
if (auth.mode !== "none" || !bindCanExposeNetwork) {
return undefined;
}
const bindReason =
params.bind === "tailnet" && isLoopbackHost(params.bindHost)
? `gateway.bind=tailnet currently resolves to ${params.bindHost} but can later resolve to a Tailnet interface`
: `gateway.bind=${params.bind} resolves to ${params.bindHost}`;
const hints: string[] = [`${bindReason}, but gateway.auth.mode=none disables Gateway auth.`];
if (normalizeOptionalString(auth.token)) {
hints.push(
`This config already has gateway.auth.token; run ${formatCliCommand("openclaw config set gateway.auth.mode token")} and then rerun ${formatCliCommand("openclaw gateway install --force")}.`,
);
} else if (normalizeOptionalString(auth.password)) {
hints.push(
`This config already has gateway.auth.password; run ${formatCliCommand("openclaw config set gateway.auth.mode password")} and then rerun ${formatCliCommand("openclaw gateway install --force")}.`,
);
} else {
hints.push(
`Configure token/password auth, use trusted-proxy auth, or set ${formatCliCommand("openclaw config set gateway.bind loopback")} before installing the managed service.`,
);
}
return hints.join(" ");
}
/** Merge safe existing service environment into the current install invocation environment. */
export function mergeInstallInvocationEnv(params: {
env: NodeJS.ProcessEnv;
existingServiceEnv?: Record<string, string>;
platform?: NodeJS.Platform;
}): NodeJS.ProcessEnv {
const platform = params.platform ?? process.platform;
const normalizeInstallEnvKey = (key: string) => (platform === "win32" ? key.toUpperCase() : key);
const currentEnv: NodeJS.ProcessEnv = {};
for (const [rawKey, rawValue] of Object.entries(params.env)) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key || isDangerousHostEnvVarName(key)) {
continue;
}
currentEnv[normalizeInstallEnvKey(key)] = rawValue;
}
if (!params.existingServiceEnv || Object.keys(params.existingServiceEnv).length === 0) {
return currentEnv;
}
const preservedServiceEnv: NodeJS.ProcessEnv = {};
for (const [rawKey, rawValue] of Object.entries(params.existingServiceEnv)) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key) {
continue;
}
const upper = key.toUpperCase();
if (upper === OPENCLAW_WRAPPER_ENV_KEY) {
const value = rawValue.trim();
if (value) {
preservedServiceEnv[normalizeInstallEnvKey(OPENCLAW_WRAPPER_ENV_KEY)] = value;
}
continue;
}
if (
upper === "HOME" ||
upper === "PATH" ||
upper === "TMPDIR" ||
upper === "HOMEBREW_PREFIX" ||
upper.startsWith("OPENCLAW_")
) {
continue;
}
// An installed CA file is additive, operator-owned Node startup trust; retain it on reinstall.
// Never replay service-owned TLS-disable, proxy, or loader overrides from the old environment.
if (
isDangerousHostEnvVarName(key) ||
(isDangerousHostEnvOverrideVarName(key) && upper !== "NODE_EXTRA_CA_CERTS")
) {
continue;
}
const value = rawValue.trim();
if (!value) {
continue;
}
preservedServiceEnv[normalizeInstallEnvKey(key)] = value;
}
return {
...preservedServiceEnv,
...currentEnv,
};
}
/** Install or refresh the managed Gateway service. */
export async function runDaemonInstall(opts: DaemonInstallOptions) {
const { json, stdout, warnings, emit, fail } = createDaemonInstallActionContext(opts.json);
const warn = (message: string) => {
if (json) {
warnings.push(message);
} else {
defaultRuntime.log(message);
}
};
const installBlock = resolveDaemonInstallBlockMessage("gateway");
if (installBlock) {
fail(installBlock);
return;
}
if (
opts.deferActivation &&
(process.platform !== "linux" || !process.send || !process.connected)
) {
fail("Deferred service load requires Linux and the updater IPC channel.");
return;
}
const service = resolveGatewayService();
let existingServiceCommand: GatewayServiceCommandConfig | null;
try {
existingServiceCommand = await service.readCommand(process.env, { requireEffective: true });
} catch (error) {
fail(sanitizeServiceInspectionError(error).message);
return;
}
let loaded;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (error) {
if (!isNonFatalSystemdInstallProbeError(error)) {
fail(`Gateway service check failed: ${String(error)}`);
return;
}
loaded = false;
}
const existingManagedCommand = resolveManagedGatewayServiceCommand(existingServiceCommand);
const existingServiceEnv = existingManagedCommand?.environment;
const installEnv = mergeInstallInvocationEnv({
env: process.env,
existingServiceEnv,
});
const effectiveServiceEnv = mergeGatewayServiceEnv(process.env, existingServiceCommand);
const assertWritable = async () => {
try {
// Drop-ins can redirect effective state away from the files this install will publish.
for (const environment of [effectiveServiceEnv, installEnv]) {
const capability = await service
.readDefinitionMutationCapability?.({ env: process.env, environment })
.catch(() => ({ kind: "unknown", reason: "inspection-failed" }) as const);
if (capability) {
assertServiceDefinitionWritable(capability);
}
}
return true;
} catch (error) {
fail(`Gateway install blocked: ${String(error)}`);
return false;
}
};
if ((opts.force || !loaded) && !(await assertWritable())) {
return;
}
let { snapshot: configSnapshot, writeOptions: configWriteOptions } =
await readConfigFileSnapshotForWrite();
const futureBlock = resolveFutureConfigActionBlock({
action: "install or rewrite the gateway service",
snapshot: configSnapshot,
});
if (futureBlock) {
fail(`Gateway install blocked: ${futureBlock.message}`, futureBlock.hints);
return;
}
let cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config;
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
fail(formatInvalidPortOption("--port"));
return;
}
const port = portOverride ?? resolveGatewayPort(cfg);
if (!Number.isFinite(port) || port <= 0 || port > 65_535) {
fail(formatInvalidConfigPort("gateway.port"));
return;
}
const runtimeRaw = opts.runtime ? opts.runtime : DEFAULT_GATEWAY_DAEMON_RUNTIME;
if (!isGatewayDaemonRuntime(runtimeRaw)) {
fail('Invalid --runtime (use "node" or "bun")');
return;
}
let wrapperPath: string | undefined;
if (opts.wrapper !== undefined) {
try {
wrapperPath = await resolveOpenClawWrapperPath(opts.wrapper);
if (!wrapperPath) {
fail("Invalid --wrapper");
return;
}
} catch (err) {
fail(`Invalid --wrapper: ${String(err)}`);
return;
}
}
if (!wrapperPath) {
try {
wrapperPath = await resolveOpenClawWrapperPath(installEnv[OPENCLAW_WRAPPER_ENV_KEY]);
} catch (err) {
fail(`Invalid ${OPENCLAW_WRAPPER_ENV_KEY}: ${String(err)}`);
return;
}
}
const installBind = resolveGatewayInstallBindMode(cfg);
const installBindHost = await resolveGatewayBindHost(installBind, cfg.gateway?.customBindHost);
const noAuthNonLoopbackBlock = formatNoAuthNonLoopbackInstallBlock({
bind: installBind,
bindHost: installBindHost,
config: cfg,
env: installEnv,
});
if (noAuthNonLoopbackBlock) {
fail(`Gateway install blocked: ${noAuthNonLoopbackBlock}`);
return;
}
let autoRefreshMessage: string | undefined;
let runtimePath: string | undefined;
const recordedNode = existingManagedCommand?.programArguments[0];
if (runtimeRaw === "node" && !wrapperPath && recordedNode && isNodeRuntime(recordedNode)) {
const recordedRuntime = await resolveNodeRuntimeInfo(recordedNode, installEnv);
if (recordedRuntime.status !== "probe-failed") {
const diagnostic = recordedRuntime.capabilityError ?? recordedRuntime.note;
if (diagnostic) {
warn(diagnostic);
}
}
const missingRuntime =
recordedRuntime.status === "probe-failed" &&
(await fs.access(recordedNode, fsConstants.X_OK).then(
() => false,
(error: unknown) => isMissingPathError(error) || hasErrnoCode(error, "EACCES"),
));
const replacement = missingRuntime
? `missing Gateway service Node (${recordedNode})`
: recordedRuntime.status === "unsupported"
? `unsupported Gateway service Node ${recordedRuntime.version} (${recordedNode})`
: undefined;
if (replacement) {
try {
runtimePath = await resolvePreferredNodePath({
env: installEnv,
runtime: "node",
preferCurrentExecPath: true,
});
if (!runtimePath) {
fail(
`No supported Node runtime is available. Install Node ${SUPPORTED_NODE_VERSIONS}, then rerun openclaw gateway install.`,
);
return;
}
} catch (error) {
fail(`Gateway runtime selection failed: ${String(error)}`);
return;
}
autoRefreshMessage = `Replacing ${replacement} with ${runtimePath}; refreshing the install.`;
} else if (recordedRuntime.status === "probe-failed" && !opts.force) {
fail(
`${recordedRuntime.error.message} Reinstall with: ${formatCliCommand("openclaw gateway install --force")}.`,
);
return;
}
}
if (loaded && !opts.force) {
autoRefreshMessage ??= await getGatewayServiceAutoRefreshMessage({
allowUnconfigured: opts.allowUnconfigured,
currentCommand: existingServiceCommand,
env: process.env,
installEnv,
port,
runtime: runtimeRaw,
wrapperPath,
existingEnvironment: existingServiceEnv,
existingEnvironmentValueSources: existingManagedCommand?.environmentValueSources,
config: cfg,
});
if (autoRefreshMessage) {
if (!(await assertWritable())) {
return;
}
}
}
if (autoRefreshMessage) {
warn(autoRefreshMessage);
}
// Native staging cannot attribute unrelated config writes. Require prior
// config preparation; do not generate defaults or credentials in this phase.
if (opts.deferActivation && (!configSnapshot.valid || cfg.gateway?.mode === undefined)) {
fail("Deferred service load requires valid, prepared gateway configuration.");
return;
}
if (configSnapshot.valid && cfg.gateway?.mode === undefined) {
const baseConfig = configSnapshot.sourceConfig ?? configSnapshot.config;
await replaceConfigFile({
sourceConfig: { ...baseConfig, gateway: { ...baseConfig.gateway, mode: "local" } },
snapshot: configSnapshot,
writeOptions: {
baseSnapshot: configSnapshot,
...configWriteOptions,
...(isUpdateOwnedGatewayServiceCommand()
? {
assertCurrent: () => {
configWriteOptions.assertCurrent?.();
assertGatewayServiceUpdateCurrent();
},
}
: {}),
skipRuntimeSnapshotRefresh: true,
},
afterWrite: { mode: "auto" },
});
const refreshed = await readConfigFileSnapshotForWrite();
configSnapshot = refreshed.snapshot;
configWriteOptions = refreshed.writeOptions;
cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config;
warn("No gateway.mode found. Set gateway.mode=local for managed gateway install.");
}
if (loaded && !opts.force && !autoRefreshMessage && !opts.deferActivation) {
emit({
ok: true,
result: "already-installed",
message: `Gateway service already ${service.loadedText}.`,
service: buildDaemonServiceSnapshot(service, loaded),
});
if (!json) {
defaultRuntime.log(`Gateway service already ${service.loadedText}.`);
defaultRuntime.log(`Reinstall with: ${formatCliCommand("openclaw gateway install --force")}`);
}
return;
}
const tokenResolution = await resolveGatewayInstallToken({
config: cfg,
env: installEnv,
explicitToken: opts.token,
requireExisting: opts.deferActivation,
...(opts.deferActivation
? {}
: { generateIfMissing: { snapshot: configSnapshot, writeOptions: configWriteOptions } }),
});
if (tokenResolution.unavailableReason) {
fail(`Gateway install blocked: ${tokenResolution.unavailableReason}`);
return;
}
for (const warning of tokenResolution.warnings) {
warn(warning);
}
const { programArguments, workingDirectory, environment, environmentValueSources } =
await buildGatewayInstallPlan({
allowUnconfigured: opts.allowUnconfigured,
env: installEnv,
port,
runtime: runtimeRaw,
runtimePath,
wrapperPath,
existingCommand: existingServiceCommand,
existingEnvironment: existingServiceEnv,
existingEnvironmentValueSources: existingManagedCommand?.environmentValueSources,
warn,
config: cfg,
});
await installDaemonServiceAndEmit({
serviceNoun: "Gateway",
service,
warnings,
emit,
fail,
install: async () => {
await service.install({
env: installEnv,
stdout,
warn,
programArguments,
workingDirectory,
environment,
environmentValueSources,
...(opts.deferActivation ? { beforeLoad: waitForGatewayServiceLoad } : {}),
});
},
});
}
async function getGatewayServiceAutoRefreshMessage(params: {
allowUnconfigured?: boolean;
currentCommand: GatewayServiceCommandConfig | null;
env: Record<string, string | undefined>;
installEnv: NodeJS.ProcessEnv;
port: number;
runtime: GatewayDaemonRuntime;
wrapperPath?: string;
existingEnvironment?: Record<string, string | undefined>;
existingEnvironmentValueSources?: GatewayServiceCommandConfig["environmentValueSources"];
config: OpenClawConfig;
}): Promise<string | undefined> {
try {
const currentCommand = resolveManagedGatewayServiceCommand(params.currentCommand);
if (!currentCommand) {
return undefined;
}
const getPlannedInstall = createLazyPromise(() =>
buildGatewayInstallPlan({
allowUnconfigured: params.allowUnconfigured,
env: params.installEnv,
port: params.port,
runtime: params.runtime,
wrapperPath: params.wrapperPath,
existingCommand: params.currentCommand,
existingEnvironment: params.existingEnvironment,
existingEnvironmentValueSources: params.existingEnvironmentValueSources,
warn: () => undefined,
config: params.config,
}),
);
const currentAllowsUnconfigured =
currentCommand.programArguments.includes("--allow-unconfigured");
if (currentAllowsUnconfigured || params.allowUnconfigured) {
const plannedInstall = await getPlannedInstall();
if (
currentAllowsUnconfigured !==
plannedInstall.programArguments.includes("--allow-unconfigured")
) {
return "Gateway service start-mode argument differs from the current install plan; refreshing the install.";
}
}
const currentEmbeddedToken = readEmbeddedGatewayToken(currentCommand);
if (currentEmbeddedToken) {
const plannedInstall = await getPlannedInstall();
const plannedEmbeddedToken = normalizeOptionalString(
plannedInstall.environment.OPENCLAW_GATEWAY_TOKEN,
);
if (currentEmbeddedToken !== plannedEmbeddedToken) {
return "Gateway service OPENCLAW_GATEWAY_TOKEN differs from the current install plan; refreshing the install.";
}
}
const wrapperRequested = Boolean(
params.wrapperPath || normalizeOptionalString(params.installEnv[OPENCLAW_WRAPPER_ENV_KEY]),
);
if (wrapperRequested) {
const plannedInstall = await getPlannedInstall();
if (
plannedInstall.programArguments.join("\u0000") !==
currentCommand.programArguments.join("\u0000")
) {
return "Gateway service command differs from the current wrapper install plan; refreshing the install.";
}
const plannedWrapperPath = normalizeOptionalString(
plannedInstall.environment[OPENCLAW_WRAPPER_ENV_KEY],
);
const currentWrapperPath = normalizeOptionalString(
currentCommand.environment?.[OPENCLAW_WRAPPER_ENV_KEY],
);
if (plannedWrapperPath !== currentWrapperPath) {
return `Gateway service ${OPENCLAW_WRAPPER_ENV_KEY} differs from the current wrapper install plan; refreshing the install.`;
}
}
const currentExecPath = currentCommand.programArguments[0]?.trim();
if (!currentExecPath) {
return undefined;
}
const currentEnvironment = currentCommand.environment ?? {};
const currentNodeExtraCaCerts = currentEnvironment.NODE_EXTRA_CA_CERTS?.trim();
const expectedNodeExtraCaCerts = resolveNodeStartupTlsEnvironment({
env: {
...params.env,
...currentEnvironment,
NODE_EXTRA_CA_CERTS: undefined,
},
execPath: currentExecPath,
includeDarwinDefaults: false,
}).NODE_EXTRA_CA_CERTS;
if (!expectedNodeExtraCaCerts) {
return undefined;
}
if (currentNodeExtraCaCerts !== expectedNodeExtraCaCerts) {
return "Gateway service is missing the nvm TLS CA bundle; refreshing the install.";
}
return undefined;
} catch {
return undefined;
}
}