fix(daemon): keep launchd enable scoped to owned stops

This commit is contained in:
Nimrod Gutman
2026-04-10 21:54:51 +03:00
committed by Peter Steinberger
parent c0ddcf6630
commit affffddf04
5 changed files with 282 additions and 46 deletions

View File

@@ -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"}`,

View File

@@ -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');
});
});

View File

@@ -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<string, string | undefined>;
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,

View File

@@ -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<string, string | undefined>,
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<string, string | undefined>) {
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<string, string | undefined> = {
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<string, string | undefined> = {
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<string, string | undefined> = {
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();

View File

@@ -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<string, string | undefined>;
forceEnable?: boolean;
}): Promise<LaunchAgentBootstrapRepairResult> {
const env = args.env ?? (process.env as Record<string, string | undefined>);
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<boolean> {
try {
await fs.access(resolveLaunchAgentDisableMarkerPath(params.env, params.label));
return true;
} catch {
return false;
}
}
async function writeLaunchAgentDisableMarker(params: {
env: GatewayServiceEnv;
label: string;
}): Promise<void> {
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<void> {
await fs
.unlink(resolveLaunchAgentDisableMarkerPath(params.env, params.label))
.catch(() => undefined);
}
async function enableLaunchAgentIfOwnedStop(params: {
env: GatewayServiceEnv;
serviceTarget: string;
label: string;
force?: boolean;
}): Promise<boolean> {
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<void> {
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) {