Download src/daemon/launchd.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 146 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/daemon/launchd.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/daemon/launchd.test.ts
-
curl -L -o launchd.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/daemon/launchd.test.ts
146 kB
| // 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<string, "running" | "stopped" | "not-loaded">(), | |
| stopLeavesRunning: false, | |
| dirs: new Set<string>(), | |
| dirModes: new Map<string, number>(), | |
| files: new Map<string, string>(), | |
| fileModes: new Map<string, number>(), | |
| fileWrites: [] as Array<{ path: string; data: string }>, | |
| cleanupProtectedPids: [] as Array<number | undefined>, | |
| realExecFile: false, | |
| })); | |
| const launchdRestartHandoffState = vi.hoisted(() => ({ | |
| scheduleDetachedLaunchdMaintenancePark: vi.fn< | |
| (_params: unknown) => { ok: true; value: Promise<boolean> } | { ok: false; error: string } | |
| >(() => ({ ok: true, value: Promise.resolve(true) })), | |
| scheduleDetachedLaunchdRestartHandoff: vi.fn< | |
| (_params: unknown) => { ok: true; value: Promise<boolean> } | { ok: false; error: string } | |
| >(() => ({ ok: true, value: Promise.resolve(true) })), | |
| })); | |
| const launchdSystemState = vi.hoisted(() => ({ | |
| assertNoSystemLaunchDaemonOwnership: vi.fn<(label: string) => Promise<void>>(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<number>>()); | |
| 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<typeof import("../infra/ports-inspect.js").inspectPortUsage>(async () => ({ | |
| port: 18789, | |
| status: "free", | |
| listeners: [], | |
| hints: [], | |
| })), | |
| ); | |
| const probePortUsage = vi.hoisted(() => | |
| vi.fn<typeof import("../infra/ports-probe.js").probePortUsage>(async () => "free"), | |
| ); | |
| const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."])); | |
| const resolveGatewayServiceProbeHosts = vi.hoisted(() => | |
| vi.fn<(_params?: unknown) => Promise<readonly string[]>>(async () => ["127.0.0.1"]), | |
| ); | |
| const defaultProgramArguments = ["node", "-e", "process.exit(0)"]; | |
| function countMatching<T>(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(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/i); | |
| return Array.from((match?.[1] ?? "").matchAll(/<string>([\s\S]*?)<\/string>/gi)).map( | |
| (item) => item[1] ?? "", | |
| ); | |
| } | |
| function createDefaultLaunchdEnv(): Record<string, string | undefined> { | |
| return { | |
| HOME: "/Users/test", | |
| OPENCLAW_PROFILE: "default", | |
| }; | |
| } | |
| function createLaunchdEnvWithGatewayPort(port: string): Record<string, string | undefined> { | |
| 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, | |
| [ | |
| '<?xml version="1.0" encoding="UTF-8"?>', | |
| '<plist version="1.0">', | |
| " <dict>", | |
| " <key>Label</key>", | |
| " <string>ai.openclaw.gateway</string>", | |
| " <key>ProgramArguments</key>", | |
| " <array>", | |
| " <string>node</string>", | |
| " <string>gateway.js</string>", | |
| " </array>", | |
| ...extraLines, | |
| " </dict>", | |
| "</plist>", | |
| ].join("\n"), | |
| ); | |
| } | |
| async function installLaunchAgent( | |
| args: Parameters<typeof installLaunchAgentImpl>[0], | |
| ): ReturnType<typeof installLaunchAgentImpl> { | |
| 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, string>; | |
| }): string { | |
| const argsXml = params.programArguments.map((arg) => ` <string>${arg}</string>`).join("\n"); | |
| const envXml = params.environment | |
| ? [ | |
| " <key>EnvironmentVariables</key>", | |
| " <dict>", | |
| ...Object.entries(params.environment).flatMap(([key, value]) => [ | |
| ` <key>${key}</key>`, | |
| ` <string>${value}</string>`, | |
| ]), | |
| " </dict>", | |
| ].join("\n") | |
| : ""; | |
| return [ | |
| '<?xml version="1.0" encoding="UTF-8"?>', | |
| '<plist version="1.0">', | |
| " <dict>", | |
| " <key>Label</key>", | |
| ` <string>${params.label}</string>`, | |
| " <key>ProgramArguments</key>", | |
| " <array>", | |
| argsXml, | |
| " </array>", | |
| envXml, | |
| " </dict>", | |
| "</plist>", | |
| "", | |
| ].join("\n"); | |
| } | |
| function setLaunchAgentPlist( | |
| env: Record<string, string | undefined>, | |
| label: string, | |
| programArguments: string[], | |
| environment?: Record<string, string>, | |
| ): void { | |
| state.files.set( | |
| `${env.HOME}/Library/LaunchAgents/${label}.plist`, | |
| createTestLaunchAgentPlist({ label, programArguments, environment }), | |
| ); | |
| } | |
| type LaunchAgentInstallFixture = Parameters<typeof installLaunchAgentImpl>[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<typeof stopLaunchAgent>[0] & | |
| Parameters<typeof uninstallLaunchAgent>[0]; | |
| function launchAgentControlFixture( | |
| env: LaunchAgentControlFixture["env"], | |
| overrides: Omit<LaunchAgentControlFixture, "env" | "stdout"> = {}, | |
| ): LaunchAgentControlFixture { | |
| return { env, stdout: new PassThrough(), ...overrides }; | |
| } | |
| async function runStopLaunchAgentWithFakeTimers(args: Parameters<typeof stopLaunchAgent>[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<typeof restartLaunchAgent>[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<string, string | undefined>, | |
| 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<string, string | undefined>, | |
| ): Promise<void> { | |
| 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<typeof import("../process/exec.js")>()), | |
| 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<typeof import("node:child_process")>("node:child_process"), | |
| { spawnSync: (...args: unknown[]) => launchctlSpawnSync(...args) }, | |
| ); | |
| }); | |
| vi.mock("./exec-file.js", async (importOriginal) => { | |
| const actual = await importOriginal<typeof import("./exec-file.js")>(); | |
| return { | |
| execFileUtf8: vi.fn(async (...args: Parameters<typeof actual.execFileUtf8>) => | |
| 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<typeof import("node:fs/promises")>("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<typeof import("node:fs/promises")>("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), "<plist/>"); | |
| 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), "<plist/>"); | |
| 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), "<plist/>"); | |
| 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 = "<plist><dict><key>Label</key><string>previous</string></dict></plist>"; | |
| 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("<key>Comment</key>\n <string>OpenClaw Node Host</string>"); | |
| 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("<key>EnvironmentVariables</key>"); | |
| 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 <path>"); | |
| 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 <path>"); | |
| 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( | |
| [ | |
| `<string>${LAUNCH_AGENT_ENV_WRAPPER_SHELL}</string>`, | |
| `<string>${wrapperPath}</string>`, | |
| `<string>${envFilePath}</string>`, | |
| ].join("\n "), | |
| [`<string>${wrapperPath}</string>`, `<string>${envFilePath}</string>`].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("<key>KeepAlive</key>"); | |
| expect(plist).toContain("<true/>"); | |
| expect(plist).toContain("<key>StandardInPath</key>"); | |
| expect(plist).toContain("<string>/dev/null</string>"); | |
| expect(plist).toContain("<key>StandardOutPath</key>"); | |
| expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>"); | |
| expect(plist).not.toContain("<key>SuccessfulExit</key>"); | |
| expect(plist).toContain("<key>ExitTimeOut</key>"); | |
| expect(plist).toContain(`<integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>`); | |
| expect(plist).toContain("<key>ProcessType</key>"); | |
| expect(plist).toContain("<string>Interactive</string>"); | |
| expect(plist).toContain("<key>Umask</key>"); | |
| expect(plist).toContain("<integer>63</integer>"); | |
| expect(plist).toContain("<key>ThrottleInterval</key>"); | |
| expect(plist).toContain("<integer>10</integer>"); | |
| }); | |
| 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(`<key>StandardOutPath</key>\n <string>${logPath}</string>`); | |
| expect(plist).toContain(`<key>StandardErrorPath</key>\n <string>${logPath}</string>`); | |
| expect(plist).not.toContain("<key>StandardErrorPath</key>\n <string>/dev/null</string>"); | |
| }); | |
| 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, [ | |
| " <key>EnvironmentVariables</key>", | |
| " <dict>", | |
| " <key>OPENCLAW_SERVICE_VERSION</key>", | |
| " <string>2026.4.24</string>", | |
| " </dict>", | |
| ]); | |
| await restartLaunchAgent(launchAgentControlFixture(env)); | |
| const plist = state.files.get(plistPath) ?? ""; | |
| expect(plist).toContain("<key>StandardInPath</key>"); | |
| expect(plist).toContain("<key>StandardOutPath</key>"); | |
| expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>"); | |
| expect(plist).toContain( | |
| "<key>StandardErrorPath</key>\n <string>/Users/test/Library/Logs/openclaw/gateway.log</string>", | |
| ); | |
| expect(plist).toContain("<key>KeepAlive</key>"); | |
| expect(plist).toContain("<string>node</string>"); | |
| 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, [ | |
| " <key>StandardOutPath</key>", | |
| " <string>/Users/test/.openclaw-default/logs/gateway.log</string>", | |
| ]); | |
| const onMutation = vi.fn(); | |
| await restartLaunchAgent( | |
| launchAgentControlFixture(env, { | |
| onMutation, | |
| }), | |
| ); | |
| const plist = state.files.get(plistPath) ?? ""; | |
| expect(plist).toContain("<key>StandardInPath</key>"); | |
| expect(plist).toContain("<string>/dev/null</string>"); | |
| expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>"); | |
| 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, [ | |
| " <key>StandardOutPath</key>", | |
| " <string>/Users/test/.openclaw-default/logs/gateway.log</string>", | |
| ]); | |
| 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, [ | |
| " <key>StandardOutPath</key>", | |
| " <string>/Users/test/.openclaw-default/logs/gateway.log</string>", | |
| ]); | |
| 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. */ | |