Skip to content

Daemon: system service

源码版本v2026.6.11

Responsibilities

resolveGatewayService (resolveGatewayService:L370-L375) is OpenClaw's unified entry for installing the gateway as a system-level daemon: LaunchAgent on macOS (managed by launchd), systemd user unit on Linux (managed by systemd --user), Scheduled Task on Windows (registered via schtasks with a logon trigger). The three platforms' commands are wrapped into the same GatewayService (GatewayService:L75-L87) interface: stage/install/uninstall/stop/restart/isLoaded/readCommand/readRuntime; the CLI layer doesn't care about platform differences.

It doesn't handle "running the gateway" — the gateway itself is still node openclaw.mjs gateway. The daemon layer does write that command into the system service manifest (plist/unit/XML), then let the system service manager pull it up, monitor, and auto-restart per rules. So the daemon module has a lot of code on plist/unit/XML template rendering and launchctl/systemctl/schtasks CLI invocation; the actual gateway process lifecycle is controlled by the system service manager.

Design motivation

Why not just nohup openclaw gateway &?

  1. Boot auto-start + crash restart: launchd's KeepAlive=true, systemd's Restart=always, schtasks's LogonTrigger are all system-level mechanisms that auto-start on machine reboot or unexpected OpenClaw exit. nohup only "ignores SIGHUP"; on machine reboot it's dead.
  2. Unified config + profile isolation: OPENCLAW_PROFILE lets one machine run multiple gateways (e.g. dev/prod separate). resolveGatewayLaunchAgentLabel (resolveGatewayLaunchAgentLabel:L33-L39) bakes the profile into the service label — ai.openclaw.gateway (default) or ai.openclaw.<profile>; systemd and schtasks get corresponding suffixes. Each profile gets an independent plist/unit/XML, no interference.
  3. future config guard: withFutureConfigGuard (withFutureConfigGuard:L336-L362) calls assertFutureConfigActionAllowed before each write operation (stage/install/uninstall/stop/restart). If it detects the current OpenClaw version is newer than what's in the service config (or vice versa, config written by a future version), it refuses to modify — a safety net against "downgrade start corrupting new-format service files".
  4. repair detection: collectGatewayServiceStartRepairIssues (collectGatewayServiceStartRepairIssues:L138-L172) checks the loaded service's OPENCLAW_SERVICE_VERSION, whether the program path points at a temp directory (isTemporaryProgramPath), and whether the program file still exists (isMissingProgramPath) before restart. Version drift or path invalidation requires reinstall, not a fake "restart succeeded".

Key files

Data flow

resolveGatewayService (resolveGatewayService:L370) picks an adapter from the platform 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 impl */ },
  win32:  { label: "Scheduled Task", /* ...schtasks impl */ },
};

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

withFutureConfigGuard wraps all write methods with assertFutureConfigActionAllowed — a unified "check before write" interception, preventing an older OpenClaw from corrupting service files written by a newer version.

The launchd plist template (buildLaunchAgentPlist:L267) is the most complex:

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>
`;

Four key fields: RunAtLoad=true (start immediately on load), KeepAlive=true (restart on any exit), ExitTimeOut=20 (wait 20s after SIGTERM before SIGKILL), ThrottleInterval=10 (launchd's built-in crash-loop protection; at least 10s between launches). Umask=0o077 makes all files gateway creates default to owner-only — preventing group/other from reading secrets. envXml embeds OPENCLAW_STATE_DIR/OPENCLAW_PROFILE etc. into the plist, so no extra env-file is needed.

The systemd unit (buildSystemdUnit:L68) uses a standard three-section structure:

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: don't restart on config error, avoiding an infinite loop
  "TimeoutStopSec=30",
  "TimeoutStartSec=30",
  "SuccessExitStatus=0 143",       // 143 = SIGTERM termination counts as normal exit
  "OOMPolicy=continue",            // child OOM-killed doesn't take down main service
  "KillMode=control-group",        // on restart KILL all children, avoiding orphan ACP workers
  workingDirLine,
  ...environmentFileLines,
  ...envLines,
  "",
  "[Install]",
  "WantedBy=default.target",
  "",
].filter((line) => line !== null).join("\n");

OOMPolicy=continue and KillMode=control-group are two non-default values — the former ensures a child OOM doesn't take down the whole gateway; the latter ensures no ACP/agent worker orphans on gateway restart. StartLimitBurst=5/StartLimitIntervalSec=60 is systemd's built-in crash-loop protection — 5 crashes within 60s stops restart (requires manual intervention).

Windows schtasks XML (buildScheduledTaskXml:L152) uses LogonTrigger (trigger on user logon), LeastPrivilege (no admin), DisallowStartIfOnBatteries=false (run on laptop battery too). Hidden pitfall: schtasks /XML requires UTF-16 LE BOM (writeTaskXmlTempFile:L194); Node's utf16le encoding doesn't add BOM automatically, so the code manually prepends Buffer.from([0xff, 0xfe]) + body.

Boundaries and failures

  • unsupported platform degrades: createUnsupportedGatewayService (createUnsupportedGatewayService:L275-L292) — all write methods throw createUnsupportedGatewayServiceError(), readCommand returns null, readRuntime returns { status: "unknown", detail: ... }. Systems like FreeBSD/OpenBSD whose process.platform isn't in the table get this degraded service.
  • temp path detection: TEMP_PROGRAM_ROOTS = [os.tmpdir(), "/tmp", "/private/tmp", "/var/tmp"] (TEMP_PROGRAM_ROOTS:L115). If the loaded service's program path points at these directories, collectGatewayServiceStartRepairIssues flags an issue — temp dirs can be cleaned by the system, and the service suddenly can't find the executable. This state requires reinstall, not a simple restart.
  • version drift protection: serviceVersion !== VERSION is also an issue (OPENCLAW_SERVICE_VERSION check:L146-L150). OPENCLAW_SERVICE_VERSION is written into the plist/unit/XML env vars at install and read out at runtime to compare against the current OpenClaw version — mismatch means the service was installed by an older version that may reference deleted binary paths or old working-dir layouts.
  • launchd doesn't kickstart -k: installLaunchAgent (avoid kickstart -k:L1018-L1020) comments explicitly that bootstrap already RunAtLoads; don't also kickstart -k — on slow macOS VMs, kickstart -k SIGTERMs the just-started gateway, pushing the real listener startup past the setup health-check deadline.
  • launchd spawn throttle: LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 10 (LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS:L8) is launchd's default; explicitly written into the plist to make crash loops at least 10s apart, not every second. LAUNCH_AGENT_UMASK_DECIMAL = 0o077 is explicitly rendered as decimal 63 — launchd's plist integer field doesn't accept 0o077 notation.
  • systemd ExitStatus 78: RestartPreventExitStatus=78 (RestartPreventExitStatus:L80) is EX_CONFIG — when the gateway exits with code 78 on config error, systemd sees it and doesn't restart. This avoids the infinite loop of "config file broken, systemd keeps restarting and crashing".
  • systemd linger: readSystemdUserLingerStatus (readSystemdUserLingerStatus:L28) reads loginctl show-user <user> -p Linger. In headless deployments (no active SSH session), the systemd user instance doesn't start, so the gateway can't run — needs loginctl enable-linger <user>. The daemon module reads status but doesn't force-modify, leaving the decision to the admin.
  • schtasks UTF-16 BOM: writeTaskXmlTempFile (writeTaskXmlTempFile:L194) must write FFFE BOM, otherwise Task Scheduler MMC refuses to import under some locales. This is a Windows toolchain compat detail, explicitly commented in code.
  • schtasks upgrade Change + XML: updateExistingScheduledTask (updateExistingScheduledTask:L1042) first /Change to modify /TR (task command), then /Create /F /XML to overwrite the whole XML — this ensures old-version-installed tasks can inherit new fields like <DisallowStartIfOnBatteries>false</...> on upgrade (#59299). Both steps best-effort; /Change failure retains old settings, doesn't lose the task.

Summary

The daemon module is OpenClaw's thin wrapper around three system service managers (launchd/systemd/schtasks): it doesn't run the gateway, only writes plist/unit/XML + calls system CLIs to load/unload/restart. resolveGatewayService picks an adapter by process.platform; withFutureConfigGuard protects write operations from version mismatch corruption; collectGatewayServiceStartRepairIssues detects version drift / path invalidation / temp dirs before restart. The actual gateway startup logic is in Gateway core; containerized deployment (no system service manager) is in Docker and Fly deployment; profile switching and service env vars are in openclaw.json.