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}`]);