mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-27 00:17:29 +00:00
461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { writeConfigFile, type OpenClawConfig } from "../config/config.js";
|
|
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
|
|
import { resolveSecretInputRef } from "../config/types.secrets.js";
|
|
import {
|
|
findExtraGatewayServices,
|
|
renderGatewayServiceCleanupHints,
|
|
type ExtraGatewayService,
|
|
} from "../daemon/inspect.js";
|
|
import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js";
|
|
import {
|
|
auditGatewayServiceConfig,
|
|
needsNodeRuntimeMigration,
|
|
readEmbeddedGatewayToken,
|
|
SERVICE_AUDIT_CODES,
|
|
} from "../daemon/service-audit.js";
|
|
import { resolveGatewayService } from "../daemon/service.js";
|
|
import { uninstallLegacySystemdUnits } from "../daemon/systemd.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
|
import { note } from "../terminal/note.js";
|
|
import { buildGatewayInstallPlan } from "./daemon-install-helpers.js";
|
|
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js";
|
|
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
|
|
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
|
|
import { isDoctorUpdateRepairMode } from "./doctor-repair-mode.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
function detectGatewayRuntime(programArguments: string[] | undefined): GatewayDaemonRuntime {
|
|
const first = programArguments?.[0];
|
|
if (first) {
|
|
const base = path.basename(first).toLowerCase();
|
|
if (base === "bun" || base === "bun.exe") {
|
|
return "bun";
|
|
}
|
|
if (base === "node" || base === "node.exe") {
|
|
return "node";
|
|
}
|
|
}
|
|
return DEFAULT_GATEWAY_DAEMON_RUNTIME;
|
|
}
|
|
|
|
function findGatewayEntrypoint(programArguments?: string[]): string | null {
|
|
if (!programArguments || programArguments.length === 0) {
|
|
return null;
|
|
}
|
|
const gatewayIndex = programArguments.indexOf("gateway");
|
|
if (gatewayIndex <= 0) {
|
|
return null;
|
|
}
|
|
return programArguments[gatewayIndex - 1] ?? null;
|
|
}
|
|
|
|
async function normalizeExecutablePath(value: string): Promise<string> {
|
|
const resolvedPath = path.resolve(value);
|
|
try {
|
|
return await fs.realpath(resolvedPath);
|
|
} catch {
|
|
return resolvedPath;
|
|
}
|
|
}
|
|
|
|
function extractDetailPath(detail: string, prefix: string): string | null {
|
|
if (!detail.startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
const value = detail.slice(prefix.length).trim();
|
|
return value.length > 0 ? value : null;
|
|
}
|
|
|
|
async function cleanupLegacyLaunchdService(params: {
|
|
label: string;
|
|
plistPath: string;
|
|
}): Promise<string | null> {
|
|
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
|
|
await execFileAsync("launchctl", ["bootout", domain, params.plistPath]).catch(() => undefined);
|
|
await execFileAsync("launchctl", ["unload", params.plistPath]).catch(() => undefined);
|
|
|
|
const trashDir = path.join(os.homedir(), ".Trash");
|
|
try {
|
|
await fs.mkdir(trashDir, { recursive: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
await fs.access(params.plistPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const dest = path.join(trashDir, `${params.label}-${Date.now()}.plist`);
|
|
try {
|
|
await fs.rename(params.plistPath, dest);
|
|
return dest;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function classifyLegacyServices(legacyServices: ExtraGatewayService[]): {
|
|
darwinUserServices: ExtraGatewayService[];
|
|
linuxUserServices: ExtraGatewayService[];
|
|
failed: string[];
|
|
} {
|
|
const darwinUserServices: ExtraGatewayService[] = [];
|
|
const linuxUserServices: ExtraGatewayService[] = [];
|
|
const failed: string[] = [];
|
|
|
|
for (const svc of legacyServices) {
|
|
if (svc.platform === "darwin") {
|
|
if (svc.scope === "user") {
|
|
darwinUserServices.push(svc);
|
|
} else {
|
|
failed.push(`${svc.label} (${svc.scope})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (svc.platform === "linux") {
|
|
if (svc.scope === "user") {
|
|
linuxUserServices.push(svc);
|
|
} else {
|
|
failed.push(`${svc.label} (${svc.scope})`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
failed.push(`${svc.label} (${svc.platform})`);
|
|
}
|
|
|
|
return { darwinUserServices, linuxUserServices, failed };
|
|
}
|
|
|
|
async function cleanupLegacyDarwinServices(
|
|
services: ExtraGatewayService[],
|
|
): Promise<{ removed: string[]; failed: string[] }> {
|
|
const removed: string[] = [];
|
|
const failed: string[] = [];
|
|
|
|
for (const svc of services) {
|
|
const plistPath = extractDetailPath(svc.detail, "plist:");
|
|
if (!plistPath) {
|
|
failed.push(`${svc.label} (missing plist path)`);
|
|
continue;
|
|
}
|
|
const dest = await cleanupLegacyLaunchdService({
|
|
label: svc.label,
|
|
plistPath,
|
|
});
|
|
removed.push(dest ? `${svc.label} -> ${dest}` : svc.label);
|
|
}
|
|
|
|
return { removed, failed };
|
|
}
|
|
|
|
async function cleanupLegacyLinuxUserServices(
|
|
services: ExtraGatewayService[],
|
|
runtime: RuntimeEnv,
|
|
): Promise<{ removed: string[]; failed: string[] }> {
|
|
const removed: string[] = [];
|
|
const failed: string[] = [];
|
|
|
|
try {
|
|
const removedUnits = await uninstallLegacySystemdUnits({
|
|
env: process.env,
|
|
stdout: process.stdout,
|
|
});
|
|
const removedByLabel: Map<string, (typeof removedUnits)[number]> = new Map(
|
|
removedUnits.map((unit) => [`${unit.name}.service`, unit] as const),
|
|
);
|
|
for (const svc of services) {
|
|
const removedUnit = removedByLabel.get(svc.label);
|
|
if (!removedUnit) {
|
|
failed.push(`${svc.label} (legacy unit name not recognized)`);
|
|
continue;
|
|
}
|
|
removed.push(`${svc.label} -> ${removedUnit.unitPath}`);
|
|
}
|
|
} catch (err) {
|
|
runtime.error(`Legacy Linux gateway cleanup failed: ${String(err)}`);
|
|
for (const svc of services) {
|
|
failed.push(`${svc.label} (linux cleanup failed)`);
|
|
}
|
|
}
|
|
|
|
return { removed, failed };
|
|
}
|
|
|
|
export async function maybeRepairGatewayServiceConfig(
|
|
cfg: OpenClawConfig,
|
|
mode: "local" | "remote",
|
|
runtime: RuntimeEnv,
|
|
prompter: DoctorPrompter,
|
|
) {
|
|
if (resolveIsNixMode(process.env)) {
|
|
note("Nix mode detected; skip service updates.", "Gateway");
|
|
return;
|
|
}
|
|
|
|
if (mode === "remote") {
|
|
note("Gateway mode is remote; skipped local service audit.", "Gateway");
|
|
return;
|
|
}
|
|
|
|
const service = resolveGatewayService();
|
|
let command: Awaited<ReturnType<typeof service.readCommand>> | null = null;
|
|
try {
|
|
command = await service.readCommand(process.env);
|
|
} catch {
|
|
command = null;
|
|
}
|
|
if (!command) {
|
|
return;
|
|
}
|
|
|
|
const tokenRefConfigured = Boolean(
|
|
resolveSecretInputRef({
|
|
value: cfg.gateway?.auth?.token,
|
|
defaults: cfg.secrets?.defaults,
|
|
}).ref,
|
|
);
|
|
const gatewayTokenResolution = await resolveGatewayAuthTokenForService(cfg, process.env);
|
|
if (gatewayTokenResolution.unavailableReason) {
|
|
note(
|
|
`Unable to verify gateway service token drift: ${gatewayTokenResolution.unavailableReason}`,
|
|
"Gateway service config",
|
|
);
|
|
}
|
|
const expectedGatewayToken = tokenRefConfigured ? undefined : gatewayTokenResolution.token;
|
|
const audit = await auditGatewayServiceConfig({
|
|
env: process.env,
|
|
command,
|
|
expectedGatewayToken,
|
|
});
|
|
const serviceToken = readEmbeddedGatewayToken(command);
|
|
if (tokenRefConfigured && serviceToken) {
|
|
audit.issues.push({
|
|
code: SERVICE_AUDIT_CODES.gatewayTokenMismatch,
|
|
message:
|
|
"Gateway service OPENCLAW_GATEWAY_TOKEN should be unset when gateway.auth.token is SecretRef-managed",
|
|
detail: "service token is stale",
|
|
level: "recommended",
|
|
});
|
|
}
|
|
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
|
|
const systemNodeInfo = needsNodeRuntime
|
|
? await resolveSystemNodeInfo({ env: process.env })
|
|
: null;
|
|
const systemNodePath = systemNodeInfo?.supported ? systemNodeInfo.path : null;
|
|
if (needsNodeRuntime && !systemNodePath) {
|
|
const warning = renderSystemNodeWarning(systemNodeInfo);
|
|
if (warning) {
|
|
note(warning, "Gateway runtime");
|
|
}
|
|
note(
|
|
"System Node 22 LTS (22.14+) or Node 24 not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
|
|
"Gateway runtime",
|
|
);
|
|
}
|
|
|
|
const port = resolveGatewayPort(cfg, process.env);
|
|
const runtimeChoice = detectGatewayRuntime(command.programArguments);
|
|
const { programArguments } = await buildGatewayInstallPlan({
|
|
env: process.env,
|
|
port,
|
|
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
|
nodePath: systemNodePath ?? undefined,
|
|
warn: (message, title) => note(message, title),
|
|
config: cfg,
|
|
});
|
|
const expectedEntrypoint = findGatewayEntrypoint(programArguments);
|
|
const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
|
|
const normalizedExpectedEntrypoint = expectedEntrypoint
|
|
? await normalizeExecutablePath(expectedEntrypoint)
|
|
: null;
|
|
const normalizedCurrentEntrypoint = currentEntrypoint
|
|
? await normalizeExecutablePath(currentEntrypoint)
|
|
: null;
|
|
if (
|
|
normalizedExpectedEntrypoint &&
|
|
normalizedCurrentEntrypoint &&
|
|
normalizedExpectedEntrypoint !== normalizedCurrentEntrypoint
|
|
) {
|
|
audit.issues.push({
|
|
code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,
|
|
message: "Gateway service entrypoint does not match the current install.",
|
|
detail: `${currentEntrypoint} -> ${expectedEntrypoint}`,
|
|
level: "recommended",
|
|
});
|
|
}
|
|
|
|
if (audit.issues.length === 0) {
|
|
return;
|
|
}
|
|
|
|
note(
|
|
audit.issues
|
|
.map((issue) =>
|
|
issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`,
|
|
)
|
|
.join("\n"),
|
|
"Gateway service config",
|
|
);
|
|
|
|
const aggressiveIssues = audit.issues.filter((issue) => issue.level === "aggressive");
|
|
const needsAggressive = aggressiveIssues.length > 0;
|
|
|
|
if (needsAggressive && !prompter.shouldForce) {
|
|
note(
|
|
"Custom or unexpected service edits detected. Rerun with --force to overwrite.",
|
|
"Gateway service config",
|
|
);
|
|
}
|
|
|
|
const repair = needsAggressive
|
|
? await prompter.confirmAggressiveAutoFix({
|
|
message: "Overwrite gateway service config with current defaults now?",
|
|
initialValue: Boolean(prompter.shouldForce),
|
|
})
|
|
: await prompter.confirmAutoFix({
|
|
message: "Update gateway service config to the recommended defaults now?",
|
|
initialValue: true,
|
|
});
|
|
if (!repair) {
|
|
return;
|
|
}
|
|
const updateRepairMode = isDoctorUpdateRepairMode(prompter.repairMode);
|
|
const serviceEmbeddedToken = readEmbeddedGatewayToken(command);
|
|
const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken;
|
|
const configuredGatewayToken =
|
|
typeof cfg.gateway?.auth?.token === "string"
|
|
? normalizeOptionalString(cfg.gateway.auth.token)
|
|
: undefined;
|
|
let cfgForServiceInstall = cfg;
|
|
if (
|
|
!updateRepairMode &&
|
|
!tokenRefConfigured &&
|
|
!configuredGatewayToken &&
|
|
gatewayTokenForRepair
|
|
) {
|
|
const nextCfg: OpenClawConfig = {
|
|
...cfg,
|
|
gateway: {
|
|
...cfg.gateway,
|
|
auth: {
|
|
...cfg.gateway?.auth,
|
|
mode: cfg.gateway?.auth?.mode ?? "token",
|
|
token: gatewayTokenForRepair,
|
|
},
|
|
},
|
|
};
|
|
try {
|
|
await writeConfigFile(nextCfg);
|
|
cfgForServiceInstall = nextCfg;
|
|
note(
|
|
expectedGatewayToken
|
|
? "Persisted gateway.auth.token from environment before reinstalling service."
|
|
: "Persisted gateway.auth.token from existing service definition before reinstalling service.",
|
|
"Gateway",
|
|
);
|
|
} catch (err) {
|
|
runtime.error(`Failed to persist gateway.auth.token before service repair: ${String(err)}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const updatedPort = resolveGatewayPort(cfgForServiceInstall, process.env);
|
|
const updatedPlan = await buildGatewayInstallPlan({
|
|
env: process.env,
|
|
port: updatedPort,
|
|
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
|
|
nodePath: systemNodePath ?? undefined,
|
|
warn: (message, title) => note(message, title),
|
|
config: cfgForServiceInstall,
|
|
});
|
|
try {
|
|
await (updateRepairMode ? service.stage : service.install)({
|
|
env: process.env,
|
|
stdout: process.stdout,
|
|
programArguments: updatedPlan.programArguments,
|
|
workingDirectory: updatedPlan.workingDirectory,
|
|
environment: updatedPlan.environment,
|
|
});
|
|
} catch (err) {
|
|
runtime.error(`Gateway service update failed: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
export async function maybeScanExtraGatewayServices(
|
|
options: DoctorOptions,
|
|
runtime: RuntimeEnv,
|
|
prompter: DoctorPrompter,
|
|
) {
|
|
const extraServices = await findExtraGatewayServices(process.env, {
|
|
deep: options.deep,
|
|
});
|
|
if (extraServices.length === 0) {
|
|
return;
|
|
}
|
|
|
|
note(
|
|
extraServices.map((svc) => `- ${svc.label} (${svc.scope}, ${svc.detail})`).join("\n"),
|
|
"Other gateway-like services detected",
|
|
);
|
|
|
|
const legacyServices = extraServices.filter((svc) => svc.legacy === true);
|
|
if (legacyServices.length > 0) {
|
|
const shouldRemove = await prompter.confirmRuntimeRepair({
|
|
message: "Remove legacy gateway services now?",
|
|
initialValue: true,
|
|
});
|
|
if (shouldRemove) {
|
|
const removed: string[] = [];
|
|
const { darwinUserServices, linuxUserServices, failed } =
|
|
classifyLegacyServices(legacyServices);
|
|
|
|
if (darwinUserServices.length > 0) {
|
|
const result = await cleanupLegacyDarwinServices(darwinUserServices);
|
|
removed.push(...result.removed);
|
|
failed.push(...result.failed);
|
|
}
|
|
|
|
if (linuxUserServices.length > 0) {
|
|
const result = await cleanupLegacyLinuxUserServices(linuxUserServices, runtime);
|
|
removed.push(...result.removed);
|
|
failed.push(...result.failed);
|
|
}
|
|
|
|
if (removed.length > 0) {
|
|
note(removed.map((line) => `- ${line}`).join("\n"), "Legacy gateway removed");
|
|
}
|
|
if (failed.length > 0) {
|
|
note(failed.map((line) => `- ${line}`).join("\n"), "Legacy gateway cleanup skipped");
|
|
}
|
|
if (removed.length > 0) {
|
|
runtime.log("Legacy gateway services removed. Installing OpenClaw gateway next.");
|
|
}
|
|
}
|
|
}
|
|
|
|
const cleanupHints = renderGatewayServiceCleanupHints();
|
|
if (cleanupHints.length > 0) {
|
|
note(cleanupHints.map((hint) => `- ${hint}`).join("\n"), "Cleanup hints");
|
|
}
|
|
|
|
note(
|
|
[
|
|
"Recommendation: run a single gateway per machine for most setups.",
|
|
"One gateway supports multiple agents.",
|
|
"If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
|
|
].join("\n"),
|
|
"Gateway recommendation",
|
|
);
|
|
}
|