title: “macOS LaunchAgent 开机自启服务配置实战:以 OpenClaw 为例”
tags:

  • macOS
  • LaunchAgent
  • 开机自启
  • launchd
  • OpenClaw
    categories:
  • macOS
    description: “从原理到实战,详解 macOS LaunchAgent 的配置方法,以 OpenClaw Gateway 和 CLIProxyAPI 为例,手把手教你实现服务的开机自启和崩溃自动重启。”

导读:macOS 上想让自己的服务开机自启,很多人第一反应是往"系统设置 → 登录项"里塞,结果弹个终端窗口出来,丑且不靠谱。正确的做法是用 macOS 原生的 launchd 机制,通过 LaunchAgent plist 文件来管理。这篇文章我把自己折腾 LaunchAgent 的经验整理出来,用 OpenClaw 和 CLIProxyAPI 两个真实案例走一遍完整流程。


一、先搞清楚 launchd 是什么

launchd 是 macOS 的服务管理器,系统启动时由内核第一个拉起来(PID 1),负责管理所有系统服务和用户服务。你可以把它理解为 macOS 版的 systemd。

launchd 通过读取 .plist 配置文件来决定启动什么、什么时候启动、崩了要不要拉起来。

LaunchAgent vs LaunchDaemon

这两个东西经常搞混,其实区别很简单:

LaunchAgentLaunchDaemon
谁启动用户登录后启动系统启动时(不需要登录)
权限当前用户权限root 权限
能弹 GUI 吗不能
配置目录~/Library/LaunchAgents/Library/LaunchDaemons

日常开发中我们要自启的服务(代理、数据库、开发工具后台服务等),99% 用 LaunchAgent 就够了

plist 文件放哪

~/Library/LaunchAgents/          ← 用户级,登录后启动(最常用)
/Library/LaunchAgents/           ← 系统级,所有用户登录后启动(需要 sudo)
/Library/LaunchDaemons/          ← 系统级,开机就启动(需要 sudo + root)
/System/Library/LaunchAgents/    ← 苹果系统自带,别动
/System/Library/LaunchDaemons/   ← 苹果系统自带,别动

我们写自己的服务,放到 ~/Library/LaunchAgents/ 就行,不需要 sudo,不需要 root。


二、plist 配置文件详解

一个典型的 LaunchAgent plist 文件长这样:

<?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>com.example.myservice</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/myservice</string>
        <string>--config</string>
        <string>/path/to/config.yaml</string>
    </array>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/tmp/myservice.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/myservice.err.log</string>
</dict>
</plist>

关键配置项说明

配置项作用常用值
Label服务唯一标识,必须和文件名一致反域名格式,如 com.example.myservice
ProgramArguments启动命令和参数,数组的第一个是可执行文件路径数组形式
RunAtLoad加载时是否立即启动true / false
KeepAlive进程退出后是否自动重启true / false / 字典条件
StandardOutPath标准输出日志路径绝对路径
StandardErrorPath错误输出日志路径绝对路径
WorkingDirectory工作目录绝对路径
EnvironmentVariables环境变量字典形式
StartInterval定时执行间隔(秒)整数
WatchPaths监控路径,文件变化时触发数组
ThrottleInterval重启最小间隔(秒),防频繁重启整数,默认 10

KeepAlive 的几种写法

KeepAlive 可以是简单的 true,也可以是条件式的:

<!-- 无条件自动重启 -->
<key>KeepAlive</key>
<true/>

<!-- 仅在退出码非 0 时重启 -->
<key>KeepAlive</key>
<dict>
    <key>SuccessfulExit</key>
    <false/>
</dict>

<!-- 网络可用时才保持运行 -->
<key>KeepAlive</key>
<dict>
    <key>NetworkState</key>
    <true/>
</dict>

我的建议:大多数场景直接设 true 就完事了,除非你明确知道进程正常退出后不该重启。


三、实战案例一:OpenClaw Gateway 自启

OpenClaw 是一个开源的个人 AI 助手,可以在微信、Telegram、Discord 等平台上运行。它需要一个 Gateway 服务常驻后台。

好消息是,OpenClaw 安装时自带了 daemon 管理,一条命令就能搞定:

openclaw onboard --install-daemon

执行后它会自动在 ~/Library/LaunchAgents/ 下生成一个 ai.openclaw.gateway.plist 文件。我们来拆解一下这个真实的 plist 配置:

<?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>ai.openclaw.gateway</string>

    <key>Comment</key>
    <string>OpenClaw Gateway (v2026.4.9)</string>

    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>ThrottleInterval</key>
    <integer>1</integer>

    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/opt/node/bin/node</string>
        <string>/opt/homebrew/lib/node_modules/openclaw/dist/entry.js</string>
        <string>gateway</string>
        <string>--port</string>
        <string>18789</string>
    </array>

    <key>StandardOutPath</key>
    <string>/Users/aiksyuan/.openclaw/logs/gateway.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/aiksyuan/.openclaw/logs/gateway.err.log</string>

    <key>EnvironmentVariables</key>
    <dict>
        <key>HOME</key>
        <string>/Users/aiksyuan</string>
        <key>PATH</key>
        <string>/opt/homebrew/opt/node/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
        <key>OPENCLAW_GATEWAY_PORT</key>
        <string>18789</string>
    </dict>
</dict>
</plist>

这个配置有几个值得注意的点:

  1. ProgramArguments 用了 node 的完整路径/opt/homebrew/opt/node/bin/node),而不是直接写 node。这是因为 launchd 不会加载你的 shell 环境,PATH 变量可能不包含 node。
  2. EnvironmentVariables 里手动设了 PATH 和 HOME,确保进程能找到需要的命令。
  3. ThrottleInterval 设为 1,允许崩溃后快速重启(默认是 10 秒)。

💡 这是 OpenClaw 自动生成的配置,不需要手动写。如果你也是 OpenClaw 用户,直接跑 openclaw onboard --install-daemon 就行了。


四、实战案例二:CLIProxyAPI 自启(手动配置)

CLIProxyAPI 是一个 API 协议转换代理,用于让 Codex CLI 等工具接入智谱 GLM 模型。它没有自带的 daemon 管理功能,需要我们自己写 plist。

4.1 创建 plist 文件

vim ~/Library/LaunchAgents/com.cli-proxy-api.plist

写入以下内容(记得把 aiksyuan 换成你自己的用户名):

<?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>com.cli-proxy-api</string>

    <key>ProgramArguments</key>
    <array>
        <string>/Users/aiksyuan/.codex/cli-proxy-api</string>
        <string>-config</string>
        <string>/Users/aiksyuan/.codex/cliproxy-config.yaml</string>
    </array>

    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/Users/aiksyuan/.codex/logs/cli-proxy-api.stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/aiksyuan/.codex/logs/cli-proxy-api.stderr.log</string>
</dict>
</plist>

这里几个要点:

  • Labelcom.cli-proxy-api,文件名也是 com.cli-proxy-api.plist,保持一致。
  • KeepAlivetrue,代理崩了会自动重启,不会出现 Codex CLI 连不上的情况。
  • 日志写到 ~/.codex/logs/,方便排查问题。

4.2 加载服务

# 加载并立即启动
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.cli-proxy-api.plist

⚠️ 如果你之前用的是旧版 launchctl load 命令,macOS 10.10 之后推荐用 launchctl bootstrap 替代。两者的区别:bootstrap 是基于 domain 的操作,语义更清晰。

4.3 验证服务

# 检查服务状态
launchctl print gui/$(id -u)/com.cli-proxy-api

# 测试代理是否正常响应
curl -s -H "Authorization: Bearer sk-glm-proxy" http://127.0.0.1:8080/v1/models

正常的话应该返回模型列表。


五、launchctl 常用命令速查

这些命令我日常用得很多,建议收藏:

# 加载服务(立即启动 + 注册开机自启)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.plist

# 卸载服务(停止 + 取消开机自启)
launchctl bootout gui/$(id -u)/com.example

# 查看服务状态
launchctl print gui/$(id -u)/com.example

# 列出当前用户所有已加载的服务
launchctl list

# 列出并过滤特定服务
launchctl list | grep openclaw

# 手动启动服务(需要已加载)
launchctl kickstart gui/$(id -u)/com.example

# 停止服务(不卸载,崩溃后会自动重启如果 KeepAlive=true)
launchctl kill SIGTERM gui/$(id -u)/com.example

💡 gui/$(id -u) 是当前用户的 launchd domain。$(id -u) 获取你的用户 UID(通常是 501)。你也可以直接写 gui/501


六、我踩过的几个坑

坑 1:launchd 环境里没有你的 PATH

这是最多人踩的坑。launchd 不会读 .zshrc.bash_profile 这些文件,所以你在 shell 里能跑的命令,放到 plist 里可能找不到。

解决方案:在 ProgramArguments 里写可执行文件的绝对路径,或者通过 EnvironmentVariables 手动设 PATH。

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

坑 2:plist 文件权限不对

plist 文件必须是 644(所有者可读写,其他人只读),且所有者必须是当前用户。如果权限不对,launchd 会拒绝加载。

chmod 644 ~/Library/LaunchAgents/com.example.plist

坑 3:改了 plist 不生效

修改 plist 文件后,必须先 bootout 再 bootstrap,否则 launchd 还是用旧的配置:

launchctl bootout gui/$(id -u)/com.example
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.plist

坑 4:日志目录不存在

如果 StandardOutPathStandardErrorPath 指向的目录不存在,launchd 会静默失败,服务起不来但也不报错。确保日志目录已经创建好

mkdir -p ~/.codex/logs
mkdir -p ~/.openclaw/logs

坑 5:Label 和文件名不一致

Label 的值不要求和文件名完全一致,但这是约定俗成的最佳实践。如果 Label 冲突(两个 plist 用了同一个 Label),后加载的会覆盖前一个。


七、用 Shell 脚本包装一下更省心

如果你的服务启动前需要一些环境准备工作(比如检查端口、创建目录),可以把启动逻辑包在 Shell 脚本里,然后在 plist 中调用脚本:

#!/bin/bash
# ~/scripts/start-cli-proxy.sh

LOG_DIR="$HOME/.codex/logs"
mkdir -p "$LOG_DIR"

if lsof -i :8080 >/dev/null 2>&1; then
    echo "Port 8080 already in use, skipping"
    exit 0
fi

exec "$HOME/.codex/cli-proxy-api" -config "$HOME/.codex/cliproxy-config.yaml"

plist 中改成:

<key>ProgramArguments</key>
<array>
    <string>/bin/bash</string>
    <string>/Users/aiksyuan/scripts/start-cli-proxy.sh</string>
</array>

⚠️ 脚本必须有执行权限:chmod +x ~/scripts/start-cli-proxy.sh


八、定时任务也能用 LaunchAgent

除了开机自启,LaunchAgent 还能做定时任务。比如每天凌晨 3 点清理日志:

<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key>
    <integer>3</integer>
    <key>Minute</key>
    <integer>0</integer>
</dict>

或者每隔 5 分钟执行一次:

<key>StartInterval</key>
<integer>300</integer>

我之前用 cron 做定时任务,后来发现 macOS 对 cron 的支持越来越弱,权限还有限制。现在定时任务我全部用 LaunchAgent 来做,更原生的方案。


九、和"系统设置 → 登录项"的区别

很多人可能在"系统设置 → 通用 → 登录项"里添加过启动程序。这两者的区别:

登录项LaunchAgent
能跑后台服务吗勉强能,但会弹窗口天生为后台服务设计
崩溃自动重启不能KeepAlive 支持
日志管理没有StandardOutPath
定时任务不支持StartInterval / StartCalendarInterval
环境变量控制继承用户环境手动指定,更可控
适合场景GUI 应用(比如 iTerm、浏览器)后台服务、脚本、代理

简单说:GUI 应用用登录项,后台服务用 LaunchAgent


十、常见问题

Q:launchctl bootstrap5: Input/output error 怎么回事?

多半是服务已经加载过了。先 bootout 再 bootstrap:

launchctl bootout gui/$(id -u)/com.example
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.plist

Q:服务状态显示 exit code 1,但日志里什么都没有?

检查一下可执行文件的路径是不是对的,用绝对路径。另外看日志目录是否存在。

Q:怎么查看服务为什么崩了?

StandardErrorPath 指定的错误日志。另外 launchctl print gui/$(id -u)/com.example 里会显示最后一次退出状态。

Q:能不能不重启就测试 plist?

可以。bootstrap 加载后立即启动,不需要重启电脑。bootout 后服务立即停止。

Q:卸载服务要不要删 plist 文件?

不删也行,先 bootout 停掉就行。但如果彻底不用了,建议 bootout 后删掉 plist 文件,保持 ~/Library/LaunchAgents/ 干净。


参考链接

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐