From 80e7da92ce336548ffab6ea0fc016cad460171de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 13:46:35 +0000 Subject: [PATCH] fix: stabilize macos daemon onboarding --- CHANGELOG.md | 3 +- .../onboard-non-interactive.gateway.test.ts | 35 ++++++++++++++++++- src/commands/onboard-non-interactive/local.ts | 7 +++- src/daemon/launchd.test.ts | 6 +++- src/daemon/launchd.ts | 4 ++- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa88373053..43247ddf461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ Docs: https://docs.openclaw.ai ## Unreleased - ### Changes - Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus. @@ -28,7 +27,7 @@ Docs: https://docs.openclaw.ai - Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates. - Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path. - Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark. - +- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots. ## 2026.3.12 ### Changes diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index e7ab668ea30..f2e0724b53b 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -13,8 +13,9 @@ const gatewayClientCalls: Array<{ onClose?: (code: number, reason: string) => void; }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); +const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async () => {})); let waitForGatewayReachableMock: - | ((params: { url: string; token?: string; password?: string }) => Promise<{ + | ((params: { url: string; token?: string; password?: string; deadlineMs?: number }) => Promise<{ ok: boolean; detail?: string; }>) @@ -59,6 +60,10 @@ vi.mock("./onboard-helpers.js", async (importOriginal) => { }; }); +vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({ + installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock, +})); + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const { resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js"); const { resolveConfigPath } = await import("../config/config.js"); @@ -128,6 +133,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { afterEach(() => { waitForGatewayReachableMock = undefined; + installGatewayDaemonNonInteractiveMock.mockClear(); }); it("writes gateway token auth into config", async () => { @@ -343,6 +349,33 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("uses a longer health deadline when daemon install was requested", async () => { + await withStateDir("state-local-daemon-health-", async (stateDir) => { + let capturedDeadlineMs: number | undefined; + waitForGatewayReachableMock = vi.fn(async (params: { deadlineMs?: number }) => { + capturedDeadlineMs = params.deadlineMs; + return { ok: true }; + }); + + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: true, + gatewayBind: "loopback", + }, + runtime, + ); + + expect(installGatewayDaemonNonInteractiveMock).toHaveBeenCalledTimes(1); + expect(capturedDeadlineMs).toBe(45_000); + }); + }, 60_000); + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index 03145ff8703..0765eb1a513 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -19,6 +19,9 @@ import { logNonInteractiveOnboardingJson } from "./local/output.js"; import { applyNonInteractiveSkillsConfig } from "./local/skills-config.js"; import { resolveNonInteractiveWorkspaceDir } from "./local/workspace.js"; +const INSTALL_DAEMON_HEALTH_DEADLINE_MS = 45_000; +const ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS = 15_000; + export async function runNonInteractiveOnboardingLocal(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -107,7 +110,9 @@ export async function runNonInteractiveOnboardingLocal(params: { const probe = await waitForGatewayReachable({ url: links.wsUrl, token: gatewayResult.gatewayToken, - deadlineMs: 15_000, + deadlineMs: opts.installDaemon + ? INSTALL_DAEMON_HEALTH_DEADLINE_MS + : ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS, }); if (!probe.ok) { const message = [ diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3acd239afe1..ba43715ba28 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -250,7 +250,7 @@ describe("launchd install", () => { }; } - it("enables service before bootstrap (clears persisted disabled state)", async () => { + it("enables service before bootstrap without self-restarting the fresh agent", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent({ env, @@ -269,9 +269,13 @@ describe("launchd install", () => { const bootstrapIndex = state.launchctlCalls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); + const installKickstartIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "kickstart" && c[2] === serviceId, + ); expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(enableIndex).toBeLessThan(bootstrapIndex); + expect(installKickstartIndex).toBe(-1); }); it("writes TMPDIR to LaunchAgent environment when provided", async () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 68ae1b43edd..0e6d8610931 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -431,7 +431,9 @@ export async function installLaunchAgent({ } throw new Error(`launchctl bootstrap failed: ${detail}`); } - await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); + // `bootstrap` already loads RunAtLoad agents. Avoid `kickstart -k` here: + // on slow macOS guests it SIGTERMs the freshly booted gateway and pushes the + // real listener startup past onboarding's health deadline. // Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline). writeFormattedLines(