From aa497e9c52d7a8907dd5d780a0a34f636dce262e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 09:16:16 +0100 Subject: [PATCH] refactor: extract daemon launchd recovery helper --- src/cli/daemon-cli/launchd-recovery.test.ts | 67 +++++++++++++++++++ src/cli/daemon-cli/launchd-recovery.ts | 40 +++++++++++ src/cli/daemon-cli/lifecycle-core.ts | 28 ++++---- src/cli/daemon-cli/lifecycle.test.ts | 36 +++++----- src/cli/daemon-cli/lifecycle.ts | 28 +------- .../doctor-gateway-daemon-flow.test.ts | 2 +- src/daemon/launchd.test.ts | 30 +++++++-- src/daemon/launchd.ts | 18 +++-- 8 files changed, 183 insertions(+), 66 deletions(-) create mode 100644 src/cli/daemon-cli/launchd-recovery.test.ts create mode 100644 src/cli/daemon-cli/launchd-recovery.ts diff --git a/src/cli/daemon-cli/launchd-recovery.test.ts b/src/cli/daemon-cli/launchd-recovery.test.ts new file mode 100644 index 00000000000..7b0aa836614 --- /dev/null +++ b/src/cli/daemon-cli/launchd-recovery.test.ts @@ -0,0 +1,67 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const launchAgentPlistExists = vi.hoisted(() => vi.fn()); +const repairLaunchAgentBootstrap = vi.hoisted(() => vi.fn()); + +vi.mock("../../daemon/launchd.js", () => ({ + launchAgentPlistExists: (env: Record) => launchAgentPlistExists(env), + repairLaunchAgentBootstrap: (args: { env?: Record }) => + repairLaunchAgentBootstrap(args), +})); + +let recoverInstalledLaunchAgent: typeof import("./launchd-recovery.js").recoverInstalledLaunchAgent; +let LAUNCH_AGENT_RECOVERY_MESSAGE: typeof import("./launchd-recovery.js").LAUNCH_AGENT_RECOVERY_MESSAGE; + +describe("recoverInstalledLaunchAgent", () => { + beforeAll(async () => { + ({ recoverInstalledLaunchAgent, LAUNCH_AGENT_RECOVERY_MESSAGE } = + await import("./launchd-recovery.js")); + }); + + beforeEach(() => { + launchAgentPlistExists.mockReset(); + repairLaunchAgentBootstrap.mockReset(); + launchAgentPlistExists.mockResolvedValue(false); + repairLaunchAgentBootstrap.mockResolvedValue({ ok: true, status: "repaired" }); + }); + + it("returns null outside macOS", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("linux"); + + await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull(); + expect(launchAgentPlistExists).not.toHaveBeenCalled(); + }); + + it("returns null when the LaunchAgent plist is missing", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + launchAgentPlistExists.mockResolvedValue(false); + + await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull(); + expect(repairLaunchAgentBootstrap).not.toHaveBeenCalled(); + }); + + it("returns a loaded recovery result when bootstrap repair succeeds", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + launchAgentPlistExists.mockResolvedValue(true); + + await expect(recoverInstalledLaunchAgent({ result: "restarted" })).resolves.toEqual({ + result: "restarted", + loaded: true, + message: LAUNCH_AGENT_RECOVERY_MESSAGE, + }); + expect(launchAgentPlistExists).toHaveBeenCalledWith(process.env); + expect(repairLaunchAgentBootstrap).toHaveBeenCalledWith({ env: process.env }); + }); + + it("returns null when bootstrap repair fails", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + launchAgentPlistExists.mockResolvedValue(true); + repairLaunchAgentBootstrap.mockResolvedValue({ + ok: false, + status: "kickstart-failed", + detail: "permission denied", + }); + + await expect(recoverInstalledLaunchAgent({ result: "started" })).resolves.toBeNull(); + }); +}); diff --git a/src/cli/daemon-cli/launchd-recovery.ts b/src/cli/daemon-cli/launchd-recovery.ts new file mode 100644 index 00000000000..51f8417a447 --- /dev/null +++ b/src/cli/daemon-cli/launchd-recovery.ts @@ -0,0 +1,40 @@ +import { launchAgentPlistExists, repairLaunchAgentBootstrap } from "../../daemon/launchd.js"; + +const LAUNCH_AGENT_RECOVERY_MESSAGE = + "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service."; + +type LaunchAgentRecoveryAction = "started" | "restarted"; + +type LaunchAgentRecoveryResult = { + result: LaunchAgentRecoveryAction; + loaded: true; + message: string; +}; + +export async function recoverInstalledLaunchAgent(params: { + result: LaunchAgentRecoveryAction; + env?: Record; +}): Promise { + if (process.platform !== "darwin") { + return null; + } + const env = params.env ?? (process.env as Record); + const plistExists = await launchAgentPlistExists(env).catch(() => false); + if (!plistExists) { + return null; + } + const repaired = await repairLaunchAgentBootstrap({ env }).catch(() => ({ + ok: false as const, + status: "bootstrap-failed" as const, + })); + if (!repaired.ok) { + return null; + } + return { + result: params.result, + loaded: true, + message: LAUNCH_AGENT_RECOVERY_MESSAGE, + }; +} + +export { LAUNCH_AGENT_RECOVERY_MESSAGE }; diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 393826ad972..c8be0c8dd01 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -30,14 +30,14 @@ type RestartPostCheckContext = { fail: (message: string, hints?: string[]) => void; }; -type NotLoadedActionResult = { +type ServiceRecoveryResult = { result: "started" | "stopped" | "restarted"; message?: string; warnings?: string[]; loaded?: boolean; }; -type NotLoadedActionContext = { +type ServiceRecoveryContext = { json: boolean; stdout: Writable; fail: (message: string, hints?: string[]) => void; @@ -187,7 +187,7 @@ export async function runServiceStart(params: { service: GatewayService; renderStartHints: () => string[]; opts?: DaemonLifecycleOptions; - onNotLoaded?: (ctx: NotLoadedActionContext) => Promise; + onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise; }) { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createDaemonActionContext({ action: "start", json }); @@ -278,7 +278,7 @@ export async function runServiceStop(params: { serviceNoun: string; service: GatewayService; opts?: DaemonLifecycleOptions; - onNotLoaded?: (ctx: NotLoadedActionContext) => Promise; + onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise; }) { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createDaemonActionContext({ action: "stop", json }); @@ -349,12 +349,12 @@ export async function runServiceRestart(params: { opts?: DaemonLifecycleOptions; checkTokenDrift?: boolean; postRestartCheck?: (ctx: RestartPostCheckContext) => Promise; - onNotLoaded?: (ctx: NotLoadedActionContext) => Promise; + onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise; }): Promise { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createDaemonActionContext({ action: "restart", json }); const warnings: string[] = []; - let handledNotLoaded: NotLoadedActionResult | null = null; + let handledRecovery: ServiceRecoveryResult | null = null; let recoveredLoadedState: boolean | null = null; const emitScheduledRestart = ( restartStatus: ReturnType, @@ -397,12 +397,12 @@ export async function runServiceRestart(params: { if (!loaded) { try { - handledNotLoaded = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null; + handledRecovery = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null; } catch (err) { fail(`${params.serviceNoun} restart failed: ${String(err)}`); return false; } - if (!handledNotLoaded) { + if (!handledRecovery) { await handleServiceNotLoaded({ serviceNoun: params.serviceNoun, service: params.service, @@ -413,10 +413,10 @@ export async function runServiceRestart(params: { }); return false; } - if (handledNotLoaded.warnings?.length) { - warnings.push(...handledNotLoaded.warnings); + if (handledRecovery.warnings?.length) { + warnings.push(...handledRecovery.warnings); } - recoveredLoadedState = handledNotLoaded.loaded ?? null; + recoveredLoadedState = handledRecovery.loaded ?? null; } if (loaded && params.checkTokenDrift) { @@ -486,12 +486,12 @@ export async function runServiceRestart(params: { emit({ ok: true, result: "restarted", - message: handledNotLoaded?.message, + message: handledRecovery?.message, service: buildDaemonServiceSnapshot(params.service, restarted), warnings: warnings.length ? warnings : undefined, }); - if (!json && handledNotLoaded?.message) { - defaultRuntime.log(handledNotLoaded.message); + if (!json && handledRecovery?.message) { + defaultRuntime.log(handledRecovery.message); } return true; } catch (err) { diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 25d6a6b3c4b..53ded2b5790 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -51,8 +51,7 @@ const probeGateway = vi.fn< >(); const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true); const loadConfig = vi.hoisted(() => vi.fn(() => ({}))); -const launchAgentPlistExists = vi.hoisted(() => vi.fn()); -const repairLaunchAgentBootstrap = vi.hoisted(() => vi.fn()); +const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn()); vi.mock("../../config/config.js", () => ({ loadConfig: () => loadConfig(), @@ -84,10 +83,9 @@ vi.mock("../../daemon/service.js", () => ({ resolveGatewayService: () => service, })); -vi.mock("../../daemon/launchd.js", () => ({ - launchAgentPlistExists: (env: Record) => launchAgentPlistExists(env), - repairLaunchAgentBootstrap: (args: { env?: Record }) => - repairLaunchAgentBootstrap(args), +vi.mock("./launchd-recovery.js", () => ({ + recoverInstalledLaunchAgent: (args: { result: "started" | "restarted" }) => + recoverInstalledLaunchAgent(args), })); vi.mock("./restart-health.js", () => ({ @@ -160,8 +158,7 @@ describe("runDaemonRestart health checks", () => { probeGateway.mockReset(); isRestartEnabled.mockReset(); loadConfig.mockReset(); - launchAgentPlistExists.mockReset(); - repairLaunchAgentBootstrap.mockReset(); + recoverInstalledLaunchAgent.mockReset(); service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway", "--port", "18789"], @@ -169,8 +166,7 @@ describe("runDaemonRestart health checks", () => { }); service.restart.mockResolvedValue({ outcome: "completed" }); runServiceStart.mockResolvedValue(undefined); - launchAgentPlistExists.mockResolvedValue(false); - repairLaunchAgentBootstrap.mockResolvedValue({ ok: true }); + recoverInstalledLaunchAgent.mockResolvedValue(null); runServiceRestart.mockImplementation(async (params: RestartParams) => { const fail = (message: string, hints?: string[]) => { @@ -213,15 +209,18 @@ describe("runDaemonRestart health checks", () => { it("re-bootstraps an installed LaunchAgent when start finds it not loaded", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - launchAgentPlistExists.mockResolvedValue(true); + recoverInstalledLaunchAgent.mockResolvedValue({ + result: "started", + loaded: true, + message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.", + }); runServiceStart.mockImplementation(async (params: { onNotLoaded?: () => Promise }) => { await params.onNotLoaded?.(); }); await runDaemonStart({ json: true }); - expect(launchAgentPlistExists).toHaveBeenCalledWith(process.env); - expect(repairLaunchAgentBootstrap).toHaveBeenCalledWith({ env: process.env }); + expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "started" }); }); it("kills stale gateway pids and retries restart", async () => { @@ -344,21 +343,24 @@ describe("runDaemonRestart health checks", () => { it("prefers unmanaged restart over launchd repair when a gateway listener is present", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - launchAgentPlistExists.mockResolvedValue(true); findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]); mockUnmanagedRestart({ runPostRestartCheck: true }); await runDaemonRestart({ json: true }); expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGUSR1"); - expect(repairLaunchAgentBootstrap).not.toHaveBeenCalled(); + expect(recoverInstalledLaunchAgent).not.toHaveBeenCalled(); expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1); expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled(); }); it("re-bootstraps an installed LaunchAgent on restart when no unmanaged listener exists", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - launchAgentPlistExists.mockResolvedValue(true); + recoverInstalledLaunchAgent.mockResolvedValue({ + result: "restarted", + loaded: true, + message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.", + }); findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]); runServiceRestart.mockImplementation( async (params: RestartParams & { onNotLoaded?: () => Promise }) => { @@ -377,7 +379,7 @@ describe("runDaemonRestart health checks", () => { await runDaemonRestart({ json: true }); - expect(repairLaunchAgentBootstrap).toHaveBeenCalledWith({ env: process.env }); + expect(recoverInstalledLaunchAgent).toHaveBeenCalledWith({ result: "restarted" }); expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled(); expect(waitForGatewayHealthyListener).not.toHaveBeenCalled(); expect(waitForGatewayHealthyRestart).toHaveBeenCalledTimes(1); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 56924d8a9d2..30b319bcc92 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,6 +1,5 @@ import { isRestartEnabled } from "../../config/commands.js"; import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; -import { launchAgentPlistExists, repairLaunchAgentBootstrap } from "../../daemon/launchd.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { probeGateway } from "../../gateway/probe.js"; import { @@ -11,6 +10,7 @@ import { import { defaultRuntime } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; import { formatCliCommand } from "../command-format.js"; +import { recoverInstalledLaunchAgent } from "./launchd-recovery.js"; import { runServiceRestart, runServiceStart, @@ -131,28 +131,6 @@ async function restartGatewayWithoutServiceManager(port: number) { }; } -async function repairLaunchAgentIfInstalled(params: { result: "started" | "restarted" }) { - if (process.platform !== "darwin") { - return null; - } - const serviceEnv = process.env as Record; - const plistExists = await launchAgentPlistExists(serviceEnv).catch(() => false); - if (!plistExists) { - return null; - } - const repaired = await repairLaunchAgentBootstrap({ env: serviceEnv }).catch(() => ({ - ok: false, - })); - if (!repaired.ok) { - return null; - } - return { - result: params.result, - loaded: true, - message: "Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.", - } as const; -} - export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) { return await runServiceUninstall({ serviceNoun: "Gateway", @@ -170,7 +148,7 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) { renderStartHints: renderGatewayServiceStartHints, onNotLoaded: process.platform === "darwin" - ? async () => await repairLaunchAgentIfInstalled({ result: "started" }) + ? async () => await recoverInstalledLaunchAgent({ result: "started" }) : undefined, opts, }); @@ -216,7 +194,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi restartedWithoutServiceManager = true; return handled; } - return await repairLaunchAgentIfInstalled({ result: "restarted" }); + return await recoverInstalledLaunchAgent({ result: "restarted" }); }, postRestartCheck: async ({ warnings, fail, stdout }) => { if (restartedWithoutServiceManager) { diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 4b2e6c712d6..4790ff52f23 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -40,7 +40,7 @@ vi.mock("../daemon/launchd.js", async () => { isLaunchAgentListed: vi.fn(async () => false), isLaunchAgentLoaded: vi.fn(async () => false), launchAgentPlistExists: vi.fn(async () => false), - repairLaunchAgentBootstrap: vi.fn(async () => ({ ok: true })), + repairLaunchAgentBootstrap: vi.fn(async () => ({ ok: true, status: "repaired" })), }; }); diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index c18ac82e1fb..a5fb6207f96 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -247,7 +247,7 @@ describe("launchd bootstrap repair", () => { OPENCLAW_PROFILE: "default", }; const repair = await repairLaunchAgentBootstrap({ env }); - expect(repair.ok).toBe(true); + expect(repair).toEqual({ ok: true, status: "repaired" }); const { serviceId, bootstrapIndex } = expectLaunchctlEnableBootstrapOrder(env); const kickstartIndex = state.launchctlCalls.findIndex( @@ -268,7 +268,7 @@ describe("launchd bootstrap repair", () => { const repair = await repairLaunchAgentBootstrap({ env }); - expect(repair.ok).toBe(true); + expect(repair).toEqual({ ok: true, status: "already-loaded" }); expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1); }); @@ -282,7 +282,7 @@ describe("launchd bootstrap repair", () => { const repair = await repairLaunchAgentBootstrap({ env }); - expect(repair.ok).toBe(true); + expect(repair).toEqual({ ok: true, status: "already-loaded" }); expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toHaveLength(1); }); @@ -295,10 +295,30 @@ describe("launchd bootstrap repair", () => { const repair = await repairLaunchAgentBootstrap({ env }); - expect(repair.ok).toBe(false); - expect(repair.detail).toContain("Could not find specified service"); + expect(repair).toMatchObject({ + ok: false, + status: "bootstrap-failed", + detail: expect.stringContaining("Could not find specified service"), + }); expect(state.launchctlCalls.some((call) => call[0] === "kickstart")).toBe(false); }); + + it("returns a typed kickstart failure", async () => { + state.kickstartError = "launchctl kickstart failed: permission denied"; + state.kickstartFailuresRemaining = 1; + const env: Record = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }; + + const repair = await repairLaunchAgentBootstrap({ env }); + + expect(repair).toEqual({ + ok: false, + status: "kickstart-failed", + detail: "launchctl kickstart failed: permission denied", + }); + }); }); describe("launchd install", () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 7a11db70b7a..fa4393ed071 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -313,9 +313,13 @@ export async function readLaunchAgentRuntime( }; } +export type LaunchAgentBootstrapRepairResult = + | { ok: true; status: "repaired" | "already-loaded" } + | { ok: false; status: "bootstrap-failed" | "kickstart-failed"; detail?: string }; + export async function repairLaunchAgentBootstrap(args: { env?: Record; -}): Promise<{ ok: boolean; detail?: string }> { +}): Promise { const env = args.env ?? (process.env as Record); const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); @@ -324,19 +328,25 @@ export async function repairLaunchAgentBootstrap(args: { // (matches the same guard in installLaunchAgent and restartLaunchAgent). await execLaunchctl(["enable", `${domain}/${label}`]); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); + let repairStatus: LaunchAgentBootstrapRepairResult["status"] = "repaired"; if (boot.code !== 0) { const detail = (boot.stderr || boot.stdout).trim(); const normalized = detail.toLowerCase(); const alreadyLoaded = boot.code === 130 || normalized.includes("already exists in domain"); if (!alreadyLoaded) { - return { ok: false, detail: detail || undefined }; + return { ok: false, status: "bootstrap-failed", detail: detail || undefined }; } + repairStatus = "already-loaded"; } const kick = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); if (kick.code !== 0) { - return { ok: false, detail: (kick.stderr || kick.stdout).trim() || undefined }; + return { + ok: false, + status: "kickstart-failed", + detail: (kick.stderr || kick.stdout).trim() || undefined, + }; } - return { ok: true }; + return { ok: true, status: repairStatus }; } export type LegacyLaunchAgent = {