From af64ccadd4716f066c1663f873f8d3060b574e4d Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:31:30 +0000 Subject: [PATCH 1/5] fix(daemon): resilient launchctl bootstrap with retry and load fallback When `launchctl bootstrap` fails because the service is already registered (exit code 5), bootout the stale registration and retry once before giving up. When the gui/ domain is unavailable (SSH, headless, or sudo), fall back to the deprecated but universal `launchctl load -w` command. Only throw the GUI-session error when both approaches fail. Fixes #8619 --- src/daemon/launchd.test.ts | 45 +++++++++++++++++++++++++++++++++++++- src/daemon/launchd.ts | 39 ++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 341f071de91..c150149a15b 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -18,6 +18,8 @@ const state = vi.hoisted(() => ({ listOutput: "", printOutput: "", bootstrapError: "", + bootstrapFailuresRemaining: Infinity, + loadError: "", kickstartError: "", kickstartFailuresRemaining: 0, dirs: new Set(), @@ -74,9 +76,13 @@ vi.mock("./exec-file.js", () => ({ if (call[0] === "print") { return { stdout: state.printOutput, stderr: "", code: 0 }; } - if (call[0] === "bootstrap" && state.bootstrapError) { + if (call[0] === "bootstrap" && state.bootstrapError && state.bootstrapFailuresRemaining > 0) { + state.bootstrapFailuresRemaining -= 1; return { stdout: "", stderr: state.bootstrapError, code: 1 }; } + if (call[0] === "load" && state.loadError) { + return { stdout: "", stderr: state.loadError, code: 1 }; + } if (call[0] === "kickstart" && state.kickstartError && state.kickstartFailuresRemaining > 0) { state.kickstartFailuresRemaining -= 1; return { stdout: "", stderr: state.kickstartError, code: 1 }; @@ -152,6 +158,8 @@ beforeEach(() => { state.listOutput = ""; state.printOutput = ""; state.bootstrapError = ""; + state.bootstrapFailuresRemaining = Infinity; + state.loadError = ""; state.kickstartError = ""; state.kickstartFailuresRemaining = 0; state.dirs.clear(); @@ -438,6 +446,7 @@ describe("launchd install", () => { it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => { state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action"; + state.loadError = "Could not find domain for user gui: 1000"; const env = createDefaultLaunchdEnv(); let message = ""; try { @@ -452,6 +461,40 @@ describe("launchd install", () => { expect(message).toContain("logged-in macOS GUI session"); expect(message).toContain("wrong user (including sudo)"); expect(message).toContain("https://docs.openclaw.ai/gateway"); + // Verify that `load -w` was attempted as a fallback before giving up. + expect(state.launchctlCalls.some((c) => c[0] === "load")).toBe(true); + }); + + it("falls back to launchctl load when gui domain is unavailable", async () => { + state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action"; + // loadError is empty → launchctl load succeeds + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + await installLaunchAgent({ + env, + stdout, + programArguments: defaultProgramArguments, + }); + expect(state.launchctlCalls.some((c) => c[0] === "load" && c[1] === "-w")).toBe(true); + }); + + it("retries bootstrap after bootout when service is already registered", async () => { + state.bootstrapError = "Bootstrap failed: 5: Input/output error"; + state.bootstrapFailuresRemaining = 1; // first bootstrap fails, retry succeeds + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + await installLaunchAgent({ + env, + stdout, + programArguments: defaultProgramArguments, + }); + // Verify bootout was called between the two bootstrap attempts. + const bootoutAfterBootstrap = state.launchctlCalls.findIndex( + (c, i) => + c[0] === "bootout" && + i > state.launchctlCalls.findIndex((b) => b[0] === "bootstrap"), + ); + expect(bootoutAfterBootstrap).toBeGreaterThanOrEqual(0); }); it("surfaces generic bootstrap failures without GUI-specific guidance", async () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 13d6fd46d33..08c79005030 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -201,7 +201,32 @@ async function bootstrapLaunchAgentOrThrow(params: { return; } const detail = (boot.stderr || boot.stdout).trim(); - if (isUnsupportedGuiDomain(detail)) { + + // If the service is already registered (e.g. re-install without prior uninstall), + // bootout the stale registration and retry once. + if (isAlreadyBootstrapped(detail)) { + await execLaunchctl(["bootout", params.domain, params.plistPath]); + const retry = await execLaunchctl(["bootstrap", params.domain, params.plistPath]); + if (retry.code === 0) { + return; + } + const retryDetail = (retry.stderr || retry.stdout).trim(); + if (isUnsupportedGuiDomain(retryDetail)) { + // Fall through to the legacy load fallback below. + } else if (!isAlreadyBootstrapped(retryDetail)) { + throw new Error(`launchctl bootstrap failed: ${retryDetail}`); + } + } + + // If the gui/ domain is unavailable (SSH, headless, or sudo), try the + // deprecated-but-universal `launchctl load` as a last resort before giving up. + if (isUnsupportedGuiDomain(detail) || isAlreadyBootstrapped(detail)) { + const load = await execLaunchctl(["load", "-w", params.plistPath]); + if (load.code === 0) { + return; + } + // `launchctl load` also failed — throw the original gui-domain error so the + // user gets the actionable hint about desktop sessions. throwBootstrapGuiSessionError({ detail, domain: params.domain, @@ -448,6 +473,18 @@ function isUnsupportedGuiDomain(detail: string): boolean { ); } +function isAlreadyBootstrapped(detail: string): boolean { + const normalized = detail.toLowerCase(); + // launchctl bootstrap returns exit code 5 ("Input/output error") or the + // explicit "already loaded" / "already bootstrapped" messages when the + // service target is already registered in the domain. + return ( + normalized.includes("bootstrap failed: 5") || + normalized.includes("already loaded") || + normalized.includes("already bootstrapped") + ); +} + export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs): Promise { const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); From 1684577259d2a817b0f51451a92859a45211bf18 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:35:44 +0000 Subject: [PATCH 2/5] chore: trigger CI re-run From e57ebd641c6bbcd351238db6b36230087cef1867 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:38:12 +0000 Subject: [PATCH 3/5] fix: track effective error detail across retries, detect 'Could not find domain' Address review feedback: - Use effectiveDetail to propagate the most relevant error message - Only show GUI-session guidance when failure is actually gui-domain related - Add 'could not find domain for' to isUnsupportedGuiDomain() detection - New test for the exact error string from issue #8619 --- src/daemon/launchd.test.ts | 13 +++++++++++++ src/daemon/launchd.ts | 28 +++++++++++++++++++--------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index c150149a15b..f101a17a687 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -497,6 +497,19 @@ describe("launchd install", () => { expect(bootoutAfterBootstrap).toBeGreaterThanOrEqual(0); }); + it("falls back to launchctl load for 'Could not find domain' error from issue #8619", async () => { + state.bootstrapError = "Could not find domain for user gui: 1000"; + // loadError is empty → launchctl load succeeds + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + await installLaunchAgent({ + env, + stdout, + programArguments: defaultProgramArguments, + }); + expect(state.launchctlCalls.some((c) => c[0] === "load" && c[1] === "-w")).toBe(true); + }); + it("surfaces generic bootstrap failures without GUI-specific guidance", async () => { state.bootstrapError = "Operation not permitted"; const env = createDefaultLaunchdEnv(); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 08c79005030..c85a4607a25 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -201,6 +201,10 @@ async function bootstrapLaunchAgentOrThrow(params: { return; } const detail = (boot.stderr || boot.stdout).trim(); + // Track the most relevant error detail across retries so that the final + // error message shown to the user reflects the actual failure, not a stale + // "already bootstrapped" message from an earlier attempt. + let effectiveDetail = detail; // If the service is already registered (e.g. re-install without prior uninstall), // bootout the stale registration and retry once. @@ -211,6 +215,7 @@ async function bootstrapLaunchAgentOrThrow(params: { return; } const retryDetail = (retry.stderr || retry.stdout).trim(); + effectiveDetail = retryDetail; if (isUnsupportedGuiDomain(retryDetail)) { // Fall through to the legacy load fallback below. } else if (!isAlreadyBootstrapped(retryDetail)) { @@ -220,18 +225,22 @@ async function bootstrapLaunchAgentOrThrow(params: { // If the gui/ domain is unavailable (SSH, headless, or sudo), try the // deprecated-but-universal `launchctl load` as a last resort before giving up. - if (isUnsupportedGuiDomain(detail) || isAlreadyBootstrapped(detail)) { + if (isUnsupportedGuiDomain(effectiveDetail) || isAlreadyBootstrapped(effectiveDetail)) { const load = await execLaunchctl(["load", "-w", params.plistPath]); if (load.code === 0) { return; } - // `launchctl load` also failed — throw the original gui-domain error so the - // user gets the actionable hint about desktop sessions. - throwBootstrapGuiSessionError({ - detail, - domain: params.domain, - actionHint: params.actionHint, - }); + const loadDetail = (load.stderr || load.stdout).trim(); + // Only show GUI-session guidance when the failure is actually gui-domain related. + // For other load failures (e.g. plist syntax, permissions), surface the real error. + if (isUnsupportedGuiDomain(effectiveDetail)) { + throwBootstrapGuiSessionError({ + detail: effectiveDetail, + domain: params.domain, + actionHint: params.actionHint, + }); + } + throw new Error(`launchctl load failed: ${loadDetail || effectiveDetail}`); } throw new Error(`launchctl bootstrap failed: ${detail}`); } @@ -469,7 +478,8 @@ function isUnsupportedGuiDomain(detail: string): boolean { const normalized = detail.toLowerCase(); return ( normalized.includes("domain does not support specified action") || - normalized.includes("bootstrap failed: 125") + normalized.includes("bootstrap failed: 125") || + normalized.includes("could not find domain for") ); } From 91db7c72a18b86c48a734918e35ae702760449e0 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:51:00 +0000 Subject: [PATCH 4/5] fix: surface load failure detail alongside GUI-session guidance When bootstrap fails with a GUI-domain error but launchctl load fails for a different reason (permissions, plist syntax), include both error details so users can diagnose the actual problem. --- src/daemon/launchd.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index c85a4607a25..b2b7559560f 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -231,11 +231,12 @@ async function bootstrapLaunchAgentOrThrow(params: { return; } const loadDetail = (load.stderr || load.stdout).trim(); - // Only show GUI-session guidance when the failure is actually gui-domain related. - // For other load failures (e.g. plist syntax, permissions), surface the real error. + // Only show GUI-session guidance when the failure is actually gui-domain related + // and the load fallback didn't reveal a different, more actionable error. if (isUnsupportedGuiDomain(effectiveDetail)) { + const isLoadDifferentError = loadDetail && !isUnsupportedGuiDomain(loadDetail); throwBootstrapGuiSessionError({ - detail: effectiveDetail, + detail: isLoadDifferentError ? `${loadDetail} (bootstrap: ${effectiveDetail})` : effectiveDetail, domain: params.domain, actionHint: params.actionHint, }); From 041a55855fd83854fd25ccc9f4ba95dc798fe38e Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:42:34 +0000 Subject: [PATCH 5/5] fix: narrow isAlreadyBootstrapped to explicit messages, exclude generic error code 5 --- src/daemon/launchd.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index b2b7559560f..ad4a70530e7 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -486,11 +486,12 @@ function isUnsupportedGuiDomain(detail: string): boolean { function isAlreadyBootstrapped(detail: string): boolean { const normalized = detail.toLowerCase(); - // launchctl bootstrap returns exit code 5 ("Input/output error") or the - // explicit "already loaded" / "already bootstrapped" messages when the - // service target is already registered in the domain. + // Match explicit "already loaded" / "already bootstrapped" messages. + // We intentionally do NOT match the generic "bootstrap failed: 5" + // (Input/output error) because that code also fires for malformed or + // unreadable plists, and treating those as "already loaded" would mask + // real bootstrap failures. return ( - normalized.includes("bootstrap failed: 5") || normalized.includes("already loaded") || normalized.includes("already bootstrapped") );