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 type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; import { findExtraGatewayServices, renderGatewayServiceCleanupHints } from "../daemon/inspect.js"; import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { auditGatewayServiceConfig, needsNodeRuntimeMigration, SERVICE_AUDIT_CODES, } from "../daemon/service-audit.js"; import type { RuntimeEnv } from "../runtime.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 type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.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; } function normalizeExecutablePath(value: string): string { return path.resolve(value); } 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 { 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; } } 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> | null = null; try { command = await service.readCommand(process.env); } catch { command = null; } if (!command) return; const audit = await auditGatewayServiceConfig({ env: process.env, command, }); 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+ 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, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port, token: cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN, 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); if ( expectedEntrypoint && currentEntrypoint && normalizeExecutablePath(expectedEntrypoint) !== normalizeExecutablePath(currentEntrypoint) ) { 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.confirmAggressive({ message: "Overwrite gateway service config with current defaults now?", initialValue: Boolean(prompter.shouldForce), }) : await prompter.confirmRepair({ message: "Update gateway service config to the recommended defaults now?", initialValue: true, }); if (!repair) return; try { await service.install({ env: process.env, stdout: process.stdout, programArguments, workingDirectory, 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.confirmSkipInNonInteractive({ message: "Remove legacy gateway services (clawdbot/moltbot) now?", initialValue: true, }); if (shouldRemove) { const removed: string[] = []; const failed: string[] = []; for (const svc of legacyServices) { if (svc.platform !== "darwin") { failed.push(`${svc.label} (${svc.platform})`); continue; } if (svc.scope !== "user") { failed.push(`${svc.label} (${svc.scope})`); continue; } 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); } 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", ); }