From 8327504c8455ee23529ef8023621fbfe63b56572 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 11 Aug 2026 04:09:49 -0700 Subject: [PATCH] refactor(agents): split systemd into concept modules (#122002) --- config/max-lines-baseline.txt | 1 - src/daemon/systemd-exec.ts | 348 +++++ src/daemon/systemd-install.ts | 623 +++++++++ src/daemon/systemd-lifecycle.ts | 242 ++++ src/daemon/systemd-runtime.ts | 191 +++ src/daemon/systemd-scope.ts | 230 ++++ src/daemon/systemd-service-files.ts | 350 +++++ src/daemon/systemd.ts | 1942 +-------------------------- 8 files changed, 2015 insertions(+), 1912 deletions(-) create mode 100644 src/daemon/systemd-exec.ts create mode 100644 src/daemon/systemd-install.ts create mode 100644 src/daemon/systemd-lifecycle.ts create mode 100644 src/daemon/systemd-runtime.ts create mode 100644 src/daemon/systemd-scope.ts create mode 100644 src/daemon/systemd-service-files.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index c1061cb60f6e..edb7180e2144 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -635,7 +635,6 @@ src/cron/store.test.ts src/daemon/launchd.test.ts src/daemon/schtasks.startup-fallback.test.ts src/daemon/systemd.test.ts -src/daemon/systemd.ts src/fleet/backup.runtime.ts src/fleet/containers.runtime.ts src/fleet/service.runtime.ts diff --git a/src/daemon/systemd-exec.ts b/src/daemon/systemd-exec.ts new file mode 100644 index 000000000000..194bbc0a06d7 --- /dev/null +++ b/src/daemon/systemd-exec.ts @@ -0,0 +1,348 @@ +/** systemctl execution, user-manager routing, and availability probes. */ +import * as fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { escapeRegExp } from "../shared/regexp.js"; +import { execFileUtf8 } from "./exec-file.js"; +import type { GatewayServiceEnv } from "./service-types.js"; +import { + classifySystemdUnavailableDetail, + isSystemctlMissingDetail, + isSystemdUserBusUnavailableDetail, +} from "./systemd-unavailable.js"; + +export type SystemdUnitScope = "system" | "user"; + +export async function execSystemctl( + args: string[], + env?: GatewayServiceEnv, + timeoutMs?: number, +): Promise<{ stdout: string; stderr: string; code: number }> { + return await execFileUtf8("systemctl", args, { + env: env ? resolveSystemctlProcessEnv(env) : process.env, + // A wedged systemd socket can leave `systemctl` blocked forever; the timeout + // kills the child so status reads fail soft instead of hanging the command. + ...(timeoutMs && timeoutMs > 0 ? { timeout: timeoutMs, killSignal: "SIGKILL" as const } : {}), + }); +} + +export function readSystemctlDetail(result: { stdout: string; stderr: string }): string { + // Concatenate both streams so pattern matchers (isSystemdUnitNotEnabled, + // isSystemctlMissing) can see the unit status from stdout even when + // execFileUtf8 populates stderr with the Node error message fallback. + return `${result.stderr} ${result.stdout}`.trim(); +} + +export const isSystemctlMissing = isSystemctlMissingDetail; + +export function isSystemdUnitNotEnabled(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = normalizeLowercaseStringOrEmpty(detail); + return ( + normalized.includes("disabled") || + normalized.includes("static") || + normalized.includes("indirect") || + normalized.includes("masked") || + normalized.includes("not-found") || + normalized.includes("could not be found") || + normalized.includes("failed to get unit file state") + ); +} + +export function isSystemdUnitMissingDetail(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = normalizeLowercaseStringOrEmpty(detail); + return ( + (normalized.includes("unit file") && normalized.includes("does not exist")) || + normalized.includes("not-found") || + normalized.includes("could not be found") + ); +} + +function isSystemdUnitAlreadyMissingOrInactive(detail: string, unitName: string): boolean { + const escapedUnitName = escapeRegExp(normalizeLowercaseStringOrEmpty(unitName)); + return new RegExp( + `^(?:failed to (?:disable unit|stop\\s+${escapedUnitName}):\\s*)?` + + `(?:unit file\\s+${escapedUnitName}\\s+does not exist|` + + `unit\\s+${escapedUnitName}(?:\\s+is)?\\s+` + + `(?:inactive|not\\s+active|not\\s+loaded|not-found|could not be found))[.!]?$`, + "u", + ).test(normalizeLowercaseStringOrEmpty(detail)); +} + +const isSystemctlBusUnavailable = isSystemdUserBusUnavailableDetail; + +export function isSystemdUserScopeUnavailable(detail: string): boolean { + return classifySystemdUnavailableDetail(detail) !== null; +} + +function isGenericSystemctlIsEnabledFailure(detail: string): boolean { + if (!detail) { + return false; + } + const normalized = normalizeLowercaseStringOrEmpty(detail); + return ( + normalized.startsWith("command failed: systemctl") && + normalized.includes(" is-enabled ") && + !normalized.includes("permission denied") && + !normalized.includes("access denied") && + !normalized.includes("no space left") && + !normalized.includes("read-only file system") && + !normalized.includes("out of memory") && + !normalized.includes("cannot allocate memory") + ); +} + +export function isNonFatalSystemdInstallProbeError(error: unknown): boolean { + const detail = error instanceof Error ? error.message : typeof error === "string" ? error : ""; + if (!detail) { + return false; + } + const normalized = normalizeLowercaseStringOrEmpty(detail); + return isSystemctlBusUnavailable(normalized) || isGenericSystemctlIsEnabledFailure(normalized); +} + +function resolveSystemctlDirectUserScopeArgs(): string[] { + return ["--user"]; +} + +function readSystemctlEnvUser(env: GatewayServiceEnv): string | null { + return env.USER?.trim() || env.LOGNAME?.trim() || null; +} + +function readSystemctlEffectiveUser(): string | null { + try { + return os.userInfo().username; + } catch { + return null; + } +} + +function readSystemctlEffectiveUid(): number | null { + if (typeof process.geteuid !== "function") { + return null; + } + try { + return process.geteuid(); + } catch { + return null; + } +} + +function resolveSystemctlProcessEnv(env: GatewayServiceEnv): NodeJS.ProcessEnv { + const processEnv = { ...process.env, ...env }; + if (processEnv.XDG_RUNTIME_DIR?.trim() && processEnv.DBUS_SESSION_BUS_ADDRESS?.trim()) { + return processEnv; + } + + const uid = readSystemctlEffectiveUid(); + if (uid === null || uid === 0) { + return processEnv; + } + + const runtimeDir = processEnv.XDG_RUNTIME_DIR?.trim() || `/run/user/${uid}`; + const busPath = path.posix.join(runtimeDir, "bus"); + if (!fsSync.existsSync(busPath)) { + return processEnv; + } + + // In non-login shells the bus socket can exist while DBUS_SESSION_BUS_ADDRESS + // is missing. Fill it so systemctl --user reaches the right user manager. + return { + ...processEnv, + XDG_RUNTIME_DIR: runtimeDir, + DBUS_SESSION_BUS_ADDRESS: processEnv.DBUS_SESSION_BUS_ADDRESS?.trim() || `unix:path=${busPath}`, + }; +} + +function isNonRootUser(user: string | null): user is string { + return Boolean(user && user !== "root"); +} + +function hasRootUserManagerEnvironment(env: GatewayServiceEnv): boolean { + const home = env.HOME?.trim(); + const runtimeDir = env.XDG_RUNTIME_DIR?.trim(); + const dbusAddress = env.DBUS_SESSION_BUS_ADDRESS?.trim(); + return ( + home === "/root" && + runtimeDir === "/run/user/0" && + Boolean(dbusAddress?.includes("/run/user/0/bus")) + ); +} + +function resolveSystemctlUserScope(env: GatewayServiceEnv): { + machineUser: string | null; + preferMachineScope: boolean; +} { + const sudoUser = env.SUDO_USER?.trim() || null; + const envUser = readSystemctlEnvUser(env); + const effectiveUid = readSystemctlEffectiveUid(); + const effectiveUser = readSystemctlEffectiveUser(); + const isEffectiveRoot = effectiveUid === null ? effectiveUser === "root" : effectiveUid === 0; + const hasRootUserManager = isEffectiveRoot && hasRootUserManagerEnvironment(env); + const isSudoToRoot = isEffectiveRoot && !hasRootUserManager && isNonRootUser(sudoUser); + const machineUser = hasRootUserManager + ? null + : isSudoToRoot + ? sudoUser + : isNonRootUser(envUser) + ? envUser + : isNonRootUser(sudoUser) + ? sudoUser + : effectiveUser || envUser || sudoUser || null; + return { + machineUser, + preferMachineScope: isSudoToRoot, + }; +} + +/** + * Resolves the account whose user manager owns the service operation. + * Keep linger diagnostics on this identity so sudo never checks root while + * systemctl targets the invoking user's manager. + */ +export function resolveSystemdUserServiceAccount(env: GatewayServiceEnv): string | null { + const { machineUser } = resolveSystemctlUserScope(env); + return machineUser ?? readSystemctlEffectiveUser() ?? readSystemctlEnvUser(env); +} + +function resolveSystemctlMachineUserScopeArgs(user: string): string[] { + const trimmedUser = user.trim(); + if (!trimmedUser) { + return []; + } + return ["--machine", `${trimmedUser}@`, "--user"]; +} + +function shouldFallbackToMachineUserScope(detail: string): boolean { + if (!isSystemdUserBusUnavailableDetail(detail)) { + return false; + } + // "Permission denied" means the bus socket exists but this process cannot connect to it. + // The machine-scope approach targets the same bus infrastructure and will also fail, + // so do not trigger the fallback in this case. + return !detail.toLowerCase().includes("permission denied"); +} + +export async function execSystemctlUser( + env: GatewayServiceEnv, + args: string[], + timeoutMs?: number, +): Promise<{ stdout: string; stderr: string; code: number }> { + const { machineUser, preferMachineScope } = resolveSystemctlUserScope(env); + + // Under sudo-to-root, prefer the invoking non-root user's scope directly via machine scope. + if (preferMachineScope && machineUser) { + const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser); + if (machineScopeArgs.length > 0) { + // Do not fall through to bare --user: under sudo that can target root's user manager. + return await execSystemctl([...machineScopeArgs, ...args], env, timeoutMs); + } + } + + const directResult = await execSystemctl( + [...resolveSystemctlDirectUserScopeArgs(), ...args], + env, + timeoutMs, + ); + if (directResult.code === 0) { + return directResult; + } + + const detail = `${directResult.stderr} ${directResult.stdout}`.trim(); + if (!machineUser || !shouldFallbackToMachineUserScope(detail)) { + return directResult; + } + + const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser); + if (machineScopeArgs.length === 0) { + return directResult; + } + return await execSystemctl([...machineScopeArgs, ...args], env, timeoutMs); +} + +export async function disableSystemdUserUnitForRemoval( + env: GatewayServiceEnv, + unitName: string, +): Promise { + const result = await execSystemctlUser(env, ["disable", "--now", unitName]); + if (result.code === 0) { + return; + } + const detail = readSystemctlDetail(result); + if (isSystemdUnitAlreadyMissingOrInactive(detail, unitName)) { + return; + } + throw new Error(`systemctl disable failed: ${detail || "unknown error"}`); +} + +export async function reloadSystemdUserManager(env: GatewayServiceEnv): Promise { + const result = await execSystemctlUser(env, ["daemon-reload"]); + if (result.code !== 0) { + throw new Error( + `systemctl daemon-reload failed: ${readSystemctlDetail(result) || "unknown error"}`, + ); + } +} + +export async function isSystemdUserServiceAvailable( + env: GatewayServiceEnv = process.env as GatewayServiceEnv, +): Promise { + const res = await execSystemctlUser(env, ["status"]); + if (res.code === 0) { + return true; + } + const detail = `${res.stderr} ${res.stdout}`.trim(); + if (!detail) { + return false; + } + return !isSystemdUserScopeUnavailable(detail); +} + +export async function isSystemdUnitActive( + env: GatewayServiceEnv, + unitName: string, + scope: SystemdUnitScope = "user", +): Promise { + const normalizedUnit = unitName.trim(); + if (!normalizedUnit) { + return false; + } + const args = ["is-active", "--quiet", normalizedUnit]; + const res = scope === "system" ? await execSystemctl(args) : await execSystemctlUser(env, args); + return res.code === 0; +} + +export async function assertSystemdAvailable( + env: GatewayServiceEnv = process.env as GatewayServiceEnv, + timeoutMs?: number, +) { + const res = await execSystemctlUser(env, ["status"], timeoutMs); + if (res.code === 0) { + return; + } + const detail = readSystemctlDetail(res); + if (isSystemctlMissing(detail)) { + throw new Error("systemctl not available; systemd user services are required on Linux."); + } + if (!detail) { + throw new Error("systemctl --user unavailable: unknown error"); + } + if (!isSystemdUserScopeUnavailable(detail)) { + return; + } + throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim()); +} + +export async function isSystemctlAvailable(env: GatewayServiceEnv): Promise { + const res = await execSystemctlUser(env, ["status"]); + if (res.code === 0) { + return true; + } + return !isSystemctlMissing(readSystemctlDetail(res)); +} diff --git a/src/daemon/systemd-install.ts b/src/daemon/systemd-install.ts new file mode 100644 index 000000000000..a1636c405f20 --- /dev/null +++ b/src/daemon/systemd-install.ts @@ -0,0 +1,623 @@ +/** systemd unit publication, installation, staging, and uninstall. */ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import { + isUnresolvedShellReference, + readStateDirDotEnvFromStateDir, +} from "../config/state-dir-dotenv.js"; +import { resolveGatewayServiceDescription } from "./constants.js"; +import { formatLine, writeFormattedLines } from "./output.js"; +import { + hasEnvironmentFileSource, + hasInlineEnvironmentSource, + isEnvironmentFileOnlySource, + normalizeServiceEnvKey, + normalizeServiceEnvKeys, + readEnvironmentValueSource, + readManagedServiceEnvKeysFromEnvironment, +} from "./service-managed-env.js"; +import type { + GatewayServiceEnv, + GatewayServiceEnvironmentValueSource, + GatewayServiceInstallArgs, + GatewayServiceManageArgs, +} from "./service-types.js"; +import { + assertSystemdAvailable, + disableSystemdUserUnitForRemoval, + execSystemctlUser, + isSystemdUnitMissingDetail, + isSystemdUserScopeUnavailable, + readSystemctlDetail, +} from "./systemd-exec.js"; +import { assertNoSystemGatewayOwnership } from "./systemd-scope.js"; +import { + isNodeSystemdEnvironment, + readSystemdEnvironmentFile, + readSystemdServiceExecStart, + resolveLegacyNodeSystemdEnvironmentFilePath, + resolveSystemdEnvironmentFilePath, + resolveSystemdServiceName, + resolveSystemdUnitPath, + serializeSystemdEnvironmentFile, +} from "./systemd-service-files.js"; +import { + buildSystemdUnit, + parseSystemdEnvAssignments, + renderSystemdEnvAssignment, +} from "./systemd-unit.js"; + +function collectSystemdInlineManagedKeys(params: { + environment?: GatewayServiceEnv; + environmentValueSources?: Record; +}): Set { + const keys = readManagedServiceEnvKeysFromEnvironment(params.environment); + for (const key of collectSystemdFileManagedKeys({ + environmentValueSources: params.environmentValueSources, + })) { + keys.delete(key); + } + for (const [rawKey, value] of Object.entries(params.environment ?? {})) { + if (typeof value !== "string" || !value.trim()) { + continue; + } + const key = normalizeServiceEnvKey(rawKey); + if (!key) { + continue; + } + const source = readEnvironmentValueSource(params.environmentValueSources, rawKey); + if (hasInlineEnvironmentSource(source) && !hasEnvironmentFileSource(source)) { + keys.add(key); + } + } + return keys; +} + +function collectSystemdFileManagedKeys(params: { + environmentValueSources?: Record; +}): Set { + const keys = new Set(); + for (const [rawKey, source] of Object.entries(params.environmentValueSources ?? {})) { + const key = normalizeServiceEnvKey(rawKey); + if (key && isEnvironmentFileOnlySource(source)) { + keys.add(key); + } + } + return keys; +} + +function collectSystemdFileBackedEnvironment(params: { + environment?: GatewayServiceEnv; + fileManagedKeys: ReadonlySet; +}): Record { + if (params.fileManagedKeys.size === 0) { + return {}; + } + const environment: Record = {}; + for (const [rawKey, rawValue] of Object.entries(params.environment ?? {})) { + if (typeof rawValue !== "string" || !rawValue.trim()) { + continue; + } + const key = normalizeServiceEnvKey(rawKey); + if (key && params.fileManagedKeys.has(key) && !isUnresolvedShellReference(rawValue)) { + environment[rawKey] = rawValue; + } + } + return environment; +} + +function sanitizeSystemdUnitBackupContent(params: { + content: string; + fileManagedKeys: ReadonlySet; +}): string { + if (params.fileManagedKeys.size === 0) { + return params.content; + } + // Backups should not retain file-managed secrets that OpenClaw moved into the + // generated EnvironmentFile during this rewrite. + const sanitizedLines: string[] = []; + for (const rawLine of params.content.split("\n")) { + const line = rawLine.trim(); + if (!line.startsWith("Environment=")) { + sanitizedLines.push(rawLine); + continue; + } + const assignments = parseSystemdEnvAssignments(line.slice("Environment=".length).trim()); + if (assignments.length === 0) { + sanitizedLines.push(rawLine); + continue; + } + const keptAssignments = assignments.filter(({ key }) => { + const normalizedKey = normalizeServiceEnvKey(key); + return !normalizedKey || !params.fileManagedKeys.has(normalizedKey); + }); + if (keptAssignments.length === assignments.length) { + sanitizedLines.push(rawLine); + continue; + } + if (keptAssignments.length === 0) { + continue; + } + const leadingWhitespace = rawLine.match(/^\s*/)?.[0] ?? ""; + sanitizedLines.push( + `${leadingWhitespace}Environment=${keptAssignments + .map(({ key, value }) => renderSystemdEnvAssignment(key, value)) + .join(" ")}`, + ); + } + return sanitizedLines.join("\n"); +} + +async function writeSystemdUnit({ + env, + programArguments, + workingDirectory, + environment, + environmentValueSources, + description, +}: Omit): Promise<{ unitPath: string; backedUp: boolean }> { + await assertSystemdAvailable(env); + await assertNoSystemGatewayOwnership(env); + + const unitPath = resolveSystemdUnitPath(env); + const priorManagedKeys = readManagedServiceEnvKeysFromEnvironment( + (await readSystemdServiceExecStart(env))?.environment, + ); + await fs.mkdir(path.dirname(unitPath), { recursive: true }); + await assertSystemdManagedPathIsNotSymlink(unitPath); + const fileManagedKeys = collectSystemdFileManagedKeys({ + environmentValueSources, + }); + + // Preserve user customizations: back up existing unit file before overwriting. + let backedUp = false; + try { + const backupPath = `${unitPath}.bak`; + const existingUnit = await fs.readFile(unitPath, "utf8"); + const existingStat = await fs.stat(unitPath); + const backupMode = existingStat.mode & 0o777 || 0o600; + const backupUnit = sanitizeSystemdUnitBackupContent({ + content: existingUnit, + fileManagedKeys, + }); + await fs.writeFile(backupPath, backupUnit, { encoding: "utf8", mode: backupMode }); + await fs.chmod(backupPath, backupMode); + backedUp = true; + } catch { + // File does not exist yet — nothing to back up. + } + + const serviceDescription = resolveGatewayServiceDescription({ env, description }); + const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); + const { entries: stateDirDotEnvEntries, skippedShellReferenceKeys } = + readStateDirDotEnvFromStateDir(stateDir); + const stateDirDotEnvVars = Object.fromEntries( + Object.entries(stateDirDotEnvEntries).filter(([key, value]) => { + const inlineValue = environment?.[key]; + if (typeof inlineValue !== "string") { + return true; + } + return inlineValue.trim() === value.trim(); + }), + ); + const inlineManagedKeys = collectSystemdInlineManagedKeys({ + environment, + environmentValueSources, + }); + const environmentFilePath = resolveSystemdEnvironmentFilePath({ + stateDir, + environment, + }); + const environmentFileSnapshot = isNodeSystemdEnvironment(env) + ? undefined + : await readSystemdFileSnapshot(environmentFilePath); + try { + const environmentFileResult = await writeSystemdGatewayEnvironmentFile({ + stateDir, + stateDirDotEnvKeys: Object.keys(stateDirDotEnvVars), + priorManagedKeys, + inlineManagedKeys, + fileManagedKeys, + skippedManagedKeys: skippedShellReferenceKeys, + fileBackedEnvironment: collectSystemdFileBackedEnvironment({ + environment, + fileManagedKeys, + }), + environment, + }); + const environmentSansDotEnvEntries = Object.fromEntries( + Object.entries(environment ?? {}).filter(([key, value]) => { + if (typeof value !== "string") { + return false; + } + const source = readEnvironmentValueSource(environmentValueSources, key); + if (hasEnvironmentFileSource(source) && isUnresolvedShellReference(value)) { + return false; + } + const normalizedKey = normalizeServiceEnvKey(key); + if ( + normalizedKey && + environmentFileResult.environmentKeys.has(normalizedKey) && + !inlineManagedKeys.has(normalizedKey) + ) { + return false; + } + const stateDirValue = stateDirDotEnvVars[key]; + if (typeof stateDirValue !== "string") { + return true; + } + return value.trim() !== stateDirValue.trim(); + }), + ); + const unit = buildSystemdUnit({ + description: serviceDescription, + programArguments, + workingDirectory, + environment: environmentSansDotEnvEntries, + environmentFiles: environmentFileResult.environmentFiles, + }); + await publishSystemdUnit({ env, unitPath, contents: unit }); + } catch (error) { + if (environmentFileSnapshot !== undefined) { + try { + await restoreSystemdFileSnapshot(environmentFilePath, environmentFileSnapshot); + } catch (rollbackError) { + const failureDetail = error instanceof Error ? error.message : String(error); + throw new Error( + `${failureDetail}\nThe previous systemd environment file at ${environmentFilePath} could not be restored.`, + { cause: rollbackError }, + ); + } + } + throw error; + } + return { unitPath, backedUp }; +} + +type SystemdFileSnapshot = { contents: Buffer; mode: number } | null; + +async function assertSystemdManagedPathIsNotSymlink(filePath: string): Promise { + try { + const stat = await fs.lstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to rewrite symlinked managed systemd file: ${filePath}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw error; + } +} + +async function readSystemdFileSnapshot(filePath: string): Promise { + try { + const stat = await fs.lstat(filePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to rewrite symlinked managed systemd file: ${filePath}`); + } + const contents = await fs.readFile(filePath); + return { contents, mode: stat.mode & 0o777 }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +async function restoreSystemdFileSnapshot( + filePath: string, + snapshot: SystemdFileSnapshot, +): Promise { + if (snapshot === null) { + await fs.rm(filePath, { force: true }); + return; + } + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const rollbackPath = `${filePath}.openclaw-${randomUUID()}.rollback`; + try { + await fs.writeFile(rollbackPath, snapshot.contents, { + flag: "wx", + mode: snapshot.mode, + }); + await fs.rename(rollbackPath, filePath); + } finally { + await fs.unlink(rollbackPath).catch(() => undefined); + } +} + +async function publishSystemdUnit(params: { + env: GatewayServiceEnv; + unitPath: string; + contents: string; +}): Promise { + const previous = await readSystemdFileSnapshot(params.unitPath); + const temporaryPath = `${params.unitPath}.openclaw-${randomUUID()}.tmp`; + await fs.writeFile(temporaryPath, params.contents, { + encoding: "utf8", + flag: "wx", + mode: previous?.mode ?? 0o644, + }); + try { + // systemd ignores the temporary suffix, so this is the last ownership check + // before the canonical user unit becomes discoverable. + await assertNoSystemGatewayOwnership(params.env); + await fs.rename(temporaryPath, params.unitPath); + try { + await assertNoSystemGatewayOwnership(params.env); + } catch (ownershipError) { + try { + await restoreSystemdFileSnapshot(params.unitPath, previous); + } catch (rollbackError) { + const ownershipDetail = + ownershipError instanceof Error ? ownershipError.message : String(ownershipError); + throw new Error( + `${ownershipDetail}\nThe previous user systemd unit at ${params.unitPath} could not be restored.`, + { cause: rollbackError }, + ); + } + throw ownershipError; + } + } finally { + await fs.unlink(temporaryPath).catch(() => undefined); + } +} + +async function writeSystemdGatewayEnvironmentFile(params: { + stateDir: string; + /** Keys loaded by the Gateway directly from the state-dir .env. They must be removed from + * generated files so a supervisor restart cannot shadow a later .env edit. */ + stateDirDotEnvKeys?: Iterable; + /** Keys owned by the previously installed service. Preserve the prior ownership record so + * deleting a managed dotenv key cannot reclassify its stale file value as operator-owned. */ + priorManagedKeys?: Iterable; + /** OpenClaw-managed keys that must not be preserved from an old env file; stale file values + * would override fresh inline Environment= entries because EnvironmentFile takes precedence. */ + inlineManagedKeys?: ReadonlySet; + /** File-managed keys that should be written from current environment values or removed when absent. */ + fileManagedKeys?: ReadonlySet; + /** State-dir .env keys OpenClaw previously managed but is now skipping (unresolved shell + * references). A prior re-stage may have written a stale literal value for them; drop it so + * the regenerated env file no longer carries the obsolete reference. */ + skippedManagedKeys?: Iterable; + fileBackedEnvironment?: Record; + environment?: GatewayServiceEnv; +}): Promise<{ environmentFiles: string[]; environmentKeys: Set }> { + const incoming = { ...params.fileBackedEnvironment }; + for (const [key, value] of Object.entries(incoming)) { + if (/[\r\n]/.test(value)) { + throw new Error( + `state-dir .env contains a multiline value for ${key}; systemd EnvironmentFile values must be single-line`, + ); + } + } + const envFilePath = resolveSystemdEnvironmentFilePath({ + stateDir: params.stateDir, + environment: params.environment, + }); + + // Read existing env files first so we can preserve operator-added secrets + // (e.g. provider API keys) across upgrades and re-stages. Node units used + // to share gateway.systemd.env, so migrate those entries into node.systemd.env. + // OpenClaw-managed keys (identified by inlineManagedKeys) are excluded: a stale + // file copy would override the fresh inline Environment= value because systemd's + // EnvironmentFile takes precedence over inline Environment= directives. + const existing: Record = {}; + const literalShellReferenceKeys = new Set(); + const legacyNodeEnvFilePath = resolveLegacyNodeSystemdEnvironmentFilePath({ + stateDir: params.stateDir, + environment: params.environment, + }); + for (const sourceEnvFilePath of [legacyNodeEnvFilePath, envFilePath]) { + if (!sourceEnvFilePath) { + continue; + } + try { + const fromFile = await readSystemdEnvironmentFile(sourceEnvFilePath); + for (const [key, value] of Object.entries(fromFile.environment)) { + existing[key] = value; + if (fromFile.literalShellReferenceKeys.has(key)) { + literalShellReferenceKeys.add(key); + } else { + literalShellReferenceKeys.delete(key); + } + } + } catch { + // File does not exist yet — nothing to preserve. + } + } + const managedKeysToDrop = normalizeServiceEnvKeys([ + ...(params.inlineManagedKeys ?? []), + ...(params.fileManagedKeys ?? []), + ...(params.priorManagedKeys ?? []), + ...(params.stateDirDotEnvKeys ?? []), + ...(params.skippedManagedKeys ?? []), + ]); + const operatorOnly = Object.fromEntries( + Object.entries(existing).filter(([key, value]) => { + const normalized = normalizeServiceEnvKey(key); + if (normalized && managedKeysToDrop.has(normalized)) { + return false; + } + // Quoting or escaping `$VAR` records operator intent; bare references can + // still be stale values copied from the state-dir dotenv file. + return literalShellReferenceKeys.has(key) || !isUnresolvedShellReference(value); + }), + ); + const merged = { ...operatorOnly, ...incoming }; + const environmentKeys = normalizeServiceEnvKeys(Object.keys(merged)); + + // If the merged result is empty there is nothing to write and no file needed. + if (Object.keys(merged).length === 0) { + await fs.rm(envFilePath, { force: true }).catch(() => undefined); + return { environmentFiles: [], environmentKeys }; + } + + const content = serializeSystemdEnvironmentFile(merged); + await fs.mkdir(path.dirname(envFilePath), { recursive: true }); + await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(envFilePath, 0o600); + return { environmentFiles: [envFilePath], environmentKeys }; +} + +async function removeNodeSystemdManagedEnvironmentKeys(env: GatewayServiceEnv): Promise { + if (!isNodeSystemdEnvironment(env)) { + return; + } + const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); + const envFilePath = resolveSystemdEnvironmentFilePath({ + stateDir, + environment: env, + }); + let existingFile: Awaited>; + try { + existingFile = await readSystemdEnvironmentFile(envFilePath); + } catch { + return; + } + const managedKeys = new Set(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]); + const remaining = Object.fromEntries( + Object.entries(existingFile.environment).filter(([key, value]) => { + const normalized = normalizeServiceEnvKey(key); + if (normalized && managedKeys.has(normalized)) { + return false; + } + return existingFile.literalShellReferenceKeys.has(key) || !isUnresolvedShellReference(value); + }), + ); + if (Object.keys(remaining).length === 0) { + await fs.rm(envFilePath, { force: true }); + return; + } + const content = serializeSystemdEnvironmentFile(remaining); + await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(envFilePath, 0o600); +} + +export async function stageSystemdService({ + stdout, + ...args +}: GatewayServiceInstallArgs): Promise<{ unitPath: string }> { + const { unitPath, backedUp } = await writeSystemdUnit(args); + writeFormattedLines( + stdout, + [ + { + label: "Staged systemd service", + value: unitPath, + }, + ...(backedUp + ? [ + { + label: "Previous unit backed up to", + value: `${unitPath}.bak`, + }, + ] + : []), + ], + { leadingBlankLine: true }, + ); + return { unitPath }; +} + +async function activateSystemdService(params: { env: GatewayServiceEnv }) { + const serviceName = resolveSystemdServiceName(params.env); + const unitName = `${serviceName}.service`; + // A system unit may appear after publication. Refuse before the user manager + // can load a second supervisor for the same gateway name. + await assertNoSystemGatewayOwnership(params.env); + const reloadSystemd = async () => await execSystemctlUser(params.env, ["daemon-reload"]); + const throwActivationFailure = ( + action: "daemon-reload" | "enable" | "restart", + result: { stdout: string; stderr: string }, + ): never => { + const detail = readSystemctlDetail(result); + if (isSystemdUserScopeUnavailable(detail)) { + throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim()); + } + throw new Error(`systemctl ${action} failed: ${detail || "unknown error"}`.trim()); + }; + const reload = await reloadSystemd(); + if (reload.code !== 0) { + throwActivationFailure("daemon-reload", reload); + } + + const runAfterReloadRetry = async (action: "enable" | "restart") => { + const result = await execSystemctlUser(params.env, [action, unitName]); + if (result.code === 0 || !isSystemdUnitMissingDetail(readSystemctlDetail(result))) { + return result; + } + const retryReload = await reloadSystemd(); + if (retryReload.code !== 0) { + throwActivationFailure("daemon-reload", retryReload); + } + return await execSystemctlUser(params.env, [action, unitName]); + }; + + const enable = await runAfterReloadRetry("enable"); + if (enable.code !== 0) { + throwActivationFailure("enable", enable); + } + + const restart = await runAfterReloadRetry("restart"); + if (restart.code !== 0) { + throwActivationFailure("restart", restart); + } +} + +export async function installSystemdService( + args: GatewayServiceInstallArgs, +): Promise<{ unitPath: string }> { + const { unitPath, backedUp } = await writeSystemdUnit(args); + await activateSystemdService({ env: args.env }); + writeFormattedLines( + args.stdout, + [ + { + label: "Installed systemd service", + value: unitPath, + }, + ...(backedUp + ? [ + { + label: "Previous unit backed up to", + value: `${unitPath}.bak`, + }, + ] + : []), + ], + { leadingBlankLine: true }, + ); + return { unitPath }; +} + +export async function uninstallSystemdService({ + env, + stdout, +}: GatewayServiceManageArgs): Promise { + await assertSystemdAvailable(env); + const serviceName = resolveSystemdServiceName(env); + const unitName = `${serviceName}.service`; + await disableSystemdUserUnitForRemoval(env, unitName); + + const unitPath = resolveSystemdUnitPath(env); + let removed = false; + try { + await fs.unlink(unitPath); + removed = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + // Unit file was already absent; still clean generated node env state below. + } + await removeNodeSystemdManagedEnvironmentKeys(env); + if (removed) { + stdout.write(`${formatLine("Removed systemd service", unitPath)}\n`); + } else { + stdout.write(`Systemd service not found at ${unitPath}\n`); + } +} diff --git a/src/daemon/systemd-lifecycle.ts b/src/daemon/systemd-lifecycle.ts new file mode 100644 index 000000000000..eee6f3b5cdea --- /dev/null +++ b/src/daemon/systemd-lifecycle.ts @@ -0,0 +1,242 @@ +/** systemd start, stop, restart, and obsolete-unit removal. */ +import fs from "node:fs/promises"; +import { LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES } from "./constants.js"; +import { formatLine } from "./output.js"; +import { createGatewayLifecycleMutationReporter } from "./service-mutation.js"; +import type { + GatewayServiceControlArgs, + GatewayServiceEnv, + GatewayServiceManageArgs, + GatewayServiceRestartResult, +} from "./service-types.js"; +import { + assertSystemdAvailable, + disableSystemdUserUnitForRemoval, + execSystemctl, + execSystemctlUser, + isSystemctlAvailable, + reloadSystemdUserManager, +} from "./systemd-exec.js"; +import { + assertNoSystemGatewayOwnership, + findInstalledSystemdGatewayScope, +} from "./systemd-scope.js"; +import { + resolveSystemdServiceName, + resolveSystemdUnitPath, + resolveSystemdUnitPathForName, +} from "./systemd-service-files.js"; + +function isRunningAsRoot(): boolean { + if (typeof process.geteuid === "function") { + try { + return process.geteuid() === 0; + } catch { + return false; + } + } + return false; +} + +async function runSystemdServiceAction(params: { + stdout: NodeJS.WritableStream; + env?: GatewayServiceEnv; + action: "start" | "stop" | "restart"; + label: string; + onMutation?: () => void; +}) { + const env = params.env ?? process.env; + const installed = await findInstalledSystemdGatewayScope(env); + const unitName = installed?.unitName ?? `${resolveSystemdServiceName(env)}.service`; + let runSystemctl: (args: string[]) => ReturnType; + if (installed?.scope === "system") { + if (!isRunningAsRoot()) { + throw new Error( + `${unitName} is a system-scope unit (${installed.unitPath}); run \`sudo systemctl ${params.action} ${unitName}\` to ${params.action} it`, + ); + } + runSystemctl = (args) => execSystemctl(args, env); + } else { + await assertSystemdAvailable(env); + if (params.action !== "stop") { + await assertNoSystemGatewayOwnership(env); + } + runSystemctl = (args) => execSystemctlUser(env, args); + } + if (params.action !== "stop") { + // Clear crash-loop start-limit latches only after scope ownership is proven; + // otherwise resetting a conflicting manager could mutate the wrong service. + await runSystemctl(["reset-failed", unitName]); + } + const res = await runSystemctl([params.action, unitName]); + if (res.code !== 0) { + throw new Error(`systemctl ${params.action} failed: ${res.stderr || res.stdout}`.trim()); + } + params.onMutation?.(); + params.stdout.write(`${formatLine(params.label, unitName)}\n`); +} + +export async function startSystemdService({ + stdout, + env, + onMutation, +}: GatewayServiceControlArgs): Promise { + const reportMutation = createGatewayLifecycleMutationReporter(onMutation); + await runSystemdServiceAction({ + stdout, + env, + action: "start", + label: "Started systemd service", + onMutation: () => reportMutation("systemctl-start"), + }); +} + +export async function stopSystemdService({ + stdout, + env, + onMutation, +}: GatewayServiceControlArgs): Promise { + const reportMutation = createGatewayLifecycleMutationReporter(onMutation); + await runSystemdServiceAction({ + stdout, + env, + action: "stop", + label: "Stopped systemd service", + onMutation: () => reportMutation("systemctl-stop"), + }); +} + +export async function restartSystemdService({ + stdout, + env, + onMutation, +}: GatewayServiceControlArgs): Promise { + const reportMutation = createGatewayLifecycleMutationReporter(onMutation); + await runSystemdServiceAction({ + stdout, + env, + action: "restart", + label: "Restarted systemd service", + onMutation: () => reportMutation("systemctl-restart"), + }); + return { outcome: "completed" }; +} + +type LegacySystemdUnit = { + name: string; + unitPath: string; + enabled: boolean; + exists: boolean; +}; + +async function findLegacySystemdUnits(env: GatewayServiceEnv): Promise { + const results: LegacySystemdUnit[] = []; + const systemctlAvailable = await isSystemctlAvailable(env); + for (const name of LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES) { + const unitPath = resolveSystemdUnitPathForName(env, name); + let exists = false; + try { + await fs.access(unitPath); + exists = true; + } catch { + // ignore + } + let enabled = false; + if (systemctlAvailable) { + const res = await execSystemctlUser(env, ["is-enabled", `${name}.service`]); + enabled = res.code === 0; + } + if (exists || enabled) { + results.push({ name, unitPath, enabled, exists }); + } + } + return results; +} + +export async function uninstallLegacySystemdUnits({ + env, + stdout, +}: GatewayServiceManageArgs): Promise { + const units = await findLegacySystemdUnits(env); + if (units.length === 0) { + return units; + } + + const systemctlAvailable = await isSystemctlAvailable(env); + let removedAny = false; + for (const unit of units) { + if (systemctlAvailable) { + await disableSystemdUserUnitForRemoval(env, `${unit.name}.service`); + } else { + stdout.write(`systemctl unavailable; removed legacy unit file only: ${unit.name}.service\n`); + } + + try { + await fs.unlink(unit.unitPath); + removedAny = true; + stdout.write(`${formatLine("Removed legacy systemd service", unit.unitPath)}\n`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + stdout.write(`Legacy systemd unit not found at ${unit.unitPath}\n`); + } + } + if (systemctlAvailable && removedAny) { + await reloadSystemdUserManager(env); + } + + return units; +} + +type UninstallUserSystemdGatewayUnitResult = { + unitName: string; + unitPath: string; + removed: boolean; + /** + * False when systemctl could not disable/stop the unit. Deleting the unit + * file alone does not evict an already-loaded unit, so callers must not + * claim the conflict is resolved on a file-only removal. + */ + disabled: boolean; +}; + +/** + * Removes the canonical *user-scope* gateway unit, leaving any system-scope + * unit untouched. Used by doctor to resolve a `dueling` installation by + * dropping the redundant user-scope leftover (issue #79375). Removing a unit + * under `$HOME` needs no root, unlike the system-scope unit. + */ +export async function uninstallUserSystemdGatewayUnit({ + env, + stdout, +}: GatewayServiceManageArgs): Promise { + const unitName = `${resolveSystemdServiceName(env)}.service`; + const unitPath = resolveSystemdUnitPath(env); + let disabled = false; + if (await isSystemctlAvailable(env)) { + await disableSystemdUserUnitForRemoval(env, unitName); + disabled = true; + } else { + stdout.write( + `systemctl unavailable; removing unit file only: ${unitName}. A loaded unit keeps running until systemd reloads.\n`, + ); + } + let removed = false; + try { + await fs.unlink(unitPath); + removed = true; + stdout.write(`${formatLine("Removed user-scope systemd service", unitPath)}\n`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + stdout.write(`User-scope systemd unit not found at ${unitPath}\n`); + } + // The manager keeps a deleted unit's definition loaded until it reloads, so + // without this the unit stays startable while the detector reports it gone. + if (removed && disabled) { + await reloadSystemdUserManager(env); + } + return { unitName, unitPath, removed, disabled }; +} diff --git a/src/daemon/systemd-runtime.ts b/src/daemon/systemd-runtime.ts new file mode 100644 index 000000000000..c1b2e551ce4b --- /dev/null +++ b/src/daemon/systemd-runtime.ts @@ -0,0 +1,191 @@ +/** systemd service enabled-state and runtime inspection. */ +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { formatErrorMessage } from "../infra/errors.js"; +import { + parseStrictInteger, + parseStrictNonNegativeInteger, + parseStrictPositiveInteger, +} from "../infra/parse-finite-number.js"; +import { parseKeyValueOutput } from "./runtime-parse.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; +import type { + GatewayServiceEnv, + GatewayServiceEnvArgs, + GatewayServiceReadOptions, +} from "./service-types.js"; +import { + assertSystemdAvailable, + execSystemctl, + execSystemctlUser, + isSystemctlMissing, + isSystemdUnitNotEnabled, + readSystemctlDetail, +} from "./systemd-exec.js"; +import { findInstalledSystemdGatewayScope } from "./systemd-scope.js"; +import { resolveSystemdServiceName } from "./systemd-service-files.js"; + +type SystemdServiceInfo = { + activeState?: string; + subState?: string; + mainPid?: number; + execMainStatus?: number; + execMainCode?: string; + result?: string; + nRestarts?: number; + startLimitBurst?: number; + unit?: string; + killMode?: string; + tasksCurrent?: number; + memoryCurrent?: number; +}; + +function parseSystemdShow(output: string): SystemdServiceInfo { + const entries = parseKeyValueOutput(output, "="); + const info: SystemdServiceInfo = {}; + const activeState = entries.activestate; + if (activeState) { + info.activeState = activeState; + } + const subState = entries.substate; + if (subState) { + info.subState = subState; + } + const mainPidValue = entries.mainpid; + if (mainPidValue) { + const pid = parseStrictPositiveInteger(mainPidValue); + if (pid !== undefined) { + info.mainPid = pid; + } + } + const execMainStatusValue = entries.execmainstatus; + if (execMainStatusValue) { + const status = parseStrictInteger(execMainStatusValue); + if (status !== undefined) { + info.execMainStatus = status; + } + } + const execMainCode = entries.execmaincode; + if (execMainCode) { + info.execMainCode = execMainCode; + } + const result = entries.result; + if (result) { + info.result = result; + } + const nRestartsValue = entries.nrestarts; + if (nRestartsValue) { + const nRestarts = parseStrictInteger(nRestartsValue); + if (nRestarts !== undefined) { + info.nRestarts = nRestarts; + } + } + const startLimitBurstValue = entries.startlimitburst; + if (startLimitBurstValue) { + const startLimitBurst = parseStrictInteger(startLimitBurstValue); + if (startLimitBurst !== undefined) { + info.startLimitBurst = startLimitBurst; + } + } + const unit = entries.id; + if (unit) { + info.unit = unit; + } + const killMode = entries.killmode; + if (killMode) { + info.killMode = killMode; + } + const tasksCurrentValue = entries.taskscurrent; + if (tasksCurrentValue) { + const tasksCurrent = parseStrictNonNegativeInteger(tasksCurrentValue); + if (tasksCurrent !== undefined) { + info.tasksCurrent = tasksCurrent; + } + } + const memoryCurrentValue = entries.memorycurrent; + if (memoryCurrentValue) { + const memoryCurrent = parseStrictNonNegativeInteger(memoryCurrentValue); + if (memoryCurrent !== undefined) { + info.memoryCurrent = memoryCurrent; + } + } + return info; +} + +export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Promise { + const env = args.env ?? process.env; + const installed = await findInstalledSystemdGatewayScope(env); + if (!installed) { + return false; + } + const res = + installed.scope === "system" + ? await execSystemctl(["is-enabled", installed.unitName], env, args.timeoutMs) + : await execSystemctlUser(env, ["is-enabled", installed.unitName], args.timeoutMs); + if (res.code === 0) { + return true; + } + const detail = readSystemctlDetail(res); + if (isSystemctlMissing(detail) || isSystemdUnitNotEnabled(detail)) { + return false; + } + throw new Error(`systemctl is-enabled unavailable: ${detail || "unknown error"}`.trim()); +} + +export async function readSystemdServiceRuntime( + env: GatewayServiceEnv = process.env as GatewayServiceEnv, + opts?: GatewayServiceReadOptions, +): Promise { + const timeoutMs = opts?.timeoutMs; + const installed = await findInstalledSystemdGatewayScope(env).catch(() => null); + if (installed?.scope !== "system") { + try { + await assertSystemdAvailable(env, timeoutMs); + } catch (err) { + return { + status: "unknown", + detail: formatErrorMessage(err), + }; + } + } + const unitName = installed?.unitName ?? `${resolveSystemdServiceName(env)}.service`; + const showArgs = [ + "show", + unitName, + "--no-page", + "--property", + "Id,ActiveState,SubState,Result,NRestarts,StartLimitBurst,MainPID,ExecMainStatus,ExecMainCode,KillMode,TasksCurrent,MemoryCurrent", + ]; + const res = + installed?.scope === "system" + ? await execSystemctl(showArgs, env, timeoutMs) + : await execSystemctlUser(env, showArgs, timeoutMs); + if (res.code !== 0) { + const detail = (res.stderr || res.stdout).trim(); + const missing = normalizeLowercaseStringOrEmpty(detail).includes("not found"); + return { + status: missing ? "stopped" : "unknown", + detail: detail || undefined, + missingUnit: missing, + }; + } + const parsed = parseSystemdShow(res.stdout || ""); + const activeState = normalizeLowercaseStringOrEmpty(parsed.activeState); + const status = activeState === "active" ? "running" : activeState ? "stopped" : "unknown"; + return { + status, + state: parsed.activeState, + subState: parsed.subState, + pid: parsed.mainPid, + lastExitStatus: parsed.execMainStatus, + lastExitReason: parsed.execMainCode, + systemd: { + unit: parsed.unit ?? unitName, + killMode: parsed.killMode, + tasksCurrent: parsed.tasksCurrent, + memoryCurrent: parsed.memoryCurrent, + result: parsed.result, + nRestarts: parsed.nRestarts, + startLimitBurst: parsed.startLimitBurst, + }, + }; +} diff --git a/src/daemon/systemd-scope.ts b/src/daemon/systemd-scope.ts new file mode 100644 index 000000000000..6d6b0e761271 --- /dev/null +++ b/src/daemon/systemd-scope.ts @@ -0,0 +1,230 @@ +/** Installed systemd scope discovery and dueling-manager diagnostics. */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import type { GatewayServiceEnv } from "./service-types.js"; +import { execSystemctl, isSystemdUnitActive, type SystemdUnitScope } from "./systemd-exec.js"; +import { resolveSystemdServiceName, resolveSystemdUnitPath } from "./systemd-service-files.js"; +import { assertNoSystemSystemdOwnership } from "./systemd-system.js"; + +const SYSTEM_SYSTEMD_UNIT_DIRS = [ + "/etc/systemd/system", + "/usr/lib/systemd/system", + "/lib/systemd/system", +] as const; + +async function findSystemSystemdUnitPath(env: GatewayServiceEnv): Promise { + const serviceFile = `${resolveSystemdServiceName(env)}.service`; + for (const dir of SYSTEM_SYSTEMD_UNIT_DIRS) { + const candidate = path.posix.join(dir, serviceFile); + try { + await fs.access(candidate); + return candidate; + } catch { + continue; + } + } + return null; +} + +type InstalledSystemdGatewayScope = { + scope: SystemdUnitScope; + unitName: string; + unitPath: string; +}; + +export async function assertNoSystemGatewayOwnership(env: GatewayServiceEnv): Promise { + if (env.OPENCLAW_SERVICE_KIND?.trim() === "node") { + return; + } + await assertNoSystemSystemdOwnership(`${resolveSystemdServiceName(env)}.service`); +} + +async function findMarkerOwnedSystemSystemdUnit(): Promise<{ + unitName: string; + unitPath: string; +} | null> { + // System-scope installs may use non-canonical names; inspect marker-owned + // units before declaring no installed service exists. + const { findSystemGatewayServices } = await import("./inspect.js"); + let services: Awaited>; + try { + services = await findSystemGatewayServices(); + } catch { + return null; + } + for (const svc of services) { + if ( + svc.platform !== "linux" || + svc.scope !== "system" || + svc.marker !== "openclaw" || + !svc.label?.endsWith(".service") + ) { + continue; + } + const match = /^unit:\s*(.+)$/.exec(svc.detail.trim()); + const unitPath = match?.[1]?.trim(); + if (unitPath) { + return { unitName: svc.label, unitPath }; + } + } + return null; +} + +/** + * The full installed-gateway picture across both systemd scopes. + * + * Modeled as a discriminated union so the "both a user-scope and a + * system-scope unit are installed" (`dueling`) state is representable and + * cannot be confused with the single-scope states. The old single-scope + * detector could never surface this, which is the root cause of the + * upgrade restart cascade in issue #79375: two supervisors bind the same + * port and SIGTERM each other forever. + */ +type SystemdGatewayInstallation = + | { kind: "none" } + | { kind: "user"; user: InstalledSystemdGatewayScope } + | { kind: "system"; system: InstalledSystemdGatewayScope } + | { + kind: "dueling"; + user: InstalledSystemdGatewayScope; + system: InstalledSystemdGatewayScope; + }; + +async function findUserSystemdGatewayScope( + env: GatewayServiceEnv, +): Promise { + const canonicalUnitName = `${resolveSystemdServiceName(env)}.service`; + let userPath: string | null; + try { + userPath = resolveSystemdUnitPath(env); + } catch { + userPath = null; + } + if (!userPath) { + return null; + } + try { + await fs.access(userPath); + return { scope: "user", unitName: canonicalUnitName, unitPath: userPath }; + } catch { + return null; + } +} + +async function findSystemSystemdGatewayScope( + env: GatewayServiceEnv, +): Promise { + const canonicalUnitName = `${resolveSystemdServiceName(env)}.service`; + const systemPath = await findSystemSystemdUnitPath(env); + if (systemPath) { + return { scope: "system", unitName: canonicalUnitName, unitPath: systemPath }; + } + // System-scope installs may use a non-canonical unit name; fall back to a + // marker-owned lookup before declaring no system unit exists. + const owned = await findMarkerOwnedSystemSystemdUnit(); + return owned ? { scope: "system", unitName: owned.unitName, unitPath: owned.unitPath } : null; +} + +/** + * Canonical detector: reports every installed scope without early-returning, + * so a coexisting user + system unit surfaces as `dueling`. + */ +export async function findSystemdGatewayInstallation( + env: GatewayServiceEnv, +): Promise { + const [user, system] = await Promise.all([ + findUserSystemdGatewayScope(env), + findSystemSystemdGatewayScope(env), + ]); + if (user && system) { + // Only the SAME canonical gateway installed in both scopes is a dueling + // conflict (issue #79375). A marker-owned system unit with a *different* + // name is an intentional separate gateway — e.g. a rescue bot on the same + // host (see docs: /gateway#multiple-gateways-same-host) — and must never + // be treated as a duplicate of the user unit, or doctor could remove a + // legitimate user gateway. The user unit is always canonical; the direct + // system path is canonical too, so the real #79375 case still matches. + if (user.unitName === system.unitName) { + return { kind: "dueling", user, system }; + } + return { kind: "user", user }; + } + if (user) { + return { kind: "user", user }; + } + if (system) { + return { kind: "system", system }; + } + return { kind: "none" }; +} + +/** + * The single scope to act on, preserving the long-standing user-first + * preference its four lifecycle callers (stop/restart/is-enabled/runtime) + * rely on. Dueling resolution (removing the redundant user unit) is handled + * separately by doctor via {@link findSystemdGatewayInstallation}; this + * function intentionally does not change lifecycle semantics. + */ +export async function findInstalledSystemdGatewayScope( + env: GatewayServiceEnv, +): Promise { + const installation = await findSystemdGatewayInstallation(env); + // User-first: dueling resolves to the user scope, same as a user-only install. + if (installation.kind === "dueling" || installation.kind === "user") { + return installation.user; + } + if (installation.kind === "system") { + return installation.system; + } + return null; +} + +/** + * True only when the system-scope unit is running now AND persistently enabled + * at boot. Doctor's dueling repair deletes the user unit behind this probe, so + * both halves are required: an enabled-but-failed unit would leave no gateway + * until the next boot, and an active-but-unenabled unit would leave none after + * it. Uncheckable (systemctl missing/erroring) reads as false so the repair + * fails closed to hints rather than removing a working user-scope gateway. + */ +export async function isSystemUnitActiveAndEnabled( + env: GatewayServiceEnv, + unitName: string, +): Promise { + if (!(await isSystemdUnitActive(env, unitName, "system"))) { + return false; + } + const res = await execSystemctl(["is-enabled", unitName], env); + if (res.code !== 0) { + return false; + } + // `is-enabled` also exits 0 for enabled-runtime, alias, static, indirect, + // generated, and transient (systemctl(1) Table 3). Only a plain `enabled` + // symlink survives a reboot, so anything else must not authorize deleting + // the user unit. + return normalizeLowercaseStringOrEmpty(res.stdout) === "enabled"; +} + +/** + * Builds the operator-facing warning for a `dueling` installation, or null for + * any other state. Pure (no I/O) so the startup guard's messaging is unit + * testable without faking the whole service-mode boot path. + */ +export function formatDuelingScopesWarning( + installation: SystemdGatewayInstallation, + port: number, +): string | null { + if (installation.kind !== "dueling") { + return null; + } + const { user, system } = installation; + // Deliberately no copy-paste removal command: this formatter has no ownership + // evidence, and blindly deleting the user unit can remove the only working + // gateway. `doctor --fix` decides that behind the active+enabled probe. + return ( + `detected BOTH a user-scope (${user.unitPath}) and a system-scope (${system.unitPath}) ` + + `gateway unit bound to port ${port}; they will SIGTERM each other in a restart loop. ` + + `Run \`openclaw doctor --fix\` to resolve which unit should own this gateway.` + ); +} diff --git a/src/daemon/systemd-service-files.ts b/src/daemon/systemd-service-files.ts new file mode 100644 index 000000000000..3776c8082178 --- /dev/null +++ b/src/daemon/systemd-service-files.ts @@ -0,0 +1,350 @@ +/** Linux systemd unit paths and environment-file parsing. */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { isUnresolvedShellReference } from "../config/state-dir-dotenv.js"; +import { splitArgsPreservingQuotes } from "./arg-split.js"; +import { resolveGatewaySystemdServiceName } from "./constants.js"; +import { toPosixPath } from "./output.js"; +import { resolveDaemonHomeDir } from "./paths.js"; +import type { + GatewayServiceCommandConfig, + GatewayServiceEnv, + GatewayServiceEnvironmentValueSource, +} from "./service-types.js"; +import { parseSystemdEnvAssignments, parseSystemdExecStart } from "./systemd-unit.js"; + +const SYSTEMD_GATEWAY_DOTENV_FILENAME = "gateway.systemd.env"; +const SYSTEMD_NODE_DOTENV_FILENAME = "node.systemd.env"; + +export function resolveSystemdUnitPathForName(env: GatewayServiceEnv, name: string): string { + const home = toPosixPath(resolveDaemonHomeDir(env)); + return path.posix.join(home, ".config", "systemd", "user", `${name}.service`); +} + +export function resolveSystemdServiceName(env: GatewayServiceEnv): string { + const override = env.OPENCLAW_SYSTEMD_UNIT?.trim(); + if (override) { + return override.endsWith(".service") ? override.slice(0, -".service".length) : override; + } + return resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE); +} + +export function resolveSystemdUnitPath(env: GatewayServiceEnv): string { + return resolveSystemdUnitPathForName(env, resolveSystemdServiceName(env)); +} + +export function resolveSystemdUserUnitPath(env: GatewayServiceEnv): string { + return resolveSystemdUnitPath(env); +} + +// Unit file parsing/rendering: see systemd-unit.ts + +export async function readSystemdServiceExecStart( + env: GatewayServiceEnv, +): Promise { + const unitPath = resolveSystemdUnitPath(env); + try { + const content = await fs.readFile(unitPath, "utf8"); + let execStart = ""; + let workingDirectory = ""; + const inlineEnvironment: Record = {}; + const environmentFileSpecs: string[] = []; + for (const rawLine of content.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + if (line.startsWith("ExecStart=")) { + execStart = line.slice("ExecStart=".length).trim(); + } else if (line.startsWith("WorkingDirectory=")) { + workingDirectory = line.slice("WorkingDirectory=".length).trim(); + } else if (line.startsWith("Environment=")) { + const raw = line.slice("Environment=".length).trim(); + for (const parsed of parseSystemdEnvAssignments(raw)) { + inlineEnvironment[parsed.key] = parsed.value; + } + } else if (line.startsWith("EnvironmentFile=")) { + const raw = line.slice("EnvironmentFile=".length).trim(); + if (raw) { + environmentFileSpecs.push(raw); + } + } + } + if (!execStart) { + return null; + } + const environmentFromFiles = await resolveSystemdEnvironmentFiles({ + environmentFileSpecs, + env, + unitPath, + }); + const mergedEnvironment = { + ...inlineEnvironment, + ...environmentFromFiles.environment, + }; + const mergedEnvironmentSources = mergeEnvironmentValueSources( + inlineEnvironment, + environmentFromFiles.environment, + ); + const programArguments = parseSystemdExecStart(execStart); + return { + programArguments, + ...(workingDirectory ? { workingDirectory } : {}), + ...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}), + ...(Object.keys(mergedEnvironmentSources).length > 0 + ? { environmentValueSources: mergedEnvironmentSources } + : {}), + sourcePath: unitPath, + }; + } catch { + return null; + } +} + +function buildEnvironmentValueSources( + environment: Record, + source: "inline" | "file", +): Record { + return Object.fromEntries(Object.keys(environment).map((key) => [key, source])); +} + +function mergeEnvironmentValueSources( + inlineEnvironment: Record, + fileEnvironment: Record, +): Record { + const sources = buildEnvironmentValueSources(inlineEnvironment, "inline"); + for (const key of Object.keys(fileEnvironment)) { + sources[key] = Object.hasOwn(inlineEnvironment, key) ? "inline-and-file" : "file"; + } + return sources; +} + +export function resolveSystemdEnvironmentFilePath(params: { + stateDir: string; + environment?: GatewayServiceEnv; +}): string { + const serviceKind = params.environment?.OPENCLAW_SERVICE_KIND?.trim(); + const filename = + serviceKind === "node" ? SYSTEMD_NODE_DOTENV_FILENAME : SYSTEMD_GATEWAY_DOTENV_FILENAME; + return path.join(params.stateDir, filename); +} + +export function resolveLegacyNodeSystemdEnvironmentFilePath(params: { + stateDir: string; + environment?: GatewayServiceEnv; +}): string | null { + if (params.environment?.OPENCLAW_SERVICE_KIND?.trim() !== "node") { + return null; + } + const legacyPath = path.join(params.stateDir, SYSTEMD_GATEWAY_DOTENV_FILENAME); + const currentPath = resolveSystemdEnvironmentFilePath(params); + return legacyPath === currentPath ? null : legacyPath; +} + +export function isNodeSystemdEnvironment(env: GatewayServiceEnv): boolean { + return env.OPENCLAW_SERVICE_KIND?.trim() === "node"; +} + +function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string { + // Support the common unit-specifier used in user services. + return input.replaceAll("%h", toPosixPath(resolveDaemonHomeDir(env))); +} + +function parseEnvironmentFileSpecs(raw: string): string[] { + return normalizeStringEntries(splitArgsPreservingQuotes(raw, { escapeMode: "backslash" })); +} + +function decodeSystemdEnvironmentFileValue(rawValue: string): { + value: string; + literalDollar: boolean; +} { + type ParseState = + | "pre" + | "unquoted" + | "unquoted-escape" + | "single-quoted" + | "double-quoted" + | "double-quoted-escape"; + + // Mirror systemd's parse_env_file_internal state transitions. In particular, + // a closing quoted segment returns to `pre`, so `"foo"bar` decodes to `foobar`. + let state: ParseState = "pre"; + let decoded = ""; + let literalDollar = false; + let trailingWhitespaceStart: number | undefined; + for (const char of rawValue) { + const whitespace = char === " " || char === "\t" || char === "\r"; + if (state === "pre") { + if (whitespace) { + continue; + } + if (char === "'") { + state = "single-quoted"; + continue; + } + if (char === '"') { + state = "double-quoted"; + continue; + } + if (char === "\\") { + state = "unquoted-escape"; + continue; + } + state = "unquoted"; + decoded += char; + continue; + } + if (state === "unquoted") { + if (char === "\\") { + state = "unquoted-escape"; + trailingWhitespaceStart = undefined; + continue; + } + if (whitespace) { + trailingWhitespaceStart ??= decoded.length; + } else { + trailingWhitespaceStart = undefined; + } + decoded += char; + continue; + } + if (state === "unquoted-escape") { + state = "unquoted"; + literalDollar ||= char === "$"; + decoded += char; + continue; + } + if (state === "single-quoted") { + if (char === "'") { + state = "pre"; + } else { + literalDollar ||= char === "$"; + decoded += char; + } + continue; + } + if (state === "double-quoted") { + if (char === '"') { + state = "pre"; + } else if (char === "\\") { + state = "double-quoted-escape"; + } else { + literalDollar ||= char === "$"; + decoded += char; + } + continue; + } + state = "double-quoted"; + if (['"', "\\", "`", "$"].includes(char)) { + literalDollar ||= char === "$"; + decoded += char; + } else { + decoded += `\\${char}`; + } + } + if (state === "unquoted" && trailingWhitespaceStart !== undefined) { + decoded = decoded.slice(0, trailingWhitespaceStart); + } + return { value: decoded, literalDollar }; +} + +function parseEnvironmentFileLine( + rawLine: string, +): { key: string; value: string; literalShellReference: boolean } | null { + const trimmedStart = rawLine.trimStart(); + if (!trimmedStart || trimmedStart.startsWith("#") || trimmedStart.startsWith(";")) { + return null; + } + const eq = trimmedStart.indexOf("="); + if (eq <= 0) { + return null; + } + const key = trimmedStart.slice(0, eq).trim(); + if (!key) { + return null; + } + const decoded = decodeSystemdEnvironmentFileValue(trimmedStart.slice(eq + 1)); + return { + key, + value: decoded.value, + literalShellReference: decoded.literalDollar && isUnresolvedShellReference(decoded.value), + }; +} + +function serializeSystemdEnvironmentFileValue(value: string): string { + // EnvironmentFile double quotes only unescape \", \\, \`, and \$. Escape + // exactly that set so credentials survive systemd parsing byte-for-byte. + if (!/[\s\\'"`$]/u.test(value)) { + return value; + } + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("`", "\\`") + .replaceAll("$", "\\$"); + return `"${escaped}"`; +} + +export function serializeSystemdEnvironmentFile(environment: Record): string { + return Object.entries(environment) + .map(([key, value]) => `${key}=${serializeSystemdEnvironmentFileValue(value)}`) + .join("\n"); +} + +export async function readSystemdEnvironmentFile(pathname: string): Promise<{ + environment: Record; + literalShellReferenceKeys: Set; +}> { + const environment: Record = {}; + const literalShellReferenceKeys = new Set(); + const content = await fs.readFile(pathname, "utf8"); + for (const rawLine of content.split(/\r?\n/)) { + const parsed = parseEnvironmentFileLine(rawLine); + if (!parsed) { + continue; + } + environment[parsed.key] = parsed.value; + if (parsed.literalShellReference) { + literalShellReferenceKeys.add(parsed.key); + } else { + literalShellReferenceKeys.delete(parsed.key); + } + } + return { environment, literalShellReferenceKeys }; +} + +async function resolveSystemdEnvironmentFiles(params: { + environmentFileSpecs: string[]; + env: GatewayServiceEnv; + unitPath: string; +}): Promise<{ environment: Record }> { + const resolved: Record = {}; + if (params.environmentFileSpecs.length === 0) { + return { environment: resolved }; + } + const unitDir = path.posix.dirname(params.unitPath); + for (const specRaw of params.environmentFileSpecs) { + for (const token of parseEnvironmentFileSpecs(specRaw)) { + const optional = token.startsWith("-"); + const pathnameRaw = optional ? token.slice(1).trim() : token; + if (!pathnameRaw) { + continue; + } + const expanded = expandSystemdSpecifier(pathnameRaw, params.env); + const pathname = path.posix.isAbsolute(expanded) + ? expanded + : path.posix.resolve(unitDir, expanded); + try { + const fromFile = await readSystemdEnvironmentFile(pathname); + Object.assign(resolved, fromFile.environment); + } catch { + // Keep service auditing resilient even when env files are unavailable + // in the current runtime context. Both optional and non-optional + // EnvironmentFile entries are skipped gracefully for diagnostics. + continue; + } + } + } + return { environment: resolved }; +} diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index a19cbc1fb47a..ab98b89afd34 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -1,1912 +1,32 @@ /** Linux systemd user service installer, parser, and lifecycle controls. */ -import { randomUUID } from "node:crypto"; -import * as fsSync from "node:fs"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; -import { resolveStateDir } from "../config/paths.js"; -import { - isUnresolvedShellReference, - readStateDirDotEnvFromStateDir, -} from "../config/state-dir-dotenv.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import { - parseStrictInteger, - parseStrictNonNegativeInteger, - parseStrictPositiveInteger, -} from "../infra/parse-finite-number.js"; -import { escapeRegExp } from "../shared/regexp.js"; -import { splitArgsPreservingQuotes } from "./arg-split.js"; -import { - LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, - resolveGatewayServiceDescription, - resolveGatewaySystemdServiceName, -} from "./constants.js"; -import { execFileUtf8 } from "./exec-file.js"; -import { formatLine, toPosixPath, writeFormattedLines } from "./output.js"; -import { resolveDaemonHomeDir } from "./paths.js"; -import { parseKeyValueOutput } from "./runtime-parse.js"; -import { - hasEnvironmentFileSource, - hasInlineEnvironmentSource, - isEnvironmentFileOnlySource, - normalizeServiceEnvKey, - normalizeServiceEnvKeys, - readEnvironmentValueSource, - readManagedServiceEnvKeysFromEnvironment, -} from "./service-managed-env.js"; -import { createGatewayLifecycleMutationReporter } from "./service-mutation.js"; -import type { GatewayServiceRuntime } from "./service-runtime.js"; -import type { - GatewayServiceCommandConfig, - GatewayServiceControlArgs, - GatewayServiceEnv, - GatewayServiceEnvArgs, - GatewayServiceEnvironmentValueSource, - GatewayServiceInstallArgs, - GatewayServiceManageArgs, - GatewayServiceReadOptions, - GatewayServiceRestartResult, -} from "./service-types.js"; -import { enableSystemdUserLinger, readSystemdUserLingerStatus } from "./systemd-linger.js"; -import { assertNoSystemSystemdOwnership } from "./systemd-system.js"; -import { - classifySystemdUnavailableDetail, - isSystemctlMissingDetail, - isSystemdUserBusUnavailableDetail, -} from "./systemd-unavailable.js"; -import { - buildSystemdUnit, - parseSystemdEnvAssignments, - parseSystemdExecStart, - renderSystemdEnvAssignment, -} from "./systemd-unit.js"; - -const SYSTEMD_GATEWAY_DOTENV_FILENAME = "gateway.systemd.env"; -const SYSTEMD_NODE_DOTENV_FILENAME = "node.systemd.env"; - -function resolveSystemdUnitPathForName(env: GatewayServiceEnv, name: string): string { - const home = toPosixPath(resolveDaemonHomeDir(env)); - return path.posix.join(home, ".config", "systemd", "user", `${name}.service`); -} - -function resolveSystemdServiceName(env: GatewayServiceEnv): string { - const override = env.OPENCLAW_SYSTEMD_UNIT?.trim(); - if (override) { - return override.endsWith(".service") ? override.slice(0, -".service".length) : override; - } - return resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE); -} - -function resolveSystemdUnitPath(env: GatewayServiceEnv): string { - return resolveSystemdUnitPathForName(env, resolveSystemdServiceName(env)); -} - -export function resolveSystemdUserUnitPath(env: GatewayServiceEnv): string { - return resolveSystemdUnitPath(env); -} - -const SYSTEM_SYSTEMD_UNIT_DIRS = [ - "/etc/systemd/system", - "/usr/lib/systemd/system", - "/lib/systemd/system", -] as const; - -async function findSystemSystemdUnitPath(env: GatewayServiceEnv): Promise { - const serviceFile = `${resolveSystemdServiceName(env)}.service`; - for (const dir of SYSTEM_SYSTEMD_UNIT_DIRS) { - const candidate = path.posix.join(dir, serviceFile); - try { - await fs.access(candidate); - return candidate; - } catch { - continue; - } - } - return null; -} - -type InstalledSystemdGatewayScope = { - scope: SystemdUnitScope; - unitName: string; - unitPath: string; -}; - -async function findMarkerOwnedSystemSystemdUnit(): Promise<{ - unitName: string; - unitPath: string; -} | null> { - // System-scope installs may use non-canonical names; inspect marker-owned - // units before declaring no installed service exists. - const { findSystemGatewayServices } = await import("./inspect.js"); - let services: Awaited>; - try { - services = await findSystemGatewayServices(); - } catch { - return null; - } - for (const svc of services) { - if ( - svc.platform !== "linux" || - svc.scope !== "system" || - svc.marker !== "openclaw" || - !svc.label?.endsWith(".service") - ) { - continue; - } - const match = /^unit:\s*(.+)$/.exec(svc.detail.trim()); - const unitPath = match?.[1]?.trim(); - if (unitPath) { - return { unitName: svc.label, unitPath }; - } - } - return null; -} - -/** - * The full installed-gateway picture across both systemd scopes. - * - * Modeled as a discriminated union so the "both a user-scope and a - * system-scope unit are installed" (`dueling`) state is representable and - * cannot be confused with the single-scope states. The old single-scope - * detector could never surface this, which is the root cause of the - * upgrade restart cascade in issue #79375: two supervisors bind the same - * port and SIGTERM each other forever. - */ -type SystemdGatewayInstallation = - | { kind: "none" } - | { kind: "user"; user: InstalledSystemdGatewayScope } - | { kind: "system"; system: InstalledSystemdGatewayScope } - | { - kind: "dueling"; - user: InstalledSystemdGatewayScope; - system: InstalledSystemdGatewayScope; - }; - -async function findUserSystemdGatewayScope( - env: GatewayServiceEnv, -): Promise { - const canonicalUnitName = `${resolveSystemdServiceName(env)}.service`; - let userPath: string | null; - try { - userPath = resolveSystemdUnitPath(env); - } catch { - userPath = null; - } - if (!userPath) { - return null; - } - try { - await fs.access(userPath); - return { scope: "user", unitName: canonicalUnitName, unitPath: userPath }; - } catch { - return null; - } -} - -async function findSystemSystemdGatewayScope( - env: GatewayServiceEnv, -): Promise { - const canonicalUnitName = `${resolveSystemdServiceName(env)}.service`; - const systemPath = await findSystemSystemdUnitPath(env); - if (systemPath) { - return { scope: "system", unitName: canonicalUnitName, unitPath: systemPath }; - } - // System-scope installs may use a non-canonical unit name; fall back to a - // marker-owned lookup before declaring no system unit exists. - const owned = await findMarkerOwnedSystemSystemdUnit(); - return owned ? { scope: "system", unitName: owned.unitName, unitPath: owned.unitPath } : null; -} - -/** - * Canonical detector: reports every installed scope without early-returning, - * so a coexisting user + system unit surfaces as `dueling`. - */ -export async function findSystemdGatewayInstallation( - env: GatewayServiceEnv, -): Promise { - const [user, system] = await Promise.all([ - findUserSystemdGatewayScope(env), - findSystemSystemdGatewayScope(env), - ]); - if (user && system) { - // Only the SAME canonical gateway installed in both scopes is a dueling - // conflict (issue #79375). A marker-owned system unit with a *different* - // name is an intentional separate gateway — e.g. a rescue bot on the same - // host (see docs: /gateway#multiple-gateways-same-host) — and must never - // be treated as a duplicate of the user unit, or doctor could remove a - // legitimate user gateway. The user unit is always canonical; the direct - // system path is canonical too, so the real #79375 case still matches. - if (user.unitName === system.unitName) { - return { kind: "dueling", user, system }; - } - return { kind: "user", user }; - } - if (user) { - return { kind: "user", user }; - } - if (system) { - return { kind: "system", system }; - } - return { kind: "none" }; -} - -/** - * The single scope to act on, preserving the long-standing user-first - * preference its four lifecycle callers (stop/restart/is-enabled/runtime) - * rely on. Dueling resolution (removing the redundant user unit) is handled - * separately by doctor via {@link findSystemdGatewayInstallation}; this - * function intentionally does not change lifecycle semantics. - */ -export async function findInstalledSystemdGatewayScope( - env: GatewayServiceEnv, -): Promise { - const installation = await findSystemdGatewayInstallation(env); - // User-first: dueling resolves to the user scope, same as a user-only install. - if (installation.kind === "dueling" || installation.kind === "user") { - return installation.user; - } - if (installation.kind === "system") { - return installation.system; - } - return null; -} - -/** - * True only when the system-scope unit is running now AND persistently enabled - * at boot. Doctor's dueling repair deletes the user unit behind this probe, so - * both halves are required: an enabled-but-failed unit would leave no gateway - * until the next boot, and an active-but-unenabled unit would leave none after - * it. Uncheckable (systemctl missing/erroring) reads as false so the repair - * fails closed to hints rather than removing a working user-scope gateway. - */ -export async function isSystemUnitActiveAndEnabled( - env: GatewayServiceEnv, - unitName: string, -): Promise { - if (!(await isSystemdUnitActive(env, unitName, "system"))) { - return false; - } - const res = await execSystemctl(["is-enabled", unitName], env); - if (res.code !== 0) { - return false; - } - // `is-enabled` also exits 0 for enabled-runtime, alias, static, indirect, - // generated, and transient (systemctl(1) Table 3). Only a plain `enabled` - // symlink survives a reboot, so anything else must not authorize deleting - // the user unit. - return normalizeLowercaseStringOrEmpty(res.stdout) === "enabled"; -} - -/** - * Builds the operator-facing warning for a `dueling` installation, or null for - * any other state. Pure (no I/O) so the startup guard's messaging is unit - * testable without faking the whole service-mode boot path. - */ -export function formatDuelingScopesWarning( - installation: SystemdGatewayInstallation, - port: number, -): string | null { - if (installation.kind !== "dueling") { - return null; - } - const { user, system } = installation; - // Deliberately no copy-paste removal command: this formatter has no ownership - // evidence, and blindly deleting the user unit can remove the only working - // gateway. `doctor --fix` decides that behind the active+enabled probe. - return ( - `detected BOTH a user-scope (${user.unitPath}) and a system-scope (${system.unitPath}) ` + - `gateway unit bound to port ${port}; they will SIGTERM each other in a restart loop. ` + - `Run \`openclaw doctor --fix\` to resolve which unit should own this gateway.` - ); -} - -export { enableSystemdUserLinger, readSystemdUserLingerStatus }; - -// Unit file parsing/rendering: see systemd-unit.ts - -export async function readSystemdServiceExecStart( - env: GatewayServiceEnv, -): Promise { - const unitPath = resolveSystemdUnitPath(env); - try { - const content = await fs.readFile(unitPath, "utf8"); - let execStart = ""; - let workingDirectory = ""; - const inlineEnvironment: Record = {}; - const environmentFileSpecs: string[] = []; - for (const rawLine of content.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - if (line.startsWith("ExecStart=")) { - execStart = line.slice("ExecStart=".length).trim(); - } else if (line.startsWith("WorkingDirectory=")) { - workingDirectory = line.slice("WorkingDirectory=".length).trim(); - } else if (line.startsWith("Environment=")) { - const raw = line.slice("Environment=".length).trim(); - for (const parsed of parseSystemdEnvAssignments(raw)) { - inlineEnvironment[parsed.key] = parsed.value; - } - } else if (line.startsWith("EnvironmentFile=")) { - const raw = line.slice("EnvironmentFile=".length).trim(); - if (raw) { - environmentFileSpecs.push(raw); - } - } - } - if (!execStart) { - return null; - } - const environmentFromFiles = await resolveSystemdEnvironmentFiles({ - environmentFileSpecs, - env, - unitPath, - }); - const mergedEnvironment = { - ...inlineEnvironment, - ...environmentFromFiles.environment, - }; - const mergedEnvironmentSources = mergeEnvironmentValueSources( - inlineEnvironment, - environmentFromFiles.environment, - ); - const programArguments = parseSystemdExecStart(execStart); - return { - programArguments, - ...(workingDirectory ? { workingDirectory } : {}), - ...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}), - ...(Object.keys(mergedEnvironmentSources).length > 0 - ? { environmentValueSources: mergedEnvironmentSources } - : {}), - sourcePath: unitPath, - }; - } catch { - return null; - } -} - -function buildEnvironmentValueSources( - environment: Record, - source: "inline" | "file", -): Record { - return Object.fromEntries(Object.keys(environment).map((key) => [key, source])); -} - -function mergeEnvironmentValueSources( - inlineEnvironment: Record, - fileEnvironment: Record, -): Record { - const sources = buildEnvironmentValueSources(inlineEnvironment, "inline"); - for (const key of Object.keys(fileEnvironment)) { - sources[key] = Object.hasOwn(inlineEnvironment, key) ? "inline-and-file" : "file"; - } - return sources; -} - -function collectSystemdInlineManagedKeys(params: { - environment?: GatewayServiceEnv; - environmentValueSources?: Record; -}): Set { - const keys = readManagedServiceEnvKeysFromEnvironment(params.environment); - for (const key of collectSystemdFileManagedKeys({ - environmentValueSources: params.environmentValueSources, - })) { - keys.delete(key); - } - for (const [rawKey, value] of Object.entries(params.environment ?? {})) { - if (typeof value !== "string" || !value.trim()) { - continue; - } - const key = normalizeServiceEnvKey(rawKey); - if (!key) { - continue; - } - const source = readEnvironmentValueSource(params.environmentValueSources, rawKey); - if (hasInlineEnvironmentSource(source) && !hasEnvironmentFileSource(source)) { - keys.add(key); - } - } - return keys; -} - -function collectSystemdFileManagedKeys(params: { - environmentValueSources?: Record; -}): Set { - const keys = new Set(); - for (const [rawKey, source] of Object.entries(params.environmentValueSources ?? {})) { - const key = normalizeServiceEnvKey(rawKey); - if (key && isEnvironmentFileOnlySource(source)) { - keys.add(key); - } - } - return keys; -} - -function collectSystemdFileBackedEnvironment(params: { - environment?: GatewayServiceEnv; - fileManagedKeys: ReadonlySet; -}): Record { - if (params.fileManagedKeys.size === 0) { - return {}; - } - const environment: Record = {}; - for (const [rawKey, rawValue] of Object.entries(params.environment ?? {})) { - if (typeof rawValue !== "string" || !rawValue.trim()) { - continue; - } - const key = normalizeServiceEnvKey(rawKey); - if (key && params.fileManagedKeys.has(key) && !isUnresolvedShellReference(rawValue)) { - environment[rawKey] = rawValue; - } - } - return environment; -} - -function sanitizeSystemdUnitBackupContent(params: { - content: string; - fileManagedKeys: ReadonlySet; -}): string { - if (params.fileManagedKeys.size === 0) { - return params.content; - } - // Backups should not retain file-managed secrets that OpenClaw moved into the - // generated EnvironmentFile during this rewrite. - const sanitizedLines: string[] = []; - for (const rawLine of params.content.split("\n")) { - const line = rawLine.trim(); - if (!line.startsWith("Environment=")) { - sanitizedLines.push(rawLine); - continue; - } - const assignments = parseSystemdEnvAssignments(line.slice("Environment=".length).trim()); - if (assignments.length === 0) { - sanitizedLines.push(rawLine); - continue; - } - const keptAssignments = assignments.filter(({ key }) => { - const normalizedKey = normalizeServiceEnvKey(key); - return !normalizedKey || !params.fileManagedKeys.has(normalizedKey); - }); - if (keptAssignments.length === assignments.length) { - sanitizedLines.push(rawLine); - continue; - } - if (keptAssignments.length === 0) { - continue; - } - const leadingWhitespace = rawLine.match(/^\s*/)?.[0] ?? ""; - sanitizedLines.push( - `${leadingWhitespace}Environment=${keptAssignments - .map(({ key, value }) => renderSystemdEnvAssignment(key, value)) - .join(" ")}`, - ); - } - return sanitizedLines.join("\n"); -} - -function resolveSystemdEnvironmentFilePath(params: { - stateDir: string; - environment?: GatewayServiceEnv; -}): string { - const serviceKind = params.environment?.OPENCLAW_SERVICE_KIND?.trim(); - const filename = - serviceKind === "node" ? SYSTEMD_NODE_DOTENV_FILENAME : SYSTEMD_GATEWAY_DOTENV_FILENAME; - return path.join(params.stateDir, filename); -} - -function resolveLegacyNodeSystemdEnvironmentFilePath(params: { - stateDir: string; - environment?: GatewayServiceEnv; -}): string | null { - if (params.environment?.OPENCLAW_SERVICE_KIND?.trim() !== "node") { - return null; - } - const legacyPath = path.join(params.stateDir, SYSTEMD_GATEWAY_DOTENV_FILENAME); - const currentPath = resolveSystemdEnvironmentFilePath(params); - return legacyPath === currentPath ? null : legacyPath; -} - -function isNodeSystemdEnvironment(env: GatewayServiceEnv): boolean { - return env.OPENCLAW_SERVICE_KIND?.trim() === "node"; -} - -async function assertNoSystemGatewayOwnership(env: GatewayServiceEnv): Promise { - if (isNodeSystemdEnvironment(env)) { - return; - } - await assertNoSystemSystemdOwnership(`${resolveSystemdServiceName(env)}.service`); -} - -function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string { - // Support the common unit-specifier used in user services. - return input.replaceAll("%h", toPosixPath(resolveDaemonHomeDir(env))); -} - -function parseEnvironmentFileSpecs(raw: string): string[] { - return normalizeStringEntries(splitArgsPreservingQuotes(raw, { escapeMode: "backslash" })); -} - -function decodeSystemdEnvironmentFileValue(rawValue: string): { - value: string; - literalDollar: boolean; -} { - type ParseState = - | "pre" - | "unquoted" - | "unquoted-escape" - | "single-quoted" - | "double-quoted" - | "double-quoted-escape"; - - // Mirror systemd's parse_env_file_internal state transitions. In particular, - // a closing quoted segment returns to `pre`, so `"foo"bar` decodes to `foobar`. - let state: ParseState = "pre"; - let decoded = ""; - let literalDollar = false; - let trailingWhitespaceStart: number | undefined; - for (const char of rawValue) { - const whitespace = char === " " || char === "\t" || char === "\r"; - if (state === "pre") { - if (whitespace) { - continue; - } - if (char === "'") { - state = "single-quoted"; - continue; - } - if (char === '"') { - state = "double-quoted"; - continue; - } - if (char === "\\") { - state = "unquoted-escape"; - continue; - } - state = "unquoted"; - decoded += char; - continue; - } - if (state === "unquoted") { - if (char === "\\") { - state = "unquoted-escape"; - trailingWhitespaceStart = undefined; - continue; - } - if (whitespace) { - trailingWhitespaceStart ??= decoded.length; - } else { - trailingWhitespaceStart = undefined; - } - decoded += char; - continue; - } - if (state === "unquoted-escape") { - state = "unquoted"; - literalDollar ||= char === "$"; - decoded += char; - continue; - } - if (state === "single-quoted") { - if (char === "'") { - state = "pre"; - } else { - literalDollar ||= char === "$"; - decoded += char; - } - continue; - } - if (state === "double-quoted") { - if (char === '"') { - state = "pre"; - } else if (char === "\\") { - state = "double-quoted-escape"; - } else { - literalDollar ||= char === "$"; - decoded += char; - } - continue; - } - state = "double-quoted"; - if (['"', "\\", "`", "$"].includes(char)) { - literalDollar ||= char === "$"; - decoded += char; - } else { - decoded += `\\${char}`; - } - } - if (state === "unquoted" && trailingWhitespaceStart !== undefined) { - decoded = decoded.slice(0, trailingWhitespaceStart); - } - return { value: decoded, literalDollar }; -} - -function parseEnvironmentFileLine( - rawLine: string, -): { key: string; value: string; literalShellReference: boolean } | null { - const trimmedStart = rawLine.trimStart(); - if (!trimmedStart || trimmedStart.startsWith("#") || trimmedStart.startsWith(";")) { - return null; - } - const eq = trimmedStart.indexOf("="); - if (eq <= 0) { - return null; - } - const key = trimmedStart.slice(0, eq).trim(); - if (!key) { - return null; - } - const decoded = decodeSystemdEnvironmentFileValue(trimmedStart.slice(eq + 1)); - return { - key, - value: decoded.value, - literalShellReference: decoded.literalDollar && isUnresolvedShellReference(decoded.value), - }; -} - -function serializeSystemdEnvironmentFileValue(value: string): string { - // EnvironmentFile double quotes only unescape \", \\, \`, and \$. Escape - // exactly that set so credentials survive systemd parsing byte-for-byte. - if (!/[\s\\'"`$]/u.test(value)) { - return value; - } - const escaped = value - .replaceAll("\\", "\\\\") - .replaceAll('"', '\\"') - .replaceAll("`", "\\`") - .replaceAll("$", "\\$"); - return `"${escaped}"`; -} - -function serializeSystemdEnvironmentFile(environment: Record): string { - return Object.entries(environment) - .map(([key, value]) => `${key}=${serializeSystemdEnvironmentFileValue(value)}`) - .join("\n"); -} - -async function readSystemdEnvironmentFile(pathname: string): Promise<{ - environment: Record; - literalShellReferenceKeys: Set; -}> { - const environment: Record = {}; - const literalShellReferenceKeys = new Set(); - const content = await fs.readFile(pathname, "utf8"); - for (const rawLine of content.split(/\r?\n/)) { - const parsed = parseEnvironmentFileLine(rawLine); - if (!parsed) { - continue; - } - environment[parsed.key] = parsed.value; - if (parsed.literalShellReference) { - literalShellReferenceKeys.add(parsed.key); - } else { - literalShellReferenceKeys.delete(parsed.key); - } - } - return { environment, literalShellReferenceKeys }; -} - -async function resolveSystemdEnvironmentFiles(params: { - environmentFileSpecs: string[]; - env: GatewayServiceEnv; - unitPath: string; -}): Promise<{ environment: Record }> { - const resolved: Record = {}; - if (params.environmentFileSpecs.length === 0) { - return { environment: resolved }; - } - const unitDir = path.posix.dirname(params.unitPath); - for (const specRaw of params.environmentFileSpecs) { - for (const token of parseEnvironmentFileSpecs(specRaw)) { - const optional = token.startsWith("-"); - const pathnameRaw = optional ? token.slice(1).trim() : token; - if (!pathnameRaw) { - continue; - } - const expanded = expandSystemdSpecifier(pathnameRaw, params.env); - const pathname = path.posix.isAbsolute(expanded) - ? expanded - : path.posix.resolve(unitDir, expanded); - try { - const fromFile = await readSystemdEnvironmentFile(pathname); - Object.assign(resolved, fromFile.environment); - } catch { - // Keep service auditing resilient even when env files are unavailable - // in the current runtime context. Both optional and non-optional - // EnvironmentFile entries are skipped gracefully for diagnostics. - continue; - } - } - } - return { environment: resolved }; -} - -type SystemdServiceInfo = { - activeState?: string; - subState?: string; - mainPid?: number; - execMainStatus?: number; - execMainCode?: string; - result?: string; - nRestarts?: number; - startLimitBurst?: number; - unit?: string; - killMode?: string; - tasksCurrent?: number; - memoryCurrent?: number; -}; - -function parseSystemdShow(output: string): SystemdServiceInfo { - const entries = parseKeyValueOutput(output, "="); - const info: SystemdServiceInfo = {}; - const activeState = entries.activestate; - if (activeState) { - info.activeState = activeState; - } - const subState = entries.substate; - if (subState) { - info.subState = subState; - } - const mainPidValue = entries.mainpid; - if (mainPidValue) { - const pid = parseStrictPositiveInteger(mainPidValue); - if (pid !== undefined) { - info.mainPid = pid; - } - } - const execMainStatusValue = entries.execmainstatus; - if (execMainStatusValue) { - const status = parseStrictInteger(execMainStatusValue); - if (status !== undefined) { - info.execMainStatus = status; - } - } - const execMainCode = entries.execmaincode; - if (execMainCode) { - info.execMainCode = execMainCode; - } - const result = entries.result; - if (result) { - info.result = result; - } - const nRestartsValue = entries.nrestarts; - if (nRestartsValue) { - const nRestarts = parseStrictInteger(nRestartsValue); - if (nRestarts !== undefined) { - info.nRestarts = nRestarts; - } - } - const startLimitBurstValue = entries.startlimitburst; - if (startLimitBurstValue) { - const startLimitBurst = parseStrictInteger(startLimitBurstValue); - if (startLimitBurst !== undefined) { - info.startLimitBurst = startLimitBurst; - } - } - const unit = entries.id; - if (unit) { - info.unit = unit; - } - const killMode = entries.killmode; - if (killMode) { - info.killMode = killMode; - } - const tasksCurrentValue = entries.taskscurrent; - if (tasksCurrentValue) { - const tasksCurrent = parseStrictNonNegativeInteger(tasksCurrentValue); - if (tasksCurrent !== undefined) { - info.tasksCurrent = tasksCurrent; - } - } - const memoryCurrentValue = entries.memorycurrent; - if (memoryCurrentValue) { - const memoryCurrent = parseStrictNonNegativeInteger(memoryCurrentValue); - if (memoryCurrent !== undefined) { - info.memoryCurrent = memoryCurrent; - } - } - return info; -} - -export type SystemdUnitScope = "system" | "user"; - -async function execSystemctl( - args: string[], - env?: GatewayServiceEnv, - timeoutMs?: number, -): Promise<{ stdout: string; stderr: string; code: number }> { - return await execFileUtf8("systemctl", args, { - env: env ? resolveSystemctlProcessEnv(env) : process.env, - // A wedged systemd socket can leave `systemctl` blocked forever; the timeout - // kills the child so status reads fail soft instead of hanging the command. - ...(timeoutMs && timeoutMs > 0 ? { timeout: timeoutMs, killSignal: "SIGKILL" as const } : {}), - }); -} - -function readSystemctlDetail(result: { stdout: string; stderr: string }): string { - // Concatenate both streams so pattern matchers (isSystemdUnitNotEnabled, - // isSystemctlMissing) can see the unit status from stdout even when - // execFileUtf8 populates stderr with the Node error message fallback. - return `${result.stderr} ${result.stdout}`.trim(); -} - -const isSystemctlMissing = isSystemctlMissingDetail; - -function isSystemdUnitNotEnabled(detail: string): boolean { - if (!detail) { - return false; - } - const normalized = normalizeLowercaseStringOrEmpty(detail); - return ( - normalized.includes("disabled") || - normalized.includes("static") || - normalized.includes("indirect") || - normalized.includes("masked") || - normalized.includes("not-found") || - normalized.includes("could not be found") || - normalized.includes("failed to get unit file state") - ); -} - -function isSystemdUnitMissingDetail(detail: string): boolean { - if (!detail) { - return false; - } - const normalized = normalizeLowercaseStringOrEmpty(detail); - return ( - (normalized.includes("unit file") && normalized.includes("does not exist")) || - normalized.includes("not-found") || - normalized.includes("could not be found") - ); -} - -function isSystemdUnitAlreadyMissingOrInactive(detail: string, unitName: string): boolean { - const escapedUnitName = escapeRegExp(normalizeLowercaseStringOrEmpty(unitName)); - return new RegExp( - `^(?:failed to (?:disable unit|stop\\s+${escapedUnitName}):\\s*)?` + - `(?:unit file\\s+${escapedUnitName}\\s+does not exist|` + - `unit\\s+${escapedUnitName}(?:\\s+is)?\\s+` + - `(?:inactive|not\\s+active|not\\s+loaded|not-found|could not be found))[.!]?$`, - "u", - ).test(normalizeLowercaseStringOrEmpty(detail)); -} - -const isSystemctlBusUnavailable = isSystemdUserBusUnavailableDetail; - -function isSystemdUserScopeUnavailable(detail: string): boolean { - return classifySystemdUnavailableDetail(detail) !== null; -} - -function isGenericSystemctlIsEnabledFailure(detail: string): boolean { - if (!detail) { - return false; - } - const normalized = normalizeLowercaseStringOrEmpty(detail); - return ( - normalized.startsWith("command failed: systemctl") && - normalized.includes(" is-enabled ") && - !normalized.includes("permission denied") && - !normalized.includes("access denied") && - !normalized.includes("no space left") && - !normalized.includes("read-only file system") && - !normalized.includes("out of memory") && - !normalized.includes("cannot allocate memory") - ); -} - -export function isNonFatalSystemdInstallProbeError(error: unknown): boolean { - const detail = error instanceof Error ? error.message : typeof error === "string" ? error : ""; - if (!detail) { - return false; - } - const normalized = normalizeLowercaseStringOrEmpty(detail); - return isSystemctlBusUnavailable(normalized) || isGenericSystemctlIsEnabledFailure(normalized); -} - -function resolveSystemctlDirectUserScopeArgs(): string[] { - return ["--user"]; -} - -function readSystemctlEnvUser(env: GatewayServiceEnv): string | null { - return env.USER?.trim() || env.LOGNAME?.trim() || null; -} - -function readSystemctlEffectiveUser(): string | null { - try { - return os.userInfo().username; - } catch { - return null; - } -} - -function readSystemctlEffectiveUid(): number | null { - if (typeof process.geteuid !== "function") { - return null; - } - try { - return process.geteuid(); - } catch { - return null; - } -} - -function resolveSystemctlProcessEnv(env: GatewayServiceEnv): NodeJS.ProcessEnv { - const processEnv = { ...process.env, ...env }; - if (processEnv.XDG_RUNTIME_DIR?.trim() && processEnv.DBUS_SESSION_BUS_ADDRESS?.trim()) { - return processEnv; - } - - const uid = readSystemctlEffectiveUid(); - if (uid === null || uid === 0) { - return processEnv; - } - - const runtimeDir = processEnv.XDG_RUNTIME_DIR?.trim() || `/run/user/${uid}`; - const busPath = path.posix.join(runtimeDir, "bus"); - if (!fsSync.existsSync(busPath)) { - return processEnv; - } - - // In non-login shells the bus socket can exist while DBUS_SESSION_BUS_ADDRESS - // is missing. Fill it so systemctl --user reaches the right user manager. - return { - ...processEnv, - XDG_RUNTIME_DIR: runtimeDir, - DBUS_SESSION_BUS_ADDRESS: processEnv.DBUS_SESSION_BUS_ADDRESS?.trim() || `unix:path=${busPath}`, - }; -} - -function isNonRootUser(user: string | null): user is string { - return Boolean(user && user !== "root"); -} - -function hasRootUserManagerEnvironment(env: GatewayServiceEnv): boolean { - const home = env.HOME?.trim(); - const runtimeDir = env.XDG_RUNTIME_DIR?.trim(); - const dbusAddress = env.DBUS_SESSION_BUS_ADDRESS?.trim(); - return ( - home === "/root" && - runtimeDir === "/run/user/0" && - Boolean(dbusAddress?.includes("/run/user/0/bus")) - ); -} - -function resolveSystemctlUserScope(env: GatewayServiceEnv): { - machineUser: string | null; - preferMachineScope: boolean; -} { - const sudoUser = env.SUDO_USER?.trim() || null; - const envUser = readSystemctlEnvUser(env); - const effectiveUid = readSystemctlEffectiveUid(); - const effectiveUser = readSystemctlEffectiveUser(); - const isEffectiveRoot = effectiveUid === null ? effectiveUser === "root" : effectiveUid === 0; - const hasRootUserManager = isEffectiveRoot && hasRootUserManagerEnvironment(env); - const isSudoToRoot = isEffectiveRoot && !hasRootUserManager && isNonRootUser(sudoUser); - const machineUser = hasRootUserManager - ? null - : isSudoToRoot - ? sudoUser - : isNonRootUser(envUser) - ? envUser - : isNonRootUser(sudoUser) - ? sudoUser - : effectiveUser || envUser || sudoUser || null; - return { - machineUser, - preferMachineScope: isSudoToRoot, - }; -} - -/** - * Resolves the account whose user manager owns the service operation. - * Keep linger diagnostics on this identity so sudo never checks root while - * systemctl targets the invoking user's manager. - */ -export function resolveSystemdUserServiceAccount(env: GatewayServiceEnv): string | null { - const { machineUser } = resolveSystemctlUserScope(env); - return machineUser ?? readSystemctlEffectiveUser() ?? readSystemctlEnvUser(env); -} - -function resolveSystemctlMachineUserScopeArgs(user: string): string[] { - const trimmedUser = user.trim(); - if (!trimmedUser) { - return []; - } - return ["--machine", `${trimmedUser}@`, "--user"]; -} - -function shouldFallbackToMachineUserScope(detail: string): boolean { - if (!isSystemdUserBusUnavailableDetail(detail)) { - return false; - } - // "Permission denied" means the bus socket exists but this process cannot connect to it. - // The machine-scope approach targets the same bus infrastructure and will also fail, - // so do not trigger the fallback in this case. - return !detail.toLowerCase().includes("permission denied"); -} - -async function execSystemctlUser( - env: GatewayServiceEnv, - args: string[], - timeoutMs?: number, -): Promise<{ stdout: string; stderr: string; code: number }> { - const { machineUser, preferMachineScope } = resolveSystemctlUserScope(env); - - // Under sudo-to-root, prefer the invoking non-root user's scope directly via machine scope. - if (preferMachineScope && machineUser) { - const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser); - if (machineScopeArgs.length > 0) { - // Do not fall through to bare --user: under sudo that can target root's user manager. - return await execSystemctl([...machineScopeArgs, ...args], env, timeoutMs); - } - } - - const directResult = await execSystemctl( - [...resolveSystemctlDirectUserScopeArgs(), ...args], - env, - timeoutMs, - ); - if (directResult.code === 0) { - return directResult; - } - - const detail = `${directResult.stderr} ${directResult.stdout}`.trim(); - if (!machineUser || !shouldFallbackToMachineUserScope(detail)) { - return directResult; - } - - const machineScopeArgs = resolveSystemctlMachineUserScopeArgs(machineUser); - if (machineScopeArgs.length === 0) { - return directResult; - } - return await execSystemctl([...machineScopeArgs, ...args], env, timeoutMs); -} - -async function disableSystemdUserUnitForRemoval( - env: GatewayServiceEnv, - unitName: string, -): Promise { - const result = await execSystemctlUser(env, ["disable", "--now", unitName]); - if (result.code === 0) { - return; - } - const detail = readSystemctlDetail(result); - if (isSystemdUnitAlreadyMissingOrInactive(detail, unitName)) { - return; - } - throw new Error(`systemctl disable failed: ${detail || "unknown error"}`); -} - -async function reloadSystemdUserManager(env: GatewayServiceEnv): Promise { - const result = await execSystemctlUser(env, ["daemon-reload"]); - if (result.code !== 0) { - throw new Error( - `systemctl daemon-reload failed: ${readSystemctlDetail(result) || "unknown error"}`, - ); - } -} - -export async function isSystemdUserServiceAvailable( - env: GatewayServiceEnv = process.env as GatewayServiceEnv, -): Promise { - const res = await execSystemctlUser(env, ["status"]); - if (res.code === 0) { - return true; - } - const detail = `${res.stderr} ${res.stdout}`.trim(); - if (!detail) { - return false; - } - return !isSystemdUserScopeUnavailable(detail); -} - -export async function isSystemdUnitActive( - env: GatewayServiceEnv, - unitName: string, - scope: SystemdUnitScope = "user", -): Promise { - const normalizedUnit = unitName.trim(); - if (!normalizedUnit) { - return false; - } - const args = ["is-active", "--quiet", normalizedUnit]; - const res = scope === "system" ? await execSystemctl(args) : await execSystemctlUser(env, args); - return res.code === 0; -} - -async function assertSystemdAvailable( - env: GatewayServiceEnv = process.env as GatewayServiceEnv, - timeoutMs?: number, -) { - const res = await execSystemctlUser(env, ["status"], timeoutMs); - if (res.code === 0) { - return; - } - const detail = readSystemctlDetail(res); - if (isSystemctlMissing(detail)) { - throw new Error("systemctl not available; systemd user services are required on Linux."); - } - if (!detail) { - throw new Error("systemctl --user unavailable: unknown error"); - } - if (!isSystemdUserScopeUnavailable(detail)) { - return; - } - throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim()); -} - -async function writeSystemdUnit({ - env, - programArguments, - workingDirectory, - environment, - environmentValueSources, - description, -}: Omit): Promise<{ unitPath: string; backedUp: boolean }> { - await assertSystemdAvailable(env); - await assertNoSystemGatewayOwnership(env); - - const unitPath = resolveSystemdUnitPath(env); - const priorManagedKeys = readManagedServiceEnvKeysFromEnvironment( - (await readSystemdServiceExecStart(env))?.environment, - ); - await fs.mkdir(path.dirname(unitPath), { recursive: true }); - await assertSystemdManagedPathIsNotSymlink(unitPath); - const fileManagedKeys = collectSystemdFileManagedKeys({ - environmentValueSources, - }); - - // Preserve user customizations: back up existing unit file before overwriting. - let backedUp = false; - try { - const backupPath = `${unitPath}.bak`; - const existingUnit = await fs.readFile(unitPath, "utf8"); - const existingStat = await fs.stat(unitPath); - const backupMode = existingStat.mode & 0o777 || 0o600; - const backupUnit = sanitizeSystemdUnitBackupContent({ - content: existingUnit, - fileManagedKeys, - }); - await fs.writeFile(backupPath, backupUnit, { encoding: "utf8", mode: backupMode }); - await fs.chmod(backupPath, backupMode); - backedUp = true; - } catch { - // File does not exist yet — nothing to back up. - } - - const serviceDescription = resolveGatewayServiceDescription({ env, description }); - const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); - const { entries: stateDirDotEnvEntries, skippedShellReferenceKeys } = - readStateDirDotEnvFromStateDir(stateDir); - const stateDirDotEnvVars = Object.fromEntries( - Object.entries(stateDirDotEnvEntries).filter(([key, value]) => { - const inlineValue = environment?.[key]; - if (typeof inlineValue !== "string") { - return true; - } - return inlineValue.trim() === value.trim(); - }), - ); - const inlineManagedKeys = collectSystemdInlineManagedKeys({ - environment, - environmentValueSources, - }); - const environmentFilePath = resolveSystemdEnvironmentFilePath({ - stateDir, - environment, - }); - const environmentFileSnapshot = isNodeSystemdEnvironment(env) - ? undefined - : await readSystemdFileSnapshot(environmentFilePath); - try { - const environmentFileResult = await writeSystemdGatewayEnvironmentFile({ - stateDir, - stateDirDotEnvKeys: Object.keys(stateDirDotEnvVars), - priorManagedKeys, - inlineManagedKeys, - fileManagedKeys, - skippedManagedKeys: skippedShellReferenceKeys, - fileBackedEnvironment: collectSystemdFileBackedEnvironment({ - environment, - fileManagedKeys, - }), - environment, - }); - const environmentSansDotEnvEntries = Object.fromEntries( - Object.entries(environment ?? {}).filter(([key, value]) => { - if (typeof value !== "string") { - return false; - } - const source = readEnvironmentValueSource(environmentValueSources, key); - if (hasEnvironmentFileSource(source) && isUnresolvedShellReference(value)) { - return false; - } - const normalizedKey = normalizeServiceEnvKey(key); - if ( - normalizedKey && - environmentFileResult.environmentKeys.has(normalizedKey) && - !inlineManagedKeys.has(normalizedKey) - ) { - return false; - } - const stateDirValue = stateDirDotEnvVars[key]; - if (typeof stateDirValue !== "string") { - return true; - } - return value.trim() !== stateDirValue.trim(); - }), - ); - const unit = buildSystemdUnit({ - description: serviceDescription, - programArguments, - workingDirectory, - environment: environmentSansDotEnvEntries, - environmentFiles: environmentFileResult.environmentFiles, - }); - await publishSystemdUnit({ env, unitPath, contents: unit }); - } catch (error) { - if (environmentFileSnapshot !== undefined) { - try { - await restoreSystemdFileSnapshot(environmentFilePath, environmentFileSnapshot); - } catch (rollbackError) { - const failureDetail = error instanceof Error ? error.message : String(error); - throw new Error( - `${failureDetail}\nThe previous systemd environment file at ${environmentFilePath} could not be restored.`, - { cause: rollbackError }, - ); - } - } - throw error; - } - return { unitPath, backedUp }; -} - -type SystemdFileSnapshot = { contents: Buffer; mode: number } | null; - -async function assertSystemdManagedPathIsNotSymlink(filePath: string): Promise { - try { - const stat = await fs.lstat(filePath); - if (stat.isSymbolicLink()) { - throw new Error(`Refusing to rewrite symlinked managed systemd file: ${filePath}`); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return; - } - throw error; - } -} - -async function readSystemdFileSnapshot(filePath: string): Promise { - try { - const stat = await fs.lstat(filePath); - if (stat.isSymbolicLink()) { - throw new Error(`Refusing to rewrite symlinked managed systemd file: ${filePath}`); - } - const contents = await fs.readFile(filePath); - return { contents, mode: stat.mode & 0o777 }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw error; - } -} - -async function restoreSystemdFileSnapshot( - filePath: string, - snapshot: SystemdFileSnapshot, -): Promise { - if (snapshot === null) { - await fs.rm(filePath, { force: true }); - return; - } - await fs.mkdir(path.dirname(filePath), { recursive: true }); - const rollbackPath = `${filePath}.openclaw-${randomUUID()}.rollback`; - try { - await fs.writeFile(rollbackPath, snapshot.contents, { - flag: "wx", - mode: snapshot.mode, - }); - await fs.rename(rollbackPath, filePath); - } finally { - await fs.unlink(rollbackPath).catch(() => undefined); - } -} - -async function publishSystemdUnit(params: { - env: GatewayServiceEnv; - unitPath: string; - contents: string; -}): Promise { - const previous = await readSystemdFileSnapshot(params.unitPath); - const temporaryPath = `${params.unitPath}.openclaw-${randomUUID()}.tmp`; - await fs.writeFile(temporaryPath, params.contents, { - encoding: "utf8", - flag: "wx", - mode: previous?.mode ?? 0o644, - }); - try { - // systemd ignores the temporary suffix, so this is the last ownership check - // before the canonical user unit becomes discoverable. - await assertNoSystemGatewayOwnership(params.env); - await fs.rename(temporaryPath, params.unitPath); - try { - await assertNoSystemGatewayOwnership(params.env); - } catch (ownershipError) { - try { - await restoreSystemdFileSnapshot(params.unitPath, previous); - } catch (rollbackError) { - const ownershipDetail = - ownershipError instanceof Error ? ownershipError.message : String(ownershipError); - throw new Error( - `${ownershipDetail}\nThe previous user systemd unit at ${params.unitPath} could not be restored.`, - { cause: rollbackError }, - ); - } - throw ownershipError; - } - } finally { - await fs.unlink(temporaryPath).catch(() => undefined); - } -} - -async function writeSystemdGatewayEnvironmentFile(params: { - stateDir: string; - /** Keys loaded by the Gateway directly from the state-dir .env. They must be removed from - * generated files so a supervisor restart cannot shadow a later .env edit. */ - stateDirDotEnvKeys?: Iterable; - /** Keys owned by the previously installed service. Preserve the prior ownership record so - * deleting a managed dotenv key cannot reclassify its stale file value as operator-owned. */ - priorManagedKeys?: Iterable; - /** OpenClaw-managed keys that must not be preserved from an old env file; stale file values - * would override fresh inline Environment= entries because EnvironmentFile takes precedence. */ - inlineManagedKeys?: ReadonlySet; - /** File-managed keys that should be written from current environment values or removed when absent. */ - fileManagedKeys?: ReadonlySet; - /** State-dir .env keys OpenClaw previously managed but is now skipping (unresolved shell - * references). A prior re-stage may have written a stale literal value for them; drop it so - * the regenerated env file no longer carries the obsolete reference. */ - skippedManagedKeys?: Iterable; - fileBackedEnvironment?: Record; - environment?: GatewayServiceEnv; -}): Promise<{ environmentFiles: string[]; environmentKeys: Set }> { - const incoming = { ...params.fileBackedEnvironment }; - for (const [key, value] of Object.entries(incoming)) { - if (/[\r\n]/.test(value)) { - throw new Error( - `state-dir .env contains a multiline value for ${key}; systemd EnvironmentFile values must be single-line`, - ); - } - } - const envFilePath = resolveSystemdEnvironmentFilePath({ - stateDir: params.stateDir, - environment: params.environment, - }); - - // Read existing env files first so we can preserve operator-added secrets - // (e.g. provider API keys) across upgrades and re-stages. Node units used - // to share gateway.systemd.env, so migrate those entries into node.systemd.env. - // OpenClaw-managed keys (identified by inlineManagedKeys) are excluded: a stale - // file copy would override the fresh inline Environment= value because systemd's - // EnvironmentFile takes precedence over inline Environment= directives. - const existing: Record = {}; - const literalShellReferenceKeys = new Set(); - const legacyNodeEnvFilePath = resolveLegacyNodeSystemdEnvironmentFilePath({ - stateDir: params.stateDir, - environment: params.environment, - }); - for (const sourceEnvFilePath of [legacyNodeEnvFilePath, envFilePath]) { - if (!sourceEnvFilePath) { - continue; - } - try { - const fromFile = await readSystemdEnvironmentFile(sourceEnvFilePath); - for (const [key, value] of Object.entries(fromFile.environment)) { - existing[key] = value; - if (fromFile.literalShellReferenceKeys.has(key)) { - literalShellReferenceKeys.add(key); - } else { - literalShellReferenceKeys.delete(key); - } - } - } catch { - // File does not exist yet — nothing to preserve. - } - } - const managedKeysToDrop = normalizeServiceEnvKeys([ - ...(params.inlineManagedKeys ?? []), - ...(params.fileManagedKeys ?? []), - ...(params.priorManagedKeys ?? []), - ...(params.stateDirDotEnvKeys ?? []), - ...(params.skippedManagedKeys ?? []), - ]); - const operatorOnly = Object.fromEntries( - Object.entries(existing).filter(([key, value]) => { - const normalized = normalizeServiceEnvKey(key); - if (normalized && managedKeysToDrop.has(normalized)) { - return false; - } - // Quoting or escaping `$VAR` records operator intent; bare references can - // still be stale values copied from the state-dir dotenv file. - return literalShellReferenceKeys.has(key) || !isUnresolvedShellReference(value); - }), - ); - const merged = { ...operatorOnly, ...incoming }; - const environmentKeys = normalizeServiceEnvKeys(Object.keys(merged)); - - // If the merged result is empty there is nothing to write and no file needed. - if (Object.keys(merged).length === 0) { - await fs.rm(envFilePath, { force: true }).catch(() => undefined); - return { environmentFiles: [], environmentKeys }; - } - - const content = serializeSystemdEnvironmentFile(merged); - await fs.mkdir(path.dirname(envFilePath), { recursive: true }); - await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 }); - await fs.chmod(envFilePath, 0o600); - return { environmentFiles: [envFilePath], environmentKeys }; -} - -async function removeNodeSystemdManagedEnvironmentKeys(env: GatewayServiceEnv): Promise { - if (!isNodeSystemdEnvironment(env)) { - return; - } - const stateDir = resolveStateDir(env as NodeJS.ProcessEnv); - const envFilePath = resolveSystemdEnvironmentFilePath({ - stateDir, - environment: env, - }); - let existingFile: Awaited>; - try { - existingFile = await readSystemdEnvironmentFile(envFilePath); - } catch { - return; - } - const managedKeys = new Set(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]); - const remaining = Object.fromEntries( - Object.entries(existingFile.environment).filter(([key, value]) => { - const normalized = normalizeServiceEnvKey(key); - if (normalized && managedKeys.has(normalized)) { - return false; - } - return existingFile.literalShellReferenceKeys.has(key) || !isUnresolvedShellReference(value); - }), - ); - if (Object.keys(remaining).length === 0) { - await fs.rm(envFilePath, { force: true }); - return; - } - const content = serializeSystemdEnvironmentFile(remaining); - await fs.writeFile(envFilePath, `${content}\n`, { encoding: "utf8", mode: 0o600 }); - await fs.chmod(envFilePath, 0o600); -} - -export async function stageSystemdService({ - stdout, - ...args -}: GatewayServiceInstallArgs): Promise<{ unitPath: string }> { - const { unitPath, backedUp } = await writeSystemdUnit(args); - writeFormattedLines( - stdout, - [ - { - label: "Staged systemd service", - value: unitPath, - }, - ...(backedUp - ? [ - { - label: "Previous unit backed up to", - value: `${unitPath}.bak`, - }, - ] - : []), - ], - { leadingBlankLine: true }, - ); - return { unitPath }; -} - -async function activateSystemdService(params: { env: GatewayServiceEnv }) { - const serviceName = resolveSystemdServiceName(params.env); - const unitName = `${serviceName}.service`; - // A system unit may appear after publication. Refuse before the user manager - // can load a second supervisor for the same gateway name. - await assertNoSystemGatewayOwnership(params.env); - const reloadSystemd = async () => await execSystemctlUser(params.env, ["daemon-reload"]); - const throwActivationFailure = ( - action: "daemon-reload" | "enable" | "restart", - result: { stdout: string; stderr: string }, - ): never => { - const detail = readSystemctlDetail(result); - if (isSystemdUserScopeUnavailable(detail)) { - throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim()); - } - throw new Error(`systemctl ${action} failed: ${detail || "unknown error"}`.trim()); - }; - const reload = await reloadSystemd(); - if (reload.code !== 0) { - throwActivationFailure("daemon-reload", reload); - } - - const runAfterReloadRetry = async (action: "enable" | "restart") => { - const result = await execSystemctlUser(params.env, [action, unitName]); - if (result.code === 0 || !isSystemdUnitMissingDetail(readSystemctlDetail(result))) { - return result; - } - const retryReload = await reloadSystemd(); - if (retryReload.code !== 0) { - throwActivationFailure("daemon-reload", retryReload); - } - return await execSystemctlUser(params.env, [action, unitName]); - }; - - const enable = await runAfterReloadRetry("enable"); - if (enable.code !== 0) { - throwActivationFailure("enable", enable); - } - - const restart = await runAfterReloadRetry("restart"); - if (restart.code !== 0) { - throwActivationFailure("restart", restart); - } -} - -export async function installSystemdService( - args: GatewayServiceInstallArgs, -): Promise<{ unitPath: string }> { - const { unitPath, backedUp } = await writeSystemdUnit(args); - await activateSystemdService({ env: args.env }); - writeFormattedLines( - args.stdout, - [ - { - label: "Installed systemd service", - value: unitPath, - }, - ...(backedUp - ? [ - { - label: "Previous unit backed up to", - value: `${unitPath}.bak`, - }, - ] - : []), - ], - { leadingBlankLine: true }, - ); - return { unitPath }; -} - -export async function uninstallSystemdService({ - env, - stdout, -}: GatewayServiceManageArgs): Promise { - await assertSystemdAvailable(env); - const serviceName = resolveSystemdServiceName(env); - const unitName = `${serviceName}.service`; - await disableSystemdUserUnitForRemoval(env, unitName); - - const unitPath = resolveSystemdUnitPath(env); - let removed = false; - try { - await fs.unlink(unitPath); - removed = true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - // Unit file was already absent; still clean generated node env state below. - } - await removeNodeSystemdManagedEnvironmentKeys(env); - if (removed) { - stdout.write(`${formatLine("Removed systemd service", unitPath)}\n`); - } else { - stdout.write(`Systemd service not found at ${unitPath}\n`); - } -} - -function isRunningAsRoot(): boolean { - if (typeof process.geteuid === "function") { - try { - return process.geteuid() === 0; - } catch { - return false; - } - } - return false; -} - -async function runSystemdServiceAction(params: { - stdout: NodeJS.WritableStream; - env?: GatewayServiceEnv; - action: "start" | "stop" | "restart"; - label: string; - onMutation?: () => void; -}) { - const env = params.env ?? process.env; - const installed = await findInstalledSystemdGatewayScope(env); - const unitName = installed?.unitName ?? `${resolveSystemdServiceName(env)}.service`; - let runSystemctl: (args: string[]) => ReturnType; - if (installed?.scope === "system") { - if (!isRunningAsRoot()) { - throw new Error( - `${unitName} is a system-scope unit (${installed.unitPath}); run \`sudo systemctl ${params.action} ${unitName}\` to ${params.action} it`, - ); - } - runSystemctl = (args) => execSystemctl(args, env); - } else { - await assertSystemdAvailable(env); - if (params.action !== "stop") { - await assertNoSystemGatewayOwnership(env); - } - runSystemctl = (args) => execSystemctlUser(env, args); - } - if (params.action !== "stop") { - // Clear crash-loop start-limit latches only after scope ownership is proven; - // otherwise resetting a conflicting manager could mutate the wrong service. - await runSystemctl(["reset-failed", unitName]); - } - const res = await runSystemctl([params.action, unitName]); - if (res.code !== 0) { - throw new Error(`systemctl ${params.action} failed: ${res.stderr || res.stdout}`.trim()); - } - params.onMutation?.(); - params.stdout.write(`${formatLine(params.label, unitName)}\n`); -} - -export async function startSystemdService({ - stdout, - env, - onMutation, -}: GatewayServiceControlArgs): Promise { - const reportMutation = createGatewayLifecycleMutationReporter(onMutation); - await runSystemdServiceAction({ - stdout, - env, - action: "start", - label: "Started systemd service", - onMutation: () => reportMutation("systemctl-start"), - }); -} - -export async function stopSystemdService({ - stdout, - env, - onMutation, -}: GatewayServiceControlArgs): Promise { - const reportMutation = createGatewayLifecycleMutationReporter(onMutation); - await runSystemdServiceAction({ - stdout, - env, - action: "stop", - label: "Stopped systemd service", - onMutation: () => reportMutation("systemctl-stop"), - }); -} - -export async function restartSystemdService({ - stdout, - env, - onMutation, -}: GatewayServiceControlArgs): Promise { - const reportMutation = createGatewayLifecycleMutationReporter(onMutation); - await runSystemdServiceAction({ - stdout, - env, - action: "restart", - label: "Restarted systemd service", - onMutation: () => reportMutation("systemctl-restart"), - }); - return { outcome: "completed" }; -} - -export async function isSystemdServiceEnabled(args: GatewayServiceEnvArgs): Promise { - const env = args.env ?? process.env; - const installed = await findInstalledSystemdGatewayScope(env); - if (!installed) { - return false; - } - const res = - installed.scope === "system" - ? await execSystemctl(["is-enabled", installed.unitName], env, args.timeoutMs) - : await execSystemctlUser(env, ["is-enabled", installed.unitName], args.timeoutMs); - if (res.code === 0) { - return true; - } - const detail = readSystemctlDetail(res); - if (isSystemctlMissing(detail) || isSystemdUnitNotEnabled(detail)) { - return false; - } - throw new Error(`systemctl is-enabled unavailable: ${detail || "unknown error"}`.trim()); -} - -export async function readSystemdServiceRuntime( - env: GatewayServiceEnv = process.env as GatewayServiceEnv, - opts?: GatewayServiceReadOptions, -): Promise { - const timeoutMs = opts?.timeoutMs; - const installed = await findInstalledSystemdGatewayScope(env).catch(() => null); - if (installed?.scope !== "system") { - try { - await assertSystemdAvailable(env, timeoutMs); - } catch (err) { - return { - status: "unknown", - detail: formatErrorMessage(err), - }; - } - } - const unitName = installed?.unitName ?? `${resolveSystemdServiceName(env)}.service`; - const showArgs = [ - "show", - unitName, - "--no-page", - "--property", - "Id,ActiveState,SubState,Result,NRestarts,StartLimitBurst,MainPID,ExecMainStatus,ExecMainCode,KillMode,TasksCurrent,MemoryCurrent", - ]; - const res = - installed?.scope === "system" - ? await execSystemctl(showArgs, env, timeoutMs) - : await execSystemctlUser(env, showArgs, timeoutMs); - if (res.code !== 0) { - const detail = (res.stderr || res.stdout).trim(); - const missing = normalizeLowercaseStringOrEmpty(detail).includes("not found"); - return { - status: missing ? "stopped" : "unknown", - detail: detail || undefined, - missingUnit: missing, - }; - } - const parsed = parseSystemdShow(res.stdout || ""); - const activeState = normalizeLowercaseStringOrEmpty(parsed.activeState); - const status = activeState === "active" ? "running" : activeState ? "stopped" : "unknown"; - return { - status, - state: parsed.activeState, - subState: parsed.subState, - pid: parsed.mainPid, - lastExitStatus: parsed.execMainStatus, - lastExitReason: parsed.execMainCode, - systemd: { - unit: parsed.unit ?? unitName, - killMode: parsed.killMode, - tasksCurrent: parsed.tasksCurrent, - memoryCurrent: parsed.memoryCurrent, - result: parsed.result, - nRestarts: parsed.nRestarts, - startLimitBurst: parsed.startLimitBurst, - }, - }; -} -type LegacySystemdUnit = { - name: string; - unitPath: string; - enabled: boolean; - exists: boolean; -}; - -async function isSystemctlAvailable(env: GatewayServiceEnv): Promise { - const res = await execSystemctlUser(env, ["status"]); - if (res.code === 0) { - return true; - } - return !isSystemctlMissing(readSystemctlDetail(res)); -} - -async function findLegacySystemdUnits(env: GatewayServiceEnv): Promise { - const results: LegacySystemdUnit[] = []; - const systemctlAvailable = await isSystemctlAvailable(env); - for (const name of LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES) { - const unitPath = resolveSystemdUnitPathForName(env, name); - let exists = false; - try { - await fs.access(unitPath); - exists = true; - } catch { - // ignore - } - let enabled = false; - if (systemctlAvailable) { - const res = await execSystemctlUser(env, ["is-enabled", `${name}.service`]); - enabled = res.code === 0; - } - if (exists || enabled) { - results.push({ name, unitPath, enabled, exists }); - } - } - return results; -} - -export async function uninstallLegacySystemdUnits({ - env, - stdout, -}: GatewayServiceManageArgs): Promise { - const units = await findLegacySystemdUnits(env); - if (units.length === 0) { - return units; - } - - const systemctlAvailable = await isSystemctlAvailable(env); - let removedAny = false; - for (const unit of units) { - if (systemctlAvailable) { - await disableSystemdUserUnitForRemoval(env, `${unit.name}.service`); - } else { - stdout.write(`systemctl unavailable; removed legacy unit file only: ${unit.name}.service\n`); - } - - try { - await fs.unlink(unit.unitPath); - removedAny = true; - stdout.write(`${formatLine("Removed legacy systemd service", unit.unitPath)}\n`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - stdout.write(`Legacy systemd unit not found at ${unit.unitPath}\n`); - } - } - if (systemctlAvailable && removedAny) { - await reloadSystemdUserManager(env); - } - - return units; -} - -type UninstallUserSystemdGatewayUnitResult = { - unitName: string; - unitPath: string; - removed: boolean; - /** - * False when systemctl could not disable/stop the unit. Deleting the unit - * file alone does not evict an already-loaded unit, so callers must not - * claim the conflict is resolved on a file-only removal. - */ - disabled: boolean; -}; - -/** - * Removes the canonical *user-scope* gateway unit, leaving any system-scope - * unit untouched. Used by doctor to resolve a `dueling` installation by - * dropping the redundant user-scope leftover (issue #79375). Removing a unit - * under `$HOME` needs no root, unlike the system-scope unit. - */ -export async function uninstallUserSystemdGatewayUnit({ - env, - stdout, -}: GatewayServiceManageArgs): Promise { - const unitName = `${resolveSystemdServiceName(env)}.service`; - const unitPath = resolveSystemdUnitPath(env); - let disabled = false; - if (await isSystemctlAvailable(env)) { - await disableSystemdUserUnitForRemoval(env, unitName); - disabled = true; - } else { - stdout.write( - `systemctl unavailable; removing unit file only: ${unitName}. A loaded unit keeps running until systemd reloads.\n`, - ); - } - let removed = false; - try { - await fs.unlink(unitPath); - removed = true; - stdout.write(`${formatLine("Removed user-scope systemd service", unitPath)}\n`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - stdout.write(`User-scope systemd unit not found at ${unitPath}\n`); - } - // The manager keeps a deleted unit's definition loaded until it reloads, so - // without this the unit stays startable while the detector reports it gone. - if (removed && disabled) { - await reloadSystemdUserManager(env); - } - return { unitName, unitPath, removed, disabled }; -} -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ +export { + isNonFatalSystemdInstallProbeError, + isSystemdUnitActive, + isSystemdUserServiceAvailable, + resolveSystemdUserServiceAccount, + type SystemdUnitScope, +} from "./systemd-exec.js"; +export { + installSystemdService, + stageSystemdService, + uninstallSystemdService, +} from "./systemd-install.js"; +export { + restartSystemdService, + startSystemdService, + stopSystemdService, + uninstallLegacySystemdUnits, + uninstallUserSystemdGatewayUnit, +} from "./systemd-lifecycle.js"; +export { enableSystemdUserLinger, readSystemdUserLingerStatus } from "./systemd-linger.js"; +export { isSystemdServiceEnabled, readSystemdServiceRuntime } from "./systemd-runtime.js"; +export { + readSystemdServiceExecStart, + resolveSystemdUserUnitPath, +} from "./systemd-service-files.js"; +export { + findInstalledSystemdGatewayScope, + findSystemdGatewayInstallation, + formatDuelingScopesWarning, + isSystemUnitActiveAndEnabled, +} from "./systemd-scope.js";