Skip to content

Daemon: Systemdienst

源码版本v2026.6.11

Verantwortung

resolveGatewayService (resolveGatewayService:370-375) ist der einheitliche Eintritt, über den OpenClaw das Gateway als „systemlevel residenten Dienst (daemon)" installiert: auf macOS ein LaunchAgent (von launchd verwaltet), auf Linux eine systemd-user-unit (von systemd --user verwaltet), auf Windows eine Scheduled Task (über schtasks registrierte Logon-Trigger-Aufgabe). Die drei Plattformen haben je einen Befehl, der in dasselbe GatewayService-Interface (GatewayService:75-87) gekapselt wird: stage/install/uninstall/stop/restart/isLoaded/readCommand/readRuntime; die CLI-Schicht muss Plattformunterschiede nicht kennen.

Es behandelt nicht „das Gateway betreiben" — das Gateway bleibt node openclaw.mjs gateway. Die Daemon-Schicht macht diesen Befehl in die Systemdienstmanifeste (plist/unit/XML) schreiben und den Systemdienstmanager nach Regel hochfahren, überwachen und automatisch neustarten lassen. Daher steckt im Daemon-Modul viel Code in plist-/unit-/XML-Templaterendering und launchctl-/systemctl-/schtasks-CLI-Aufrufen; der echte Gateway-Prozesslebenszyklus wird vom Systemdienstmanager kontrolliert.

Designmotivation

Warum nicht einfach nohup openclaw gateway &?

  1. Boot-Autostart + Crash-Neustart: KeepAlive=true bei launchd, Restart=always bei systemd, LogonTrigger bei schtasks sind systemlevel Mechanismen, die beim Reboot oder unerwartetem Exit des OpenClaw-Prozesses automatisch hochfahren. nohup „ignoriert nur SIGHUP"; bei Reboot ist es wertlos.
  2. Einheitliche Konfiguration + Profile-Isolation: OPENCLAW_PROFILE erlaubt derselben Maschine, mehrere Gateway-Sets zu betreiben (z. B. dev/prod getrennt); resolveGatewayLaunchAgentLabel (resolveGatewayLaunchAgentLabel:33-39) backt das Profile in das service-label — ai.openclaw.gateway (default) oder ai.openclaw.<profile>; systemd und schtasks tragen entsprechend ein Suffix. Jedes Profile hat eigene plist/unit/XML, ohne sich gegenseitig zu stören.
  3. future-config-guard: withFutureConfigGuard (withFutureConfigGuard:336-362) ruft vor jedem Schreibvorgang (stage/install/uninstall/stop/restart) assertFutureConfigActionAllowed auf; wird erkannt, dass die aktuelle OpenClaw-Version neuer als die im Dienst eingetragene ist (oder vice versa, die Konfiguration wurde von einer zukünftigen Version erzeugt), wird der Schreib abgewiesen — Sicherheitsnetz gegen „Downgrade-Start beschädigt neue Dienstdateien".
  4. repair-Detektion: collectGatewayServiceStartRepairIssues (collectGatewayServiceStartRepairIssues:138-172) prüft vor restart die OPENCLAW_SERVICE_VERSION des geladenen Dienstes, ob der Programmpfad auf ein temporäres Verzeichnis zeigt (isTemporaryProgramPath) und ob die Programmdatei existiert (isMissingProgramPath). Versionsdrift oder Pfadverlust erfordert Neuinstallation statt vorgetäuschtem Restart.

Schlüsseldateien

Datenfluss

resolveGatewayService (resolveGatewayService:370) wählt den Adapter über die Plattform-Registry:

typescript
const GATEWAY_SERVICE_REGISTRY: Record<SupportedGatewayServicePlatform, GatewayService> = {
  darwin: {
    label: "LaunchAgent",
    loadedText: "loaded",
    notLoadedText: "not loaded",
    stage: ignoreServiceWriteResult(stageLaunchAgent),
    install: ignoreServiceWriteResult(installLaunchAgent),
    uninstall: uninstallLaunchAgent,
    stop: stopLaunchAgent,
    restart: restartLaunchAgent,
    isLoaded: isLaunchAgentLoaded,
    readCommand: readLaunchAgentProgramArguments,
    readRuntime: readLaunchAgentRuntime,
  },
  linux:  { label: "systemd user", /* ...systemd Implementierung */ },
  win32:  { label: "Scheduled Task", /* ...schtasks Implementierung */ },
};

export function resolveGatewayService(): GatewayService {
  if (isSupportedGatewayServicePlatform(process.platform)) {
    return withFutureConfigGuard(GATEWAY_SERVICE_REGISTRY[process.platform]);
  }
  return createUnsupportedGatewayService();
}

withFutureConfigGuard wickelt alle Schreibmethoden in assertFutureConfigActionAllowed ein — ein einheitlicher „erst prüfen, dann schreiben"-Interceptor, der verhindert, eine alte OpenClaw-Version die von einer neuen Version erzeugten Dienstdateien überschreiben zu lassen.

Die plist-Vorlage für launchd (buildLaunchAgentPlist:267) ist die komplexeste:

typescript
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>${plistEscape(label)}</string>
    ${commentXml}
    <key>RunAtLoad</key><true/>
    <key>KeepAlive</key><true/>
    <key>ExitTimeOut</key><integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>
    <key>ProcessType</key><string>${LAUNCH_AGENT_PROCESS_TYPE}</string>
    <key>ThrottleInterval</key><integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>
    <key>Umask</key><integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>
    <key>ProgramArguments</key>
    <array>${argsXml}
    </array>
    ${workingDirXml}
    <key>StandardInPath</key><string>/dev/null</string>
    <key>StandardOutPath</key><string>${plistEscape(stdoutPath)}</string>
    <key>StandardErrorPath</key><string>${plistEscape(stderrPath)}</string>${envXml}
  </dict>
</plist>
`;

Vier Schlüsselfelder: RunAtLoad=true (beim Laden sofort starten), KeepAlive=true (jeder Exit startet neu), ExitTimeOut=20 (nach SIGTERM 20s warten, dann SIGKILL), ThrottleInterval=10 (launchds Crash-Loop-Schutz: mindestens 10s zwischen zwei Starts). Umask=0o077 macht alle vom Gateway erzeugten Dateien default owner-only — schützt vor group/other-Lesezugriff auf Secrets. envXml backt OPENCLAW_STATE_DIR/OPENCLAW_PROFILE u. a. in die plist ein, sodass keine zusätzliche env-Datei nötig ist.

Die systemd-unit (buildSystemdUnit:68) nutzt die dreiteilige Standardstruktur:

typescript
return [
  "[Unit]",
  descriptionLine,
  "After=network-online.target",
  "Wants=network-online.target",
  "StartLimitBurst=5",
  "StartLimitIntervalSec=60",
  "",
  "[Service]",
  `ExecStart=${execStart}`,
  "Restart=always",
  "RestartSec=5",
  "RestartPreventExitStatus=78",  // EX_CONFIG: bei Konfigurationsfehlern nicht neustarten, Endlosloop vermeiden
  "TimeoutStopSec=30",
  "TimeoutStartSec=30",
  "SuccessExitStatus=0 143",       // 143 = SIGTERM als normaler Exit
  "OOMPolicy=continue",            // Kind-Prozess OOM-töten stoppt den Hauptdienst nicht
  "KillMode=control-group",        // Beim Neustart alle Kind-Prozesse mit KILL, keine ACP-worker-Ophan
  workingDirLine,
  ...environmentFileLines,
  ...envLines,
  "",
  "[Install]",
  "WantedBy=default.target",
  "",
].filter((line) => line !== null).join("\n");

OOMPolicy=continue und KillMode=control-group sind zwei Nicht-Defaults — ersteres stellt sicher, dass ein OOM im Kind-Prozess das Gateway nicht mitreißt; letzteres stellt sicher, dass beim Neustart des Gateways keine ACP-/agent-worker-Ophans bleiben. StartLimitBurst=5/StartLimitIntervalSec=60 ist systemds Crash-Loop-Schutz: nach 5 Crashes innerhalb 60s wird der Neustart gestoppt (menschliches Eingreifen nötig).

Die Windows-schtasks-XML (buildScheduledTaskXml:152) nutzt LogonTrigger (bei Nutzerlogin), LeastPrivilege (ohne Admin), DisallowStartIfOnBatteries=false (auch im Akkumodus laufen). Versteckte Falle: schtasks /XML verlangt UTF-16 LE BOM (writeTaskXmlTempFile:194); Nodes utf16le-Codierung fügt automatisch kein BOM hinzu, der Code baut manuell Buffer.from([0xff, 0xfe]) + body zusammen.

Grenzen und Fehler

  • Nicht unterstützte Plattform-Delegation: createUnsupportedGatewayService (createUnsupportedGatewayService:275-292) lässt alle Schreibmethoden throw createUnsupportedGatewayServiceError() werfen; readCommand liefert null, readRuntime liefert { status: "unknown", detail: ... }. FreeBSD/OpenBSD u. a., deren process.platform nicht in der Tabelle steht, bekommen diesen delegierten Service.
  • Temporärpfad-Detektion: TEMP_PROGRAM_ROOTS = [os.tmpdir(), "/tmp", "/private/tmp", "/var/tmp"] (TEMP_PROGRAM_ROOTS:115). Zeigt der Programmpfad des geladenen Dienstes auf eines dieser Verzeichnisse, markiert collectGatewayServiceStartRepairIssues ein issue — temporäre Verzeichnisse können vom System aufgeräumt werden, und der Dienst findet plötzlich die Binärdatei nicht. In diesem Zustand ist Neuinstallation statt einfachem restart nötig.
  • Versionsdrift-Schutz: serviceVersion !== VERSION gilt ebenfalls als issue (OPENCLAW_SERVICE_VERSION Prüfung:146-150). OPENCLAW_SERVICE_VERSION wird bei der Dienst-Installation als Umgebungsvariable in plist/unit/XML geschrieben und zur Laufzeit zurückgelesen und mit der aktuellen OpenClaw-Version verglichen — inkonsistent bedeutet, der Dienst wurde von einer alten Version installiert und referenziert möglicherweise gelöschte Binärpfade oder altes Arbeitsverzeichnis-Layout.
  • launchd kein kickstart -k: installLaunchAgent (avoid kickstart -k:1018-1020) kommentiert explizit, dass bootstrap bereits RunAtLoad macht und nicht zusätzlich kickstart -k gerufen werden soll — auf langsamen macOS-VMs schickt kickstart -k dem gerade gestarteten Gateway SIGTERM und drückt den echten Listener-Start über die Setup-Health-Check-Deadline hinaus.
  • launchd spawn throttle: LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 10 (LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS:8) ist der launchd-Default; explizit in die plist geschrieben, damit eine Crash-Loop (crash loop) mindestens 10s pro Iteration braucht, statt jede Sekunde hochzufahren. LAUNCH_AGENT_UMASK_DECIMAL = 0o077 wird explizit als dezimal 63 gerendert — launchds plist-Integer-Feld akzeptiert die Schreibweise 0o077 nicht.
  • systemd ExitStatus 78: RestartPreventExitStatus=78 (RestartPreventExitStatus:80) ist EX_CONFIG — bei Konfigurationsfehler beendet sich der Gateway-Prozess mit Exit-Code 78; systemd sieht diesen Status und startet nicht neu. Das verhindert die Endlos-Loop „Konfigurationsdatei kaputt, systemd restartet ständig und crasht ständig".
  • systemd linger: readSystemdUserLingerStatus (readSystemdUserLingerStatus:28) liest loginctl show-user <user> -p Linger. In Headless-Deployments (ohne aktive SSH-Session) startet die systemd-user-instance nicht, und das Gateway läuft nicht — dann ist loginctl enable-linger <user> nötig; das Daemon-Modul liest den Status, ändert aber nicht zwingend, überlässt das dem Administrator.
  • schtasks UTF-16 BOM: writeTaskXmlTempFile (writeTaskXmlTempFile:194) muss FFFE-BOM schreiben, sonst lehnt Task Scheduler MMC in manchen Locales den Import ab. Das ist ein Kompatibilitätsdetail der Windows-Toolchain; der Code enthält einen expliziten Kommentar.
  • schtasks Upgrade Change + XML: updateExistingScheduledTask (updateExistingScheduledTask:1042) ändert zuerst über /Change das /TR (Task-Command) und überschreibt dann über /Create /F /XML das gesamte XML — das garantiert, dass ein von einer alten Version installierter Task beim Upgrade neue Felder wie <DisallowStartIfOnBatteries>false</...> übernimmt (#59299). Beide Schritte sind best-effort; schlägt /Change fehl, bleiben die alten Einstellungen erhalten, der Task geht nicht verloren.

Zusammenfassung

Das Daemon-Modul ist eine dünne Hülle von OpenClaw über drei Systemdienstmanagern (launchd/systemd/schtasks): es betreibt das Gateway nicht, sondern schreibt nur plist/unit/XML + ruft System-CLI zum Laden/Entladen/Neustarten. resolveGatewayService wählt Adapter nach process.platform; withFutureConfigGuard schützt Schreiboperationen vor Versionsversatz; collectGatewayServiceStartRepairIssues prüft vor restart die drei Problemklassen Versionsdrift/Pfadverlust/temporäres Verzeichnis. Die konkrete Gateway-Startlogik siehe Gateway-Kern; containerisiertes Deployment (ohne Systemdienstmanager) siehe Docker- und Fly-Deployment; Profile-Wechsel und Dienst-Umgebungsvariablen siehe openclaw.json.