From be7462af1eca1facc50d1206eb54c6ea99ebf9b4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Feb 2026 02:03:23 -0800 Subject: [PATCH] Gateway: clarify launchctl domain bootstrap error (#13795) --- src/daemon/launchd.test.ts | 61 ++++++++++++++++++++++++++++++++++++++ src/daemon/launchd.ts | 22 +++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 8365879b28..279c91d925 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -171,6 +171,67 @@ describe("launchd install", () => { expect(plist).toContain("TMPDIR"); expect(plist).toContain(`${tmpDir}`); }); + + it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => { + const originalPath = process.env.PATH; + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-launchctl-test-")); + try { + const binDir = path.join(tmpDir, "bin"); + const homeDir = path.join(tmpDir, "home"); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(homeDir, { recursive: true }); + + const stubJsPath = path.join(binDir, "launchctl.js"); + await fs.writeFile( + stubJsPath, + [ + "const args = process.argv.slice(2);", + 'if (args[0] === "bootstrap") {', + ' process.stderr.write("Bootstrap failed: 125: Domain does not support specified action\\n");', + " process.exit(1);", + "}", + "process.exit(0);", + "", + ].join("\n"), + "utf8", + ); + + if (process.platform === "win32") { + await fs.writeFile( + path.join(binDir, "launchctl.cmd"), + `@echo off\r\nnode "%~dp0\\launchctl.js" %*\r\n`, + "utf8", + ); + } else { + const shPath = path.join(binDir, "launchctl"); + await fs.writeFile(shPath, `#!/bin/sh\nnode "$(dirname "$0")/launchctl.js" "$@"\n`, "utf8"); + await fs.chmod(shPath, 0o755); + } + + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}`; + + const env: Record = { + HOME: homeDir, + OPENCLAW_PROFILE: "default", + }; + let message = ""; + try { + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: ["node", "-e", "process.exit(0)"], + }); + } catch (error) { + message = String(error); + } + expect(message).toContain("logged-in macOS GUI session"); + expect(message).toContain("wrong user (including sudo)"); + expect(message).toContain("https://docs.openclaw.ai/gateway"); + } finally { + process.env.PATH = originalPath; + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); }); describe("resolveLaunchAgentPlistPath", () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 914f2a4e00..ebe2397189 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -334,6 +334,14 @@ function isLaunchctlNotLoaded(res: { stdout: string; stderr: string; code: numbe ); } +function isUnsupportedGuiDomain(detail: string): boolean { + const normalized = detail.toLowerCase(); + return ( + normalized.includes("domain does not support specified action") || + normalized.includes("bootstrap failed: 125") + ); +} + export async function stopLaunchAgent({ stdout, env, @@ -402,7 +410,19 @@ export async function installLaunchAgent({ await execLaunchctl(["enable", `${domain}/${label}`]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); if (boot.code !== 0) { - throw new Error(`launchctl bootstrap failed: ${boot.stderr || boot.stdout}`.trim()); + const detail = (boot.stderr || boot.stdout).trim(); + if (isUnsupportedGuiDomain(detail)) { + throw new Error( + [ + `launchctl bootstrap failed: ${detail}`, + `LaunchAgent install requires a logged-in macOS GUI session for this user (${domain}).`, + "This usually means you are running from SSH/headless context or as the wrong user (including sudo).", + "Fix: sign in to the macOS desktop as the target user and rerun `openclaw gateway install --force`.", + "Headless deployments should use a dedicated logged-in user session or a custom LaunchDaemon (not shipped): https://docs.openclaw.ai/gateway", + ].join("\n"), + ); + } + throw new Error(`launchctl bootstrap failed: ${detail}`); } await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);