Skip to content

Daemon:系統服務

源码版本v2026.6.11

職責

resolveGatewayService(resolveGatewayService:370-375) 是 OpenClaw 把 gateway 安裝成「系統級常駐服務 (daemon)」的統一入口:macOS 上是 LaunchAgent(launchd 管理)、Linux 上是 systemd user unit(systemd --user 管理)、Windows 上是 Scheduled Task(schtasks 註冊的登錄觸發任務)。三種平台各自的指令封裝成同一個 GatewayService(GatewayService:75-87)介面:stage/install/uninstall/stop/restart/isLoaded/readCommand/readRuntime,CLI 層不關心平台差異。

它處理的不是「執行 gateway」——gateway 本身還是 node openclaw.mjs gateway。Daemon 這層做的是把這條指令寫進系統服務清單 (plist/unit/XML),再讓系統服務管理器按規則拉起、監控、自動重啟。所以 daemon 模組大量程式碼在 plist/unit/XML 模板渲染和 launchctl/systemctl/schtasks CLI 呼叫上,真正的 gateway 進程生命週期由系統服務管理器掌控。

設計動機

為什麼不直接 nohup openclaw gateway &?

  1. 開機自啟 + 崩潰重啟:launchdKeepAlive=true、systemd 的 Restart=always、schtasks 的 LogonTrigger 都是系統級機制,機器重啟或 OpenClaw 進程意外退出時自動拉起。nohup 只能「忽略 SIGHUP」,機器重啟就廢。
  2. 設定統一 + profile 隔離:OPENCLAW_PROFILE 可以讓同一台機器跑多套 gateway(比如開發/生產分開),resolveGatewayLaunchAgentLabel(resolveGatewayLaunchAgentLabel:33-39)把 profile 編進 service label——ai.openclaw.gateway(default)或 ai.openclaw.<profile>,systemd 和 schtasks 也對應加後綴。每個 profile 獨立 plist/unit/XML,互不干擾。
  3. future config guard:withFutureConfigGuard(withFutureConfigGuard:336-362)在 stage/install/uninstall/stop/restart 每個寫操作前呼叫 assertFutureConfigActionAllowed,如果偵測到當前 OpenClaw 版本比服務設定寫入時新(或反之,設定由未來版本產生),就拒絕改寫——這是防止「降級啟動把新格式的服務檔寫壞」的安全網。
  4. repair 偵測:collectGatewayServiceStartRepairIssues(collectGatewayServiceStartRepairIssues:138-172)在 restart 前檢查已載入服務的 OPENCLAW_SERVICE_VERSION、program path 是否指向臨時目錄(isTemporaryProgramPath)、program 檔案是否還存在(isMissingProgramPath)。版本漂移或路徑失效會要求重裝,而不是假裝 restart 成功。

關鍵檔案

資料流

resolveGatewayService(resolveGatewayService:370)用平台登錄表選 adapter:

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 實作 */ },
  win32:  { label: "Scheduled Task", /* ...schtasks 實作 */ },
};

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

withFutureConfigGuard 給所有寫方法套一層 assertFutureConfigActionAllowed,這是「先檢查再寫」的統一攔截——避免用舊版本 OpenClaw 去改寫新版本產生的服務檔導致格式損壞。

launchd 的 plist 模板(buildLaunchAgentPlist:267)是最複雜的一個:

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

四個關鍵鍵:RunAtLoad=true(裝載時立即啟動)、KeepAlive=true(任何退出都重啟)、ExitTimeOut=20(SIGTERM 後等 20s 再 SIGKILL)、ThrottleInterval=10(launchd 自帶的崩潰循環保護,兩次啟動間隔至少 10s)。Umask=0o077 讓 gateway 建立的所有檔案預設 owner-only——避免 group/other 讀到 secrets。envXmlOPENCLAW_STATE_DIR/OPENCLAW_PROFILE 等環境變數嵌進 plist,這樣不需要額外的 env-file。

systemd unit(buildSystemdUnit:68)用三段標準結構:

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: 設定錯誤不重啟,避免死循環
  "TimeoutStopSec=30",
  "TimeoutStartSec=30",
  "SuccessExitStatus=0 143",       // 143 = SIGTERM 終止算正常退出
  "OOMPolicy=continue",            // 子進程被 OOM 殺掉時主服務不停
  "KillMode=control-group",        // 重啟時把所有子進程一併 KILL,避免孤兒 ACP worker
  workingDirLine,
  ...environmentFileLines,
  ...envLines,
  "",
  "[Install]",
  "WantedBy=default.target",
  "",
].filter((line) => line !== null).join("\n");

OOMPolicy=continueKillMode=control-group 是兩個非預設值——前者保證子進程 OOM 不會拖垮整個 gateway,後者保證 gateway 重啟時不留 ACP/agent worker 孤兒。StartLimitBurst=5/StartLimitIntervalSec=60 是 systemd 自帶的崩潰循環保護,60s 內連續崩 5 次後停止重啟(需要人工介入)。

Windows schtasks 的 XML(buildScheduledTaskXml:152)用 LogonTrigger(使用者登入時觸發)、LeastPrivilege(不用管理員)、DisallowStartIfOnBatteries=false(筆電電池模式也執行)。一個隱藏坑:schtasks /XML 要求 UTF-16 LE BOM(writeTaskXmlTempFile:194),Node 的 utf16le 編碼不會自動加 BOM,程式碼手動拼 Buffer.from([0xff, 0xfe]) + body。

邊界與失敗

  • 不支援平台降級:createUnsupportedGatewayService(createUnsupportedGatewayService:275-292)的所有寫方法都 throw createUnsupportedGatewayServiceError(),readCommand 回傳 null,readRuntime 回傳 { status: "unknown", detail: ... }。FreeBSD/OpenBSD 等 process.platform 不在表裡的系統會得到這個降級服務。
  • 臨時路徑偵測:TEMP_PROGRAM_ROOTS = [os.tmpdir(), "/tmp", "/private/tmp", "/var/tmp"](TEMP_PROGRAM_ROOTS:115)。如果已載入服務的 program path 指向這些目錄,collectGatewayServiceStartRepairIssues 會標 issue——臨時目錄可能被系統清理,服務會突然找不到可執行檔。這種狀態需要重裝而不是簡單 restart。
  • 版本漂移保護:serviceVersion !== VERSION 也算 issue(OPENCLAW_SERVICE_VERSION 檢查:146-150)。OPENCLAW_SERVICE_VERSION 在服務 install 時寫入 plist/unit/XML 的環境變數,執行時再讀出來和當前 OpenClaw 版本對比——不一致說明服務是舊版本裝的,可能引用了已刪除的二進位路徑或舊的工作目錄佈局。
  • launchd 不 kickstart -k:installLaunchAgent(avoid kickstart -k:1018-1020)註解明確說 bootstrap 已經 RunAtLoad 了,不要再 kickstart -k——在慢的 macOS 虛擬機上,kickstart -k 會 SIGTERM 剛啟動的 gateway,把真正的監聽器啟動推過 setup 的健康檢查 deadline。
  • launchd spawn throttle:LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 10(LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS:8)是 launchd 自帶預設值,顯式寫入 plist 讓崩潰循環 (crash loop) 至少 10s 一次,而不是每秒拉起。LAUNCH_AGENT_UMASK_DECIMAL = 0o077 顯式渲染成十進位 63——launchd 的 plist 整數欄位不接受 0o077 寫法。
  • systemd ExitStatus 78:RestartPreventExitStatus=78(RestartPreventExitStatus:80)是 EX_CONFIG——設定錯誤時 gateway 進程退出碼 78,systemd 看到這個狀態不重啟。這避免了「設定檔壞了,systemd 一直重啟一直崩」的死循環。
  • systemd linger:readSystemdUserLingerStatus(readSystemdUserLingerStatus:28)讀 loginctl show-user <user> -p Linger。headless 部署(沒有活躍 SSH 會話)下,systemd user instance 不啟動 gateway 就跑不起來,這時需要 loginctl enable-linger <user>——daemon 模組讀狀態但不強制修改,留給管理員決定。
  • schtasks UTF-16 BOM:writeTaskXmlTempFile(writeTaskXmlTempFile:194)必須寫 FFFE BOM,否則 Task Scheduler MMC 在某些 locale 下拒絕匯入。這是 Windows 工具鏈的相容性細節,程式碼裡有顯式註解。
  • schtasks 升級 Change + XML:updateExistingScheduledTask(updateExistingScheduledTask:1042)先 /Change/TR(task command),再 /Create /F /XML 覆蓋整個 XML——這保證舊版本安裝的 task 在升級時能繼承新的 <DisallowStartIfOnBatteries>false</...> 等欄位(#59299)。兩步都 best-effort,/Change 失敗會保留舊設定,不會把 task 弄丟。

小結

Daemon 模組是 OpenClaw 對接三種系統服務管理器(launchd/systemd/schtasks)的薄包裝:它不跑 gateway,只負責寫 plist/unit/XML + 調系統 CLI 裝載/卸載/重啟。resolveGatewayServiceprocess.platform 選 adapter,withFutureConfigGuard 保護寫操作不被版本錯位破壞,collectGatewayServiceStartRepairIssues 在 restart 前偵測版本漂移/路徑失效/臨時目錄三類問題。具體的 gateway 啟動邏輯看 Gateway 核心,容器化部署(沒有系統服務管理器)看 Docker 與 Fly 部署,profile 切換和服務環境變數見 openclaw.json