Skip to content

Commit 2d81aa7

Browse files
committed
fix(mac): retry stale launchd bootstrap
1 parent 9490127 commit 2d81aa7

3 files changed

Lines changed: 80 additions & 1 deletion

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: retry LaunchAgent bootstrap once after booting out an already-registered launchd job, while keeping headless/sudo installs on the logged-in GUI-session guidance path. Refs #46153 and #48476. 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.

src/daemon/launchd.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const state = vi.hoisted(() => ({
2828
printFailuresRemaining: 0,
2929
bootstrapError: "",
3030
bootstrapCode: 1,
31+
bootstrapFailuresRemaining: Number.POSITIVE_INFINITY,
32+
bootstrapResults: [] as Array<{ stderr: string; code: number }>,
3133
kickstartError: "",
3234
kickstartCode: 1,
3335
kickstartFailuresRemaining: 0,
@@ -187,7 +189,12 @@ vi.mock("./exec-file.js", () => ({
187189
return { stdout: "", stderr: "", code: 0 };
188190
}
189191
if (call[0] === "bootstrap") {
190-
if (state.bootstrapError) {
192+
const queued = state.bootstrapResults.shift();
193+
if (queued) {
194+
return { stdout: "", stderr: queued.stderr, code: queued.code };
195+
}
196+
if (state.bootstrapError && state.bootstrapFailuresRemaining > 0) {
197+
state.bootstrapFailuresRemaining -= 1;
191198
return { stdout: "", stderr: state.bootstrapError, code: state.bootstrapCode };
192199
}
193200
state.serviceLoaded = true;
@@ -288,6 +295,8 @@ beforeEach(() => {
288295
state.printFailuresRemaining = 0;
289296
state.bootstrapError = "";
290297
state.bootstrapCode = 1;
298+
state.bootstrapFailuresRemaining = Number.POSITIVE_INFINITY;
299+
state.bootstrapResults.length = 0;
291300
state.kickstartError = "";
292301
state.kickstartCode = 1;
293302
state.kickstartFailuresRemaining = 0;
@@ -511,6 +520,46 @@ describe("launchd install", () => {
511520
expect(installKickstartIndex).toBe(-1);
512521
});
513522

523+
it("retries bootstrap once after booting out an already-registered LaunchAgent", async () => {
524+
const env = createDefaultLaunchdEnv();
525+
state.bootstrapError =
526+
"Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501";
527+
state.bootstrapCode = 5;
528+
state.bootstrapFailuresRemaining = 1;
529+
530+
await installLaunchAgent({
531+
env,
532+
stdout: new PassThrough(),
533+
programArguments: defaultProgramArguments,
534+
});
535+
536+
const { domain, serviceId } = expectLaunchctlEnableBootstrapOrder(env);
537+
const plistPath = resolveLaunchAgentPlistPath(env);
538+
expect(state.launchctlCalls).toContainEqual(["bootout", domain, plistPath]);
539+
expect(state.launchctlCalls).toContainEqual(["bootout", serviceId]);
540+
expect(countMatching(state.launchctlCalls, (call) => call[0] === "bootstrap")).toBe(2);
541+
});
542+
543+
it("reports the retry bootstrap error when already-registered recovery fails differently", async () => {
544+
const env = createDefaultLaunchdEnv();
545+
state.bootstrapResults.push(
546+
{
547+
stderr:
548+
"Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501",
549+
code: 5,
550+
},
551+
{ stderr: "Bootstrap failed: 125: Domain does not support specified action", code: 125 },
552+
);
553+
554+
await expect(
555+
installLaunchAgent({
556+
env,
557+
stdout: new PassThrough(),
558+
programArguments: defaultProgramArguments,
559+
}),
560+
).rejects.toThrow("launchctl bootstrap failed: Bootstrap failed: 125");
561+
});
562+
514563
it("writes LaunchAgent environment to an owner-only env file when provided", async () => {
515564
const env = createDefaultLaunchdEnv();
516565
const tmpDir = "/Users/test/.openclaw/tmp";

src/daemon/launchd.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,25 @@ async function bootstrapLaunchAgentOrThrow(params: {
315315
actionHint: params.actionHint,
316316
});
317317
}
318+
if (isAlreadyBootstrappedDetail(detail, boot.code)) {
319+
const bootout = await execLaunchctl(["bootout", params.serviceTarget]);
320+
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
321+
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
322+
}
323+
const retry = await execLaunchctl(["bootstrap", params.domain, params.plistPath]);
324+
if (retry.code === 0) {
325+
return;
326+
}
327+
const retryDetail = (retry.stderr || retry.stdout).trim();
328+
if (isUnsupportedGuiDomain(retryDetail)) {
329+
throwBootstrapGuiSessionError({
330+
detail: retryDetail,
331+
domain: params.domain,
332+
actionHint: params.actionHint,
333+
});
334+
}
335+
throw new Error(`launchctl bootstrap failed: ${retryDetail}`);
336+
}
318337
throw new Error(`launchctl bootstrap failed: ${detail}`);
319338
}
320339

@@ -529,6 +548,16 @@ function isUnsupportedGuiDomain(detail: string): boolean {
529548
);
530549
}
531550

551+
function isAlreadyBootstrappedDetail(detail: string, code?: number): boolean {
552+
const normalized = normalizeLowercaseStringOrEmpty(detail);
553+
return (
554+
normalized.includes("already bootstrapped") ||
555+
normalized.includes("already loaded") ||
556+
normalized.includes("already exists in domain") ||
557+
(code === 5 && normalized.includes("input/output error"))
558+
);
559+
}
560+
532561
function formatLaunchctlResultDetail(res: {
533562
stdout: string;
534563
stderr: string;

0 commit comments

Comments
 (0)