UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

842 lines (841 loc) • 46.3 kB
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs"; import { n as isNodeRuntime } from "./runtime-binary-BczyKE0p.mjs"; import { n as resolveOpenClawPackageRoot } from "./openclaw-root-BGdnWXIW.mjs"; import { t as sleep } from "./sleep-dMoNLoKC.mjs"; import { h as resolveDefaultSecretProviderAlias } from "./ref-contract-Bfd9_-Lp.mjs"; import { g as resolveSecretInputRef } from "./types.secrets-DD-Q2SS_.mjs"; import { t as hasErrnoCode } from "./errno-FR4CRyya.mjs"; import { r as parseTcpPortFromArgs } from "./tcp-port-B-4SirsX.mjs"; import { C as resolveIsNixMode, b as resolveGatewayPort, o as isDefaultInstallIdentity } from "./paths-BP0DYpE8.mjs"; import { t as formatCliCommand } from "./command-format-DwEdrDKL.mjs"; import { n as SUPPORTED_NODE_VERSIONS } from "./node-version-Dq_scYWR.mjs"; import { r as hasCommandProcessCleanupError } from "./exec-result-DXhDHhYI.mjs"; import { n as NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, r as assertGatewayServiceMutationAllowed } from "./gateway-supervision-D9M_dluZ.mjs"; import { n as GatewayServiceAuthorityError } from "./service-update-authority-CkaptUU4.mjs"; import { d as sanitizeServiceInspectionError } from "./service-inspection-error-ChLx0NoG.mjs"; import { a as formatGatewayServiceInstallationDrift } from "./shared-BlizK8_P.mjs"; import { n as readInstallOwner, t as formatInstallOwnerMessage } from "./install-owner-lgHfm2Mr.mjs"; import { t as ConfigWritePostCommitError } from "./io.write-errors-B-bJYfsA.mjs"; import { x as OPENCLAW_WRAPPER_ENV_KEY } from "./schtasks-ChoEJt_e.mjs"; import { a as hasGatewayServiceLauncherOverride, i as hasGatewayServiceEnvironmentOverride, o as resolveManagedGatewayServiceCommand, t as assertServiceDefinitionWritable } from "./service-types-CPk6CBn6.mjs"; import { c as summarizeGatewayServiceLayout, r as inspectGatewayServiceInstallationDrift, t as gatewayServiceCommandMatchesRoot } from "./service-layout-Cwf6zauj.mjs"; import { t as mergeGatewayServiceEnv } from "./service-env-merge-D7nWnWSC.mjs"; import { b as isLaunchctlNotLoaded, v as execLaunchctl } from "./launchd-service-files-CBsl27Ae.mjs"; import { V as isSystemdUnitActive } from "./systemd-service-files-Crio-kus.mjs"; import { t as withGatewayServiceOperationLock } from "./service-operation-lock-BjiIoJcj.mjs"; import { i as readGatewayServiceState, n as resolveGatewayService } from "./service-BFsg6EmN.mjs"; import { a as readDaemonRuntimePin } from "./runtime-pin-state-CnXfmzZZ.mjs"; import { n as inspectGatewayHeapLimit, t as formatGatewayHeapLimitReport } from "./gateway-heap-CeTBEt8D.mjs"; import { a as findSystemdGatewayInstallation, s as isSystemUnitActiveAndEnabled } from "./systemd-scope-D-Kcmp_z.mjs"; import { c as normalizeServiceEnvKey, d as readManagedServiceEnvKeysFromEnvironment } from "./service-managed-env-DEiL_iOe.mjs"; import "./systemd-DygLJFXs.mjs"; import { a as uninstallUserSystemdGatewayUnit } from "./systemd-lifecycle-DjfaQ7UX.mjs"; import { d as resolveSystemNodeInfo, i as renderSystemNodeWarning, o as resolveNodeRuntimeInfo } from "./runtime-paths-CNOx8gw6.mjs"; import { i as renderGatewayServiceCleanupHints, t as findExtraGatewayServices } from "./inspect-BugMGOOn.mjs"; import { t as note } from "./note-Bi0VsI6R.mjs"; import { i as resolveGatewayDaemonRuntime } from "./daemon-runtime-85RWDsUF.mjs"; import { t as SERVICE_PROXY_ENV_KEYS } from "./service-env-C9wcFHgr.mjs"; import { a as readEmbeddedGatewayToken, i as needsNodeRuntimeMigration, n as auditGatewayServiceConfig, t as SERVICE_AUDIT_CODES } from "./service-audit-BuoA3viB.mjs"; import { t as resolveGatewayAuthToken } from "./auth-token-resolution-da44MR3g.mjs"; import { t as buildGatewayInstallPlan } from "./daemon-install-helpers-mpbJG20J.mjs"; import { a as isServiceRepairDeferred, i as formatServiceRepairDeferredNote, l as shouldManageGatewayService, r as confirmDoctorServiceRepair, s as resolveServiceRepairPolicy } from "./doctor-service-repair-policy-DQJbEDp3.mjs"; import { n as cleanupLegacyLinuxUserServices, t as classifyLegacyServices } from "./doctor-gateway-legacy-services-CUHdb7kO.mjs"; import path from "node:path"; import fs from "node:fs/promises"; import os from "node:os"; import { isDeepStrictEqual } from "node:util"; //#region src/commands/doctor-gateway-auth-token.ts /** Preserve a recovered credential without overwriting any existing store entry. */ async function preserveGatewayAuthTokenForService(params) { const { listSecretStoreEntries, readSecretStoreValue, writeSecretStoreEntryForConfigRef } = await import("./secret-store-Bm1MKYpk.mjs"); const database = { env: params.env }; const scope = { kind: "team" }; const provider = resolveDefaultSecretProviderAlias(params.cfg, "store", { preferFirstProviderForSource: true }); for (const entry of await listSecretStoreEntries({ scope, database, assertCurrent: params.assertCurrent })) { const stored = await readSecretStoreValue({ scope, database, name: entry.name, assertCurrent: params.assertCurrent }); params.assertCurrent?.(); if (!stored.ok) { if (stored.error.code === "SECRET_STORE_NOT_FOUND") continue; throw new Error(stored.error.message); } if (stored.value === params.token) return { ref: { source: "store", provider, id: entry.name }, reused: true }; } const { stat } = await import("node:fs/promises"); const { randomUUID } = await import("node:crypto"); const { resolveOpenClawStateSqlitePath } = await import("./openclaw-state-db.paths-aljaknM8.mjs"); const databasePath = resolveOpenClawStateSqlitePath(params.env); const existing = await stat(databasePath).catch((error) => { if (hasErrnoCode(error, "ENOENT")) return; throw error; }); let backupPath; if (existing) { const { createVerifiedSqliteSnapshot } = await import("./sqlite-snapshot-Chf2pY80.mjs"); params.assertCurrent?.(); ({path: backupPath} = await createVerifiedSqliteSnapshot({ sourcePath: databasePath, targetPath: `${databasePath}.doctor-gateway-token.${randomUUID()}.bak`, preserveRowIds: true })); } return { ref: { source: "store", provider, id: await writeSecretStoreEntryForConfigRef({ baseName: "OPENCLAW_GATEWAY_TOKEN", value: params.token, updatedBy: "doctor", database, assertCurrent: params.assertCurrent }) }, reused: false, backupPath }; } /** * 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-installation.ts async function canRepairRunningGatewayDefinition(params) { if ((await params.service.readRuntime(mergeGatewayServiceEnv(params.env, params.command)).catch(() => null))?.status === "running") return true; note(`Gateway native-policy repair requires a running managed service. The existing definition and stop state were preserved; inspect it with \`${formatCliCommand("openclaw gateway status --deep", params.env)}\` before using \`${formatCliCommand("openclaw gateway install --force", params.env)}\`.`, "Gateway service definition"); return false; } /** One native writer retains Doctor custody through publication and recovery. */ async function installDoctorGatewayService(params) { try { const install = async (assertCurrent = params.maintenance?.assertCurrent) => { const publish = async (definitionTransaction) => { if (params.repair.kind === "definition" && !params.maintenance) { if (!await canRepairRunningGatewayDefinition({ service: params.service, command: params.command, env: params.args.env })) throw new Error("Gateway stopped before native-policy repair; its definition was preserved."); } await params.service.install({ ...params.args, assertCurrent, definitionTransaction }); }; if (params.repair.kind !== "installation" && params.repair.root) { const { reconcileGatewayServiceDefinition } = await import("./service-reconciliation-xaNYFyoG.mjs"); await reconcileGatewayServiceDefinition({ env: params.args.env, root: params.repair.root, command: params.command, expectedCommand: params.args, install: publish, warn: (message) => note(message, "Gateway service definition") }); } else await publish(); }; if (params.repair.kind === "installation") { const repair = { service: params.service, command: params.command, activeRoot: params.repair.root, maintenance: params.maintenance, env: params.args.env }; await withGatewayServiceOperationLock(repair.env, async (assertNative) => { const assertCurrent = () => { assertNative(); repair.maintenance?.assertCurrent(); }; await assertGatewayServiceInstallationRepairAllowed(repair); assertCurrent(); await install(assertCurrent); if (process.platform === "win32" && !repair.maintenance) await repair.service.restart({ env: repair.env, stdout: process.stdout, assertCurrent }); }); note("Gateway service installation reconciled with the active CLI.", "Gateway service installation"); } else await install(); } catch (err) { if (err instanceof GatewayServiceAuthorityError || hasCommandProcessCleanupError(err)) throw err; params.runtime.error(`Gateway service update failed: ${String(err)}`); } } /** Doctor consumes the updater's verified ownership; a recorded path alone grants no repair. */ async function assertGatewayServiceInstallationRepairAllowed(params) { const { inspectManagedGatewayServiceBeforeUpdate, GATEWAY_SERVICE_INSPECTION_WARNING } = await import("./update-command-service-plan-BNcUdHRy.mjs"); const maintenance = params.maintenance; const state = await readGatewayServiceState(params.service, { env: process.env, requireEffective: true, requireLoadedCommand: true, ...maintenance?.managerUid !== void 0 ? { loadForInspection: { managerUid: maintenance.managerUid, assertCurrent: maintenance.assertCurrent, assertReadCurrent: maintenance.assertReadCurrent } } : {}, validateEnvBeforeStatusRead: (env) => assertGatewayServiceMutationAllowed("repair the gateway service installation", env) }).catch((error) => { throw new Error(`${GATEWAY_SERVICE_INSPECTION_WARNING} ${sanitizeServiceInspectionError(error).message}`, { cause: error }); }); const verdict = await inspectManagedGatewayServiceBeforeUpdate({ state, root: params.activeRoot, allowInstallRootChange: true }); maintenance?.assertCurrent(); if (verdict.kind === "unavailable") throw new Error(verdict.message); if (!isDeepStrictEqual(state.command, params.command)) throw new Error("Gateway service definition changed during Doctor; rerun doctor to inspect the current installation."); if (verdict.kind !== "owned" || !verdict.requiresInstallRootRefresh) throw new Error(`Gateway service installation is controlled by another owner; automatic installation repair was skipped. Inspect it with \`${formatCliCommand("openclaw gateway status --deep", state.env)}\`.`); } const EXECSTART_REPAIR_CODES = /* @__PURE__ */ new Set([SERVICE_AUDIT_CODES.gatewayCommandMissing, SERVICE_AUDIT_CODES.gatewayEntrypointMismatch]); function isExecStartRepairIssue(issue) { return EXECSTART_REPAIR_CODES.has(issue.code); } 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"; } 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 \`${formatCliCommand("openclaw gateway install --force")}\` when you want to replace the active launcher.`; } //#endregion //#region src/commands/doctor-gateway-runtime-plan.ts /** Builds Doctor service plans while preserving installed runtime and environment intent. */ async function buildExpectedGatewayServicePlan(params) { const managed = resolveManagedGatewayServiceCommand(params.command); const recordedNode = managed?.programArguments[0]; let runtimePath = params.runtimePath; if (runtimePath === void 0 && !params.pinnedRuntimePath && params.runtime === "node" && !params.serviceInstallEnv["OPENCLAW_WRAPPER"]?.trim() && recordedNode && isNodeRuntime(recordedNode) && (await resolveNodeRuntimeInfo(recordedNode, params.serviceInstallEnv)).status === "supported") runtimePath = recordedNode; return buildGatewayInstallPlan({ env: params.serviceInstallEnv, port: params.port, runtime: params.runtime, runtimePath, pinnedRuntimePath: params.pinnedRuntimePath, existingCommand: params.command, existingEnvironment: managed?.environment, existingEnvironmentValueSources: managed?.environmentValueSources, warn: (message, title) => note(message, title), config: params.cfg }); } //#endregion //#region src/commands/doctor-service-audit.ts function formatServiceConfigIssues(issues) { return issues.map(({ message, detail }) => `- ${message}${detail ? ` (${detail})` : ""}`); } /** Keep source-checkout and native audit findings in one panel. */ function reportGatewayServiceConfigAudit(audit, layout, definitionRepair) { const sourceCheckoutWarning = layout?.entrypointSourceCheckout ? [`Gateway service entrypoint resolves to a source checkout: ${layout.packageRootReal ?? layout.packageRoot ?? layout.entrypointReal ?? layout.entrypoint}.`, `Run \`${formatCliCommand("openclaw gateway install --force")}\` from the intended package install to replace the gateway service definition.`].join("\n") : null; const sourceCheckoutWarningToShow = audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayEntrypointMismatch) ? null : sourceCheckoutWarning; if (audit.issues.length === 0 && !definitionRepair) { if (sourceCheckoutWarningToShow !== null) note(sourceCheckoutWarningToShow, "Gateway service config"); return sourceCheckoutWarningToShow; } note([...sourceCheckoutWarningToShow !== null ? [sourceCheckoutWarningToShow, ""] : [], ...formatServiceConfigIssues(audit.issues)].join("\n"), "Gateway service config"); return sourceCheckoutWarningToShow; } /** Describe the selected install plan without changing runtime or launcher policy. */ function formatGatewayServiceRepairPreview(params) { const unresolvedFindings = []; if (params.missingSystemNode) unresolvedFindings.push(`No supported system Node is available; this repair retains ${params.plan.programArguments[0]}. Install system Node ${SUPPORTED_NODE_VERSIONS} and rerun Doctor to resolve the runtime finding.`); if (params.plannedLayout?.entrypointSourceCheckout) unresolvedFindings.push(`This repair retains a source-checkout entrypoint. Run \`${formatCliCommand("openclaw gateway install --force")}\` from the intended package install to replace it.`); return { preview: [ `Runtime: ${params.managedDefinition.programArguments[0]} -> ${params.plan.programArguments[0]}`, `Entrypoint: ${params.currentLayout?.entrypoint ?? "not identified"} -> ${params.plannedLayout?.entrypoint ?? "not identified"}`, `PATH: ${params.command.environment?.PATH ?? "not set"} -> ${params.plan.environment?.PATH ?? "not set"}`, ...(params.definitionDrift ?? []).flatMap((finding) => finding.kind === "outdated" && (finding.key === "ExitTimeOut" || finding.key === "TimeoutStopSec") ? [`Shutdown budget (${finding.key}): ${String(finding.current)} -> ${String(finding.expected)}`] : []), ...unresolvedFindings.map((finding) => `Will remain: ${finding}`) ].join("\n"), unresolvedFindings }; } function reportServiceDefinitionDrift(audit) { const messages = [...(audit.definitionDrift ?? []).map((fact) => fact.message), ...audit.definitionDriftError ? [audit.definitionDriftError] : []]; if (messages.length > 0) note(messages.map((message) => `- ${message}`).join("\n"), "Gateway service definition"); } /** Installation repair cannot implicitly approve other service-definition changes. */ function isServiceInstallationOnlyRepair(audit) { return !audit.definitionDriftError && !audit.definitionDrift?.length && audit.issues.every((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayEntrypointMismatch); } function hasRepairableServiceDefinitionDrift(audit) { return !audit.definitionDriftError && audit.definitionDrift?.some((finding) => finding.kind === "outdated") === true && !audit.definitionDrift.some((finding) => finding.kind === "unknown-edit"); } function isPreservedLaunchdTimeoutWarning(audit) { return audit.issues.every((issue) => issue.code === "launchd-stop-timeout") && audit.definitionDrift?.some((finding) => finding.kind === "preserved" && finding.key === "ExitTimeOut") === true; } /** Native policy repair must not auto-approve unrelated command or credential changes. */ function isServiceDefinitionOnlyRepair(audit) { const policyCodes = /* @__PURE__ */ new Set([ SERVICE_AUDIT_CODES.systemdAfterNetworkOnline, SERVICE_AUDIT_CODES.systemdWantsNetworkOnline, SERVICE_AUDIT_CODES.systemdRestartSec, SERVICE_AUDIT_CODES.systemdKillModeProcessOrNone, SERVICE_AUDIT_CODES.systemdKillModeControlGroup, SERVICE_AUDIT_CODES.systemdStopTimeout, "launchd-run-at-load", "launchd-keep-alive", "launchd-stop-timeout", "launchd-env-wrapper-outdated" ]); return audit.issues.every((issue) => policyCodes.has(issue.code)); } /** The install plan retains pins/wrappers; only a changed, owned Node runtime is repairable. */ async function canRepairRuntime(audit, command, plan, layout, platform = process.platform) { const runtimeCodes = /* @__PURE__ */ new Set([ SERVICE_AUDIT_CODES.gatewayRuntimeNode, SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager, SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing, SERVICE_AUDIT_CODES.gatewayPathMissing, SERVICE_AUDIT_CODES.gatewayPathMissingDirs, SERVICE_AUDIT_CODES.gatewayPathNonMinimal ]); return !layout?.entrypointSourceCheckout && !hasGatewayServiceLauncherOverride(command) && plan.runtime === "node" && plan.programArguments[0] !== resolveManagedGatewayServiceCommand(command)?.programArguments[0] && audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNode) && audit.issues.every((issue) => runtimeCodes.has(issue.code)) && !audit.definitionDriftError && !audit.definitionDrift?.some((finding) => finding.kind === "unknown-edit" && !(platform === "linux" && finding.sourcePath && finding.sourcePath !== command.sourcePath && command.definitionPaths?.includes(finding.sourcePath))) && await gatewayServiceCommandMatchesRoot(layout?.packageRootReal, command) === true; } function isOperatorOwnedServiceIssue(issue, command, environmentValueSources) { const hasOverride = (keys) => hasGatewayServiceEnvironmentOverride(command, keys, { environmentValueSources }); switch (issue.code) { case SERVICE_AUDIT_CODES.gatewayRuntimeNode: return hasGatewayServiceLauncherOverride(command); case SERVICE_AUDIT_CODES.gatewayPathMissing: case SERVICE_AUDIT_CODES.gatewayPathMissingDirs: case SERVICE_AUDIT_CODES.gatewayPathNonMinimal: return hasOverride(["PATH"]); case SERVICE_AUDIT_CODES.gatewayTokenEmbedded: case SERVICE_AUDIT_CODES.gatewayTokenMismatch: case SERVICE_AUDIT_CODES.gatewayTokenDrift: return hasOverride(["OPENCLAW_GATEWAY_TOKEN"]); case SERVICE_AUDIT_CODES.gatewayPasswordEmbedded: return hasOverride(["OPENCLAW_GATEWAY_PASSWORD"]); 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; } } //#endregion //#region src/commands/doctor-gateway-dueling.ts async function maybeResolveDuelingSystemdGatewayScopes(runtime, prompter) { if (process.platform !== "linux") return; const installation = await findSystemdGatewayInstallation(process.env).catch(() => { note("Could not verify the effective Gateway service identities. Nothing was removed; inspect both units with openclaw gateway status --deep.", "Gateway cleanup needs an owner decision"); return 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 (isServiceRepairDeferred(policy)) { note(formatServiceRepairDeferredNote(), "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; } const current = await findSystemdGatewayInstallation(process.env).catch(() => null); if (current?.kind !== "dueling" || current.user.unitName !== user.unitName || current.user.unitPath !== user.unitPath || current.system.unitName !== system.unitName || current.system.unitPath !== system.unitPath || !await isSystemUnitActiveAndEnabled(process.env, system.unitName).catch(() => false)) { note("Gateway service ownership changed or could not be verified after confirmation. Nothing was removed; inspect both units and run Doctor again.", "Gateway cleanup needs an owner decision"); return; } const result = await uninstallUserSystemdGatewayUnit({ env: process.env, stdout: process.stdout, target: user }).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"); return null; }); if (!result) return; note(result.removed ? `Removed user-scope unit ${result.unitPath}.` : `User-scope unit already absent at ${result.unitPath}.`, "Redundant user gateway removed"); if (!result.disabled) { runtime.log(`Removed the user-scope unit file, but systemctl was unavailable to stop it. Run: systemctl --user disable --now ${result.unitName} && systemctl --user daemon-reload`); return; } const remaining = await findSystemdGatewayInstallation(process.env, { requireLoaded: true }).catch(() => null); const completed = `Cleanup of ${result.unitName} completed.`; if (remaining?.kind === "user" || remaining?.kind === "dueling") runtime.log(`${completed} Another user-scope unit remains at ${remaining.user.unitPath}. It was not removed. Run openclaw doctor again to inspect it before removal.`); else if (remaining?.kind === "system" && remaining.system.unitName === system.unitName && remaining.system.unitPath === system.unitPath) runtime.log(`${completed} No other matching installed user-scope unit was found.`); else runtime.log(`${completed} Remaining service ownership could not be verified. Run openclaw gateway status --deep and openclaw doctor before any further cleanup.`); } //#endregion //#region src/commands/doctor-gateway-services.ts 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 sleep(delayMs); } return false; } 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 { services: [], errors: [] }; const detectedExtraServices = await findExtraGatewayServices(process.env, { deep: options.deep }); return { services: await filterInactiveExtraGatewayServices(detectedExtraServices.services), errors: detectedExtraServices.errors }; } 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" }; } } async function cleanupLegacyDarwinServices(services) { const removed = []; const failed = []; for (const svc of services) { const plistPath = svc.sourcePath; 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 }; } /** * Audits and optionally rewrites the installed local gateway service configuration. * * The repair preserves managed env sources and avoids Nix/remote installs. * Updater-driven Doctor leaves service publication with update finalization. */ 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 root = await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, argv1: process.argv[1] }); const installOwner = await readInstallOwner(root); if (installOwner) { note(formatInstallOwnerMessage(installOwner), "Gateway runtime"); return cfg; } const serviceRepairPolicy = resolveServiceRepairPolicy(); const serviceRepairDeferred = isServiceRepairDeferred(serviceRepairPolicy); const platform = process.platform; const service = resolveGatewayService(); const command = await service.readCommand(process.env).catch(() => null); if (!command) { const audit = await auditGatewayServiceConfig({ env: process.env, command: null, platform }); reportServiceDefinitionDrift(audit); if (audit.issues.length > 0) note(formatServiceConfigIssues(audit.issues).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 pinSnapshot = readDaemonRuntimePin({ kind: "gateway", env: process.env }, command); const serviceInstallEnv = { ...process.env, ...managedWrapperPath && !Object.hasOwn(process.env, "OPENCLAW_WRAPPER") ? { [OPENCLAW_WRAPPER_ENV_KEY]: managedWrapperPath } : {} }; 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 serviceOwner = await readInstallOwner(serviceLayout?.packageRootReal ?? serviceLayout?.packageRoot ?? null); if (serviceOwner) { note(formatInstallOwnerMessage(serviceOwner), "Gateway runtime"); return cfg; } 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 hasInstallWrapper = Boolean(serviceInstallEnv[OPENCLAW_WRAPPER_ENV_KEY]?.trim()); const activeRuntimePin = hasInstallWrapper ? void 0 : pinSnapshot.pin?.path; const runtimeChoice = hasInstallWrapper ? "node" : resolveGatewayDaemonRuntime(activeRuntimePin ? [activeRuntimePin] : managedDefinition.programArguments); const installedRuntimePath = runtimeChoice === "bun" ? activeRuntimePin ?? managedDefinition.programArguments[0] : void 0; const expectedPlan = await buildExpectedGatewayServicePlan({ cfg, command, serviceInstallEnv, port, runtime: runtimeChoice, runtimePath: installedRuntimePath, pinnedRuntimePath: pinSnapshot.pin?.path }); const expectedLayout = await summarizeGatewayServiceLayout(expectedPlan); const expectedRoot = expectedLayout?.packageRootReal; const installationDrift = expectedRoot ? await inspectGatewayServiceInstallationDrift(serviceLayout, expectedRoot) : void 0; const repairPort = installationDrift && cfg.gateway?.port === void 0 && !process.env.OPENCLAW_GATEWAY_PORT?.trim() ? parseTcpPortFromArgs(command.programArguments) ?? port : port; const expectedManagedServiceEnvKeys = readManagedServiceEnvKeysFromEnvironment(expectedPlan.environment); const audit = await auditGatewayServiceConfig({ platform, env: process.env, command, expectedGatewayToken, expectedManagedServiceEnvKeys, expectedServicePath: expectedPlan.environment.PATH, expectedPort: repairPort, ...installationDrift ? { expectedCommand: expectedPlan } : {} }); reportServiceDefinitionDrift(audit); if (audit.runtimeNote) note(audit.runtimeNote, "Gateway runtime"); 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 = !hasInstallWrapper && !activeRuntimePin && needsNodeRuntimeMigration(audit.issues); const systemNodeInfo = needsNodeRuntime ? await resolveSystemNodeInfo({ env: process.env, platform }) : null; const systemNodePath = systemNodeInfo?.status === "supported" ? systemNodeInfo.path : null; if (needsNodeRuntime && !systemNodePath && runtimeChoice !== "node") note(renderSystemNodeWarning(systemNodeInfo) || `System Node ${SUPPORTED_NODE_VERSIONS} not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.`, "Gateway runtime"); const repairPlan = needsNodeRuntime && systemNodePath ? await buildExpectedGatewayServicePlan({ cfg, command, serviceInstallEnv, port: repairPort, runtime: "node", runtimePath: systemNodePath }) : expectedPlan; if (installationDrift && expectedRoot) { note(formatGatewayServiceInstallationDrift(installationDrift, void 0, serviceInstallEnv), "Gateway service installation"); if (!serviceRepairDeferred) try { await assertGatewayServiceInstallationRepairAllowed({ service, command, activeRoot: expectedRoot, maintenance: options.serviceMaintenance }); } catch (error) { note(String(error), "Gateway service installation"); return cfg; } } const runtimeLayout = repairPlan === expectedPlan ? expectedLayout : await summarizeGatewayServiceLayout(repairPlan); if (runtimeLayout?.entrypointReal && serviceLayout?.entrypointReal && runtimeLayout.entrypointReal !== serviceLayout.entrypointReal) audit.issues.push({ code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch, message: "Gateway service entrypoint does not match the current install.", detail: `${serviceLayout?.entrypoint} -> ${runtimeLayout?.entrypoint}`, level: "recommended" }); const runtimeRepair = await canRepairRuntime(audit, command, repairPlan, expectedLayout); const definitionRepair = !runtimeRepair && !installationDrift && Boolean(expectedRoot) && !expectedLayout?.entrypointSourceCheckout && hasRepairableServiceDefinitionDrift(audit); const serviceRewriteBlock = installationDrift ? void 0 : await resolveSystemdServiceRewriteBlock(command, audit.issues); if (serviceRewriteBlock) note(serviceRewriteBlock, "Gateway service config"); const sourceCheckoutWarningToShow = reportGatewayServiceConfigAudit(audit, serviceLayout, definitionRepair); if (audit.issues.length === 0 && !definitionRepair) return cfg; if (!definitionRepair && !installationDrift && isPreservedLaunchdTimeoutWarning(audit)) return cfg; if (audit.issues.length > 0 && audit.issues.every((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeProbeFailed)) return cfg; const needsAggressive = audit.issues.some((issue) => issue.level === "aggressive") || installationDrift !== void 0 && (audit.definitionDriftError !== void 0 || audit.definitionDrift?.some((finding) => finding.kind === "unknown-edit") === true); if (needsAggressive && !prompter.shouldForce && !runtimeRepair) note("Custom or unexpected service edits detected. Rerun with --force to overwrite.", "Gateway service config"); if (serviceRepairDeferred) { note(formatServiceRepairDeferredNote(), "Gateway service config"); return cfg; } if (definitionRepair && !options.serviceMaintenance && !await canRepairRunningGatewayDefinition({ service, command, env: serviceInstallEnv })) return cfg; if (serviceRewriteBlock) return cfg; if (platform === "linux" && audit.issues.some((issue) => isExecStartRepairIssue(issue) && hasGatewayServiceLauncherOverride(command) || issue.code === SERVICE_AUDIT_CODES.gatewayPortMismatch && hasGatewayServiceLauncherOverride(command, { includeWorkingDirectory: false }) || isOperatorOwnedServiceIssue(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 gatewayTokenForRepair = expectedGatewayToken ?? readEmbeddedGatewayToken(managedDefinition); const configuredGatewayToken = typeof cfg.gateway?.auth?.token === "string" ? normalizeOptionalString(cfg.gateway.auth.token) : void 0; const needsConfigWrite = !tokenRefConfigured && !configuredGatewayToken && Boolean(gatewayTokenForRepair); const { preview, unresolvedFindings: unresolvedRepairFindings } = formatGatewayServiceRepairPreview({ command, managedDefinition, plan: repairPlan, currentLayout: serviceLayout, plannedLayout: runtimeLayout, missingSystemNode: needsNodeRuntime && !systemNodePath && !runtimeRepair, definitionDrift: audit.definitionDrift }); note(preview, "Gateway service repair preview"); if (!await prompter.confirmRuntimeRepair({ message: needsConfigWrite ? "Preserve the Gateway token in the secret store, write its SecretRef to config, and reinstall the service now?" : needsAggressive && !runtimeRepair ? "Overwrite gateway service config with the repair shown above now?" : "Apply the gateway service repair shown above now?", initialValue: needsConfigWrite ? false : needsAggressive ? prompter.shouldForce : true, requiresInteractiveConfirmation: needsConfigWrite || !(installationDrift && isServiceInstallationOnlyRepair(audit)) && !runtimeRepair && !(definitionRepair && isServiceDefinitionOnlyRepair(audit)) })) { if (needsConfigWrite) note("Skipped Gateway token preservation and service repair. Rerun `openclaw doctor --fix` in an interactive terminal to approve saving a store SecretRef in gateway.auth.token.", "Gateway service config"); if (sourceCheckoutWarningToShow === null) note(`Run \`${formatCliCommand("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), repairPlan.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; } let cfgForServiceInstall = cfg; if (needsConfigWrite && gatewayTokenForRepair) try { const { ref, reused, backupPath } = await preserveGatewayAuthTokenForService({ cfg, env: serviceInstallEnv, token: gatewayTokenForRepair, assertCurrent: options.serviceMaintenance?.assertCurrent }); note(reused ? `Gateway token already exists in secret store entry "${ref.id}"; the store was left unchanged.` : `Saved Gateway token in secret store entry "${ref.id}".${backupPath ? ` Backup: ${backupPath}` : ""} If config publication fails, the entry is retained and reused on the next repair.`, "Gateway"); options.serviceMaintenance?.assertCurrent(); cfgForServiceInstall = await options.writeConfig({ ...cfg, gateway: { ...cfg.gateway, auth: { ...cfg.gateway?.auth, mode: cfg.gateway?.auth?.mode ?? "token", token: ref } } }); note("Configured gateway.auth.token as a store SecretRef before reinstalling service.", "Gateway"); } catch (err) { if (err instanceof ConfigWritePostCommitError) throw err; runtime.error(`Failed to persist gateway.auth.token before service repair: ${String(err)}`); return cfg; } const updatedPlan = await buildExpectedGatewayServicePlan({ cfg: cfgForServiceInstall, command, serviceInstallEnv, port: repairPort, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, runtimePath: needsNodeRuntime && systemNodePath ? systemNodePath : installedRuntimePath, pinnedRuntimePath: pinSnapshot.pin?.path }); await installDoctorGatewayService({ service, command, maintenance: options.serviceMaintenance, runtime, repair: installationDrift && expectedRoot ? { kind: "installation", root: expectedRoot } : definitionRepair && expectedRoot ? { kind: "definition", root: expectedRoot } : { kind: "config", ...expectedRoot && !expectedLayout?.entrypointSourceCheckout && (runtimeRepair || audit.definitionDrift?.some((finding) => finding.kind === "preserved")) ? { root: expectedRoot } : {} }, args: { ...updatedPlan, runtimePinUpdate: { expected: pinSnapshot, pin: pinSnapshot.pin }, env: serviceInstallEnv, stdout: process.stdout, warn: (message) => note(message, "Gateway") } }); if (unresolvedRepairFindings.length > 0) note(unresolvedRepairFindings.join("\n"), "Gateway service findings still unresolved"); return cfgForServiceInstall; } async function maybeScanExtraGatewayServices(options, runtime, prompter) { if (!isDefaultInstallIdentity(process.env)) { note(NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, "Gateway"); return; } const { services: extraServices, errors } = await detectExtraGatewayServiceIssues(options); if (errors.length > 0) note(errors.map((error) => `- ${error.source}: ${error.message}`).join("\n"), "Gateway service inspection incomplete"); 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 serviceRepairDeferred = isServiceRepairDeferred(serviceRepairPolicy); if (serviceRepairDeferred) note(formatServiceRepairDeferredNote(), "Legacy gateway cleanup skipped"); if (serviceRepairDeferred ? false : await confirmDoctorServiceRepair(prompter, { message: "Remove legacy gateway services now?", initialValue: true }, serviceRepairPolicy)) { const removed = []; const { darwinUserServices, linuxUserServices, failed } = classifyLegacyServices(legacyServices); for (const [services, cleanup] of [[darwinUserServices, () => cleanupLegacyDarwinServices(darwinUserServices)], [linuxUserServices, () => cleanupLegacyLinuxUserServices(linuxUserServices, runtime)]]) if (services.length > 0) { const result = await cleanup(); 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"), process.platform === "darwin" ? "Cleanup hints" : "Inspection 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"); } //#endregion export { detectExtraGatewayServiceIssues, maybeRepairGatewayServiceConfig, maybeResolveDuelingSystemdGatewayScopes, maybeScanExtraGatewayServices };