UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

712 lines (711 loc) 36.5 kB
import { l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js"; import { n as isTruthyEnvValue } from "./env-M3R40TOb.js"; import { _ as resolveGatewayPort, a as isDefaultInstallIdentity, y as resolveIsNixMode } from "./paths-D2sRr1a_.js"; import { v as resolveSecretInputRef } from "./types.secrets-kC0nOetj.js"; import { n as NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON } from "./gateway-supervision-D7p37rG2.js"; import { r as replaceConfigFile } from "./mutate-ZNN4iFCn.js"; import "./config-Cs0XXL3x.js"; import { i as readWindowsProcessArgsSync } from "./windows-port-pids-XcH5-qBM.js"; import { n as SUPPORTED_NODE_VERSIONS } from "./node-version-WVgs0-1c.js"; import { r as summarizeGatewayServiceLayout } from "./service-layout-bTjykwbW.js"; import { a as resolveManagedGatewayServiceCommand, i as hasGatewayServiceLauncherOverride, r as hasGatewayServiceEnvironmentOverride, t as assertServiceDefinitionWritable } from "./service-types-CK8rxZ9x.js"; import { n as inspectGatewayHeapLimit, t as formatGatewayHeapLimitReport } from "./gateway-heap-DTzoQBFN.js"; import { t as OPENCLAW_WRAPPER_ENV_KEY } from "./program-args-CrrbBbqY.js"; import { c as normalizeServiceEnvKey, d as readManagedServiceEnvKeysFromEnvironment } from "./service-managed-env-j4Dxj-Tf.js"; import { t as buildGatewayInstallPlan } from "./daemon-install-helpers-w30vR2Jt.js"; import { d as SERVICE_PROXY_ENV_KEYS, r as renderSystemNodeWarning, s as resolveSystemNodeInfo } from "./runtime-paths-BjNV6RBz.js"; import { i as resolveGatewayDaemonRuntime } from "./daemon-runtime-CdTwKGNX.js"; import { t as resolveGatewayAuthToken } from "./auth-token-resolution-B9de62Be.js"; import { g as isLaunchctlNotLoaded, m as execLaunchctl } from "./launchd-system-BlHvFe-Q.js"; import { n as mergeGatewayServiceEnv } from "./gateway-service-probe-hosts-CH6J1p0Q.js"; import { u as isSystemdUnitActive } from "./systemd-exec-C9fneC4I.js"; import { a as readEmbeddedGatewayToken, i as needsNodeRuntimeMigration, n as auditGatewayServiceConfig, t as SERVICE_AUDIT_CODES } from "./service-audit-D98Rd9JS.js"; import { o as resolveGatewayService } from "./service-Cc6NX6Jm.js"; import { p as readWindowsStartupFallbackRuntimeForUpdate } from "./schtasks-CgL6lOw1.js"; import { a as isSystemUnitActiveAndEnabled, r as findSystemdGatewayInstallation } from "./systemd-scope-MYrAg51P.js"; import { c as uninstallLegacySystemdUnits, l as uninstallUserSystemdGatewayUnit } from "./systemd-C1W5NZTY.js"; import { i as renderGatewayServiceCleanupHints, n as findExtraGatewayServices } from "./inspect-csgprKbh.js"; import { t as note } from "./note-DqHk3fA1.js"; import { i as UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV } from "./update-phase-J3w3q1-f.js"; import { a as resolveServiceRepairPolicy, i as isServiceRepairExternallyManaged, o as resolveUpdateParentGatewayActivation, r as confirmDoctorServiceRepair, s as shouldManageGatewayService, t as EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy-S9bPaCiq.js"; import { t as isDoctorUpdateRepairMode } from "./doctor-repair-mode-DYiiDZuy.js"; import path from "node:path"; import fs from "node:fs/promises"; import os from "node:os"; //#region src/commands/doctor-gateway-auth-token.ts /** * Resolves the token a managed gateway service can receive at install/update time. * * Exec SecretRefs are skipped by default because the service installer cannot safely evaluate * arbitrary commands. Configured SecretRefs never fall back to ambient credentials. */ async function resolveGatewayAuthTokenForService(cfg, env, options = {}) { if (resolveSecretInputRef({ value: cfg.gateway?.auth?.token, defaults: cfg.secrets?.defaults }).ref?.source === "exec" && options.allowExecSecretRefs !== true) return { unavailableReason: "gateway.auth.token SecretRef is configured but unavailable because exec SecretRef resolution is disabled." }; const resolved = await resolveGatewayAuthToken({ cfg, env, unresolvedReasonStyle: "detailed" }); if (resolved.token) return { token: resolved.token }; if (!resolved.secretRefConfigured) return {}; if (resolved.unresolvedRefReason?.includes("resolved to an empty value")) return { unavailableReason: resolved.unresolvedRefReason }; return { unavailableReason: `gateway.auth.token SecretRef is configured but unresolved (${resolved.unresolvedRefReason ?? "unknown reason"}).` }; } //#endregion //#region src/commands/doctor-gateway-services.ts /** Doctor repairs for installed gateway service config and duplicate legacy services. */ function shouldSkipLegacyUpdateRepairConfigWrite(env) { return isTruthyEnvValue(env["OPENCLAW_UPDATE_IN_PROGRESS"]) && !isTruthyEnvValue(env["OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE"]); } function updateParentAllowsGatewayActivation(env) { const activationPolicy = resolveUpdateParentGatewayActivation(env); if (activationPolicy !== void 0) return activationPolicy; const parentArgs = readWindowsProcessArgsSync(process.ppid, 1500); if (parentArgs === null) return false; const normalizedParentArgs = parentArgs.map(normalizeLowercaseStringOrEmpty); const updateIndex = Math.max(normalizedParentArgs.lastIndexOf("update"), normalizedParentArgs.lastIndexOf("--update")); const legacyDoctorUpdateParent = normalizedParentArgs.lastIndexOf("doctor") >= 0; const legacyWizardParent = updateIndex >= 0 && normalizedParentArgs[updateIndex + 1] === "wizard"; return (updateIndex >= 0 || legacyDoctorUpdateParent) && !legacyWizardParent && !normalizedParentArgs.includes("--no-restart"); } function updateParentAllowsGatewayServiceRepair(env) { const repairPolicy = env[UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]; return repairPolicy !== void 0 && isTruthyEnvValue(repairPolicy); } const EXECSTART_REPAIR_CODES = /* @__PURE__ */ new Set([SERVICE_AUDIT_CODES.gatewayCommandMissing, SERVICE_AUDIT_CODES.gatewayEntrypointMismatch]); const DOCTOR_LAUNCHCTL_TIMEOUT_MS = 5e3; const DOCTOR_LAUNCHCTL_CONFIRM_POLL_MS = 100; async function confirmLegacyLaunchdServiceUnloaded(serviceTarget) { const deadline = Date.now() + DOCTOR_LAUNCHCTL_TIMEOUT_MS; while (Date.now() < deadline) { const remainingMs = Math.max(1, deadline - Date.now()); const probe = await execLaunchctl(["print", serviceTarget], Math.min(DOCTOR_LAUNCHCTL_TIMEOUT_MS, remainingMs)); if (probe.code !== 0) return isLaunchctlNotLoaded(probe); const delayMs = Math.min(DOCTOR_LAUNCHCTL_CONFIRM_POLL_MS, deadline - Date.now()); if (delayMs <= 0) break; await new Promise((resolve) => { setTimeout(resolve, delayMs); }); } return false; } const GATEWAY_SERVICES_EXTRA_CHECK_ID = "core/doctor/gateway-services/extra"; function findGatewayEntrypoint(programArguments) { if (!programArguments || programArguments.length === 0) return null; const gatewayIndex = programArguments.indexOf("gateway"); if (gatewayIndex <= 0) return null; return programArguments[gatewayIndex - 1] ?? null; } async function buildExpectedGatewayServicePlan(params) { const managed = resolveManagedGatewayServiceCommand(params.command); return buildGatewayInstallPlan({ env: params.serviceInstallEnv, port: params.port, runtime: params.runtime, runtimePath: params.runtimePath, existingCommand: params.command, existingEnvironment: managed?.environment, existingEnvironmentValueSources: managed?.environmentValueSources, warn: (message, title) => note(message, title), config: params.cfg }); } async function normalizeExecutablePath(value) { const resolvedPath = path.resolve(value); try { return await fs.realpath(resolvedPath); } catch { return resolvedPath; } } function extractDetailPath(detail, prefix) { if (!detail.startsWith(prefix)) return null; const value = detail.slice(prefix.length).trim(); return value.length > 0 ? value : null; } function isExecStartRepairIssue(issue) { return EXECSTART_REPAIR_CODES.has(issue.code); } function isOperatorOwnedEnvironmentIssue(issue, command, environmentValueSources) { switch (issue.code) { case SERVICE_AUDIT_CODES.gatewayPathMissing: case SERVICE_AUDIT_CODES.gatewayPathMissingDirs: case SERVICE_AUDIT_CODES.gatewayPathNonMinimal: return hasGatewayServiceEnvironmentOverride(command, ["PATH"], { environmentValueSources }); case SERVICE_AUDIT_CODES.gatewayTokenEmbedded: case SERVICE_AUDIT_CODES.gatewayTokenMismatch: case SERVICE_AUDIT_CODES.gatewayTokenDrift: return hasGatewayServiceEnvironmentOverride(command, ["OPENCLAW_GATEWAY_TOKEN"], { environmentValueSources }); case SERVICE_AUDIT_CODES.gatewayPasswordEmbedded: return hasGatewayServiceEnvironmentOverride(command, ["OPENCLAW_GATEWAY_PASSWORD"], { environmentValueSources }); case SERVICE_AUDIT_CODES.gatewayManagedEnvEmbedded: return hasGatewayServiceEnvironmentOverride(command, issue.environmentKeys ?? [], { environmentValueSources, normalizeKey: normalizeServiceEnvKey }); case SERVICE_AUDIT_CODES.gatewayProxyEnvEmbedded: return hasGatewayServiceEnvironmentOverride(command, (issue.environmentKeys ?? []).filter((key) => SERVICE_PROXY_ENV_KEYS.some((proxyKey) => proxyKey === key)), { ignoreResets: true }); default: return false; } } function resolveSystemdScopeFromServicePath(sourcePath) { const normalized = sourcePath?.replaceAll("\\", "/") ?? ""; return normalized.startsWith("/etc/systemd/") || normalized.startsWith("/usr/lib/systemd/") || normalized.startsWith("/lib/systemd/") ? "system" : "user"; } function resolveSystemdUnitNameFromServicePath(sourcePath) { const base = sourcePath ? path.posix.basename(sourcePath.replaceAll("\\", "/")) : ""; return base.endsWith(".service") ? base : "openclaw-gateway.service"; } function shouldDeferUpdateModeSystemdServiceRepair(params) { return process.platform === "linux" && isDoctorUpdateRepairMode(params.repairMode) && !params.shouldForce; } async function readWindowsGatewayRuntimeForUpdateRepair(params) { if (process.platform !== "win32") return null; return await params.service.readRuntime(params.env).catch(() => null); } async function resolveSystemdServiceRewriteBlock(command, issues) { if (process.platform !== "linux" || !issues.some(isExecStartRepairIssue)) return; const unitName = resolveSystemdUnitNameFromServicePath(command.sourcePath); const scope = resolveSystemdScopeFromServicePath(command.sourcePath); const active = await isSystemdUnitActive(process.env, unitName, scope); if (!active.ok) return `Could not determine whether gateway service ${unitName} is active: ${active.error}. Leaving supervisor metadata unchanged. Check \`systemctl${scope === "user" ? " --user" : ""} status ${unitName}\` and rerun doctor.`; if (!active.value) return; issues.splice(0, issues.length, ...issues.filter((issue) => !isExecStartRepairIssue(issue))); return `Gateway service ${unitName} is running; skipped command/entrypoint rewrites and leaving supervisor metadata unchanged. Stop the service first or use \`openclaw gateway install --force\` when you want to replace the active launcher.`; } async function filterInactiveExtraGatewayServices(services) { if (process.platform !== "linux") return services; const activeOrLegacy = []; for (const svc of services) { if (svc.platform !== "linux" || svc.legacy === true) { activeOrLegacy.push(svc); continue; } const active = await isSystemdUnitActive(process.env, svc.label, svc.scope); if (!active.ok || active.value) activeOrLegacy.push(svc); } return activeOrLegacy; } async function detectExtraGatewayServiceIssues(options = {}) { if (!isDefaultInstallIdentity(process.env) || !await shouldManageGatewayService()) return []; return await filterInactiveExtraGatewayServices(await findExtraGatewayServices(process.env, { deep: options.deep })); } function extraGatewayServiceToHealthFinding(service) { return { checkId: GATEWAY_SERVICES_EXTRA_CHECK_ID, severity: service.legacy === true ? "warning" : "info", message: `Other gateway-like service detected: ${service.label} (${service.scope}, ${service.detail})`, source: service.platform, target: service.label, fixHint: service.legacy === true ? "Run `openclaw doctor` interactively to review legacy gateway services and confirm supported cleanup." : "Run a single gateway per machine unless this extra gateway is intentional." }; } function extraGatewayServiceToRepairEffects(service) { if (service.legacy !== true) return []; return [{ kind: "service", action: "would-remove-legacy-gateway-service", target: service.label, dryRunSafe: false }]; } async function cleanupLegacyLaunchdService(params) { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; await execLaunchctl([ "bootout", domain, params.plistPath ], DOCTOR_LAUNCHCTL_TIMEOUT_MS); await execLaunchctl(["unload", params.plistPath], DOCTOR_LAUNCHCTL_TIMEOUT_MS); if (!await confirmLegacyLaunchdServiceUnloaded(`${domain}/${params.label}`)) return { status: "failed", reason: "launchctl could not confirm unload" }; const trashDir = path.join(os.homedir(), ".Trash"); try { await fs.mkdir(trashDir, { recursive: true }); } catch {} try { await fs.access(params.plistPath); } catch (error) { if (error.code === "ENOENT") return { status: "removed" }; return { status: "failed", reason: "could not inspect plist" }; } const dest = path.join(trashDir, `${params.label}-${Date.now()}.plist`); try { await fs.rename(params.plistPath, dest); return { status: "removed", destination: dest }; } catch { return { status: "failed", reason: "could not move plist" }; } } function classifyLegacyServices(legacyServices) { const darwinUserServices = []; const linuxUserServices = []; const failed = []; 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) { const removed = []; const failed = []; for (const svc of services) { const plistPath = extractDetailPath(svc.detail, "plist:"); if (!plistPath) { failed.push(`${svc.label} (missing plist path)`); continue; } const result = await cleanupLegacyLaunchdService({ label: svc.label, plistPath }); if (result.status === "removed") removed.push(result.destination ? `${svc.label} -> ${result.destination}` : svc.label); else failed.push(`${svc.label} (${result.reason})`); } return { removed, failed }; } async function cleanupLegacyLinuxUserServices(services, runtime) { const removed = []; const failed = []; try { const removedUnits = await uninstallLegacySystemdUnits({ env: process.env, stdout: process.stdout }); const removedByLabel = new Map(removedUnits.map((unit) => [`${unit.name}.service`, unit])); 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 }; } /** * Audits and optionally rewrites the installed local gateway service configuration. * * The repair preserves managed env sources and avoids Nix/remote installs. Update-mode repairs * stay staged except for running Windows services, which must be activated to replace a fallback. */ async function maybeRepairGatewayServiceConfig(cfg, mode, runtime, prompter, options = {}) { if (!isDefaultInstallIdentity(process.env)) { note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway"); return cfg; } if (resolveIsNixMode(process.env)) { note("Nix mode detected; skip service updates.", "Gateway"); return cfg; } if (mode === "remote") { note("Gateway mode is remote; skipped local service audit.", "Gateway"); return cfg; } const service = resolveGatewayService(); let command; try { command = await service.readCommand(process.env); } catch { command = null; } if (!command) { const audit = await auditGatewayServiceConfig({ env: process.env, command: null, platform: process.platform }); if (audit.issues.length > 0) note(audit.issues.map((issue) => issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`).join("\n"), "Gateway service config"); return cfg; } const managedDefinition = resolveManagedGatewayServiceCommand(command) ?? command; note(formatGatewayHeapLimitReport(inspectGatewayHeapLimit(command.environment?.NODE_OPTIONS, {}, command.programArguments)), "Gateway heap"); const managedWrapperPath = managedDefinition.environment?.[OPENCLAW_WRAPPER_ENV_KEY]?.trim(); const serviceInstallEnv = managedWrapperPath && !Object.hasOwn(process.env, "OPENCLAW_WRAPPER") ? { ...process.env, [OPENCLAW_WRAPPER_ENV_KEY]: managedWrapperPath } : process.env; const serviceWrapperPath = normalizeOptionalString(command.environment?.[OPENCLAW_WRAPPER_ENV_KEY]); if (serviceWrapperPath) note(`Gateway service invokes ${OPENCLAW_WRAPPER_ENV_KEY}: ${serviceWrapperPath}`, "Gateway"); const serviceLayout = await summarizeGatewayServiceLayout(command); const sourceCheckoutWarning = serviceLayout?.entrypointSourceCheckout ? [`Gateway service entrypoint resolves to a source checkout: ${serviceLayout.packageRootReal ?? serviceLayout.packageRoot ?? serviceLayout.entrypointReal ?? serviceLayout.entrypoint}.`, "Run `openclaw gateway install --force` from the intended package install to replace the gateway service definition."].join("\n") : null; const tokenRefConfigured = Boolean(resolveSecretInputRef({ value: cfg.gateway?.auth?.token, defaults: cfg.secrets?.defaults }).ref); const gatewayTokenResolution = await resolveGatewayAuthTokenForService(cfg, process.env, { allowExecSecretRefs: options.allowExecSecretRefs === true }); if (gatewayTokenResolution.unavailableReason) note(`Unable to verify gateway service token drift: ${gatewayTokenResolution.unavailableReason}`, "Gateway service config"); const expectedGatewayToken = tokenRefConfigured ? void 0 : gatewayTokenResolution.token; const port = resolveGatewayPort(cfg, process.env); const runtimeChoice = resolveGatewayDaemonRuntime(managedDefinition.programArguments); const installedRuntimePath = runtimeChoice === "bun" ? managedDefinition.programArguments[0] : void 0; const expectedPlan = await buildExpectedGatewayServicePlan({ cfg, command, serviceInstallEnv, port, runtime: runtimeChoice, runtimePath: installedRuntimePath }); const expectedManagedServiceEnvKeys = readManagedServiceEnvKeysFromEnvironment(expectedPlan.environment); const audit = await auditGatewayServiceConfig({ env: process.env, command, expectedGatewayToken, expectedManagedServiceEnvKeys, expectedServicePath: expectedPlan.environment.PATH, expectedPort: port }); 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?.status === "supported" ? systemNodeInfo.path : null; if (needsNodeRuntime && !systemNodePath && runtimeChoice !== "node") { const warning = renderSystemNodeWarning(systemNodeInfo); if (warning) note(warning, "Gateway runtime"); else note(`System Node ${SUPPORTED_NODE_VERSIONS} not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.`, "Gateway runtime"); } const expectedRuntimePlan = needsNodeRuntime && systemNodePath ? await buildExpectedGatewayServicePlan({ cfg, command, serviceInstallEnv, port, runtime: "node", runtimePath: systemNodePath }) : expectedPlan; const { programArguments } = expectedRuntimePlan; const expectedEntrypoint = findGatewayEntrypoint(programArguments); const currentEntrypoint = findGatewayEntrypoint(command.programArguments); const normalizedExpectedEntrypoint = expectedEntrypoint ? await normalizeExecutablePath(expectedEntrypoint) : null; const normalizedCurrentEntrypoint = serviceLayout?.entrypoint ? await normalizeExecutablePath(serviceLayout.entrypoint) : 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" }); const serviceRewriteBlock = await resolveSystemdServiceRewriteBlock(command, audit.issues); if (serviceRewriteBlock) note(serviceRewriteBlock, "Gateway service config"); const hasEntrypointMismatch = audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayEntrypointMismatch); const showSourceCheckoutWarning = sourceCheckoutWarning !== null && !hasEntrypointMismatch; if (audit.issues.length === 0) { if (sourceCheckoutWarning !== null && !hasEntrypointMismatch) note(sourceCheckoutWarning, "Gateway service config"); return cfg; } const serviceRepairPolicy = resolveServiceRepairPolicy(); const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy); const consolidatedLines = []; let emittedSourceCheckoutWarning = false; if (sourceCheckoutWarning !== null && showSourceCheckoutWarning) { consolidatedLines.push(sourceCheckoutWarning); consolidatedLines.push(""); emittedSourceCheckoutWarning = true; } consolidatedLines.push(...audit.issues.map((issue) => issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`)); note(consolidatedLines.join("\n"), "Gateway service config"); if (audit.issues.every((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeProbeFailed)) return cfg; const needsAggressive = audit.issues.filter((issue) => issue.level === "aggressive").length > 0; if (needsAggressive && !prompter.shouldForce) note("Custom or unexpected service edits detected. Rerun with --force to overwrite.", "Gateway service config"); if (serviceRepairExternal) { note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway service config"); return cfg; } if (serviceRewriteBlock) return cfg; if (process.platform === "linux" && audit.issues.some((issue) => isExecStartRepairIssue(issue) && hasGatewayServiceLauncherOverride(command) || issue.code === SERVICE_AUDIT_CODES.gatewayPortMismatch && hasGatewayServiceLauncherOverride(command, { includeWorkingDirectory: false }) || isOperatorOwnedEnvironmentIssue(issue, command, expectedPlan.environmentValueSources))) { const unitName = resolveSystemdUnitNameFromServicePath(command.sourcePath); const inspectCommand = `systemctl${resolveSystemdScopeFromServicePath(command.sourcePath) === "user" ? " --user" : ""} cat ${unitName}`; note(`Gateway service command, working directory, or environment comes from an operator-owned systemd drop-in; rewriting the managed unit cannot repair it. Inspect with \`${inspectCommand}\`, then update or remove the drop-in and rerun doctor.`, "Gateway service config"); return cfg; } const updateRepairMode = isDoctorUpdateRepairMode(prompter.repairMode); if (updateRepairMode && !updateParentAllowsGatewayServiceRepair(process.env)) { note("Update parent did not authorize changes to this gateway service definition; leaving it unchanged.", "Gateway service config"); return cfg; } if (shouldDeferUpdateModeSystemdServiceRepair({ repairMode: prompter.repairMode, shouldForce: prompter.shouldForce })) { note("Update-mode doctor detected gateway service drift but left the live systemd unit unchanged. Review the service file and run `openclaw gateway install --force` when you want OpenClaw to rewrite its managed unit; operator-owned drop-ins remain unchanged.", "Gateway service config"); return cfg; } const repairMessage = needsAggressive ? "Overwrite gateway service config with current defaults now?" : "Update gateway service config to the recommended defaults now?"; if (!(updateRepairMode ? needsAggressive ? await prompter.confirmAggressiveAutoFix({ message: repairMessage, initialValue: prompter.shouldForce }) : await prompter.confirmAutoFix({ message: repairMessage, initialValue: true }) : await prompter.confirmRuntimeRepair({ message: repairMessage, initialValue: needsAggressive ? prompter.shouldForce : true, requiresInteractiveConfirmation: true }))) { if (!emittedSourceCheckoutWarning) note("Run `openclaw gateway install --force` when you want to replace the gateway service definition.", "Gateway service config"); return cfg; } try { for (const environment of [mergeGatewayServiceEnv(serviceInstallEnv, command), expectedRuntimePlan.environment]) { const capability = await service.readDefinitionMutationCapability?.({ env: serviceInstallEnv, environment }).catch(() => ({ kind: "unknown", reason: "inspection-failed" })); if (capability) assertServiceDefinitionWritable(capability); } } catch (err) { runtime.error(`Gateway service repair blocked: ${String(err)}`); return cfg; } const serviceEmbeddedToken = readEmbeddedGatewayToken(managedDefinition); const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken; const configuredGatewayToken = typeof cfg.gateway?.auth?.token === "string" ? normalizeOptionalString(cfg.gateway.auth.token) : void 0; let cfgForServiceInstall = cfg; const updateRepairWillRewriteWindowsTask = updateRepairMode && process.platform === "win32"; const serviceRuntimeEnv = { ...serviceInstallEnv, ...managedDefinition.environment }; const installedWindowsTaskName = managedDefinition.environment?.OPENCLAW_WINDOWS_TASK_NAME?.trim(); const serviceRepairEnv = updateRepairWillRewriteWindowsTask && installedWindowsTaskName ? { ...serviceInstallEnv, OPENCLAW_WINDOWS_TASK_NAME: installedWindowsTaskName } : serviceInstallEnv; const updateRepairShouldInstall = (updateRepairWillRewriteWindowsTask && updateParentAllowsGatewayActivation(process.env) ? await readWindowsGatewayRuntimeForUpdateRepair({ service, env: serviceRuntimeEnv }) : null)?.status === "running"; let startupFallbackTakeoverRuntime; if (updateRepairShouldInstall) try { const fallbackRuntime = await readWindowsStartupFallbackRuntimeForUpdate(serviceRuntimeEnv); if (fallbackRuntime && (fallbackRuntime.status !== "running" || !fallbackRuntime.pid)) { note("Could not verify the running Windows login item before service repair; leaving it unchanged.", "Gateway"); return cfg; } startupFallbackTakeoverRuntime = fallbackRuntime ?? void 0; } catch (err) { runtime.error(`Could not inspect the Windows login item before service repair: ${String(err)}`); return cfg; } if ((!updateRepairMode || updateRepairWillRewriteWindowsTask) && !tokenRefConfigured && !configuredGatewayToken && gatewayTokenForRepair) { if (updateRepairWillRewriteWindowsTask && shouldSkipLegacyUpdateRepairConfigWrite(process.env)) { note("Legacy update parent cannot persist gateway.auth.token before service repair; leaving the existing gateway service unchanged.", "Gateway"); return cfg; } const nextCfg = { ...cfg, gateway: { ...cfg.gateway, auth: { ...cfg.gateway?.auth, mode: cfg.gateway?.auth?.mode ?? "token", token: gatewayTokenForRepair } } }; try { await replaceConfigFile({ nextConfig: nextCfg, afterWrite: { mode: "auto" }, writeOptions: { auditOrigin: "doctor", allowConfigSizeDrop: options.allowConfigSizeDrop === true || updateRepairMode, skipPluginValidation: options.skipPluginValidation === true || updateRepairMode, preservedLegacyRootKeys: options.preservedLegacyRootKeys, ...options.lastTouchedVersionOverride ? { lastTouchedVersionOverride: options.lastTouchedVersionOverride } : {} } }); 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 cfg; } } const updatedPort = resolveGatewayPort(cfgForServiceInstall, process.env); const updatedPlan = await buildExpectedGatewayServicePlan({ cfg: cfgForServiceInstall, command, serviceInstallEnv, port: updatedPort, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, runtimePath: needsNodeRuntime && systemNodePath ? systemNodePath : installedRuntimePath }); const repairService = updateRepairMode && !updateRepairShouldInstall ? service.stage : service.install; try { await repairService({ env: serviceRepairEnv, stdout: process.stdout, warn: (message) => note(message, "Gateway"), programArguments: updatedPlan.programArguments, workingDirectory: updatedPlan.workingDirectory, environment: updatedPlan.environment, environmentValueSources: updatedPlan.environmentValueSources, startupFallbackTakeoverRuntime }); if (updateRepairShouldInstall && !isTruthyEnvValue(process.env["OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART"])) { const restartEnv = { ...serviceRepairEnv, ...updatedPlan.environment }; if (installedWindowsTaskName) restartEnv.OPENCLAW_WINDOWS_TASK_NAME = installedWindowsTaskName; await service.restart({ env: restartEnv, stdout: process.stdout }); note("Restarted the repaired gateway for a legacy update parent.", "Gateway"); } } catch (err) { runtime.error(`Gateway service update failed: ${String(err)}`); } return cfgForServiceInstall; } /** * Reports duplicate gateway-like services and removes legacy user services after confirmation. */ async function maybeScanExtraGatewayServices(options, runtime, prompter) { if (!isDefaultInstallIdentity(process.env)) { note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway"); return; } const extraServices = await detectExtraGatewayServiceIssues(options); 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 serviceRepairPolicy = resolveServiceRepairPolicy(); const serviceRepairExternal = isServiceRepairExternallyManaged(serviceRepairPolicy); if (serviceRepairExternal) note(EXTERNAL_SERVICE_REPAIR_NOTE, "Legacy gateway cleanup skipped"); if (serviceRepairExternal ? false : await confirmDoctorServiceRepair(prompter, { message: "Remove legacy gateway services now?", initialValue: true }, serviceRepairPolicy)) { const removed = []; 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"); } } const cleanupHints = renderGatewayServiceCleanupHints(extraServices.filter((service) => service.legacy !== true)); 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"); } /** * Resolves a `dueling` systemd install (both a user-scope and a system-scope * gateway unit present) by removing the redundant user-scope unit after * confirmation, keeping the root-installed system-scope unit as authoritative. * * This is the fix for issue #79375: on Linux the two units bind the same port * and SIGTERM each other in an endless restart loop. The canonical units are * deliberately excluded from `findExtraGatewayServices`, so this detects the * condition directly via `findSystemdGatewayInstallation`. Removing a unit * under `$HOME` needs no root; the system-scope unit is never auto-removed * (only a `sudo`-flavored hint is offered for that direction). */ async function maybeResolveDuelingSystemdGatewayScopes(runtime, prompter) { if (process.platform !== "linux") return; const installation = await findSystemdGatewayInstallation(process.env).catch(() => null); if (installation?.kind !== "dueling") return; const { user, system } = installation; note([ "Both a user-scope and a system-scope OpenClaw gateway unit are installed:", `- user: ${user.unitPath}`, `- system: ${system.unitPath}`, "They bind the same port and will SIGTERM each other in a restart loop." ].join("\n"), "Dueling gateway services detected"); if (!await isSystemUnitActiveAndEnabled(process.env, system.unitName).catch(() => false)) { note([ "Could not verify the system-scope unit is both running and enabled at boot, so the", "user-scope unit may be your working gateway. Not removing anything", "automatically.", "If the system-scope unit is the one you want, activate it and re-run doctor:", `- sudo systemctl enable --now ${system.unitName}`, "If the user-scope unit is the one you want, remove the system unit:", `- sudo systemctl disable --now ${system.unitName} && sudo rm ${system.unitPath}` ].join("\n"), "Gateway cleanup needs an owner decision"); return; } note(["The system-scope unit is the active and boot-enabled supervisor and is", "treated as authoritative; the user-scope unit is the redundant leftover."].join("\n"), "System-scope unit owns the gateway"); const policy = resolveServiceRepairPolicy(); if (isServiceRepairExternallyManaged(policy)) { note(EXTERNAL_SERVICE_REPAIR_NOTE, "Gateway cleanup skipped"); return; } if (!await confirmDoctorServiceRepair(prompter, { message: "Remove the redundant user-scope gateway unit and keep the system-scope unit?", initialValue: true }, policy)) { const hints = renderGatewayServiceCleanupHints(); if (hints.length > 0) note(hints.map((hint) => `- ${hint}`).join("\n"), "Cleanup hints"); return; } try { const result = await uninstallUserSystemdGatewayUnit({ env: process.env, stdout: process.stdout }); note(result.removed ? `Removed user-scope unit ${result.unitPath}.` : `User-scope unit already absent at ${result.unitPath}.`, "Redundant user gateway removed"); runtime.log(result.disabled ? "Removed the redundant user-scope gateway unit. The system-scope unit is now the sole gateway manager." : `Removed the user-scope unit file, but systemctl was unavailable to stop it. Run: systemctl --user disable --now ${result.unitName} && systemctl --user daemon-reload`); } catch (err) { runtime.error(`Failed to remove redundant user-scope gateway unit: ${String(err)}`); const hints = renderGatewayServiceCleanupHints(); if (hints.length > 0) note(hints.map((hint) => `- ${hint}`).join("\n"), "Cleanup hints"); } } //#endregion export { detectExtraGatewayServiceIssues, extraGatewayServiceToHealthFinding, extraGatewayServiceToRepairEffects, maybeRepairGatewayServiceConfig, maybeResolveDuelingSystemdGatewayScopes, maybeScanExtraGatewayServices };