Skip to content

Commit 68bf98c

Browse files
committed
fix(mac): avoid launchd startup crash loops
1 parent 9490127 commit 68bf98c

6 files changed

Lines changed: 77 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
1414

1515
### Fixes
1616

17+
- macOS/Gateway: make LaunchAgent keepalive relaunch clean exits for intentional update/restart handoffs without respawning non-zero startup failures in a launchd crash loop. Fixes #73673. Thanks @BunsDev.
1718
- Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang.
1819
- Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom.
1920
- Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd.

docs/platforms/mac/bundled-gateway.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ Behavior:
4646
- App quit does **not** stop the gateway (launchd keeps it alive).
4747
- If a Gateway is already running on the configured port, the app attaches to
4848
it instead of starting a new one.
49+
- Launchd relaunches clean Gateway exits used by update/restart handoffs, but
50+
non-zero startup failures remain stopped so logs stay inspectable instead of
51+
looping indefinitely.
4952

5053
Logging:
5154

src/daemon/launchd-plist.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import fs from "node:fs/promises";
22
import type { GatewayServiceEnvironmentValueSource } from "./service-types.js";
33

4-
// launchd defaults to a 10s spawn throttle. Keep that default explicitly so
5-
// crash loops back off instead of respawning every second while still allowing
6-
// explicit kickstart restarts to take effect.
4+
// launchd defaults to a 10s spawn throttle. Keep that default explicitly for
5+
// clean-exit relaunch handoffs and operator kickstart restarts.
76
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 10;
87
export const LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 20;
98
// launchd stores plist integer values in decimal; 0o077 renders as 63 (owner-only files).
@@ -180,5 +179,5 @@ export function buildLaunchAgentPlist({
180179
? `\n <key>Comment</key>\n <string>${plistEscape(comment.trim())}</string>`
181180
: "";
182181
const envXml = renderEnvDict(environment);
183-
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ExitTimeOut</key>\n <integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>\n <key>ProcessType</key>\n <string>${LAUNCH_AGENT_PROCESS_TYPE}</string>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>Umask</key>\n <integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
182+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <true/>\n </dict>\n <key>ExitTimeOut</key>\n <integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>\n <key>ProcessType</key>\n <string>${LAUNCH_AGENT_PROCESS_TYPE}</string>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>Umask</key>\n <integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
184183
}

src/daemon/launchd.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ describe("launchd install", () => {
559559
expect(state.dirModes.get(tmpDir)).toBe(0o700);
560560
});
561561

562-
it("writes KeepAlive=true policy with shutdown and throttle limits", async () => {
562+
it("writes clean-exit KeepAlive policy with shutdown and throttle limits", async () => {
563563
const env = createDefaultLaunchdEnv();
564564
await installLaunchAgent({
565565
env,
@@ -570,8 +570,9 @@ describe("launchd install", () => {
570570
const plistPath = resolveLaunchAgentPlistPath(env);
571571
const plist = state.files.get(plistPath) ?? "";
572572
expect(plist).toContain("<key>KeepAlive</key>");
573+
expect(plist).toContain("<dict>");
574+
expect(plist).toContain("<key>SuccessfulExit</key>");
573575
expect(plist).toContain("<true/>");
574-
expect(plist).not.toContain("<key>SuccessfulExit</key>");
575576
expect(plist).toContain("<key>ExitTimeOut</key>");
576577
expect(plist).toContain(`<integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>`);
577578
expect(plist).toContain("<key>ProcessType</key>");

src/daemon/service-audit.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,67 @@ describe("auditGatewayServiceConfig", () => {
462462
});
463463
});
464464

465+
describe("auditGatewayServiceConfig launchd policy", () => {
466+
async function withLaunchdPlist(
467+
contents: string,
468+
run: (params: { env: Record<string, string>; plistPath: string }) => Promise<void>,
469+
) {
470+
const tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchd-audit-"));
471+
const launchdDir = path.join(tmpHome, "Library", "LaunchAgents");
472+
const plistPath = path.join(launchdDir, "ai.openclaw.gateway.plist");
473+
try {
474+
await fs.mkdir(launchdDir, { recursive: true });
475+
await fs.writeFile(plistPath, contents);
476+
await run({ env: { HOME: tmpHome }, plistPath });
477+
} finally {
478+
await fs.rm(tmpHome, { recursive: true, force: true });
479+
}
480+
}
481+
482+
it("accepts the canonical clean-exit KeepAlive dictionary", async () => {
483+
await withLaunchdPlist(
484+
`<?xml version="1.0" encoding="UTF-8"?>
485+
<plist version="1.0"><dict>
486+
<key>RunAtLoad</key><true/>
487+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><true/></dict>
488+
</dict></plist>`,
489+
async ({ env }) => {
490+
const audit = await auditGatewayServiceConfig({
491+
env,
492+
platform: "darwin",
493+
command: {
494+
programArguments: ["/usr/bin/node", "gateway"],
495+
environment: { PATH: "/usr/bin:/bin" },
496+
},
497+
});
498+
499+
expect(hasIssue(audit, SERVICE_AUDIT_CODES.launchdKeepAlive)).toBe(false);
500+
},
501+
);
502+
});
503+
504+
it("flags launchd plists without the canonical KeepAlive policy", async () => {
505+
await withLaunchdPlist(
506+
`<?xml version="1.0" encoding="UTF-8"?>
507+
<plist version="1.0"><dict>
508+
<key>RunAtLoad</key><true/>
509+
</dict></plist>`,
510+
async ({ env }) => {
511+
const audit = await auditGatewayServiceConfig({
512+
env,
513+
platform: "darwin",
514+
command: {
515+
programArguments: ["/usr/bin/node", "gateway"],
516+
environment: { PATH: "/usr/bin:/bin" },
517+
},
518+
});
519+
520+
expect(hasIssue(audit, SERVICE_AUDIT_CODES.launchdKeepAlive)).toBe(true);
521+
},
522+
);
523+
});
524+
});
525+
465526
describe("checkTokenDrift", () => {
466527
it("returns null when both tokens are undefined", () => {
467528
const result = checkTokenDrift({ serviceToken: undefined, configToken: undefined });

src/daemon/service-audit.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,11 @@ async function auditLaunchdPlist(
186186
}
187187

188188
const hasRunAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/i.test(content);
189-
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
189+
const hasKeepAlive =
190+
/<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content) ||
191+
/<key>KeepAlive<\/key>\s*<dict>[\s\S]*?<key>SuccessfulExit<\/key>\s*<true\s*\/>[\s\S]*?<\/dict>/i.test(
192+
content,
193+
);
190194
if (!hasRunAtLoad) {
191195
issues.push({
192196
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
@@ -198,7 +202,7 @@ async function auditLaunchdPlist(
198202
if (!hasKeepAlive) {
199203
issues.push({
200204
code: SERVICE_AUDIT_CODES.launchdKeepAlive,
201-
message: "LaunchAgent is missing KeepAlive=true",
205+
message: "LaunchAgent is missing the canonical KeepAlive policy",
202206
detail: plistPath,
203207
level: "recommended",
204208
});

0 commit comments

Comments
 (0)