From affffddf045d3142ed7825271b5d3b1694d062a5 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 10 Apr 2026 21:54:51 +0300 Subject: [PATCH] fix(daemon): keep launchd enable scoped to owned stops --- src/commands/doctor-gateway-daemon-flow.ts | 2 +- src/daemon/launchd-restart-handoff.test.ts | 29 ++++- src/daemon/launchd-restart-handoff.ts | 22 +++- src/daemon/launchd.test.ts | 135 ++++++++++++++++++-- src/daemon/launchd.ts | 140 ++++++++++++++++----- 5 files changed, 282 insertions(+), 46 deletions(-) diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 5d98c3e9719..b2cdda67de3 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -68,7 +68,7 @@ async function maybeRepairLaunchAgentBootstrap(params: { } params.runtime.log(`Bootstrapping ${params.title} LaunchAgent...`); - const repair = await repairLaunchAgentBootstrap({ env: params.env }); + const repair = await repairLaunchAgentBootstrap({ env: params.env, forceEnable: true }); if (!repair.ok) { params.runtime.error( `${params.title} LaunchAgent bootstrap failed: ${repair.detail ?? "unknown error"}`, diff --git a/src/daemon/launchd-restart-handoff.test.ts b/src/daemon/launchd-restart-handoff.test.ts index ff7a82bc354..d2dfe6a1d31 100644 --- a/src/daemon/launchd-restart-handoff.test.ts +++ b/src/daemon/launchd-restart-handoff.test.ts @@ -38,11 +38,34 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => { const [, args] = spawnMock.mock.calls[0] as [string, string[]]; expect(args[0]).toBe("-c"); expect(args[2]).toBe("openclaw-launchd-restart-handoff"); - expect(args[6]).toBe("9876"); + expect(args[6]).toBe("0"); + expect(args[7]).toBe("9876"); expect(args[1]).toContain('while kill -0 "$wait_pid" >/dev/null 2>&1; do'); - expect(args[1]).toContain('launchctl enable "$service_target" >/dev/null 2>&1'); - expect(args[1]).toContain('launchctl kickstart -k "$service_target" >/dev/null 2>&1'); + expect(args[1]).not.toContain('launchctl enable "$service_target" >/dev/null 2>&1\nif !'); + expect(args[1]).toContain( + 'if ! launchctl kickstart -k "$service_target" >/dev/null 2>&1; then', + ); expect(args[1]).not.toContain("sleep 1"); expect(unrefMock).toHaveBeenCalledTimes(1); }); + + it("only injects launchctl enable when the caller requested re-enable", () => { + spawnMock.mockReturnValue({ pid: 4242, unref: unrefMock }); + + scheduleDetachedLaunchdRestartHandoff({ + env: { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }, + mode: "kickstart", + shouldEnable: true, + enableMarkerPath: "/Users/test/.openclaw/service/marker", + }); + + const [, args] = spawnMock.mock.calls[0] as [string, string[]]; + expect(args[6]).toBe("1"); + expect(args[8]).toBe("/Users/test/.openclaw/service/marker"); + expect(args[1]).toContain('launchctl enable "$service_target" >/dev/null 2>&1'); + expect(args[1]).toContain('rm -f "$enable_marker_path" >/dev/null 2>&1 || true'); + }); }); diff --git a/src/daemon/launchd-restart-handoff.ts b/src/daemon/launchd-restart-handoff.ts index 06d97542e0c..b465160fd85 100644 --- a/src/daemon/launchd-restart-handoff.ts +++ b/src/daemon/launchd-restart-handoff.ts @@ -66,7 +66,9 @@ export function isCurrentProcessLaunchdServiceLabel( } function buildLaunchdRestartScript(mode: LaunchdRestartHandoffMode): string { - const waitForCallerPid = `wait_pid="$4" + const waitForCallerPid = `should_enable="$4" +wait_pid="$5" +enable_marker_path="$6" if [ -n "$wait_pid" ] && [ "$wait_pid" -gt 1 ] 2>/dev/null; then while kill -0 "$wait_pid" >/dev/null 2>&1; do sleep 0.1 @@ -79,7 +81,12 @@ fi domain="$2" plist_path="$3" ${waitForCallerPid} -launchctl enable "$service_target" >/dev/null 2>&1 +if [ "$should_enable" = "1" ]; then + launchctl enable "$service_target" >/dev/null 2>&1 + if [ -n "$enable_marker_path" ]; then + rm -f "$enable_marker_path" >/dev/null 2>&1 || true + fi +fi if ! launchctl kickstart -k "$service_target" >/dev/null 2>&1; then if launchctl bootstrap "$domain" "$plist_path" >/dev/null 2>&1; then launchctl kickstart -k "$service_target" >/dev/null 2>&1 || true @@ -93,7 +100,12 @@ domain="$2" plist_path="$3" label="$(basename "$service_target")" ${waitForCallerPid} -launchctl enable "$service_target" >/dev/null 2>&1 +if [ "$should_enable" = "1" ]; then + launchctl enable "$service_target" >/dev/null 2>&1 + if [ -n "$enable_marker_path" ]; then + rm -f "$enable_marker_path" >/dev/null 2>&1 || true + fi +fi if ! launchctl start "$label" >/dev/null 2>&1; then if launchctl bootstrap "$domain" "$plist_path" >/dev/null 2>&1; then launchctl start "$label" >/dev/null 2>&1 || launchctl kickstart -k "$service_target" >/dev/null 2>&1 || true @@ -107,7 +119,9 @@ fi export function scheduleDetachedLaunchdRestartHandoff(params: { env?: Record; mode: LaunchdRestartHandoffMode; + shouldEnable?: boolean; waitForPid?: number; + enableMarkerPath?: string; }): LaunchdRestartHandoffResult { const target = resolveLaunchdRestartTarget(params.env); const waitForPid = @@ -124,7 +138,9 @@ export function scheduleDetachedLaunchdRestartHandoff(params: { target.serviceTarget, target.domain, target.plistPath, + params.shouldEnable ? "1" : "0", String(waitForPid), + params.enableMarkerPath ?? "", ], { detached: true, diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index a451ad101e3..30d95d5a40d 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { PassThrough } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -49,6 +50,19 @@ const cleanStaleGatewayProcessesSync = vi.hoisted(() => ); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; +function resolveDisableMarkerPath( + env: Record, + label = "ai.openclaw.gateway", +) { + const profile = env.OPENCLAW_PROFILE?.trim(); + const suffix = !profile || profile.toLowerCase() === "default" ? "" : `-${profile}`; + return path.join( + env.OPENCLAW_STATE_DIR ?? path.join(env.HOME ?? "/Users/test", `.openclaw${suffix}`), + "service", + `${encodeURIComponent(label)}.launchd-disabled-by-openclaw`, + ); +} + function expectLaunchctlEnableBootstrapOrder(env: Record) { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; @@ -318,7 +332,7 @@ describe("launchctl list detection", () => { }); describe("launchd bootstrap repair", () => { - it("enables, bootstraps, and kickstarts the resolved label", async () => { + it("bootstraps and kickstarts the resolved label without enabling unrelated disabled state", async () => { const env: Record = { HOME: "/Users/test", OPENCLAW_PROFILE: "default", @@ -326,11 +340,16 @@ describe("launchd bootstrap repair", () => { const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: true, status: "repaired" }); - const { serviceId, bootstrapIndex } = expectLaunchctlEnableBootstrapOrder(env); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const serviceId = `${domain}/ai.openclaw.gateway`; + const bootstrapIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "bootstrap" && c[1] === domain, + ); const kickstartIndex = state.launchctlCalls.findIndex( (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, ); + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(false); expect(kickstartIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeLessThan(kickstartIndex); }); @@ -396,6 +415,30 @@ describe("launchd bootstrap repair", () => { detail: "launchctl kickstart failed: permission denied", }); }); + + it("re-enables when the disabled marker shows OpenClaw owns the stop state", async () => { + const env: Record = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }; + state.files.set(resolveDisableMarkerPath(env), "disabled_by_openclaw\n"); + + await repairLaunchAgentBootstrap({ env }); + + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); + expect(state.files.has(resolveDisableMarkerPath(env))).toBe(false); + }); + + it("allows explicit repairs to force re-enable disabled services", async () => { + const env: Record = { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + }; + + await repairLaunchAgentBootstrap({ env, forceEnable: true }); + + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); + }); }); describe("launchd install", () => { @@ -492,6 +535,7 @@ describe("launchd install", () => { expect(state.launchctlCalls).toContainEqual(["disable", serviceId]); expect(state.launchctlCalls).toContainEqual(["stop", "ai.openclaw.gateway"]); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); + expect(state.files.has(resolveDisableMarkerPath(env))).toBe(true); expect(output).toContain("Stopped LaunchAgent"); }); @@ -508,6 +552,7 @@ describe("launchd install", () => { expect(state.launchctlCalls.some((call) => call[0] === "stop")).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(state.files.has(resolveDisableMarkerPath(env))).toBe(false); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain("used bootout fallback"); }); @@ -529,6 +574,22 @@ describe("launchd install", () => { expect(output).toContain("did not fully stop the service"); }); + it("falls back to bootout when launchctl stop itself errors", async () => { + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + let output = ""; + state.stopError = "stop failed due to transient launchd error"; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + await stopLaunchAgent({ env, stdout }); + + expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(true); + expect(output).toContain("Stopped LaunchAgent (degraded)"); + expect(output).toContain("launchctl stop failed; used bootout fallback"); + }); + it("falls back to bootout when launchctl print cannot confirm the stop state", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough(); @@ -553,10 +614,26 @@ describe("launchd install", () => { state.bootoutError = "launchctl bootout permission denied"; await expect(stopLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( - "launchctl print could not confirm stop: launchctl print permission denied; launchctl bootout failed: launchctl bootout permission denied", + "launchctl print could not confirm stop; used bootout fallback and left service unloaded: launchctl print permission denied; launchctl bootout failed: launchctl bootout permission denied", ); }); + it("sanitizes launchctl details before writing warnings", async () => { + const env = createDefaultLaunchdEnv(); + const stdout = new PassThrough(); + let output = ""; + state.disableError = "boom\n\u001b[31mred\u001b[0m\tmsg"; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + + await stopLaunchAgent({ env, stdout }); + + expect(output).not.toContain("\u001b[31m"); + expect(output).not.toContain("\nred\n"); + expect(output).toContain("boom red msg"); + }); + it("restarts LaunchAgent with kickstart and no bootout", async () => { const env = { ...createDefaultLaunchdEnv(), @@ -572,12 +649,30 @@ describe("launchd install", () => { const serviceId = `${domain}/${label}`; expect(result).toEqual({ outcome: "completed" }); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(18789); - expect(state.launchctlCalls).toContainEqual(["enable", serviceId]); + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(false); expect(state.launchctlCalls).toContainEqual(["kickstart", "-k", serviceId]); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); }); + it("re-enables before restart when OpenClaw owns the persisted disabled state", async () => { + const env = { + ...createDefaultLaunchdEnv(), + OPENCLAW_GATEWAY_PORT: "18789", + }; + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const serviceId = `${domain}/ai.openclaw.gateway`; + state.files.set(resolveDisableMarkerPath(env), "disabled_by_openclaw\n"); + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + expect(state.launchctlCalls).toContainEqual(["enable", serviceId]); + expect(state.files.has(resolveDisableMarkerPath(env))).toBe(false); + }); + it("uses the configured gateway port for stale cleanup", async () => { const env = { ...createDefaultLaunchdEnv(), @@ -614,12 +709,15 @@ describe("launchd install", () => { stdout: new PassThrough(), }); - const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); + const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; + const serviceId = `${domain}/ai.openclaw.gateway`; const kickstartCalls = state.launchctlCalls.filter( (c) => c[0] === "kickstart" && c[1] === "-k" && c[2] === serviceId, ); expect(result).toEqual({ outcome: "completed" }); + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(false); + expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(true); expect(kickstartCalls).toHaveLength(2); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); }); @@ -636,7 +734,7 @@ describe("launchd install", () => { }), ).rejects.toThrow("launchctl kickstart failed: Input/output error"); - expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); }); @@ -653,7 +751,7 @@ describe("launchd install", () => { }), ).rejects.toThrow("launchctl kickstart failed: Input/output error"); - expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(true); }); @@ -669,7 +767,7 @@ describe("launchd install", () => { }), ).rejects.toThrow("launchctl kickstart failed: Input/output error"); - expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(true); + expect(state.launchctlCalls.some((call) => call[0] === "enable")).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootstrap")).toBe(false); }); @@ -686,11 +784,32 @@ describe("launchd install", () => { expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({ env, mode: "kickstart", + shouldEnable: false, waitForPid: process.pid, + enableMarkerPath: undefined, }); expect(state.launchctlCalls).toEqual([]); }); + it("passes marker-owned re-enable intent to the detached handoff", async () => { + const env = createDefaultLaunchdEnv(); + state.files.set(resolveDisableMarkerPath(env), "disabled_by_openclaw\n"); + launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(true); + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({ + env, + mode: "kickstart", + shouldEnable: true, + waitForPid: process.pid, + enableMarkerPath: resolveDisableMarkerPath(env), + }); + }); + it("shows actionable guidance when launchctl gui domain does not support bootstrap", async () => { state.bootstrapError = "Bootstrap failed: 125: Domain does not support specified action"; const env = createDefaultLaunchdEnv(); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index e5a513b626c..1055db68f9d 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; +import { sanitizeForLog } from "../terminal/ansi.js"; import { GATEWAY_LAUNCH_AGENT_LABEL, resolveGatewayServiceDescription, @@ -195,8 +196,11 @@ async function bootstrapLaunchAgentOrThrow(params: { serviceTarget: string; plistPath: string; actionHint: string; + enableBeforeBootstrap?: boolean; }) { - await execLaunchctl(["enable", params.serviceTarget]); + if (params.enableBeforeBootstrap) { + await execLaunchctl(["enable", params.serviceTarget]); + } const boot = await execLaunchctl(["bootstrap", params.domain, params.plistPath]); if (boot.code === 0) { return; @@ -320,14 +324,18 @@ export type LaunchAgentBootstrapRepairResult = export async function repairLaunchAgentBootstrap(args: { env?: Record; + forceEnable?: boolean; }): Promise { const env = args.env ?? (process.env as Record); const domain = resolveGuiDomain(); const label = resolveLaunchAgentLabel({ env }); const plistPath = resolveLaunchAgentPlistPath(env); - // launchd can persist "disabled" state after bootout; clear it before bootstrap - // (matches the same guard in installLaunchAgent and restartLaunchAgent). - await execLaunchctl(["enable", `${domain}/${label}`]); + await enableLaunchAgentIfOwnedStop({ + env, + serviceTarget: `${domain}/${label}`, + label, + force: args.forceEnable, + }); const boot = await execLaunchctl(["bootstrap", domain, plistPath]); let repairStatus: LaunchAgentBootstrapRepairResult["status"] = "repaired"; if (boot.code !== 0) { @@ -469,7 +477,80 @@ function formatLaunchctlResultDetail(res: { stderr: string; code: number; }): string { - return (res.stderr || res.stdout).trim(); + return sanitizeForLog((res.stderr || res.stdout).replace(/[\r\n\t]+/g, " ")) + .replace(/\s+/g, " ") + .trim() + .slice(0, 1000); +} + +function resolveLaunchAgentDisableMarkerPath(env: GatewayServiceEnv, label: string): string { + return path.join( + resolveGatewayStateDir(env), + "service", + `${encodeURIComponent(label)}.launchd-disabled-by-openclaw`, + ); +} + +async function hasLaunchAgentDisableMarker(params: { + env: GatewayServiceEnv; + label: string; +}): Promise { + try { + await fs.access(resolveLaunchAgentDisableMarkerPath(params.env, params.label)); + return true; + } catch { + return false; + } +} + +async function writeLaunchAgentDisableMarker(params: { + env: GatewayServiceEnv; + label: string; +}): Promise { + const markerPath = resolveLaunchAgentDisableMarkerPath(params.env, params.label); + await ensureSecureDirectory(path.dirname(markerPath)); + await fs.writeFile(markerPath, "disabled_by_openclaw\n", { mode: 0o600 }); + await fs.chmod(markerPath, 0o600).catch(() => undefined); +} + +async function clearLaunchAgentDisableMarker(params: { + env: GatewayServiceEnv; + label: string; +}): Promise { + await fs + .unlink(resolveLaunchAgentDisableMarkerPath(params.env, params.label)) + .catch(() => undefined); +} + +async function enableLaunchAgentIfOwnedStop(params: { + env: GatewayServiceEnv; + serviceTarget: string; + label: string; + force?: boolean; +}): Promise { + const shouldEnable = + params.force || (await hasLaunchAgentDisableMarker({ env: params.env, label: params.label })); + if (!shouldEnable) { + return false; + } + await execLaunchctl(["enable", params.serviceTarget]); + await clearLaunchAgentDisableMarker({ env: params.env, label: params.label }); + return true; +} + +async function bootoutLaunchAgentOrThrow(params: { + serviceTarget: string; + warning: string; + stdout: NodeJS.WritableStream; +}): Promise { + const bootout = await execLaunchctl(["bootout", params.serviceTarget]); + if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { + throw new Error( + `${params.warning}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`, + ); + } + params.stdout.write(`${formatLine("Warning", params.warning)}\n`); + params.stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", params.serviceTarget)}\n`); } type LaunchAgentProbeResult = @@ -524,43 +605,33 @@ export async function stopLaunchAgent({ stdout, env }: GatewayServiceControlArgs // the command still leaves the gateway down. const disable = await execLaunchctl(["disable", serviceTarget]); if (disable.code !== 0) { - const bootout = await execLaunchctl(["bootout", serviceTarget]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - throw new Error( - `launchctl disable failed: ${formatLaunchctlResultDetail(disable)}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`, - ); - } - stdout.write( - `${formatLine("Warning", `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disable)}`)}\n`, - ); - stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); + await bootoutLaunchAgentOrThrow({ + serviceTarget, + stdout, + warning: `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disable)}`, + }); return; } + await writeLaunchAgentDisableMarker({ env: serviceEnv, label }); // `launchctl stop` targets the plain label (not the fully-qualified service target). const stop = await execLaunchctl(["stop", label]); if (stop.code !== 0 && !isLaunchctlNotLoaded(stop)) { - throw new Error(`launchctl stop failed: ${formatLaunchctlResultDetail(stop)}`); + await bootoutLaunchAgentOrThrow({ + serviceTarget, + stdout, + warning: `launchctl stop failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(stop)}`, + }); + return; } const stopState = await waitForLaunchAgentStopped(serviceTarget); if (stopState.state !== "stopped" && stopState.state !== "not-loaded") { - const bootout = await execLaunchctl(["bootout", serviceTarget]); - if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) { - const reason = - stopState.state === "unknown" - ? `launchctl print could not confirm stop: ${stopState.detail ?? "unknown error"}` - : "launchctl stop left the service running"; - throw new Error( - `${reason}; launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`, - ); - } const warning = stopState.state === "unknown" ? `launchctl print could not confirm stop; used bootout fallback and left service unloaded: ${stopState.detail ?? "unknown error"}` : "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded"; - stdout.write(`${formatLine("Warning", warning)}\n`); - stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`); + await bootoutLaunchAgentOrThrow({ serviceTarget, stdout, warning }); return; } @@ -640,6 +711,7 @@ async function activateLaunchAgent(params: { env: GatewayServiceEnv; plistPath: serviceTarget: `${domain}/${label}`, plistPath: params.plistPath, actionHint: "openclaw gateway install --force", + enableBeforeBootstrap: true, }); } @@ -696,11 +768,17 @@ export async function restartLaunchAgent({ // Restart requests issued from inside the managed gateway process tree need a // detached handoff. A direct `kickstart -k` would terminate the caller before // it can finish the restart command. + const shouldEnable = await hasLaunchAgentDisableMarker({ env: serviceEnv, label }); + if (isCurrentProcessLaunchdServiceLabel(label)) { const handoff = scheduleDetachedLaunchdRestartHandoff({ env: serviceEnv, mode: "kickstart", + shouldEnable, waitForPid: process.pid, + enableMarkerPath: shouldEnable + ? resolveLaunchAgentDisableMarkerPath(serviceEnv, label) + : undefined, }); if (!handoff.ok) { throw new Error(`launchd restart handoff failed: ${handoff.detail ?? "unknown error"}`); @@ -714,9 +792,9 @@ export async function restartLaunchAgent({ cleanStaleGatewayProcessesSync(cleanupPort); } - // Clear any persisted disabled state left behind by `openclaw gateway stop` - // before trying the normal restart path. - await execLaunchctl(["enable", serviceTarget]); + // Only re-enable disabled LaunchAgents when OpenClaw itself owns the + // persisted stop state. + await enableLaunchAgentIfOwnedStop({ env: serviceEnv, serviceTarget, label }); const start = await execLaunchctl(["kickstart", "-k", serviceTarget]); if (start.code === 0) {