// Launchd tests cover macOS service plist generation and command handling. import fs from "node:fs/promises"; import { PassThrough } from "node:stream"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PortListener } from "../infra/ports-types.js"; import { withEnvAsync } from "../test-utils/env.js"; import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "./constants.js"; import type { ExecResult } from "./exec-file.js"; import { LAUNCH_AGENT_ENV_WRAPPER_SHELL, LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS, } from "./launchd-plist.js"; import { decodeLaunchAgentPlistFixture } from "./launchd-plist.test-support.js"; import { installLaunchAgent as installLaunchAgentImpl, disableCurrentOpenClawUpdateLaunchdJob, disableOpenClawUpdateLaunchdJob, findStaleOpenClawUpdateLaunchdJobs, isLaunchAgentEnabled, isLaunchAgentLoaded, parkCurrentLaunchAgentForMaintenance, parseLaunchAgentEnabled, parseLaunchctlPrint, parseLaunchctlListOpenClawUpdateJobs, readLaunchAgentProgramArguments, readLaunchAgentRuntime, repairLaunchAgentBootstrap, restartLaunchAgent, resolveLaunchAgentPlistPath, stageLaunchAgent, startLaunchAgent, stopLaunchAgent, uninstallLaunchAgent, } from "./launchd.js"; const state = vi.hoisted(() => ({ launchctlCalls: [] as string[][], listOutput: "", printOutput: "", printDisabledOutput: "", printDisabledError: "", printDisabledCode: 0, printNotLoadedRemaining: 0, printError: "", printCode: 1, printFailuresRemaining: 0, bootstrapError: "", bootstrapCode: 1, bootstrapTermination: "exit" as ExecResult["termination"], bootstrapLoadsServiceOnFailure: false, bootstrapTransient: false, kickstartError: "", kickstartCode: 1, kickstartFailuresRemaining: 0, disableError: "", disableCode: 1, stopError: "", stopCode: 1, bootoutError: "", bootoutCode: 1, serviceLoaded: true, serviceRunning: true, serviceStates: new Map(), stopLeavesRunning: false, dirs: new Set(), dirModes: new Map(), files: new Map(), fileModes: new Map(), fileWrites: [] as Array<{ path: string; data: string }>, cleanupProtectedPids: [] as Array, realExecFile: false, })); const launchdRestartHandoffState = vi.hoisted(() => ({ scheduleDetachedLaunchdMaintenancePark: vi.fn< (_params: unknown) => { ok: true; value: Promise } | { ok: false; error: string } >(() => ({ ok: true, value: Promise.resolve(true) })), scheduleDetachedLaunchdRestartHandoff: vi.fn< (_params: unknown) => { ok: true; value: Promise } | { ok: false; error: string } >(() => ({ ok: true, value: Promise.resolve(true) })), })); const launchdSystemState = vi.hoisted(() => ({ assertNoSystemLaunchDaemonOwnership: vi.fn<(label: string) => Promise>(async () => {}), inspectSystemLaunchDaemonOwnership: vi.fn< ( label: string, options?: { scanInstalledPlists?: boolean }, ) => Promise<{ status: "absent" | "loaded" | "unverifiable"; serviceTarget: string; operation?: "launchctl"; detail?: string; }> >(async (label: string) => ({ status: "absent" as const, serviceTarget: `system/${label}`, })), })); type CleanStaleGatewayProcessesOptions = { protectedPid?: number; resolveProtectedPid?: () => number | undefined; }; const cleanStaleGatewayProcessesSync = vi.hoisted(() => vi.fn<(port?: number, options?: CleanStaleGatewayProcessesOptions) => number[]>(() => []), ); const getSelfAndAncestorPidsSync = vi.hoisted(() => vi.fn<() => Set>()); const launchdCallerPids = vi.hoisted(() => { // Keep the synthetic caller graph separate from host PIDs and both service fixture PIDs. const caller = Math.max(process.pid, process.ppid, 4242, 4343) + 1; return [caller, caller + 1]; }); const launchctlSpawnSync = vi.hoisted(() => vi.fn()); const inspectPortUsage = vi.hoisted(() => vi.fn(async () => ({ port: 18789, status: "free", listeners: [], hints: [], })), ); const probePortUsage = vi.hoisted(() => vi.fn(async () => "free"), ); const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."])); const resolveGatewayServiceProbeHosts = vi.hoisted(() => vi.fn<(_params?: unknown) => Promise>(async () => ["127.0.0.1"]), ); const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { let count = 0; for (const item of items) { if (predicate(item)) { count += 1; } } return count; } function readPlistProgramArgumentStrings(plist: string): string[] { const match = plist.match(/ProgramArguments<\/key>\s*([\s\S]*?)<\/array>/i); return Array.from((match?.[1] ?? "").matchAll(/([\s\S]*?)<\/string>/gi)).map( (item) => item[1] ?? "", ); } function createDefaultLaunchdEnv(): Record { return { HOME: "/Users/test", OPENCLAW_PROFILE: "default", }; } function createLaunchdEnvWithGatewayPort(port: string): Record { return { ...createDefaultLaunchdEnv(), OPENCLAW_GATEWAY_PORT: port }; } function capturePassThroughOutput( append: (text: string) => void, encoding?: BufferEncoding, ): PassThrough { const stdout = new PassThrough(); stdout.on("data", (chunk: Buffer) => append(chunk.toString(encoding))); return stdout; } function setLegacyGatewayLaunchAgentPlist(plistPath: string, extraLines: string[]): void { state.files.set( plistPath, [ '', '', " ", " Label", " ai.openclaw.gateway", " ProgramArguments", " ", " node", " gateway.js", " ", ...extraLines, " ", "", ].join("\n"), ); } async function installLaunchAgent( args: Parameters[0], ): ReturnType { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceTarget = `${domain}/ai.openclaw.gateway`; if ( !state.files.has(resolveLaunchAgentPlistPath(args.env)) && !state.serviceStates.has(serviceTarget) ) { // Most install cases model a genuinely fresh definition. Cached jobs with // no plist must opt in explicitly because they have no rollback artifact. state.serviceLoaded = false; state.serviceRunning = false; } return await installLaunchAgentImpl(args); } function createTestLaunchAgentPlist(params: { label: string; programArguments: string[]; environment?: Record; }): string { const argsXml = params.programArguments.map((arg) => ` ${arg}`).join("\n"); const envXml = params.environment ? [ " EnvironmentVariables", " ", ...Object.entries(params.environment).flatMap(([key, value]) => [ ` ${key}`, ` ${value}`, ]), " ", ].join("\n") : ""; return [ '', '', " ", " Label", ` ${params.label}`, " ProgramArguments", " ", argsXml, " ", envXml, " ", "", "", ].join("\n"); } function setLaunchAgentPlist( env: Record, label: string, programArguments: string[], environment?: Record, ): void { state.files.set( `${env.HOME}/Library/LaunchAgents/${label}.plist`, createTestLaunchAgentPlist({ label, programArguments, environment }), ); } type LaunchAgentInstallFixture = Parameters[0]; type LaunchAgentInstallOverrides = Omit< LaunchAgentInstallFixture, "env" | "stdout" | "programArguments" >; function launchAgentFixture( env: LaunchAgentInstallFixture["env"], programArguments: string[], overrides: LaunchAgentInstallOverrides = {}, ): LaunchAgentInstallFixture { return { env, stdout: new PassThrough(), programArguments, ...overrides }; } function defaultLaunchAgentFixture( env: LaunchAgentInstallFixture["env"], overrides: LaunchAgentInstallOverrides = {}, ): LaunchAgentInstallFixture { return launchAgentFixture(env, defaultProgramArguments, overrides); } type LaunchAgentControlFixture = Parameters[0] & Parameters[0]; function launchAgentControlFixture( env: LaunchAgentControlFixture["env"], overrides: Omit = {}, ): LaunchAgentControlFixture { return { env, stdout: new PassThrough(), ...overrides }; } async function runStopLaunchAgentWithFakeTimers(args: Parameters[0]) { vi.useFakeTimers(); try { const stopPromise = stopLaunchAgent(args) .then(() => ({ ok: true as const })) .catch((error: unknown) => ({ ok: false as const, error })); await vi.runAllTimersAsync(); const result = await stopPromise; if (!result.ok) { throw result.error; } } finally { vi.useRealTimers(); } } async function runRestartLaunchAgentWithFakeTimers(args: Parameters[0]) { vi.useFakeTimers(); try { const restartPromise = restartLaunchAgent(args) .then((value) => ({ ok: true as const, value })) .catch((error: unknown) => ({ ok: false as const, error })); await vi.runAllTimersAsync(); const result = await restartPromise; if (!result.ok) { throw result.error; } return result.value; } finally { vi.useRealTimers(); } } function expectLaunchctlEnableBootstrapOrder( env: Record, label = "ai.openclaw.gateway", ) { const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const plistPath = resolveLaunchAgentPlistPath(env); const serviceId = `${domain}/${label}`; const enableIndex = state.launchctlCalls.findIndex( (c) => c[0] === "enable" && c[1] === serviceId, ); const bootstrapIndex = state.launchctlCalls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(enableIndex).toBeLessThan(bootstrapIndex); return { domain, label, serviceId, bootstrapIndex }; } async function expectRestartLaunchAgentKickstartFailure( env: Record, ): Promise { await expect( restartLaunchAgent({ env, stdout: new PassThrough(), }), ).rejects.toThrow("launchctl kickstart failed: Input/output error"); } function launchctlCommandNames(): string[] { return state.launchctlCalls.map(([command]) => command ?? ""); } function createSystemOwnershipError( status: "loaded" | "installed" | "unverifiable" = "loaded", ): Error { const ownership = status === "installed" ? { status, serviceTarget: "system/ai.openclaw.gateway", plistPath: "/Library/LaunchDaemons/custom-openclaw.plist", } : status === "unverifiable" ? { status, serviceTarget: "system/ai.openclaw.gateway", operation: "launchctl", detail: "permission denied", } : { status, serviceTarget: "system/ai.openclaw.gateway" }; return Object.assign(new Error(`system ownership blocked: ${status}`), { code: "SYSTEM_LAUNCH_DAEMON_OWNERSHIP", ownership, }); } function normalizeLaunchctlArgs(file: string, args: string[]): string[] { if (file === "launchctl") { return args; } const idx = args.indexOf("launchctl"); if (idx >= 0) { return args.slice(idx + 1); } return args; } function executeLaunchctlMock(file: string, args: string[]) { const call = normalizeLaunchctlArgs(file, args); state.launchctlCalls.push(call); if (call[0] === "list") { return { stdout: state.listOutput, stderr: "", code: 0 }; } if (call[0] === "print-disabled") { return { stdout: state.printDisabledOutput, stderr: state.printDisabledError, code: state.printDisabledCode, }; } if (call[0] === "print") { if (state.printNotLoadedRemaining > 0) { state.printNotLoadedRemaining -= 1; return { stdout: "", stderr: "Could not find service", code: 113 }; } if (state.printError && state.printFailuresRemaining > 0) { state.printFailuresRemaining -= 1; return { stdout: "", stderr: state.printError, code: state.printCode }; } const serviceState = state.serviceStates.get(call[1] ?? ""); if (serviceState === "not-loaded") { return { stdout: "", stderr: "Could not find service", code: 113 }; } if (serviceState === "stopped") { return { stdout: ["state = waiting", "pid = 0"].join("\n"), stderr: "", code: 0 }; } if (serviceState === "running") { return { stdout: ["state = running", "pid = 4242"].join("\n"), stderr: "", code: 0 }; } if (!state.serviceLoaded) { return { stdout: "", stderr: "Could not find service", code: 113 }; } if (state.printOutput) { return { stdout: state.printOutput, stderr: "", code: 0 }; } if (!state.serviceRunning) { return { stdout: ["state = waiting", "pid = 0"].join("\n"), stderr: "", code: 0 }; } return { stdout: ["state = running", "pid = 4242"].join("\n"), stderr: "", code: 0 }; } if (call[0] === "disable" && state.disableError) { return { stdout: "", stderr: state.disableError, code: state.disableCode }; } if (call[0] === "stop") { if (state.stopError) { return { stdout: "", stderr: state.stopError, code: state.stopCode }; } if (!state.stopLeavesRunning) { state.serviceRunning = false; } return { stdout: "", stderr: "", code: 0 }; } if (call[0] === "bootout") { if (state.bootoutError) { return { stdout: "", stderr: state.bootoutError, code: state.bootoutCode }; } state.serviceLoaded = false; state.serviceRunning = false; return { stdout: "", stderr: "", code: 0 }; } if (call[0] === "enable") { return { stdout: "", stderr: "", code: 0 }; } if (call[0] === "bootstrap") { if (state.bootstrapError) { const detail = state.bootstrapError; // Transient failures clear after one attempt so recovery paths that retry // a bootstrap can be exercised the way launchd behaves once a booted-out // job finishes tearing down. if (state.bootstrapTransient) { state.bootstrapError = ""; } if (state.bootstrapLoadsServiceOnFailure) { state.serviceLoaded = true; state.serviceRunning = true; } return { stdout: "", stderr: detail, code: state.bootstrapCode, termination: state.bootstrapTermination, }; } state.serviceLoaded = true; state.serviceRunning = true; return { stdout: "", stderr: "", code: 0 }; } if (call[0] === "kickstart") { if (state.kickstartError && state.kickstartFailuresRemaining > 0) { state.kickstartFailuresRemaining -= 1; return { stdout: "", stderr: state.kickstartError, code: state.kickstartCode }; } state.serviceLoaded = true; state.serviceRunning = true; return { stdout: "", stderr: "", code: 0 }; } return { stdout: "", stderr: "", code: 0 }; } vi.mock("../process/exec.js", async (importOriginal) => ({ ...(await importOriginal()), runExec: vi.fn( async (_command: string, _args: string[], options: { input: string | Uint8Array }) => decodeLaunchAgentPlistFixture(options.input), ), })); vi.mock("node:child_process", async () => { const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks"); return mockNodeBuiltinModule( () => vi.importActual("node:child_process"), { spawnSync: (...args: unknown[]) => launchctlSpawnSync(...args) }, ); }); vi.mock("./exec-file.js", async (importOriginal) => { const actual = await importOriginal(); return { execFileUtf8: vi.fn(async (...args: Parameters) => state.realExecFile ? await actual.execFileUtf8(...args) : { termination: "exit" as const, ...executeLaunchctlMock(args[0], args[1]) }, ), }; }); vi.mock("./launchd-restart-handoff.js", () => ({ scheduleDetachedLaunchdMaintenancePark: (params: unknown) => launchdRestartHandoffState.scheduleDetachedLaunchdMaintenancePark(params), scheduleDetachedLaunchdRestartHandoff: (params: unknown) => launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff(params), })); vi.mock("./launchd-system.js", () => ({ assertNoSystemLaunchDaemonOwnership: (label: string) => launchdSystemState.assertNoSystemLaunchDaemonOwnership(label), inspectSystemLaunchDaemonOwnership: ( label: string, options?: { scanInstalledPlists?: boolean }, ) => launchdSystemState.inspectSystemLaunchDaemonOwnership(label, options), formatSystemLaunchDaemonOwnershipSummary: (ownership: { serviceTarget: string }) => `System LaunchDaemon ${ownership.serviceTarget} already owns this gateway label.`, isSystemLaunchDaemonOwnershipError: (error: unknown) => (error as { code?: string } | null)?.code === "SYSTEM_LAUNCH_DAEMON_OWNERSHIP", })); vi.mock("../infra/restart-stale-pids.js", () => ({ getSelfAndAncestorPidsSync, cleanStaleGatewayProcessesSync: (port?: number, options?: CleanStaleGatewayProcessesOptions) => options === undefined ? cleanStaleGatewayProcessesSync(port) : cleanStaleGatewayProcessesSync(port, options), })); vi.mock("../infra/ports-format.js", () => ({ formatPortDiagnostics, })); vi.mock("../infra/ports-inspect.js", () => ({ inspectPortUsage })); vi.mock("../infra/ports-probe.js", () => ({ LOOPBACK_PORT_PROBE_HOSTS: ["127.0.0.1"], probePortUsage, })); vi.mock("./gateway-service-probe-hosts.js", () => ({ resolveGatewayServiceProbeHosts: (params: unknown) => resolveGatewayServiceProbeHosts(params), })); vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); const wrapped = { ...actual, access: vi.fn(async (p: string) => { const key = p; if ( (state.files.has(key) && state.files.get(key) !== "dangling-launchagent-symlink") || state.dirs.has(key) ) { return; } throw Object.assign(new Error(`ENOENT: no such file or directory, access '${key}'`), { code: "ENOENT", }); }), lstat: vi.fn(async (p: string) => { const key = p; if (state.files.has(key) || state.dirs.has(key)) { return { isSymbolicLink: () => state.files.get(key) === "dangling-launchagent-symlink", }; } throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${key}'`), { code: "ENOENT", }); }), mkdir: vi.fn(async (p: string, opts?: { mode?: number }) => { const key = p; state.dirs.add(key); state.dirModes.set(key, opts?.mode ?? 0o777); }), stat: vi.fn(async (p: string) => { const key = p; if (state.dirs.has(key)) { return { mode: state.dirModes.get(key) ?? 0o777 }; } if (state.files.has(key)) { return { mode: state.fileModes.get(key) ?? 0o666 }; } throw new Error(`ENOENT: no such file or directory, stat '${key}'`); }), chmod: vi.fn(async (p: string, mode: number) => { const key = p; if (state.dirs.has(key)) { state.dirModes.set(key, mode); return; } if (state.files.has(key)) { state.fileModes.set(key, mode); return; } throw new Error(`ENOENT: no such file or directory, chmod '${key}'`); }), readFile: vi.fn(async (p: string) => { const key = p; const data = state.files.get(key); if (data !== undefined) { return data; } throw Object.assign(new Error(`ENOENT: no such file or directory, open '${key}'`), { code: "ENOENT", }); }), unlink: vi.fn(async (p: string) => { state.files.delete(p); }), rename: vi.fn(async (from: string, to: string) => { const data = state.files.get(from); if (data === undefined) { throw Object.assign(new Error(`ENOENT: no such file or directory, rename '${from}'`), { code: "ENOENT", }); } state.files.delete(from); state.files.set(to, data); const mode = state.fileModes.get(from); state.fileModes.delete(from); if (mode !== undefined) { state.fileModes.set(to, mode); } state.fileWrites.push({ path: to, data }); }), writeFile: vi.fn(async (p: string, data: string, opts?: { mode?: number }) => { const key = p; state.files.set(key, data); state.fileWrites.push({ path: key, data }); state.dirs.add(key.split("/").slice(0, -1).join("/")); state.fileModes.set(key, opts?.mode ?? 0o666); }), }; return { ...wrapped, default: wrapped }; }); afterEach(() => vi.unstubAllEnvs()); beforeEach(() => { state.launchctlCalls.length = 0; state.listOutput = ""; state.printOutput = ""; state.printDisabledOutput = 'disabled services = {\n\t"ai.openclaw.gateway" => enabled\n}'; state.printDisabledError = ""; state.printDisabledCode = 0; state.printNotLoadedRemaining = 0; state.printError = ""; state.printCode = 1; state.printFailuresRemaining = 0; state.bootstrapError = ""; state.bootstrapCode = 1; state.bootstrapTermination = "exit"; state.bootstrapLoadsServiceOnFailure = false; state.bootstrapTransient = false; state.kickstartError = ""; state.kickstartCode = 1; state.kickstartFailuresRemaining = 0; state.disableError = ""; state.disableCode = 1; state.stopError = ""; state.stopCode = 1; state.bootoutError = ""; state.bootoutCode = 1; state.serviceLoaded = true; state.serviceRunning = true; state.stopLeavesRunning = false; state.dirs.clear(); state.dirModes.clear(); state.files.clear(); state.fileModes.clear(); state.fileWrites.length = 0; state.cleanupProtectedPids.length = 0; state.realExecFile = false; state.serviceStates.clear(); launchctlSpawnSync.mockReset(); launchctlSpawnSync.mockImplementation((file: string, args: string[]) => { const result = executeLaunchctlMock(file, args); return { ...result, status: result.code, error: undefined }; }); cleanStaleGatewayProcessesSync.mockReset(); getSelfAndAncestorPidsSync.mockReset(); getSelfAndAncestorPidsSync.mockReturnValue(new Set(launchdCallerPids)); cleanStaleGatewayProcessesSync.mockImplementation((_port, options) => { state.cleanupProtectedPids.push(options?.resolveProtectedPid?.() ?? options?.protectedPid); return []; }); inspectPortUsage.mockReset(); inspectPortUsage.mockResolvedValue({ port: 18789, status: "free", listeners: [], hints: [] }); probePortUsage.mockReset(); probePortUsage.mockResolvedValue("free"); formatPortDiagnostics.mockReset(); formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]); resolveGatewayServiceProbeHosts.mockReset(); resolveGatewayServiceProbeHosts.mockResolvedValue(["127.0.0.1"]); launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset(); launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReturnValue({ ok: true, value: Promise.resolve(true), }); launchdRestartHandoffState.scheduleDetachedLaunchdMaintenancePark.mockReset(); launchdRestartHandoffState.scheduleDetachedLaunchdMaintenancePark.mockReturnValue({ ok: true, value: Promise.resolve(true), }); launchdSystemState.assertNoSystemLaunchDaemonOwnership.mockReset(); launchdSystemState.assertNoSystemLaunchDaemonOwnership.mockResolvedValue(); launchdSystemState.inspectSystemLaunchDaemonOwnership.mockReset(); launchdSystemState.inspectSystemLaunchDaemonOwnership.mockImplementation(async (label) => ({ status: "absent", serviceTarget: `system/${label}`, })); vi.clearAllMocks(); }); describe("launchd process ancestry guards", () => { it.each([ { name: "a Gateway ancestor", inside: true, servicePid: 4242 }, { name: "an external caller", inside: false, servicePid: 4242 }, { name: "a service PID matching the host PID", inside: false, servicePid: process.pid }, { name: "a service PID matching the host parent PID", inside: false, servicePid: process.ppid }, ])("restarts without env markers with $name", async ({ inside, servicePid }) => { const env = createDefaultLaunchdEnv(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; state.printOutput = ["state = running", `pid = ${servicePid}`].join("\n"); if (inside) { getSelfAndAncestorPidsSync.mockReturnValue(new Set([...launchdCallerPids, 4242])); } const result = await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, }, async () => restartLaunchAgent(launchAgentControlFixture(env)), ); expect(getSelfAndAncestorPidsSync).toHaveBeenCalledOnce(); if (inside) { expect(result).toEqual({ outcome: "scheduled" }); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith( { env, mode: "kickstart", waitForPid: process.pid, }, ); expect(state.launchctlCalls).toStrictEqual([["print", serviceId]]); expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); } else { expect(result).toEqual({ outcome: "completed" }); expect( launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff, ).not.toHaveBeenCalled(); expect(state.launchctlCalls).toStrictEqual([ ["print", serviceId], ["enable", serviceId], ["kickstart", "-k", serviceId], ]); } }); it.each([false, true])( "refuses stop without env markers with a Gateway ancestor (disable: %s)", async (disable) => { const env = createDefaultLaunchdEnv(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; getSelfAndAncestorPidsSync.mockReturnValue(new Set([...launchdCallerPids, 4242])); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, }, async () => { await expect( stopLaunchAgent(launchAgentControlFixture(env, { disable })), ).rejects.toThrow( "Refusing to stop LaunchAgent ai.openclaw.gateway from inside the same launchd service", ); }, ); expect(state.launchctlCalls).toEqual([["print", `${domain}/ai.openclaw.gateway`]]); }, ); it("parks without env markers when the Gateway is an ancestor", async () => { const env = createDefaultLaunchdEnv(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; getSelfAndAncestorPidsSync.mockReturnValue(new Set([...launchdCallerPids, 4242])); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, }, async () => { await expect(parkCurrentLaunchAgentForMaintenance({ env })).resolves.toBe(true); }, ); expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["disable", serviceId], ]); expect(launchdRestartHandoffState.scheduleDetachedLaunchdMaintenancePark).toHaveBeenCalledWith({ env, waitForPid: process.pid, }); }); it.each(["install", "uninstall"] as const)( "refuses %s without env markers with a Gateway ancestor", async (action) => { const env = createDefaultLaunchdEnv(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; state.serviceStates.set(serviceId, "running"); getSelfAndAncestorPidsSync.mockReturnValue(new Set([...launchdCallerPids, 4242])); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, }, async () => { await expect( action === "install" ? installLaunchAgent(defaultLaunchAgentFixture(env)) : uninstallLaunchAgent(launchAgentControlFixture(env)), ).rejects.toThrow( `Refusing to ${action} LaunchAgent ai.openclaw.gateway from inside ai.openclaw.gateway`, ); }, ); expect(state.fileWrites).toEqual([]); expect(state.launchctlCalls).toEqual([["print", serviceId]]); }, ); it("refuses install from a legacy Gateway ancestor after probing the target first", async () => { const env = createDefaultLaunchdEnv(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; const legacyServiceId = `${domain}/ai.openclaw.legacy-gateway`; state.serviceStates.set(serviceId, "not-loaded"); state.serviceStates.set(legacyServiceId, "running"); getSelfAndAncestorPidsSync.mockReturnValue(new Set([...launchdCallerPids, 4242])); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.legacy-gateway", }, async () => { await expect(installLaunchAgent(defaultLaunchAgentFixture(env))).rejects.toThrow( "Refusing to install LaunchAgent ai.openclaw.gateway from inside ai.openclaw.legacy-gateway", ); }, ); expect(state.fileWrites).toEqual([]); expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["print", legacyServiceId], ]); expect(getSelfAndAncestorPidsSync).toHaveBeenCalledOnce(); }); }); describe("launchd runtime parsing", () => { it.each([ ['disabled services = {\n\t"ai.openclaw.gateway" => enabled\n}', true], ['disabled services = {\n\t"ai.openclaw.gateway" => disabled\n}', false], ['disabled services = {\n\t"ai.openclaw.gateway" => false\n}', true], ['disabled services = {\n\t"ai.openclaw.gateway" => true\n}', false], ['disabled services = {\n\t"other.service" => disabled\n}', true], ])("parses the LaunchAgent enabled override", (output, expected) => { expect(parseLaunchAgentEnabled(output, "ai.openclaw.gateway")).toBe(expected); }); it("rejects an unrecognized LaunchAgent enabled override", () => { expect(() => parseLaunchAgentEnabled( 'disabled services = {\n\t"ai.openclaw.gateway" => unexpected\n}', "ai.openclaw.gateway", ), ).toThrow("unrecognized state"); }); it("reads the persistent LaunchAgent enabled state", async () => { state.printDisabledOutput = 'disabled services = {\n\t"ai.openclaw.gateway" => disabled\n}'; await expect(isLaunchAgentEnabled({ env: createDefaultLaunchdEnv() })).resolves.toBe(false); expect(state.launchctlCalls).toContainEqual([ "print-disabled", typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501", ]); }); it("fails closed when the LaunchAgent enabled state cannot be read", async () => { state.printDisabledError = "Operation not permitted"; state.printDisabledCode = 1; await expect(isLaunchAgentEnabled({ env: createDefaultLaunchdEnv() })).rejects.toThrow( "launchctl print-disabled failed: Operation not permitted", ); }); it("parses state, pid, and exit status", () => { const output = [ "state = running", "pid = 4242", "last exit status = 1", "last exit reason = exited", ].join("\n"); expect(parseLaunchctlPrint(output)).toEqual({ state: "running", pid: 4242, lastExitStatus: 1, lastExitReason: "exited", }); }); it.each([ { pid: 0, state: "running", expected: undefined }, { pid: 1234, state: "running", expected: 1234 }, { pid: -1, state: "waiting", expected: undefined }, ])("accepts only positive launchctl PIDs ($pid)", ({ pid, state: serviceState, expected }) => { expect(parseLaunchctlPrint(`state = ${serviceState}\npid = ${pid}`)).toEqual({ state: serviceState, pid: expected, }); }); it("rejects pid and exit status values with junk suffixes", () => { const output = [ "state = waiting", "pid = 123abc", "last exit status = 7ms", "last exit reason = exited", ].join("\n"); expect(parseLaunchctlPrint(output)).toEqual({ state: "waiting", lastExitReason: "exited", }); }); }); describe("launchd runtime state", () => { it.runIf(process.platform === "darwin").each(["runtime", "enabled"] as const)( "bounds the %s read by the supplied deadline when launchctl blocks", async (read) => { const realFs = await vi.importActual("node:fs/promises"); const tempDir = await realFs.mkdtemp(`${process.env.TMPDIR ?? "/tmp"}/openclaw-launchd-`); await realFs.writeFile(`${tempDir}/launchctl`, "#!/bin/sh\nexec /bin/sleep 2\n", { mode: 0o755, }); state.realExecFile = true; try { await withEnvAsync({ PATH: `${tempDir}:${process.env.PATH ?? ""}` }, async () => { const startedAt = Date.now(); if (read === "enabled") { await expect( isLaunchAgentEnabled({ env: { HOME: tempDir }, timeoutMs: 100 }), ).rejects.toThrow("launchctl print-disabled failed"); } else { const runtime = await readLaunchAgentRuntime({ HOME: tempDir }, { timeoutMs: 100 }); expect(runtime.status).toBe("unknown"); } expect(Date.now() - startedAt).toBeLessThan(1_000); }); } finally { state.realExecFile = false; await realFs.rm(tempDir, { recursive: true, force: true }); } }, ); it("reports an installed but unloaded LaunchAgent as stopped", async () => { const env = createDefaultLaunchdEnv(); state.files.set(resolveLaunchAgentPlistPath(env), ""); state.printError = [ "Bad request.", 'Could not find service "ai.openclaw.gateway" in domain for user gui: 501', ].join("\n"); state.printFailuresRemaining = 1; const runtime = await readLaunchAgentRuntime(env); expect(runtime).toEqual({ status: "stopped" }); }); it.each([ "Bootstrap failed: 125: Domain does not support specified action", "Could not find domain for user gui: 999999", ])("marks installed LaunchAgents unavailable when launchd reports %s", async (detail) => { const env = createDefaultLaunchdEnv(); state.files.set(resolveLaunchAgentPlistPath(env), ""); state.printError = detail; state.printFailuresRemaining = 1; const runtime = await readLaunchAgentRuntime(env); expect(runtime.status).toBe("unknown"); expect(runtime.missingGuiSession).toBe(true); expect(runtime.detail).toBe(detail); }); it("keeps unexpected launchctl failures visible without claiming missing supervision", async () => { const env = createDefaultLaunchdEnv(); state.files.set(resolveLaunchAgentPlistPath(env), ""); state.printError = "Operation not permitted\nwhile reading launchd state"; state.printFailuresRemaining = 1; const runtime = await readLaunchAgentRuntime(env); expect(runtime).toEqual({ status: "unknown", detail: "Operation not permitted while reading launchd state", inspectionReason: "launchd-gui-domain-unavailable", }); }); it("marks a missing unit when launchd has no job and no plist exists", async () => { const env = createDefaultLaunchdEnv(); state.serviceLoaded = false; const runtime = await readLaunchAgentRuntime(env); expect(runtime.status).toBe("unknown"); expect(runtime.missingUnit).toBe(true); }); it("reports a loaded system LaunchDaemon even when the user job is also loaded", async () => { const env = createDefaultLaunchdEnv(); launchdSystemState.inspectSystemLaunchDaemonOwnership.mockResolvedValueOnce({ status: "loaded", serviceTarget: "system/ai.openclaw.gateway", }); const runtime = await readLaunchAgentRuntime(env); expect(runtime).toEqual({ status: "unknown", detail: "System LaunchDaemon system/ai.openclaw.gateway already owns this gateway label.", inspectionReason: "launchd-system-owned", systemLaunchDaemon: { status: "loaded", serviceTarget: "system/ai.openclaw.gateway", }, }); expect(launchdSystemState.inspectSystemLaunchDaemonOwnership).toHaveBeenCalledWith( "ai.openclaw.gateway", { scanInstalledPlists: false }, ); }); }); describe("launchctl list detection", () => { it("parses stale OpenClaw updater jobs from launchctl list", () => { const jobs = parseLaunchctlListOpenClawUpdateJobs( [ "123 0 ai.openclaw.gateway", "- 127 ai.openclaw.update.2026.5.12", "- 0 ai.openclaw.manual-update.1717168800", "8142 0 ai.openclaw.update.2026.5.13-beta.1", "915 0 ai.openclaw.tayoun.update.20260625T201026-0400", "- 0 ai.openclaw.manual-updater.1717168800", "- 0 com.example.other", ].join("\n"), ); expect(jobs).toEqual([ { label: "ai.openclaw.manual-update.1717168800", lastExitStatus: 0, }, { label: "ai.openclaw.update.2026.5.12", lastExitStatus: 127, }, { label: "ai.openclaw.update.2026.5.13-beta.1", pid: 8142, lastExitStatus: 0, }, ]); }); it.runIf(process.platform === "darwin")( "finds stale OpenClaw updater jobs via launchctl list", async () => { state.listOutput = "- 127 ai.openclaw.update.2026.5.12\n"; const jobs = await findStaleOpenClawUpdateLaunchdJobs(); expect(jobs).toEqual([ { label: "ai.openclaw.update.2026.5.12", lastExitStatus: 127, }, ]); }, ); it.runIf(process.platform === "darwin")( "reports profile-scoped updater jobs only when launchd metadata confirms an update command", async () => { const env = createDefaultLaunchdEnv(); const updaterLabel = "ai.openclaw.tayoun.update.20260625T201026-0400"; const gatewayLikeLabel = "ai.openclaw.dev.team.update.20260625T201026-0400"; const nonOpenClawLabel = "ai.openclaw.fake.update.20260625T201026-0400"; const prefixedCliLabel = "ai.openclaw.helper.update.20260625T201026-0400"; state.listOutput = [ `4321 0 ${updaterLabel}`, `9876 0 ${gatewayLikeLabel}`, `2468 0 ${nonOpenClawLabel}`, `1357 0 ${prefixedCliLabel}`, ].join("\n"); setLaunchAgentPlist(env, updaterLabel, [ "/opt/homebrew/bin/openclaw", "update", "--yes", "--json", ]); setLaunchAgentPlist(env, gatewayLikeLabel, ["/opt/homebrew/bin/openclaw", "gateway", "run"]); setLaunchAgentPlist(env, nonOpenClawLabel, ["/bin/echo", "update", "--yes"]); setLaunchAgentPlist(env, prefixedCliLabel, [ "/usr/local/bin/openclaw-helper", "update", "--yes", ]); const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv); expect(jobs).toEqual([ { label: updaterLabel, pid: 4321, lastExitStatus: 0, }, ]); }, ); it.runIf(process.platform === "darwin")( "accepts an explicit updater marker when confirming profile-scoped updater jobs", async () => { const env = createDefaultLaunchdEnv(); const updaterLabel = "ai.openclaw.tayoun.update.20260625T201026-0400"; state.listOutput = `4321 0 ${updaterLabel}`; setLaunchAgentPlist(env, updaterLabel, ["/opt/homebrew/bin/openclaw", "gateway", "run"], { OPENCLAW_UPDATE_RUN_HANDOFF: "1", }); const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv); expect(jobs).toEqual([ { label: updaterLabel, pid: 4321, lastExitStatus: 0, }, ]); }, ); it.runIf(process.platform === "darwin")( "unwraps generated environment-wrapper metadata for profile-scoped updater jobs", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.tayoun.update.20260625T201026-0400"; const envDir = "/Users/test/.openclaw-tayoun/service-env"; const wrapperPath = `${envDir}/${label}-env-wrapper.sh`; const envFilePath = `${envDir}/${label}.env`; state.listOutput = `4321 0 ${label}`; state.files.set(envFilePath, "export PATH='/opt/homebrew/bin:/usr/bin'\n"); setLaunchAgentPlist(env, label, [ LAUNCH_AGENT_ENV_WRAPPER_SHELL, wrapperPath, envFilePath, "/opt/homebrew/bin/openclaw", "update", "--yes", ]); const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv); expect(jobs).toEqual([ { label, pid: 4321, lastExitStatus: 0, }, ]); }, ); it.runIf(process.platform === "darwin")( "reads the updater marker from a generated environment file", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.tayoun.update.20260625T201026-0400"; const envDir = "/Users/test/.openclaw-tayoun/service-env"; const wrapperPath = `${envDir}/${label}-env-wrapper.sh`; const envFilePath = `${envDir}/${label}.env`; state.listOutput = `4321 0 ${label}`; state.files.set(envFilePath, "export OPENCLAW_UPDATE_RUN_HANDOFF='1'\n"); setLaunchAgentPlist(env, label, [ LAUNCH_AGENT_ENV_WRAPPER_SHELL, wrapperPath, envFilePath, "/opt/homebrew/bin/openclaw", "gateway", "run", ]); const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv); expect(jobs).toEqual([ { label, pid: 4321, lastExitStatus: 0, }, ]); }, ); it.runIf(process.platform === "darwin")( "does not use the scanner process marker to confirm other profile-scoped jobs", async () => { const env = { ...createDefaultLaunchdEnv(), OPENCLAW_UPDATE_RUN_HANDOFF: "1", }; const gatewayLikeLabel = "ai.openclaw.dev.team.update.20260625T201026-0400"; state.listOutput = `9876 0 ${gatewayLikeLabel}`; setLaunchAgentPlist(env, gatewayLikeLabel, ["/opt/homebrew/bin/openclaw", "gateway", "run"]); const jobs = await findStaleOpenClawUpdateLaunchdJobs(env as NodeJS.ProcessEnv); expect(jobs).toEqual([]); }, ); it.runIf(process.platform === "darwin")( "does not report current gateway labels that collide with manual update labels", async () => { state.listOutput = [ "- 0 ai.openclaw.manual-update.1717168800", "812 0 ai.openclaw.manual-update.profile", "913 0 ai.openclaw.manual-update.custom-label", ].join("\n"); const jobs = await findStaleOpenClawUpdateLaunchdJobs({ OPENCLAW_PROFILE: "manual-update.profile", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.manual-update.custom-label", OPENCLAW_SERVICE_MARKER: GATEWAY_SERVICE_MARKER, OPENCLAW_SERVICE_KIND: GATEWAY_SERVICE_KIND, } as NodeJS.ProcessEnv); expect(jobs).toEqual([ { label: "ai.openclaw.manual-update.1717168800", lastExitStatus: 0, }, ]); }, ); it.runIf(process.platform === "darwin")( "disables the current legacy updater launchd job", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ LAUNCH_JOB_LABEL: "ai.openclaw.update.2026.5.12", }), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual([ "disable", `${domain}/ai.openclaw.update.2026.5.12`, ]); expect(launchctlCommandNames()).not.toContain("remove"); }, ); it.runIf(process.platform === "darwin")( "disables the current manual updater launchd job", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ LAUNCH_JOB_LABEL: "ai.openclaw.manual-update.1717168800", }), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual([ "disable", `${domain}/ai.openclaw.manual-update.1717168800`, ]); expect(launchctlCommandNames()).not.toContain("remove"); }, ); it.runIf(process.platform === "darwin")( "disables the current legacy updater launchd job from OpenClaw label env", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.update.2026.5.12", }), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual([ "disable", `${domain}/ai.openclaw.update.2026.5.12`, ]); }, ); it.runIf(process.platform === "darwin")( "does not let non-update launchd markers mask the OpenClaw update label", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ XPC_SERVICE_NAME: "0", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.update.2026.5.12", }), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual([ "disable", `${domain}/ai.openclaw.update.2026.5.12`, ]); }, ); it.runIf(process.platform === "darwin")( "does not disable the current gateway launchd job", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ LAUNCH_JOB_LABEL: "ai.openclaw.gateway", }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); it.runIf(process.platform === "darwin")( "does not disable profile-specific gateway launchd jobs that look like updater labels", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ LAUNCH_JOB_LABEL: "ai.openclaw.update.2026.5.12", OPENCLAW_PROFILE: "update.2026.5.12", }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); it.runIf(process.platform === "darwin")( "does not disable profile-specific gateway launchd jobs that look like manual updater labels", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ LAUNCH_JOB_LABEL: "ai.openclaw.manual-update.1717168800", OPENCLAW_PROFILE: "manual-update.1717168800", }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); it.runIf(process.platform === "darwin")( "disables current profile-scoped updater launchd jobs only after metadata confirmation", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.tayoun.update.20260625T201026-0400"; setLaunchAgentPlist(env, label, [ "/usr/local/bin/node", "/opt/openclaw/openclaw.mjs", "update", "--yes", ]); await expect( disableCurrentOpenClawUpdateLaunchdJob({ ...env, LAUNCH_JOB_LABEL: label, }), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]); }, ); it.runIf(process.platform === "darwin")( "lets a profile-scoped updater self-disarm from launchd runtime metadata", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.tayoun.update.20260625T201026-0400"; await expect( disableCurrentOpenClawUpdateLaunchdJob({ ...env, LAUNCH_JOB_LABEL: label, OPENCLAW_UPDATE_RUN_HANDOFF: "1", }), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]); }, ); it.runIf(process.platform === "darwin")( "requires plist proof for a configured label preserved by an update handoff", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.dev.team.update.20260625T201026-0400"; setLaunchAgentPlist(env, label, ["/opt/homebrew/bin/openclaw", "gateway", "run"]); await expect( disableCurrentOpenClawUpdateLaunchdJob({ ...env, OPENCLAW_LAUNCHD_LABEL: label, OPENCLAW_UPDATE_RUN_HANDOFF: "1", }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); it.runIf(process.platform === "darwin")( "disables a configured profile-scoped updater only with confirming plist metadata", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.tayoun.update.20260625T201026-0400"; setLaunchAgentPlist(env, label, ["/opt/homebrew/bin/openclaw", "update", "--yes"]); await expect( disableCurrentOpenClawUpdateLaunchdJob({ ...env, OPENCLAW_LAUNCHD_LABEL: label, OPENCLAW_UPDATE_RUN_HANDOFF: "1", }), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual(["disable", `${domain}/${label}`]); }, ); it.runIf(process.platform === "darwin")( "does not disable profile-scoped gateway labels without updater metadata", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.tayoun.update.20260625T201026-0400"; setLaunchAgentPlist(env, label, ["/opt/homebrew/bin/openclaw", "gateway", "run"]); await expect( disableCurrentOpenClawUpdateLaunchdJob({ ...env, LAUNCH_JOB_LABEL: label, }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); it.runIf(process.platform === "darwin")( "does not disable custom gateway launchd labels under the manual-update prefix", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ LAUNCH_JOB_LABEL: "ai.openclaw.manual-update.gateway", }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); it.runIf(process.platform === "darwin")( "does not disable custom gateway launchd labels that look like updater labels", async () => { await expect( disableCurrentOpenClawUpdateLaunchdJob({ LAUNCH_JOB_LABEL: "ai.openclaw.update.2026.5.12", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.update.2026.5.12", OPENCLAW_SERVICE_MARKER: "openclaw", OPENCLAW_SERVICE_KIND: "gateway", }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); it.runIf(process.platform === "darwin")("disables explicit legacy updater jobs", async () => { await expect(disableOpenClawUpdateLaunchdJob("ai.openclaw.update.2026.5.12")).resolves.toBe( true, ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual([ "disable", `${domain}/ai.openclaw.update.2026.5.12`, ]); }); it.runIf(process.platform === "darwin")("disables explicit manual updater jobs", async () => { await expect( disableOpenClawUpdateLaunchdJob("ai.openclaw.manual-update.1717168800"), ).resolves.toBe(true); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toContainEqual([ "disable", `${domain}/ai.openclaw.manual-update.1717168800`, ]); }); it.runIf(process.platform === "darwin")( "does not let the process marker bypass metadata for an explicit profile job", async () => { const env = createDefaultLaunchdEnv(); const label = "ai.openclaw.tayoun.update.20260625T201026-0400"; await expect( disableOpenClawUpdateLaunchdJob(label, { ...env, OPENCLAW_UPDATE_RUN_HANDOFF: "1", }), ).resolves.toBe(false); expect(state.launchctlCalls).toEqual([]); }, ); }); describe("launchd bootstrap repair", () => { it.each([ ["loaded", "system-launchdaemon-conflict"], ["unverifiable", "system-launchdaemon-unverifiable"], ] as const)( "returns typed %s system ownership failures before rewriting", async (status, expected) => { const env = createDefaultLaunchdEnv(); launchdSystemState.assertNoSystemLaunchDaemonOwnership.mockRejectedValueOnce( createSystemOwnershipError(status), ); const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: false, status: expected, detail: `system ownership blocked: ${status}`, }); expect(state.fileWrites).toEqual([]); expect(state.launchctlCalls).toEqual([]); }, ); it("migrates inline secrets before making an existing plist readable", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; const warn = vi.fn(); const secret = "legacy-secret"; state.files.set(wrapperPath, "custom wrapper"); state.files.set( plistPath, createTestLaunchAgentPlist({ label: "ai.openclaw.gateway", programArguments: defaultProgramArguments, environment: { OPENAI_API_KEY: secret }, }), ); state.fileModes.set(plistPath, 0o600); await repairLaunchAgentBootstrap({ env, warn }); expect(warn).toHaveBeenCalledWith(expect.stringContaining("custom behavior")); expect(state.files.get(plistPath)).not.toContain(secret); expect(state.fileModes.get(plistPath)).toBe(0o644); expect(state.files.get("/Users/test/.openclaw/service-env/ai.openclaw.gateway.env")).toContain( secret, ); }); it("enables and bootstraps the resolved label without kickstarting the fresh agent", async () => { const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: true, status: "repaired" }); expectLaunchctlEnableBootstrapOrder(env); expect(launchctlCommandNames()).not.toContain("kickstart"); }); it("treats bootstrap exit 130 as success and nudges the already-loaded service when stopped", async () => { state.bootstrapError = "Service already loaded"; state.bootstrapCode = 130; state.serviceRunning = false; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); expect(repair).toEqual({ ok: true, status: "already-loaded" }); expect(state.launchctlCalls.find((call) => call[0] === "kickstart")).toEqual([ "kickstart", serviceId, ]); expect(countMatching(state.launchctlCalls, (call) => call[0] === "kickstart")).toBe(1); }); it("skips kickstart when already-loaded service is actively running", async () => { state.bootstrapError = "Service already loaded"; state.bootstrapCode = 130; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: true, status: "already-loaded" }); expect(launchctlCommandNames()).not.toContain("kickstart"); }); it.each(["exit", "timeout", "signal"] as const)( "accepts already-loaded bootstrap output only after a completed command (%s)", async (termination) => { state.bootstrapError = "Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501"; state.bootstrapTermination = termination; state.serviceRunning = false; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); const { serviceId } = expectLaunchctlEnableBootstrapOrder(env); if (termination === "exit") { expect(repair).toEqual({ ok: true, status: "already-loaded" }); expect(state.launchctlCalls.filter((call) => call[0] === "kickstart")).toEqual([ ["kickstart", serviceId], ]); } else { expect(repair).toEqual({ ok: false, status: "bootstrap-failed", detail: state.bootstrapError, }); expect(launchctlCommandNames()).not.toContain("kickstart"); } }, ); it("keeps genuine bootstrap failures as failures", async () => { state.bootstrapError = "Could not find specified service"; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); expect(repair.ok).toBe(false); if (repair.ok) { throw new Error("expected bootstrap repair to fail"); } expect(repair.status).toBe("bootstrap-failed"); expect(repair.detail).toContain("Could not find specified service"); expect(launchctlCommandNames()).not.toContain("kickstart"); }); it.each([ "Bootstrap failed: 125: Domain does not support specified action", "Could not find domain for user gui: 999999", ])("classifies %s separately from generic not-loaded repair", async (detail) => { state.bootstrapError = detail; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: false, status: "gui-session-unavailable", detail, domain: typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501", }); expect(launchctlCommandNames()).not.toContain("kickstart"); }); it("returns a typed kickstart failure when already-loaded recovery cannot nudge the service", async () => { state.bootstrapError = "Service already loaded"; state.bootstrapCode = 130; state.serviceRunning = false; state.kickstartError = "launchctl kickstart failed: permission denied"; state.kickstartFailuresRemaining = 1; const env = createDefaultLaunchdEnv(); const repair = await repairLaunchAgentBootstrap({ env }); expect(repair).toEqual({ ok: false, status: "kickstart-failed", detail: "launchctl kickstart failed: permission denied", }); }); }); describe("launchd uninstall", () => { it("rejects a permission-denied launchctl inspection", async () => { state.printError = "launchctl print permission denied"; state.printFailuresRemaining = 1; await expect(isLaunchAgentLoaded({ env: createDefaultLaunchdEnv() })).rejects.toMatchObject({ reason: "launchd-gui-domain-unavailable", }); }); it("refuses an in-band uninstall before bootout or plist removal", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); const previous = "RunAtLoad=true"; state.files.set(plistPath, previous); await withEnvAsync({ XPC_SERVICE_NAME: "ai.openclaw.gateway" }, async () => { await expect(uninstallLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( "Refusing to uninstall LaunchAgent ai.openclaw.gateway from inside ai.openclaw.gateway", ); }); expect(state.files.get(plistPath)).toBe(previous); expect(state.launchctlCalls).toEqual([]); }); it("reports a surviving LaunchAgent when moving its plist to Trash is denied", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.files.set(plistPath, "RunAtLoad=true"); vi.mocked(fs.rename).mockRejectedValueOnce( Object.assign(new Error(`EACCES: permission denied, rename '${plistPath}'`), { code: "EACCES", }), ); const uninstall = uninstallLaunchAgent(launchAgentControlFixture(env)); await expect(uninstall).rejects.toThrow("LaunchAgent removal failed (EACCES)"); await expect(uninstall).rejects.not.toThrow(plistPath); expect(state.files.has(plistPath)).toBe(true); }); it("preserves the plist when launchctl cannot boot out the service", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.files.set(plistPath, "RunAtLoad=true"); state.bootoutError = "launchctl bootout permission denied"; await expect(uninstallLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( "launchctl bootout failed: launchctl bootout permission denied", ); expect(state.files.has(plistPath)).toBe(true); }); it("reports inaccessible LaunchAgents instead of claiming they are missing", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); vi.mocked(fs.lstat).mockRejectedValueOnce( Object.assign(new Error(`EACCES: permission denied, lstat '${plistPath}'`), { code: "EACCES", }), ); const uninstall = uninstallLaunchAgent(launchAgentControlFixture(env)); await expect(uninstall).rejects.toThrow("LaunchAgent removal failed (EACCES)"); await expect(uninstall).rejects.not.toThrow(plistPath); }); it("keeps missing LaunchAgent removal idempotent", async () => { const env = createDefaultLaunchdEnv(); await expect(uninstallLaunchAgent({ env, stdout: new PassThrough() })).resolves.toBeUndefined(); }); it("uninstalls an already stopped LaunchAgent without booting it out again", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.files.set(plistPath, "RunAtLoad=true"); state.serviceLoaded = false; state.bootoutError = "Boot-out failed: 5: Input/output error"; await expect(uninstallLaunchAgent({ env, stdout: new PassThrough() })).resolves.toBeUndefined(); expect(state.files.has(plistPath)).toBe(false); expect(state.launchctlCalls.some((call) => call[0] === "bootout")).toBe(false); }); it("removes dangling LaunchAgent symlinks instead of treating their targets as missing", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.files.set(plistPath, "dangling-launchagent-symlink"); await expect(uninstallLaunchAgent({ env, stdout: new PassThrough() })).resolves.toBeUndefined(); expect(state.files.has(plistPath)).toBe(false); }); it("keeps concurrently removed LaunchAgent removal idempotent", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.files.set(plistPath, "RunAtLoad=true"); vi.mocked(fs.rename).mockImplementationOnce(async () => { state.files.delete(plistPath); throw Object.assign(new Error(`ENOENT: no such file, rename '${plistPath}'`), { code: "ENOENT", }); }); await expect(uninstallLaunchAgent({ env, stdout: new PassThrough() })).resolves.toBeUndefined(); expect(state.files.has(plistPath)).toBe(false); }); it("reports a missing Trash destination while the LaunchAgent still exists", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.files.set(plistPath, "RunAtLoad=true"); vi.mocked(fs.rename).mockRejectedValueOnce( Object.assign(new Error(`ENOENT: missing destination for '${plistPath}'`), { code: "ENOENT", }), ); const uninstall = uninstallLaunchAgent(launchAgentControlFixture(env)); await expect(uninstall).rejects.toThrow("LaunchAgent removal failed (ENOENT)"); await expect(uninstall).rejects.not.toThrow(plistPath); expect(state.files.has(plistPath)).toBe(true); }); }); describe("launchd install", () => { it("refuses an in-band reinstall before booting out its own LaunchAgent", async () => { const env = createDefaultLaunchdEnv(); await withEnvAsync({ XPC_SERVICE_NAME: "ai.openclaw.gateway" }, async () => { await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow( "Refusing to install LaunchAgent ai.openclaw.gateway from inside ai.openclaw.gateway", ); }); expect(state.fileWrites).toEqual([]); expect(state.launchctlCalls).toEqual([]); }); it("refuses an in-band label migration before mutating either LaunchAgent", async () => { const env = createDefaultLaunchdEnv(); await withEnvAsync( { XPC_SERVICE_NAME: "0", OPENCLAW_SERVICE_MARKER: GATEWAY_SERVICE_MARKER, OPENCLAW_SERVICE_KIND: GATEWAY_SERVICE_KIND, OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.legacy-gateway", }, async () => { await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow( "Refusing to install LaunchAgent ai.openclaw.gateway from inside ai.openclaw.legacy-gateway", ); }, ); expect(state.fileWrites).toEqual([]); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toEqual([["print", `${domain}/ai.openclaw.gateway`]]); }); it("stages a canonical plist without retiring a legacy LaunchAgent", async () => { const env = createDefaultLaunchdEnv(); const legacyLabel = "ai.openclaw.legacy-gateway"; const legacyPlistPath = `${env.HOME}/Library/LaunchAgents/${legacyLabel}.plist`; const previousLegacy = createTestLaunchAgentPlist({ label: legacyLabel, programArguments: ["/legacy/node", "/legacy/openclaw.mjs", "gateway"], }); state.files.set(legacyPlistPath, previousLegacy); await stageLaunchAgent(defaultLaunchAgentFixture(env)); expect(state.files.get(legacyPlistPath)).toBe(previousLegacy); expect(state.files.has(resolveLaunchAgentPlistPath(env))).toBe(true); expect(state.launchctlCalls).toEqual([]); }); it("aborts before mutation when prior LaunchAgent supervision is ambiguous", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); const previous = createTestLaunchAgentPlist({ label: "ai.openclaw.gateway", programArguments: ["/previous/node", "/previous/openclaw.mjs", "gateway"], }); state.files.set(plistPath, previous); state.printError = "launchctl print permission denied"; // Both the membership probe and the supervision snapshot are denied. state.printFailuresRemaining = 2; await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow("could not determine whether"); expect(state.files.get(plistPath)).toBe(previous); expect(state.fileWrites).toEqual([]); expect(launchctlCommandNames()).toEqual(["print", "print"]); }); it("aborts before mutation when launchd has the only copy of the prior definition", async () => { const env = createDefaultLaunchdEnv(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; state.serviceStates.set(`${domain}/ai.openclaw.gateway`, "running"); await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow("is loaded but its plist is missing"); expect(state.serviceLoaded).toBe(true); expect(state.serviceRunning).toBe(true); expect(state.fileWrites).toEqual([]); expect(launchctlCommandNames()).toEqual(["print", "print"]); }); it("keeps a previously unloaded LaunchAgent unloaded after failed reinstall", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); const previous = createTestLaunchAgentPlist({ label: "ai.openclaw.gateway", programArguments: ["/previous/node", "/previous/openclaw.mjs", "gateway"], }); state.files.set(plistPath, previous); state.serviceLoaded = false; state.serviceRunning = false; state.bootstrapError = "Operation not permitted"; state.bootstrapTransient = true; await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow("launchctl bootstrap failed: Operation not permitted"); expect(state.files.get(plistPath)).toBe(previous); expect(state.serviceLoaded).toBe(false); expect(state.serviceRunning).toBe(false); expect(launchctlCommandNames()).toEqual(["print", "print", "enable", "bootstrap", "print"]); }); it("removes generated artifacts after a failed fresh install", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); const envFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env"; const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; state.serviceLoaded = false; state.serviceRunning = false; state.bootstrapError = "Operation not permitted"; state.bootstrapTransient = true; state.bootoutError = "Boot-out failed: 5: Input/output error"; state.bootoutCode = 5; const error = await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { OPENCLAW_GATEWAY_PORT: "19000" }, }), ).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("launchctl bootstrap failed: Operation not permitted"); expect(state.files.has(plistPath)).toBe(false); expect(state.files.has(envFilePath)).toBe(false); expect(state.files.has(wrapperPath)).toBe(false); expect(launchctlCommandNames()).toEqual(["print", "print", "enable", "bootstrap", "print"]); }); it("fails closed when rollback cannot determine the replacement state", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); // Membership and the prior snapshot see absence; rollback sees the error. state.printNotLoadedRemaining = 2; state.printError = "launchctl print permission denied"; state.printFailuresRemaining = 1; state.bootstrapError = "Operation not permitted"; const error = await installLaunchAgent(defaultLaunchAgentFixture(env)).catch( (caught: unknown) => caught, ); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toContain( "launchctl bootstrap failed: Operation not permitted", ); expect((error as Error).message).toContain( "The previous LaunchAgent supervision could not be restored.", ); expect((error as Error).cause).toBeInstanceOf(Error); expect(((error as Error).cause as Error).message).toContain("could not determine whether"); expect(state.files.has(plistPath)).toBe(true); expect(launchctlCommandNames()).toEqual(["print", "print", "enable", "bootstrap", "print"]); }); it("restores the exact prior plist and supervision after external bootstrap failure", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); const envFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env"; const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; const previousEnv = "export OPENCLAW_GATEWAY_PORT='18789'\n"; const previousWrapper = '#!/bin/sh\n. "$1"\nshift\nexec "$@"\n'; const previous = createTestLaunchAgentPlist({ label: "ai.openclaw.gateway", programArguments: [ "/bin/sh", wrapperPath, envFilePath, "/previous/node", "/previous/openclaw.mjs", "gateway", ], }); state.files.set(plistPath, previous); state.files.set(envFilePath, previousEnv); state.files.set(wrapperPath, previousWrapper); state.fileModes.set(envFilePath, 0o600); state.fileModes.set(wrapperPath, 0o700); state.serviceLoaded = true; state.serviceRunning = true; state.bootstrapError = "Operation not permitted"; state.bootstrapTransient = true; await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, environment: { OPENCLAW_GATEWAY_PORT: "19000" }, }), ).rejects.toThrow("launchctl bootstrap failed: Operation not permitted"); expect(state.files.get(plistPath)).toBe(previous); expect(state.files.get(envFilePath)).toBe(previousEnv); expect(state.files.get(wrapperPath)).toBe(previousWrapper); expect(state.fileModes.get(envFilePath)).toBe(0o600); expect(state.fileModes.get(wrapperPath)).toBe(0o700); expect(state.serviceLoaded).toBe(true); expect(state.serviceRunning).toBe(true); expect(launchctlCommandNames()).toEqual([ "print", "print", "bootout", "unload", "enable", "bootstrap", "print", "enable", "bootstrap", ]); }); it("refuses install and stage before any user LaunchAgent mutation", async () => { const env = createDefaultLaunchdEnv(); launchdSystemState.assertNoSystemLaunchDaemonOwnership.mockRejectedValue( createSystemOwnershipError(), ); const args = { env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }; await expect(installLaunchAgent(args)).rejects.toThrow("system ownership blocked: loaded"); await expect(stageLaunchAgent(args)).rejects.toThrow("system ownership blocked: loaded"); expect(state.fileWrites).toEqual([]); expect(launchctlCommandNames()).toEqual(["print", "print"]); }); it("rolls back a post-publication ownership race before activation", async () => { const env = createDefaultLaunchdEnv(); launchdSystemState.assertNoSystemLaunchDaemonOwnership .mockResolvedValueOnce() .mockResolvedValueOnce() .mockRejectedValueOnce(createSystemOwnershipError("installed")); await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow("system ownership blocked: installed"); expect(launchdSystemState.assertNoSystemLaunchDaemonOwnership).toHaveBeenCalledTimes(3); expect(state.files.has(resolveLaunchAgentPlistPath(env))).toBe(false); expect(launchctlCommandNames()).toEqual(["print", "print"]); }); it("restores the previous plist when staged publication loses ownership", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); const previous = "Labelprevious"; state.files.set(plistPath, previous); launchdSystemState.assertNoSystemLaunchDaemonOwnership .mockResolvedValueOnce() .mockResolvedValueOnce() .mockRejectedValueOnce(createSystemOwnershipError("loaded")); await expect( stageLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow("system ownership blocked: loaded"); expect(state.files.get(plistPath)).toBe(previous); expect(state.launchctlCalls).toEqual([]); }); it.each([ { name: "default gateway", env: createDefaultLaunchdEnv(), label: "ai.openclaw.gateway", programArguments: defaultProgramArguments, }, { name: "profiled gateway", env: { HOME: "/Users/test", OPENCLAW_PROFILE: "qa" }, label: "ai.openclaw.qa", programArguments: defaultProgramArguments, }, { name: "node service", env: { HOME: "/Users/test", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node" }, label: "ai.openclaw.node", programArguments: ["node", "node-host.js"], }, ])( "installs a fresh $name without booting out the absent job", async ({ env, label, programArguments }) => { state.bootoutError = "Boot-out failed: 5: Input/output error"; state.bootoutCode = 5; await installLaunchAgent(launchAgentFixture(env, programArguments)); const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? ""; expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION"); const { serviceId } = expectLaunchctlEnableBootstrapOrder(env, label); const installKickstartIndex = state.launchctlCalls.findIndex( (c) => c[0] === "kickstart" && c[2] === serviceId, ); expect(installKickstartIndex).toBe(-1); expect(launchctlCommandNames()).toEqual(["print", "print", "enable", "bootstrap"]); }, ); it("keeps loaded reinstall deactivation failures fatal", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.files.set( plistPath, createTestLaunchAgentPlist({ label: "ai.openclaw.gateway", programArguments: defaultProgramArguments, }), ); state.bootoutError = "Boot-out failed: 5: Input/output error"; state.bootoutCode = 5; await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow( "launchctl bootout failed during LaunchAgent install: Boot-out failed: 5: Input/output error", ); expect(launchctlCommandNames()).toEqual(["print", "print", "bootout", "print", "bootout"]); }); it("writes a version-free node service description", async () => { const env = { HOME: "/Users/test", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node", }; await installLaunchAgent( launchAgentFixture(env, ["node", "node-host.js"], { description: "OpenClaw Node Host", }), ); const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? ""; expect(plist).toContain("Comment\n OpenClaw Node Host"); expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION"); }); it("writes LaunchAgent environment to an owner-only env file when provided", async () => { const env = createDefaultLaunchdEnv(); const tmpDir = "/Users/test/.openclaw/tmp"; const apiKey = "secret-api-key"; await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { TMPDIR: tmpDir, OPENAI_API_KEY: apiKey, NODE_OPTIONS: "", UNUSED: "" }, }), ); const plistPath = resolveLaunchAgentPlistPath(env); const envFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env"; const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; const plist = state.files.get(plistPath) ?? ""; expect(plist).not.toContain("EnvironmentVariables"); expect(plist).not.toContain(apiKey); expect(readPlistProgramArgumentStrings(plist)).toEqual([ LAUNCH_AGENT_ENV_WRAPPER_SHELL, wrapperPath, envFilePath, ...defaultProgramArguments, ]); const envFile = state.files.get(envFilePath) ?? ""; expect(envFile).toContain(`export TMPDIR='${tmpDir}'`); expect(envFile).toContain(`export OPENAI_API_KEY='${apiKey}'`); expect(envFile).toContain("export NODE_OPTIONS=''"); expect(envFile).not.toContain("UNUSED"); expect(state.fileModes.get(envFilePath)).toBe(0o600); expect(state.fileModes.get(wrapperPath)).toBe(0o700); expect(state.dirModes.get("/Users/test/.openclaw/service-env")).toBe(0o700); const command = await readLaunchAgentProgramArguments(env); expect(command?.programArguments).toEqual(defaultProgramArguments); expect(command?.environment?.TMPDIR).toBe(tmpDir); expect(command?.environment?.OPENAI_API_KEY).toBe(apiKey); expect(command?.environment?.NODE_OPTIONS).toBe(""); expect(command?.environmentValueSources?.TMPDIR).toBe("file"); expect(command?.environmentValueSources?.OPENAI_API_KEY).toBe("file"); }); it("retains custom Node CA trust when reinstalling a generated owner-only LaunchAgent", async () => { const env = createDefaultLaunchdEnv(); const extraCaCerts = "/Users/test/certs/corporate-ca.pem"; const envFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env"; const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { NODE_EXTRA_CA_CERTS: extraCaCerts }, }), ); const installedCommand = await readLaunchAgentProgramArguments(env); expect(installedCommand?.environment?.NODE_EXTRA_CA_CERTS).toBe(extraCaCerts); expect(installedCommand?.environmentValueSources?.NODE_EXTRA_CA_CERTS).toBe("file"); const initialEnvWrites = countMatching(state.fileWrites, ({ path }) => path === envFilePath); await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: installedCommand?.environment, }), ); const refreshedCommand = await readLaunchAgentProgramArguments(env); expect(refreshedCommand?.environment?.NODE_EXTRA_CA_CERTS).toBe(extraCaCerts); expect(refreshedCommand?.environmentValueSources?.NODE_EXTRA_CA_CERTS).toBe("file"); expect(countMatching(state.fileWrites, ({ path }) => path === envFilePath)).toBeGreaterThan( initialEnvWrites, ); expect(state.files.get(envFilePath)).toContain(`export NODE_EXTRA_CA_CERTS='${extraCaCerts}'`); expect(state.files.get(resolveLaunchAgentPlistPath(env))).not.toContain(extraCaCerts); expect(state.fileModes.get(envFilePath)).toBe(0o600); expect(state.fileModes.get(wrapperPath)).toBe(0o700); expect(state.dirModes.get("/Users/test/.openclaw/service-env")).toBe(0o700); }); it("warns before overwriting a customized generated LaunchAgent env wrapper", async () => { const env = createDefaultLaunchdEnv(); const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { OPENCLAW_GATEWAY_PORT: "18789" }, }), ); const generatedWrapper = state.files.get(wrapperPath); if (!generatedWrapper) { throw new Error("expected generated wrapper"); } state.files.set( wrapperPath, generatedWrapper.replace('exec "$@"', 'echo "custom-secret-provider-marker"\nexec "$@"'), ); let output = ""; const stdout = capturePassThroughOutput((text) => (output += text), "utf8"); await installLaunchAgent({ env, stdout, programArguments: defaultProgramArguments, environment: { OPENCLAW_GATEWAY_PORT: "18789" }, }); expect(output).toContain("Warning:"); expect(output).toContain("contains custom behavior and will be overwritten"); expect(output).toContain("openclaw gateway install --wrapper "); expect(output).toContain("OPENCLAW_WRAPPER"); expect(state.files.get(wrapperPath)).toBe(generatedWrapper); }); it("warns before overwriting a customized generated LaunchAgent env wrapper during restart rewrite", async () => { const env = createDefaultLaunchdEnv(); const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { OPENCLAW_GATEWAY_PORT: "18789" }, }), ); const generatedWrapper = state.files.get(wrapperPath); if (!generatedWrapper) { throw new Error("expected generated wrapper"); } state.files.set( wrapperPath, generatedWrapper.replace('exec "$@"', 'echo "custom-secret-provider-marker"\nexec "$@"'), ); state.launchctlCalls.length = 0; let output = ""; const stdout = capturePassThroughOutput((text) => (output += text), "utf8"); await restartLaunchAgent({ env, stdout, }); expect(output).toContain("Warning:"); expect(output).toContain("contains custom behavior and will be overwritten"); expect(output).toContain("openclaw gateway install --wrapper "); expect(output).toContain("OPENCLAW_WRAPPER"); expect(state.files.get(wrapperPath)).toBe(generatedWrapper); }); it("rewrites legacy LaunchAgent environment wrappers to a system shell executable", async () => { const env = createDefaultLaunchdEnv(); const envFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env"; const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { OPENCLAW_GATEWAY_PORT: "19007" }, }), ); const plistPath = resolveLaunchAgentPlistPath(env); const legacyPlist = (state.files.get(plistPath) ?? "").replace( [ `${LAUNCH_AGENT_ENV_WRAPPER_SHELL}`, `${wrapperPath}`, `${envFilePath}`, ].join("\n "), [`${wrapperPath}`, `${envFilePath}`].join("\n "), ); expect(readPlistProgramArgumentStrings(legacyPlist)).toEqual([ wrapperPath, envFilePath, ...defaultProgramArguments, ]); state.files.set(plistPath, legacyPlist); state.launchctlCalls.length = 0; await restartLaunchAgent(launchAgentControlFixture(env)); const rewritten = state.files.get(plistPath) ?? ""; expect(readPlistProgramArgumentStrings(rewritten)).toEqual([ LAUNCH_AGENT_ENV_WRAPPER_SHELL, wrapperPath, envFilePath, ...defaultProgramArguments, ]); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith( 19007, expect.objectContaining({ resolveProtectedPid: expect.any(Function), }), ); }); it("repairs a mangled label-derived service-env wrapper path on restart", async () => { const callerEnv = createDefaultLaunchdEnv(); const serviceEnv = { ...callerEnv, OPENCLAW_STATE_DIR: "/Users/test/service-env/custom-state", }; await installLaunchAgent( defaultLaunchAgentFixture(serviceEnv, { environment: { OPENCLAW_GATEWAY_PORT: "18789", OPENCLAW_STATE_DIR: serviceEnv.OPENCLAW_STATE_DIR, }, }), ); const plistPath = resolveLaunchAgentPlistPath(callerEnv); const envFilePath = "/Users/test/service-env/custom-state/service-env/ai.openclaw.gateway.env"; const wrapperPath = "/Users/test/service-env/custom-state/service-env/ai.openclaw.gateway-env-wrapper.sh"; const callerEnvFilePath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway.env"; const callerWrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; const mangledEnvFilePath = "/Users/test/service-env/custom-state/service-env/[ai.openclaw.gateway.env](http:/ai.openclaw.gateway.env)"; const mangledWrapperPath = "/Users/test/service-env/custom-state/service-env/[ai.openclaw.gateway-env-wrapper.sh](http:/ai.openclaw.gateway-env-wrapper.sh)"; state.files.set( plistPath, (state.files.get(plistPath) ?? "") .replace(wrapperPath, mangledWrapperPath) .replace(envFilePath, mangledEnvFilePath), ); const command = await readLaunchAgentProgramArguments(callerEnv); expect(command?.programArguments).toEqual(defaultProgramArguments); expect(command?.environment?.OPENCLAW_GATEWAY_PORT).toBe("18789"); expect(command?.environment?.OPENCLAW_STATE_DIR).toBe(serviceEnv.OPENCLAW_STATE_DIR); expect(command?.environmentValueSources?.OPENCLAW_GATEWAY_PORT).toBe("file"); await restartLaunchAgent(launchAgentControlFixture(callerEnv)); const rewritten = state.files.get(plistPath) ?? ""; expect(readPlistProgramArgumentStrings(rewritten)).toEqual([ LAUNCH_AGENT_ENV_WRAPPER_SHELL, callerWrapperPath, callerEnvFilePath, ...defaultProgramArguments, ]); expect(rewritten).not.toContain(mangledEnvFilePath); expect(rewritten).not.toContain(mangledWrapperPath); const rewrittenEnv = state.files.get(callerEnvFilePath) ?? ""; expect(rewrittenEnv).toContain("export OPENCLAW_GATEWAY_PORT='18789'"); expect(rewrittenEnv).toContain( "export OPENCLAW_STATE_DIR='/Users/test/service-env/custom-state'", ); }); it("creates the LaunchAgent TMPDIR before bootstrap", async () => { const env = createDefaultLaunchdEnv(); const tmpDir = "/Users/test/.openclaw/tmp"; await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { TMPDIR: tmpDir }, }), ); expect(state.dirs.has(tmpDir)).toBe(true); expect(state.dirModes.get(tmpDir)).toBe(0o700); }); it("writes KeepAlive=true policy with shutdown and throttle limits", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent(defaultLaunchAgentFixture(env)); const plistPath = resolveLaunchAgentPlistPath(env); const plist = state.files.get(plistPath) ?? ""; expect(plist).toContain("KeepAlive"); expect(plist).toContain(""); expect(plist).toContain("StandardInPath"); expect(plist).toContain("/dev/null"); expect(plist).toContain("StandardOutPath"); expect(plist).toContain("/Users/test/Library/Logs/openclaw/gateway.log"); expect(plist).not.toContain("SuccessfulExit"); expect(plist).toContain("ExitTimeOut"); expect(plist).toContain(`${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}`); expect(plist).toContain("ProcessType"); expect(plist).toContain("Interactive"); expect(plist).toContain("Umask"); expect(plist).toContain("63"); expect(plist).toContain("ThrottleInterval"); expect(plist).toContain("10"); }); it("points launchd stderr at the stdout log so startup crashes survive", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent(defaultLaunchAgentFixture(env)); const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? ""; const logPath = "/Users/test/Library/Logs/openclaw/gateway.log"; // readLastGatewayErrorLine only reads stdout on darwin, so a stderr target // that is not the stdout log discards every pre-logger startup failure. expect(plist).toContain(`StandardOutPath\n ${logPath}`); expect(plist).toContain(`StandardErrorPath\n ${logPath}`); expect(plist).not.toContain("StandardErrorPath\n /dev/null"); }); it("rewrites the plist before bootstrap during restart fallback", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); state.serviceLoaded = false; state.kickstartError = "Could not find service"; state.kickstartFailuresRemaining = 1; setLegacyGatewayLaunchAgentPlist(plistPath, [ " EnvironmentVariables", " ", " OPENCLAW_SERVICE_VERSION", " 2026.4.24", " ", ]); await restartLaunchAgent(launchAgentControlFixture(env)); const plist = state.files.get(plistPath) ?? ""; expect(plist).toContain("StandardInPath"); expect(plist).toContain("StandardOutPath"); expect(plist).toContain("/Users/test/Library/Logs/openclaw/gateway.log"); expect(plist).toContain( "StandardErrorPath\n /Users/test/Library/Logs/openclaw/gateway.log", ); expect(plist).toContain("KeepAlive"); expect(plist).toContain("node"); expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION"); const rewriteIndex = state.fileWrites.findIndex((write) => write.path === plistPath); const bootstrapIndex = state.launchctlCalls.findIndex((call) => call[0] === "bootstrap"); expect(rewriteIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(rewriteIndex).toBeLessThan(bootstrapIndex); }); it("tightens writable bits on launch agent dirs and plist", async () => { const env = createDefaultLaunchdEnv(); state.dirs.add(env.HOME!); state.dirModes.set(env.HOME!, 0o777); state.dirs.add("/Users/test/Library"); state.dirModes.set("/Users/test/Library", 0o777); await installLaunchAgent(defaultLaunchAgentFixture(env)); const plistPath = resolveLaunchAgentPlistPath(env); expect(state.dirModes.get(env.HOME!)).toBe(0o755); expect(state.dirModes.get("/Users/test/Library")).toBe(0o755); expect(state.dirModes.get("/Users/test/Library/LaunchAgents")).toBe(0o755); expect(state.fileModes.get(plistPath)).toBe(0o644); }); it("stops LaunchAgent via bootout by default, preserving KeepAlive for future crashes", async () => { const env = createDefaultLaunchdEnv(); let output = ""; const stdout = capturePassThroughOutput((text) => (output += text)); await stopLaunchAgent({ env, stdout }); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["bootout", serviceId], ]); expect(output).toContain("Stopped LaunchAgent"); }); it.each([undefined, true])( "refuses in-band LaunchAgent stop before any native mutation (disable=%s)", async (disable) => { const env = createDefaultLaunchdEnv(); await withEnvAsync({ LAUNCH_JOB_LABEL: "ai.openclaw.gateway" }, async () => { await expect(stopLaunchAgent({ env, stdout: new PassThrough(), disable })).rejects.toThrow( "Refusing to stop LaunchAgent ai.openclaw.gateway from inside the same launchd service", ); }); expect(state.launchctlCalls).toEqual([]); }, ); it("disables the current LaunchAgent before scheduling maintenance bootout", async () => { const env = createDefaultLaunchdEnv(); state.disableCode = 0; await withEnvAsync( { LAUNCH_JOB_LABEL: "ai.openclaw.gateway", }, async () => { await expect(parkCurrentLaunchAgentForMaintenance({ env })).resolves.toBe(true); }, ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toEqual([["disable", `${domain}/ai.openclaw.gateway`]]); expect(launchdRestartHandoffState.scheduleDetachedLaunchdMaintenancePark).toHaveBeenCalledWith({ env, waitForPid: process.pid, }); }); it("does not park an external LaunchAgent", async () => { const env = createDefaultLaunchdEnv(); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, }, async () => { await expect(parkCurrentLaunchAgentForMaintenance({ env })).resolves.toBe(false); }, ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toEqual([["print", `${domain}/ai.openclaw.gateway`]]); expect(getSelfAndAncestorPidsSync).toHaveBeenCalledOnce(); expect( launchdRestartHandoffState.scheduleDetachedLaunchdMaintenancePark, ).not.toHaveBeenCalled(); }); it("re-enables the LaunchAgent when the maintenance handoff cannot spawn", async () => { const env = createDefaultLaunchdEnv(); state.disableCode = 0; launchdRestartHandoffState.scheduleDetachedLaunchdMaintenancePark.mockReturnValueOnce({ ok: true, value: Promise.resolve(false), }); await withEnvAsync( { LAUNCH_JOB_LABEL: "ai.openclaw.gateway", }, async () => { await expect(parkCurrentLaunchAgentForMaintenance({ env })).rejects.toThrow( "helper failed to spawn; restored launchd enable state", ); }, ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.launchctlCalls).toEqual([ ["disable", `${domain}/ai.openclaw.gateway`], ["enable", `${domain}/ai.openclaw.gateway`], ]); }); it("refuses in-band LaunchAgent stop when XPC_SERVICE_NAME is inherited", async () => { const env = createDefaultLaunchdEnv(); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: "0", OPENCLAW_SERVICE_MARKER: "openclaw", OPENCLAW_SERVICE_KIND: "gateway", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", }, async () => { await expect(stopLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( "Refusing to stop LaunchAgent ai.openclaw.gateway from inside the same launchd service", ); }, ); expect(state.launchctlCalls).toEqual([]); }); it("allows external LaunchAgent label overrides to stop the selected target", async () => { const env = { ...createDefaultLaunchdEnv(), OPENCLAW_LAUNCHD_LABEL: "com.example.openclaw.gateway", }; let output = ""; const stdout = capturePassThroughOutput((text) => (output += text)); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, }, async () => { await stopLaunchAgent({ env, stdout }); }, ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/com.example.openclaw.gateway`; expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["bootout", serviceId], ]); expect(output).toContain("Stopped LaunchAgent"); }); it.each([ { mode: "bootout", port: 19003, disable: undefined }, { mode: "disable-stop", port: 19005, disable: true }, ])("verifies port release before reporting $mode success", async ({ port, disable }) => { const env = createLaunchdEnvWithGatewayPort(String(port)); let output = ""; const stdout = capturePassThroughOutput((text) => (output += text)); await stopLaunchAgent({ env, stdout, disable }); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(port, { env, assertCurrent: expect.any(Function), }); expect(inspectPortUsage).toHaveBeenCalledWith(port, { probeHosts: ["127.0.0.1"] }); expect(output).toContain("Stopped LaunchAgent"); }); it("waits for the configured gateway port to finish releasing after bootout", async () => { const env = createLaunchdEnvWithGatewayPort("19009"); inspectPortUsage.mockResolvedValueOnce({ port: 19009, status: "busy", listeners: [], hints: [], }); await runStopLaunchAgentWithFakeTimers(launchAgentControlFixture(env)); expect(inspectPortUsage).toHaveBeenCalledTimes(1); expect(probePortUsage).toHaveBeenCalledWith(19009, ["127.0.0.1"]); }); it("waits on the configured non-loopback host before reporting the port released", async () => { const env = createLaunchdEnvWithGatewayPort("19011"); resolveGatewayServiceProbeHosts.mockResolvedValue(["192.0.2.40"]); inspectPortUsage.mockResolvedValueOnce({ port: 19011, status: "busy", listeners: [], hints: [], }); await runStopLaunchAgentWithFakeTimers(launchAgentControlFixture(env)); expect(inspectPortUsage).toHaveBeenCalledWith(19011, { probeHosts: ["192.0.2.40"], }); expect(probePortUsage).toHaveBeenCalledWith(19011, ["192.0.2.40"]); }); it("keeps waiting until a bind probe explicitly confirms port release", async () => { const env = createLaunchdEnvWithGatewayPort("19010"); inspectPortUsage.mockResolvedValueOnce({ port: 19010, status: "busy", listeners: [], hints: [], }); probePortUsage.mockResolvedValueOnce("busy").mockResolvedValueOnce("unknown"); await runStopLaunchAgentWithFakeTimers(launchAgentControlFixture(env)); expect(probePortUsage).toHaveBeenCalledTimes(3); }); it("resolves the stop postcondition port from the stored LaunchAgent environment", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { OPENCLAW_GATEWAY_PORT: "19006", OPENCLAW_STATE_DIR: "/state/managed" }, }), ); state.launchctlCalls.length = 0; await stopLaunchAgent(launchAgentControlFixture(env)); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19006, { env: expect.objectContaining({ OPENCLAW_GATEWAY_PORT: "19006", OPENCLAW_STATE_DIR: "/state/managed", }), assertCurrent: expect.any(Function), }); expect(inspectPortUsage).toHaveBeenCalledWith(19006, { probeHosts: ["127.0.0.1"], }); }); it.each([ { mode: "bootout", port: 19004, disable: undefined }, { mode: "disable-bootout", port: 19008, disable: true }, ] as const)( "rejects $mode success while the gateway port stays busy", async ({ mode, port, disable }) => { const env = createLaunchdEnvWithGatewayPort(String(port)); let output = ""; const stdout = capturePassThroughOutput((text) => (output += text)); const onMutation = vi.fn(); if (disable) { state.disableError = "Operation not permitted"; } inspectPortUsage.mockResolvedValue({ port, status: "busy", listeners: [], hints: [] }); probePortUsage.mockResolvedValue("busy"); formatPortDiagnostics.mockReturnValue([`Port ${port} is held by pid 4242.`]); await expect( runStopLaunchAgentWithFakeTimers({ env, stdout, disable, onMutation }), ).rejects.toThrow( `gateway port ${port} is still busy after LaunchAgent stop\nPort ${port} is held by pid 4242.`, ); expect(onMutation).toHaveBeenCalledWith({ mode }); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(port, { env, assertCurrent: expect.any(Function), }); expect(inspectPortUsage).toHaveBeenCalledWith(port, { probeHosts: ["127.0.0.1"] }); expect(launchctlCommandNames()).toContain("bootout"); if (disable) { expect(output).toContain("used bootout fallback"); } expect(output).not.toContain("Stopped LaunchAgent"); }, ); it("does not treat a co-located Gateway's own port as busy when stopping a node-host LaunchAgent", async () => { const env = { ...createDefaultLaunchdEnv(), OPENCLAW_SERVICE_KIND: "node", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node", OPENCLAW_GATEWAY_PORT: "18789", }; setLaunchAgentPlist(env, "ai.openclaw.node", [ "node", "node", "run", "--host", "127.0.0.1", "--port", "18789", ]); let output = ""; const stdout = capturePassThroughOutput((text) => (output += text)); inspectPortUsage.mockResolvedValue({ port: 18789, status: "busy", listeners: [], hints: [], }); probePortUsage.mockResolvedValue("busy"); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, }, async () => { await stopLaunchAgent({ env, stdout }); }, ); expect(inspectPortUsage).not.toHaveBeenCalled(); expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); expect(output).toContain("Stopped LaunchAgent"); }); it("stops LaunchAgent with disable+stop when --disable is passed", async () => { const env = createDefaultLaunchdEnv(); let output = ""; const stdout = capturePassThroughOutput((text) => (output += text)); await stopLaunchAgent({ env, stdout, disable: true }); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["disable", serviceId], ["stop", "ai.openclaw.gateway"], ["print", serviceId], ]); expect(output).toContain("Stopped LaunchAgent"); }); it("treats already-unloaded services as successfully stopped without bootout fallback (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = capturePassThroughOutput((text) => (output += text)); let output = ""; state.serviceLoaded = false; state.serviceRunning = false; state.stopError = "Could not find service"; state.stopCode = 113; await stopLaunchAgent({ env, stdout, disable: true }); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["disable", serviceId], ["stop", "ai.openclaw.gateway"], ["print", serviceId], ]); expect(launchctlCommandNames()).not.toContain("bootout"); expect(output).toContain("Stopped LaunchAgent"); expect(output).not.toContain("degraded"); }); it("treats already-unloaded services as successfully stopped in default bootout path", async () => { const env = createDefaultLaunchdEnv(); const stdout = capturePassThroughOutput((text) => (output += text)); let output = ""; state.serviceLoaded = false; state.serviceRunning = false; await stopLaunchAgent({ env, stdout }); expect(launchctlCommandNames()).not.toContain("disable"); expect(output).toContain("Stopped LaunchAgent"); expect(output).not.toContain("degraded"); }); it.each([ { reason: "disable fails", overrides: { disableError: "Operation not permitted" }, run: stopLaunchAgent, warning: "used bootout fallback", stopCalled: false, }, { reason: "stop leaves a running process", overrides: { stopLeavesRunning: true }, run: runStopLaunchAgentWithFakeTimers, warning: "did not fully stop the service", stopCalled: true, }, { reason: "print reports running without a PID", overrides: { stopLeavesRunning: true, printOutput: "state = running\n" }, run: runStopLaunchAgentWithFakeTimers, warning: "did not fully stop the service", stopCalled: true, }, { reason: "stop errors", overrides: { stopError: "stop failed due to transient launchd error" }, run: stopLaunchAgent, warning: "launchctl stop failed; used bootout fallback", stopCalled: true, }, { reason: "print cannot confirm stop", overrides: { printError: "launchctl print permission denied", printFailuresRemaining: 11 }, run: runStopLaunchAgentWithFakeTimers, warning: "could not confirm stop", stopCalled: true, }, ])( "uses bootout fallback when $reason (--disable)", async ({ overrides, run, warning, stopCalled }) => { const env = createDefaultLaunchdEnv(); let output = ""; const stdout = capturePassThroughOutput((text) => (output += text)); Object.assign(state, overrides); await run({ env, stdout, disable: true }); expect(launchctlCommandNames().includes("stop")).toBe(stopCalled); expect(launchctlCommandNames()).toContain("bootout"); expect(output).toContain("Stopped LaunchAgent (degraded)"); expect(output).toContain(warning); }, ); it("throws when launchctl print cannot confirm stop and bootout also fails (--disable)", async () => { const env = createDefaultLaunchdEnv(); state.printError = "launchctl print permission denied"; state.printFailuresRemaining = 11; state.bootoutError = "launchctl bootout permission denied"; await expect( runStopLaunchAgentWithFakeTimers({ env, stdout: new PassThrough(), disable: true }), ).rejects.toThrow( "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("audits disable when stop and its bootout fallback both fail", async () => { const env = createDefaultLaunchdEnv(); const onMutation = vi.fn(); state.stopError = "stop failed"; state.bootoutError = "bootout failed"; await expect( stopLaunchAgent({ env, stdout: new PassThrough(), disable: true, onMutation }), ).rejects.toThrow("launchctl stop failed; used bootout fallback"); expect(onMutation).toHaveBeenCalledWith({ mode: "disable" }); expect(onMutation).not.toHaveBeenCalledWith({ mode: "disable-stop" }); expect(onMutation).not.toHaveBeenCalledWith({ mode: "disable-bootout" }); }); it("throws when default bootout fails", async () => { const env = createDefaultLaunchdEnv(); state.bootoutError = "launchctl bootout permission denied"; state.bootoutCode = 1; await expect(stopLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( "launchctl bootout failed: launchctl bootout permission denied", ); expect(launchctlCommandNames()).not.toContain("disable"); expect(launchctlCommandNames()).not.toContain("stop"); }); it("sanitizes launchctl details before writing warnings (--disable)", async () => { const env = createDefaultLaunchdEnv(); const stdout = capturePassThroughOutput((text) => (output += text)); let output = ""; state.disableError = "boom\n\u001b[31mred\u001b[0m\tmsg"; await stopLaunchAgent({ env, stdout, disable: true }); expect(output).not.toContain("\u001b[31m"); expect(output).not.toContain("\nred\n"); expect(output).toContain("boom red msg"); }); it("refuses start and restart before enable, handoff, or activation", async () => { const env = createDefaultLaunchdEnv(); launchdSystemState.assertNoSystemLaunchDaemonOwnership.mockRejectedValue( createSystemOwnershipError(), ); await expect(startLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( "system ownership blocked: loaded", ); await expect(restartLaunchAgent({ env, stdout: new PassThrough() })).rejects.toThrow( "system ownership blocked: loaded", ); expect(state.launchctlCalls).toEqual([]); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).not.toHaveBeenCalled(); }); it("restarts LaunchAgent with kickstart and no bootout", async () => { const env = createLaunchdEnvWithGatewayPort("18789"); const onMutation = vi.fn(); const result = await restartLaunchAgent( launchAgentControlFixture(env, { onMutation, }), ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const label = "ai.openclaw.gateway"; const serviceId = `${domain}/${label}`; expect(result).toEqual({ outcome: "completed" }); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith( 18789, expect.objectContaining({ resolveProtectedPid: expect.any(Function), }), ); expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["print", serviceId], ["enable", serviceId], ["kickstart", "-k", serviceId], ]); expect(launchctlCommandNames()).not.toContain("bootout"); expect(launchctlCommandNames()).not.toContain("bootstrap"); expect(onMutation.mock.calls).toEqual([[{ mode: "enable" }], [{ mode: "kickstart" }]]); }); it("starts a loaded LaunchAgent and audits before output", async () => { const env = createDefaultLaunchdEnv(); const write = vi.fn(); const onMutation = vi.fn(({ mode }: { mode: string }) => { if (mode === "kickstart") { throw new Error("audit failed"); } }); await expect( startLaunchAgent({ env, stdout: { write } as unknown as NodeJS.WritableStream, onMutation, }), ).resolves.toBeUndefined(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toEqual([ ["enable", serviceId], ["kickstart", serviceId], ]); expect(onMutation.mock.calls).toEqual([[{ mode: "enable" }], [{ mode: "kickstart" }]]); expect( expectDefined(onMutation.mock.invocationCallOrder[1], "kickstart audit call order"), ).toBeLessThan(expectDefined(write.mock.invocationCallOrder[0], "start output call order")); }); it("bootstraps an unloaded LaunchAgent and audits the successful mutation", async () => { const env = createDefaultLaunchdEnv(); const onMutation = vi.fn(); state.kickstartError = "Could not find service"; state.kickstartFailuresRemaining = 1; await startLaunchAgent( launchAgentControlFixture(env, { onMutation, }), ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(state.launchctlCalls).toEqual([ ["enable", serviceId], ["kickstart", serviceId], ["bootstrap", domain, resolveLaunchAgentPlistPath(env)], ["kickstart", serviceId], ]); expect(onMutation.mock.calls).toEqual([ [{ mode: "enable" }], [{ mode: "bootstrap" }], [{ mode: "kickstart" }], ]); }); it("fails an already-loaded bootstrap immediately instead of waiting out the teardown deadline", async () => { const env = createDefaultLaunchdEnv(); const onMutation = vi.fn(); state.kickstartError = "Could not find service"; state.kickstartFailuresRemaining = 1; // launchd answers EIO for a label that is still registered. `startLaunchAgent` // never booted the job out, so there is no teardown to wait for: retrying // until the teardown deadline would stall the start for no gain. Real timers // here so a reintroduced retry loop blows the test timeout rather than // passing under vi.runAllTimersAsync(). state.bootstrapError = "Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501"; state.bootstrapCode = 5; await expect(startLaunchAgent({ env, stdout: new PassThrough(), onMutation })).rejects.toThrow( "launchctl bootstrap failed: Could not bootstrap service: 5: Input/output error", ); expect(countMatching(state.launchctlCalls, (call) => call[0] === "bootstrap")).toBe(1); expect(onMutation).not.toHaveBeenCalledWith({ mode: "bootstrap" }); }); it("audits enable but not kickstart when the later launch fails", async () => { const env = createDefaultLaunchdEnv(); const onMutation = vi.fn(); state.kickstartError = "Input/output error"; state.kickstartFailuresRemaining = 1; await expect(startLaunchAgent({ env, stdout: new PassThrough(), onMutation })).rejects.toThrow( "launchctl kickstart failed: Input/output error", ); expect(onMutation.mock.calls).toEqual([[{ mode: "enable" }]]); }); it("audits kickstart before a later output failure", async () => { const env = createLaunchdEnvWithGatewayPort("18789"); const onMutation = vi.fn(); const stdout = { write: vi.fn(() => { throw new Error("output failed"); }), } as unknown as NodeJS.WritableStream; await expect(restartLaunchAgent({ env, stdout, onMutation })).rejects.toThrow("output failed"); expect(onMutation.mock.calls).toEqual([[{ mode: "enable" }], [{ mode: "kickstart" }]]); }); it("reloads launchd after rewriting an existing plist", async () => { const env = createLaunchdEnvWithGatewayPort("18789"); const plistPath = resolveLaunchAgentPlistPath(env); setLegacyGatewayLaunchAgentPlist(plistPath, [ " StandardOutPath", " /Users/test/.openclaw-default/logs/gateway.log", ]); const onMutation = vi.fn(); await restartLaunchAgent( launchAgentControlFixture(env, { onMutation, }), ); const plist = state.files.get(plistPath) ?? ""; expect(plist).toContain("StandardInPath"); expect(plist).toContain("/dev/null"); expect(plist).toContain("/Users/test/Library/Logs/openclaw/gateway.log"); expect(launchctlCommandNames()).toEqual([ "print", "print", "enable", "bootout", "enable", "bootstrap", ]); expect(launchctlCommandNames()).not.toContain("kickstart"); expect(onMutation.mock.calls).toEqual([ [{ mode: "enable" }], [{ mode: "bootout" }], [{ mode: "enable" }], [{ mode: "bootstrap" }], ]); }); it("audits reload bootout before a later bootstrap failure", async () => { const env = createLaunchdEnvWithGatewayPort("18789"); setLaunchAgentPlist(env, "ai.openclaw.gateway", ["node", "gateway.js"]); state.bootstrapError = "Operation not permitted"; state.bootstrapCode = 5; const onMutation = vi.fn(); await expect( restartLaunchAgent({ env, stdout: new PassThrough(), onMutation }), ).rejects.toThrow("launchctl bootstrap failed: Operation not permitted"); // The trailing enable comes from the post-failure recovery attempt, which // cannot report a bootstrap mutation because this bootstrap keeps failing. expect(onMutation.mock.calls).toEqual([ [{ mode: "enable" }], [{ mode: "bootout" }], [{ mode: "enable" }], [{ mode: "enable" }], ]); expect(onMutation).not.toHaveBeenCalledWith({ mode: "bootstrap" }); }); it.each(["exit", "timeout", "signal"] as const)( "retries teardown bootstrap output only after a completed command (%s)", async (termination) => { const env = createLaunchdEnvWithGatewayPort("18789"); setLaunchAgentPlist(env, "ai.openclaw.gateway", ["node", "gateway.js"]); state.bootstrapError = "Bootstrap failed: 5: Input/output error"; state.bootstrapCode = termination === "exit" ? 5 : 1; state.bootstrapTermination = termination; state.bootstrapTransient = true; const onMutation = vi.fn(); const result = runRestartLaunchAgentWithFakeTimers( launchAgentControlFixture(env, { onMutation }), ); if (termination === "exit") { await expect(result).resolves.toEqual({ outcome: "completed" }); } else { await expect(result).rejects.toThrow( "launchctl bootstrap failed: Bootstrap failed: 5: Input/output error", ); } // Recovery can restore the job after interruption, but must not turn the // failed restart into success by treating partial EIO output as a retry. expect(state.serviceLoaded).toBe(true); expect(onMutation).toHaveBeenCalledWith({ mode: "bootstrap" }); }, ); it("reports the LaunchAgent as unloaded when bootstrap teardown never clears", async () => { const env = createLaunchdEnvWithGatewayPort("18789"); setLaunchAgentPlist(env, "ai.openclaw.gateway", ["node", "gateway.js"]); // EIO that never clears must stay bounded instead of retrying forever, and // the restore attempt fails with it, so the label really does stay absent. state.bootstrapError = "Bootstrap failed: 5: Input/output error"; state.bootstrapCode = 5; const error = await runRestartLaunchAgentWithFakeTimers(launchAgentControlFixture(env)).catch( (caught: unknown) => caught, ); // bootout already removed the job, so the operator has to learn both why the // bootstrap failed and that nothing is left for KeepAlive to respawn. const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; expect(state.serviceLoaded).toBe(false); expect(error).toBeInstanceOf(Error); const message = (error as Error).message; expect(message).toContain( "launchctl bootstrap failed: Bootstrap failed: 5: Input/output error", ); expect(message).toContain(`LaunchAgent ${domain}/ai.openclaw.gateway is not loaded`); expect(message).toContain("The gateway is down and launchd has no job left to respawn it."); expect(message).toContain("openclaw gateway start"); }); it("does not wait out the teardown deadline when the reload bootstrap reports already-loaded", async () => { const env = createLaunchdEnvWithGatewayPort("18789"); setLaunchAgentPlist(env, "ai.openclaw.gateway", ["node", "gateway.js"]); // Same EIO code as a pending teardown, but the label is still registered // rather than draining, so there is nothing to wait for. Real timers here: // a retry loop would blow the test timeout instead of quietly passing. state.bootstrapError = "Could not bootstrap service: 5: Input/output error: already exists in domain for gui/501"; state.bootstrapCode = 5; state.bootstrapLoadsServiceOnFailure = true; const error = await restartLaunchAgent(launchAgentControlFixture(env)).catch( (caught: unknown) => caught, ); expect(countMatching(state.launchctlCalls, (call) => call[0] === "bootstrap")).toBe(1); expect(error).toBeInstanceOf(Error); const message = (error as Error).message; expect(message).toContain( "launchctl bootstrap failed: Could not bootstrap service: 5: Input/output error", ); // The label is still registered, so the recovery probe finds it and the // failure must not claim the gateway was left unloaded. expect(message).not.toContain("is not loaded"); }); it("completes reload when the mutation observer fails after bootout", async () => { const env = createLaunchdEnvWithGatewayPort("18789"); setLaunchAgentPlist(env, "ai.openclaw.gateway", ["node", "gateway.js"]); const onMutation = vi.fn(({ mode }: { mode: string }) => { if (mode === "bootout") { throw new Error("audit failed"); } }); await expect( restartLaunchAgent({ env, stdout: new PassThrough(), onMutation }), ).resolves.toEqual({ outcome: "completed" }); expect(launchctlCommandNames()).toEqual([ "print", "print", "enable", "bootout", "enable", "bootstrap", ]); expect(onMutation).toHaveBeenCalledWith({ mode: "bootout" }); expect(onMutation).toHaveBeenCalledWith({ mode: "bootstrap" }); }); it.each(["exit", "timeout", "signal"] as const)( "accepts in-progress bootstrap output only after a completed command (%s)", async (termination) => { const env = createLaunchdEnvWithGatewayPort("18789"); const plistPath = resolveLaunchAgentPlistPath(env); setLegacyGatewayLaunchAgentPlist(plistPath, [ " StandardOutPath", " /Users/test/.openclaw-default/logs/gateway.log", ]); state.bootstrapError = "Bootstrap failed: 37: Operation already in progress"; state.bootstrapCode = termination === "exit" ? 5 : 1; state.bootstrapTermination = termination; state.bootstrapLoadsServiceOnFailure = true; const onMutation = vi.fn(); const result = restartLaunchAgent(launchAgentControlFixture(env, { onMutation })); if (termination === "exit") { await expect(result).resolves.toEqual({ outcome: "completed" }); expect(onMutation).toHaveBeenCalledWith({ mode: "bootstrap" }); } else { await expect(result).rejects.toThrow( "launchctl bootstrap failed: Bootstrap failed: 37: Operation already in progress", ); expect(onMutation).not.toHaveBeenCalledWith({ mode: "bootstrap" }); } expect(launchctlCommandNames()).toEqual([ "print", "print", "enable", "bootout", "enable", "bootstrap", "print", ]); expect(launchctlCommandNames()).not.toContain("kickstart"); }, ); it("uses the configured gateway port for stale cleanup", async () => { const env = createLaunchdEnvWithGatewayPort("19001"); await restartLaunchAgent(launchAgentControlFixture(env)); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith( 19001, expect.objectContaining({ resolveProtectedPid: expect.any(Function), }), ); }); it("ignores invalid configured gateway ports for stale cleanup", async () => { const env = createLaunchdEnvWithGatewayPort("65536"); state.files.clear(); await restartLaunchAgent(launchAgentControlFixture(env)); expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); expect(inspectPortUsage).not.toHaveBeenCalled(); }); it("uses the stored LaunchAgent environment port for restart stale cleanup", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { OPENCLAW_GATEWAY_PORT: "19007" }, }), ); state.launchctlCalls.length = 0; await restartLaunchAgent(launchAgentControlFixture(env)); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith( 19007, expect.objectContaining({ resolveProtectedPid: expect.any(Function), }), ); expect(inspectPortUsage).toHaveBeenCalledWith(19007, { probeHosts: ["127.0.0.1"], }); }); it("uses the final repeated LaunchAgent port flag for restart stale cleanup", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent( launchAgentFixture(env, [...defaultProgramArguments, "--port", "18789", "--port=19008"], { environment: {}, }), ); state.launchctlCalls.length = 0; await restartLaunchAgent(launchAgentControlFixture(env)); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith( 19008, expect.objectContaining({ resolveProtectedPid: expect.any(Function), }), ); expect(inspectPortUsage).toHaveBeenCalledWith(19008, { probeHosts: ["127.0.0.1"], }); }); it("ignores invalid stored LaunchAgent environment ports for stale cleanup", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent( defaultLaunchAgentFixture(env, { environment: { OPENCLAW_GATEWAY_PORT: "65536" }, }), ); state.launchctlCalls.length = 0; await restartLaunchAgent(launchAgentControlFixture(env)); expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); expect(inspectPortUsage).not.toHaveBeenCalled(); }); it.each([ { name: "managed dual-stack ownership", managedPidAfterCleanup: 4242, listeners: [ { pid: 4242, address: "TCP 127.0.0.1:19002 (LISTEN)" }, { pid: 4242, address: "TCP [::1]:19002 (LISTEN)" }, ], }, { name: "a changed launchd PID", managedPidAfterCleanup: 4343, listeners: [{ pid: 4343, address: "TCP 127.0.0.1:19002 (LISTEN)" }], }, ] satisfies Array<{ name: string; managedPidAfterCleanup: number; listeners: PortListener[]; }>)( "protects the current service and allows $name", async ({ managedPidAfterCleanup, listeners }) => { vi.stubEnv("BOUNDARY_PARENT_ONLY", "synthetic"); const env = createLaunchdEnvWithGatewayPort("19002"); if (managedPidAfterCleanup !== 4242) { state.printOutput = ["state = running", `pid = ${managedPidAfterCleanup}`].join("\n"); } inspectPortUsage.mockResolvedValue({ port: 19002, status: "busy", listeners, hints: [], }); const result = await restartLaunchAgent(launchAgentControlFixture(env)); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(result).toEqual({ outcome: "completed" }); expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith( 19002, expect.objectContaining({ resolveProtectedPid: expect.any(Function) }), ); expect(state.cleanupProtectedPids).toEqual([managedPidAfterCleanup]); expect(launchctlSpawnSync).toHaveBeenCalledWith( "launchctl", ["print", serviceId], expect.objectContaining({ env: expect.not.objectContaining({ BOUNDARY_PARENT_ONLY: "synthetic" }), timeout: 2_000, }), ); expect(inspectPortUsage).toHaveBeenCalledWith(19002, { probeHosts: ["127.0.0.1"], }); expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["print", serviceId], ["print", serviceId], ["enable", serviceId], ["kickstart", "-k", serviceId], ]); }, ); it.each([ { name: "unrelated", listeners: [{ pid: 5151, address: "TCP 127.0.0.1:19002 (LISTEN)" }], }, { name: "mixed", listeners: [ { pid: 4242, address: "TCP 127.0.0.1:19002 (LISTEN)" }, { pid: 5151, address: "TCP [::1]:19002 (LISTEN)" }, ], }, { name: "missing-PID", listeners: [{ address: "TCP 127.0.0.1:19002 (LISTEN)" }], }, { name: "unattributed", listeners: [], }, ] satisfies Array<{ name: string; listeners: PortListener[] }>)( "rejects $name gateway port ownership before mutating launchd", async ({ listeners }) => { const env = createLaunchdEnvWithGatewayPort("19002"); setLaunchAgentPlist(env, "ai.openclaw.gateway", ["node", "gateway.js"]); const plistPath = resolveLaunchAgentPlistPath(env); const originalPlist = state.files.get(plistPath); inspectPortUsage.mockResolvedValue({ port: 19002, status: "busy", listeners, hints: ["Another process is listening on this port."], }); formatPortDiagnostics.mockReturnValue(["Port 19002 is already in use."]); await expect( restartLaunchAgent({ env, stdout: new PassThrough(), }), ).rejects.toThrow( "gateway port 19002 is busy but is not verifiably owned by LaunchAgent ai.openclaw.gateway", ); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith( 19002, expect.objectContaining({ resolveProtectedPid: expect.any(Function) }), ); expect(state.cleanupProtectedPids).toEqual([4242]); expect(inspectPortUsage).toHaveBeenCalledWith(19002, { probeHosts: ["127.0.0.1"], }); expect(state.launchctlCalls).toEqual([ ["print", serviceId], ["print", serviceId], ["print", serviceId], ]); expect(state.files.get(plistPath)).toBe(originalPlist); expect(state.fileWrites).toHaveLength(0); expect(launchctlCommandNames()).not.toContain("enable"); expect(launchctlCommandNames()).not.toContain("bootout"); expect(launchctlCommandNames()).not.toContain("bootstrap"); expect(launchctlCommandNames()).not.toContain("kickstart"); }, ); it("does not treat a co-located Gateway's own port as busy when restarting a node-host LaunchAgent", async () => { const env = { ...createDefaultLaunchdEnv(), OPENCLAW_SERVICE_KIND: "node", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node", OPENCLAW_GATEWAY_PORT: "18789", }; setLaunchAgentPlist(env, "ai.openclaw.node", [ "node", "node", "run", "--host", "127.0.0.1", "--port", "18789", ]); inspectPortUsage.mockResolvedValue({ port: 18789, status: "busy", listeners: [{ pid: 9999, address: "TCP 127.0.0.1:18789 (LISTEN)" }], hints: [], }); const result = await restartLaunchAgent(launchAgentControlFixture(env)); expect(result).toEqual({ outcome: "completed" }); expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); expect(inspectPortUsage).not.toHaveBeenCalled(); }); it("skips stale cleanup when no explicit launch agent port can be resolved", async () => { const env = createDefaultLaunchdEnv(); state.files.clear(); await restartLaunchAgent(launchAgentControlFixture(env)); expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); }); it("falls back to bootstrap when kickstart cannot find the service", async () => { const env = createDefaultLaunchdEnv(); state.kickstartError = "Could not find service"; state.kickstartFailuresRemaining = 1; const result = await restartLaunchAgent(launchAgentControlFixture(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(launchctlCommandNames()).toContain("enable"); expect(launchctlCommandNames()).toContain("bootstrap"); expect(kickstartCalls).toHaveLength(1); expect(launchctlCommandNames()).not.toContain("bootout"); }); it("surfaces kickstart failure without re-bootstrap when the service stays loaded (#52208)", async () => { const env = createDefaultLaunchdEnv(); state.kickstartError = "Input/output error"; state.kickstartFailuresRemaining = 1; await expectRestartLaunchAgentKickstartFailure(env); expect(launchctlCommandNames()).toContain("enable"); expect(launchctlCommandNames()).not.toContain("bootstrap"); }); it("re-bootstraps when kickstart failure leaves the service unloaded (#52208)", async () => { const env = createDefaultLaunchdEnv(); state.kickstartError = "Input/output error"; state.kickstartFailuresRemaining = 1; state.printNotLoadedRemaining = 2; await expectRestartLaunchAgentKickstartFailure(env); expect(launchctlCommandNames()).toContain("enable"); expect(launchctlCommandNames()).toContain("bootstrap"); }); it("hands restart off to a detached helper when invoked from the current LaunchAgent", async () => { const env = createDefaultLaunchdEnv(); const result = await withEnvAsync({ LAUNCH_JOB_LABEL: "ai.openclaw.gateway" }, async () => restartLaunchAgent(launchAgentControlFixture(env)), ); expect(result).toEqual({ outcome: "scheduled" }); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({ env, mode: "kickstart", waitForPid: process.pid, }); expect(state.launchctlCalls).toStrictEqual([]); }); it("hands plist reload off when current LaunchAgent needs rewritten paths", async () => { const env = createDefaultLaunchdEnv(); const plistPath = resolveLaunchAgentPlistPath(env); setLegacyGatewayLaunchAgentPlist(plistPath, [ " StandardOutPath", " /Users/test/.openclaw-default/logs/gateway.log", ]); const result = await withEnvAsync({ LAUNCH_JOB_LABEL: "ai.openclaw.gateway" }, async () => restartLaunchAgent(launchAgentControlFixture(env)), ); expect(result).toEqual({ outcome: "scheduled" }); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({ env, mode: "reload", waitForPid: process.pid, }); expect(state.files.get(plistPath)).toContain("/Users/test/Library/Logs/openclaw/gateway.log"); expect(state.launchctlCalls).toStrictEqual([]); }); it("surfaces detached handoff failures", async () => { const env = createDefaultLaunchdEnv(); launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReturnValue({ ok: false, error: "spawn failed", }); await expect( withEnvAsync({ LAUNCH_JOB_LABEL: "ai.openclaw.gateway" }, async () => restartLaunchAgent({ env, stdout: new PassThrough(), }), ), ).rejects.toThrow("launchd restart handoff failed: spawn failed"); }); it("hands restart off when XPC_SERVICE_NAME is inherited", async () => { const env = createDefaultLaunchdEnv(); const result = await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: "0", OPENCLAW_SERVICE_MARKER: "openclaw", OPENCLAW_SERVICE_KIND: "gateway", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", }, async () => restartLaunchAgent(launchAgentControlFixture(env)), ); expect(result).toEqual({ outcome: "scheduled" }); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({ env, mode: "kickstart", waitForPid: process.pid, }); expect(state.launchctlCalls).toStrictEqual([]); }); it("restarts an unloaded LaunchAgent synchronously for a detached update helper that inherits only the configured label", async () => { const env = createDefaultLaunchdEnv(); state.serviceLoaded = false; state.kickstartError = "Could not find service"; state.kickstartFailuresRemaining = 1; const result = await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", }, async () => restartLaunchAgent(launchAgentControlFixture(env, { preserveDefinition: true })), ); expect(result).toEqual({ outcome: "completed" }); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).not.toHaveBeenCalled(); expect(launchctlCommandNames()).toEqual([ "print", "enable", "kickstart", "enable", "bootstrap", "kickstart", ]); expect(state.kickstartFailuresRemaining).toBe(0); expect(state.serviceLoaded).toBe(true); expect(getSelfAndAncestorPidsSync).not.toHaveBeenCalled(); }); it("restarts a KeepAlive-relaunched LaunchAgent synchronously for a detached update helper", async () => { const env = createDefaultLaunchdEnv(); const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501"; const serviceId = `${domain}/ai.openclaw.gateway`; const result = await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: undefined, OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway", }, async () => restartLaunchAgent(launchAgentControlFixture(env, { preserveDefinition: true })), ); expect(result).toEqual({ outcome: "completed" }); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).not.toHaveBeenCalled(); expect(getSelfAndAncestorPidsSync).toHaveBeenCalledOnce(); expect(state.launchctlCalls).toStrictEqual([ ["print", serviceId], ["enable", serviceId], ["kickstart", "-k", serviceId], ]); expect(state.fileWrites).toEqual([]); }); it("does not hand restart off for unrelated inherited XPC service names", async () => { const env = createDefaultLaunchdEnv(); await withEnvAsync( { LAUNCH_JOB_LABEL: undefined, LAUNCH_JOB_NAME: undefined, XPC_SERVICE_NAME: "0", OPENCLAW_SERVICE_MARKER: undefined, OPENCLAW_SERVICE_KIND: undefined, OPENCLAW_LAUNCHD_LABEL: undefined, }, async () => restartLaunchAgent(launchAgentControlFixture(env)), ); expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).not.toHaveBeenCalled(); expect(launchctlCommandNames()).toContain("kickstart"); }); 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(); let message = ""; try { await installLaunchAgent(defaultLaunchAgentFixture(env)); } 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"); }); it("surfaces generic bootstrap failures without GUI-specific guidance", async () => { state.bootstrapError = "Operation not permitted"; const env = createDefaultLaunchdEnv(); await expect( installLaunchAgent({ env, stdout: new PassThrough(), programArguments: defaultProgramArguments, }), ).rejects.toThrow("launchctl bootstrap failed: Operation not permitted"); }); }); describe("resolveLaunchAgentPlistPath", () => { it.each([ { name: "uses default label when OPENCLAW_PROFILE is unset", env: { HOME: "/Users/test" }, expected: "/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist", }, { name: "uses profile-specific label when OPENCLAW_PROFILE is set to a custom value", env: { HOME: "/Users/test", OPENCLAW_PROFILE: "jbphoenix" }, expected: "/Users/test/Library/LaunchAgents/ai.openclaw.jbphoenix.plist", }, { name: "prefers OPENCLAW_LAUNCHD_LABEL over OPENCLAW_PROFILE", env: { HOME: "/Users/test", OPENCLAW_PROFILE: "jbphoenix", OPENCLAW_LAUNCHD_LABEL: "com.custom.label", }, expected: "/Users/test/Library/LaunchAgents/com.custom.label.plist", }, { name: "trims whitespace from OPENCLAW_LAUNCHD_LABEL", env: { HOME: "/Users/test", OPENCLAW_LAUNCHD_LABEL: " com.custom.label ", }, expected: "/Users/test/Library/LaunchAgents/com.custom.label.plist", }, { name: "ignores empty OPENCLAW_LAUNCHD_LABEL and falls back to profile", env: { HOME: "/Users/test", OPENCLAW_PROFILE: "myprofile", OPENCLAW_LAUNCHD_LABEL: " ", }, expected: "/Users/test/Library/LaunchAgents/ai.openclaw.myprofile.plist", }, ])("$name", ({ env, expected }) => { expect(resolveLaunchAgentPlistPath(env)).toBe(expected); }); it("rejects invalid launchd labels that contain path separators", () => { expect(() => resolveLaunchAgentPlistPath({ HOME: "/Users/test", OPENCLAW_LAUNCHD_LABEL: "../evil/label", }), ).toThrow("Invalid launchd label"); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */