UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

809 lines (808 loc) • 42.6 kB
import { l as normalizeOptionalString } from "./string-coerce-6kR-86XC.mjs"; import { n as isNodeRuntime } from "./runtime-binary-ZFwXIj1k.mjs"; import { h as sleep } from "./utils-DUAwk2p5.mjs"; import { h as resolveDefaultSecretProviderAlias } from "./ref-contract-D0kWy5jy.mjs"; import { p as resolveSecretInputRef } from "./types.secrets-BXEuin38.mjs"; import { t as hasErrnoCode } from "./errno-FR4CRyya.mjs"; import { r as parseTcpPortFromArgs } from "./tcp-port-DAu3gF8F.mjs"; import { C as resolveIsNixMode, b as resolveGatewayPort, o as isDefaultInstallIdentity } from "./paths-FklbMVOH.mjs"; import { t as formatCliCommand } from "./command-format-Cte1SWM4.mjs"; import { n as SUPPORTED_NODE_VERSIONS } from "./node-version-DdJ6cN09.mjs"; import { r as hasCommandProcessCleanupError } from "./exec-result-LNNPagt0.mjs"; import { n as GatewayServiceAuthorityError } from "./service-update-authority-CB0cX2Br.mjs"; import { u as sanitizeServiceInspectionError } from "./service-inspection-error-DN1EVxrD.mjs"; import { n as NON_DEFAULT_INSTALL_SERVICE_SKIP_REASON, r as assertGatewayServiceMutationAllowed } from "./gateway-supervision-C182SEno.mjs"; import { a as formatGatewayServiceInstallationDrift } from "./shared-BmWy-70N.mjs"; import { t as ConfigWritePostCommitError } from "./io.write-errors-DcCBZV_V.mjs"; import { x as OPENCLAW_WRAPPER_ENV_KEY } from "./schtasks-BQ5cPy29.mjs"; import { c as assertServiceDefinitionWritable, d as hasGatewayServiceEnvironmentOverride, f as hasGatewayServiceLauncherOverride, n as inspectGatewayServiceInstallationDrift, p as resolveManagedGatewayServiceCommand, s as summarizeGatewayServiceLayout } from "./service-layout-BLuPA_nr.mjs"; import { t as mergeGatewayServiceEnv } from "./service-env-merge-BGtT4Ore.mjs"; import { b as isLaunchctlNotLoaded, v as execLaunchctl } from "./launchd-service-files-KBJTfXcP.mjs"; import { z as isSystemdUnitActive } from "./systemd-service-files-B4hMJjkF.mjs"; import { t as withGatewayServiceOperationLock } from "./service-operation-lock-Bga4TVU1.mjs"; import { i as readGatewayServiceState, n as resolveGatewayService } from "./service-BF2g9b8b.mjs"; import { a as readDaemonRuntimePin } from "./runtime-pin-state-Ayh4hBAN.mjs"; import { a as inspectGatewayHeapLimit, i as formatGatewayHeapLimitReport } from "./schtasks-xml-DGQLlwEm.mjs"; import { a as findSystemdGatewayInstallation, s as isSystemUnitActiveAndEnabled } from "./systemd-scope-Diz11lFY.mjs"; import { c as normalizeServiceEnvKey, d as readManagedServiceEnvKeysFromEnvironment } from "./service-managed-env-rxzBybhg.mjs"; import { c as uninstallLegacySystemdUnits, l as uninstallUserSystemdGatewayUnit } from "./systemd-DNRC_sMB.mjs"; import { a as resolveNodeRuntimeInfo, r as renderSystemNodeWarning, u as resolveSystemNodeInfo } from "./runtime-paths-Bf_3a024.mjs"; import { t as note } from "./note-BpWs6H5N.mjs"; import { i as resolveGatewayDaemonRuntime } from "./daemon-runtime-B_YYWgfz.mjs"; import { t as SERVICE_PROXY_ENV_KEYS } from "./service-env-W1caCGxl.mjs"; import { a as readEmbeddedGatewayToken, i as needsNodeRuntimeMigration, n as auditGatewayServiceConfig, t as SERVICE_AUDIT_CODES } from "./service-audit-CoK78RVU.mjs"; import { t as resolveGatewayAuthToken } from "./auth-token-resolution-Cy9aKOVb.mjs"; import { t as buildGatewayInstallPlan } from "./daemon-install-helpers-C44Y3pus.mjs"; import { i as renderGatewayServiceCleanupHints, t as findExtraGatewayServices } from "./inspect-CeWJttlh.mjs"; import { a as isServiceRepairDeferred, i as formatServiceRepairDeferredNote, l as shouldManageGatewayService, r as confirmDoctorServiceRepair, s as resolveServiceRepairPolicy } from "./doctor-service-repair-policy-BJrQJIOB.mjs"; import { t as classifyLegacyServices } from "./doctor-gateway-legacy-services-DC6UCgMy.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-xnVGXQqK.mjs"); const database = { env: params.env }; const scope = { kind: "team" }; const provider = resolveDefaultSecretProviderAlias(params.cfg, "store", { preferFirstProviderForSource: true }); for (const entry of listSecretStoreEntries({ scope, database })) { const stored = readSecretStoreValue({ scope, database, name: entry.name }); 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-D3J7GtVa.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-C2VNcJ2i.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 === "definition") { const { reconcileGatewayServiceDefinition } = await import("./service-reconciliation-Cbst2Dzk.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") { await repairGatewayServiceInstallation({ service: params.service, command: params.command, activeRoot: params.repair.root, maintenance: params.maintenance, env: params.args.env, install }); 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-B0XPv87D.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)}\`.`); } async function repairGatewayServiceInstallation(params) { await withGatewayServiceOperationLock(params.env, async (assertNative) => { const assertCurrent = () => { assertNative(); params.maintenance?.assertCurrent(); }; await assertGatewayServiceInstallationRepairAllowed(params); assertCurrent(); await params.install(assertCurrent); if (process.platform === "win32" && !params.maintenance) await params.service.restart({ env: params.env, stdout: process.stdout, assertCurrent }); }); } 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((issue) => issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`); } 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"); } /** 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-env-wrapper-outdated" ]); return audit.issues.every((issue) => policyCodes.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; } } //#endregion //#region src/commands/doctor-gateway-dueling.ts /** Doctor repair for a user-scope leftover that duels a system gateway unit. */ 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 /** Doctor repairs for installed gateway service config and duplicate legacy services. */ 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; } function extractDetailPath(detail, prefix) { return detail.startsWith(prefix) ? detail.slice(prefix.length).trim() || null : null; } 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 = 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. * 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 serviceRepairPolicy = resolveServiceRepairPolicy(); const serviceRepairDeferred = isServiceRepairDeferred(serviceRepairPolicy); const service = resolveGatewayService(); const command = await service.readCommand(process.env).catch(() => null); if (!command) { const audit = await auditGatewayServiceConfig({ env: process.env, command: null, platform: process.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 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 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({ env: process.env, command, expectedGatewayToken, expectedManagedServiceEnvKeys, expectedServicePath: expectedPlan.environment.PATH, expectedPort: repairPort, ...installationDrift ? { expectedCommand: expectedPlan } : {} }); reportServiceDefinitionDrift(audit); const definitionRepair = !installationDrift && Boolean(expectedRoot) && !expectedLayout?.entrypointSourceCheckout && hasRepairableServiceDefinitionDrift(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 }) : 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: 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 = expectedRuntimePlan === expectedPlan ? expectedLayout : await summarizeGatewayServiceLayout(expectedRuntimePlan); 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 serviceRewriteBlock = installationDrift ? void 0 : await resolveSystemdServiceRewriteBlock(command, audit.issues); if (serviceRewriteBlock) note(serviceRewriteBlock, "Gateway service config"); 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 cfg; } const consolidatedLines = []; if (sourceCheckoutWarningToShow !== null) consolidatedLines.push(sourceCheckoutWarningToShow, ""); consolidatedLines.push(...formatServiceConfigIssues(audit.issues)); note(consolidatedLines.join("\n"), "Gateway service config"); 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) 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 (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 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); 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 ? "Overwrite gateway service config with current defaults now?" : "Update gateway service config to the recommended defaults now?", initialValue: needsConfigWrite ? false : needsAggressive ? prompter.shouldForce : true, requiresInteractiveConfirmation: needsConfigWrite || !(installationDrift && isServiceInstallationOnlyRepair(audit)) && !(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 `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; } 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" }, args: { ...updatedPlan, runtimePinUpdate: { expected: pinSnapshot, pin: pinSnapshot.pin }, env: serviceInstallEnv, stdout: process.stdout, warn: (message) => note(message, "Gateway") } }); 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 { 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); 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"), 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 };