From 557cff6a523a20357a7281ce3e77b9fa9a340158 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 11 Mar 2026 21:11:41 -0700 Subject: [PATCH] feat(cli): persist web UI across reboots via macOS LaunchAgent The gateway auto-starts on boot but the web runtime dies; this adds an equivalent LaunchAgent so the web UI also survives reboots. --- src/cli/web-runtime-command.test.ts | 68 +++++++++- src/cli/web-runtime-command.ts | 36 +++++- src/cli/web-runtime-launchd.ts | 193 ++++++++++++++++++++++++++++ src/cli/web-runtime.ts | 14 +- 4 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 src/cli/web-runtime-launchd.ts diff --git a/src/cli/web-runtime-command.test.ts b/src/cli/web-runtime-command.test.ts index e53c7cc0f61..dc5010ef925 100644 --- a/src/cli/web-runtime-command.test.ts +++ b/src/cli/web-runtime-command.test.ts @@ -72,6 +72,20 @@ vi.mock("node:child_process", () => ({ spawn: spawnMock, })); +const launchdMocks = vi.hoisted(() => ({ + installWebRuntimeLaunchAgent: vi.fn(() => ({ + started: true, + pid: 7788, + runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", + })), + uninstallWebRuntimeLaunchAgent: vi.fn(), +})); + +vi.mock("./web-runtime-launchd.js", () => ({ + installWebRuntimeLaunchAgent: launchdMocks.installWebRuntimeLaunchAgent, + uninstallWebRuntimeLaunchAgent: launchdMocks.uninstallWebRuntimeLaunchAgent, +})); + vi.mock("./web-runtime.js", () => ({ DEFAULT_WEB_APP_PORT: webRuntimeMocks.DEFAULT_WEB_APP_PORT, ensureManagedWebRuntime: webRuntimeMocks.ensureManagedWebRuntime, @@ -131,6 +145,14 @@ describe("updateWebRuntimeCommand", () => { promptMocks.isCancel.mockReset(); promptMocks.isCancel.mockImplementation(() => false); + launchdMocks.installWebRuntimeLaunchAgent.mockReset(); + launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue({ + started: true, + pid: 7788, + runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", + }); + launchdMocks.uninstallWebRuntimeLaunchAgent.mockReset(); + workspaceSeedMocks.discoverWorkspaceDirs.mockReset(); workspaceSeedMocks.discoverWorkspaceDirs.mockReturnValue(["/tmp/.openclaw-dench/workspace"]); workspaceSeedMocks.syncManagedSkills.mockReset(); @@ -324,6 +346,13 @@ describe("startWebRuntimeCommand", () => { reason: string; }, ); + launchdMocks.installWebRuntimeLaunchAgent.mockReset(); + launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue({ + started: true, + pid: 7788, + runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", + }); + launchdMocks.uninstallWebRuntimeLaunchAgent.mockReset(); }); it("fails closed when non-dench listeners still own the port (prevents cross-process takeover)", async () => { @@ -340,11 +369,13 @@ describe("startWebRuntimeCommand", () => { }); it("fails with actionable remediation when managed runtime is missing (requires explicit update/bootstrap)", async () => { - webRuntimeMocks.startManagedWebRuntime.mockReturnValue({ - started: false, + const missingResult = { + started: false as const, runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", reason: "runtime-missing", - }); + }; + webRuntimeMocks.startManagedWebRuntime.mockReturnValue(missingResult); + launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue(missingResult); const runtime = runtimeStub(); await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("npx denchclaw update"); @@ -365,7 +396,11 @@ describe("startWebRuntimeCommand", () => { port: 3100, includeLegacyStandalone: true, }); - expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({ + const startMock = + process.platform === "darwin" + ? launchdMocks.installWebRuntimeLaunchAgent + : webRuntimeMocks.startManagedWebRuntime; + expect(startMock).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, gatewayPort: 19001, @@ -386,7 +421,11 @@ describe("startWebRuntimeCommand", () => { const runtime = runtimeStub(); await startWebRuntimeCommand({ webPort: "3100" }, runtime); - expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith( + const startMock = + process.platform === "darwin" + ? launchdMocks.installWebRuntimeLaunchAgent + : webRuntimeMocks.startManagedWebRuntime; + expect(startMock).toHaveBeenCalledWith( expect.objectContaining({ gatewayPort: 19001 }), ); }); @@ -396,7 +435,11 @@ describe("startWebRuntimeCommand", () => { const runtime = runtimeStub(); await startWebRuntimeCommand({ webPort: "3100" }, runtime); - expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith( + const startMock = + process.platform === "darwin" + ? launchdMocks.installWebRuntimeLaunchAgent + : webRuntimeMocks.startManagedWebRuntime; + expect(startMock).toHaveBeenCalledWith( expect.objectContaining({ gatewayPort: 19001 }), ); }); @@ -428,6 +471,13 @@ describe("restartWebRuntimeCommand", () => { reason: string; }, ); + launchdMocks.installWebRuntimeLaunchAgent.mockReset(); + launchdMocks.installWebRuntimeLaunchAgent.mockReturnValue({ + started: true, + pid: 7788, + runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js", + }); + launchdMocks.uninstallWebRuntimeLaunchAgent.mockReset(); }); it("stops and restarts managed runtime (same stop+start lifecycle as start command)", async () => { @@ -444,7 +494,11 @@ describe("restartWebRuntimeCommand", () => { port: 3100, includeLegacyStandalone: true, }); - expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({ + const startMock = + process.platform === "darwin" + ? launchdMocks.installWebRuntimeLaunchAgent + : webRuntimeMocks.startManagedWebRuntime; + expect(startMock).toHaveBeenCalledWith({ stateDir: "/tmp/.openclaw-dench", port: 3100, gatewayPort: 19001, diff --git a/src/cli/web-runtime-command.ts b/src/cli/web-runtime-command.ts index 4c7e3c08b32..ecd7ebba1c7 100644 --- a/src/cli/web-runtime-command.ts +++ b/src/cli/web-runtime-command.ts @@ -24,6 +24,10 @@ import { stopManagedWebRuntime, waitForWebRuntime, } from "./web-runtime.js"; +import { + installWebRuntimeLaunchAgent, + uninstallWebRuntimeLaunchAgent, +} from "./web-runtime-launchd.js"; import { discoverWorkspaceDirs, syncManagedSkills, type SkillSyncResult } from "./workspace-seed.js"; type SpawnResult = { @@ -399,6 +403,11 @@ export async function updateWebRuntimeCommand( readLastKnownWebPort(stateDir) ?? DEFAULT_WEB_APP_PORT; const gatewayPort = resolveGatewayPort(stateDir); + + if (process.platform === "darwin") { + uninstallWebRuntimeLaunchAgent(); + } + const stopResult = await stopManagedWebRuntime({ stateDir, port: selectedPort, @@ -420,6 +429,10 @@ export async function updateWebRuntimeCommand( denchVersion: VERSION, port: selectedPort, gatewayPort, + startFn: + process.platform === "darwin" + ? (p) => installWebRuntimeLaunchAgent(p) + : undefined, }); const summary: UpdateWebRuntimeSummary = { @@ -494,6 +507,11 @@ export async function stopWebRuntimeCommand( const stateDir = resolveProfileStateDir(profile); const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir); + + if (process.platform === "darwin") { + uninstallWebRuntimeLaunchAgent(); + } + const stopResult = await stopManagedWebRuntime({ stateDir, port: selectedPort, @@ -556,6 +574,10 @@ export async function startWebRuntimeCommand( const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir); const gatewayPort = resolveGatewayPort(stateDir); + if (process.platform === "darwin") { + uninstallWebRuntimeLaunchAgent(); + } + const stopResult = await stopManagedWebRuntime({ stateDir, port: selectedPort, @@ -574,11 +596,15 @@ export async function startWebRuntimeCommand( json: Boolean(opts.json), }); - const startResult = startManagedWebRuntime({ - stateDir, - port: selectedPort, - gatewayPort, - }); + let startResult; + if (process.platform === "darwin") { + startResult = installWebRuntimeLaunchAgent({ stateDir, port: selectedPort, gatewayPort }); + if (!startResult.started && startResult.reason !== "runtime-missing") { + startResult = startManagedWebRuntime({ stateDir, port: selectedPort, gatewayPort }); + } + } else { + startResult = startManagedWebRuntime({ stateDir, port: selectedPort, gatewayPort }); + } if (!startResult.started) { const runtimeServerPath = resolveManagedWebRuntimeServerPath(stateDir); diff --git a/src/cli/web-runtime-launchd.ts b/src/cli/web-runtime-launchd.ts new file mode 100644 index 00000000000..4fdf7e7ccff --- /dev/null +++ b/src/cli/web-runtime-launchd.ts @@ -0,0 +1,193 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { + resolveManagedWebRuntimeServerPath, + updateManifestLastPort, + writeManagedWebRuntimeProcess, + type StartManagedWebRuntimeResult, +} from "./web-runtime.js"; + +const LAUNCH_AGENT_LABEL = "ai.denchclaw.web-runtime"; + +export function resolveLaunchAgentPlistPath(): string { + return path.join( + os.homedir(), + "Library", + "LaunchAgents", + `${LAUNCH_AGENT_LABEL}.plist`, + ); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function buildPlistXml(params: { + nodePath: string; + serverPath: string; + workingDirectory: string; + port: number; + gatewayPort: number; + stdoutPath: string; + stderrPath: string; +}): string { + const nodeDir = path.dirname(params.nodePath); + const envPath = [nodeDir, "/usr/local/bin", "/usr/bin", "/bin"] + .filter((seg, i, arr) => arr.indexOf(seg) === i) + .join(":"); + + return [ + ``, + ``, + ``, + ``, + ` Label`, + ` ${escapeXml(LAUNCH_AGENT_LABEL)}`, + ` ProgramArguments`, + ` `, + ` ${escapeXml(params.nodePath)}`, + ` ${escapeXml(params.serverPath)}`, + ` `, + ` WorkingDirectory`, + ` ${escapeXml(params.workingDirectory)}`, + ` EnvironmentVariables`, + ` `, + ` PORT`, + ` ${params.port}`, + ` HOSTNAME`, + ` 127.0.0.1`, + ` OPENCLAW_GATEWAY_PORT`, + ` ${params.gatewayPort}`, + ` NODE_ENV`, + ` production`, + ` PATH`, + ` ${escapeXml(envPath)}`, + ` `, + ` RunAtLoad`, + ` `, + ` StandardOutPath`, + ` ${escapeXml(params.stdoutPath)}`, + ` StandardErrorPath`, + ` ${escapeXml(params.stderrPath)}`, + ``, + ``, + ``, + ].join("\n"); +} + +export function isWebRuntimeLaunchAgentLoaded(): boolean { + try { + execFileSync("launchctl", ["list", LAUNCH_AGENT_LABEL], { + stdio: ["ignore", "pipe", "pipe"], + }); + return true; + } catch { + return false; + } +} + +function resolveLaunchAgentPid(): number | null { + try { + const output = execFileSync("launchctl", ["list", LAUNCH_AGENT_LABEL], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + const match = output.match(/"PID"\s*=\s*(\d+)/); + if (match?.[1]) { + const pid = Number.parseInt(match[1], 10); + if (Number.isFinite(pid) && pid > 0) return pid; + } + return null; + } catch { + return null; + } +} + +export function uninstallWebRuntimeLaunchAgent(): void { + const plistPath = resolveLaunchAgentPlistPath(); + + if (isWebRuntimeLaunchAgentLoaded()) { + try { + execFileSync("launchctl", ["unload", "-w", plistPath], { + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + try { + execFileSync("launchctl", ["remove", LAUNCH_AGENT_LABEL], { + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + // best-effort + } + } + } + + rmSync(plistPath, { force: true }); +} + +/** + * Install a macOS LaunchAgent for the web runtime so it auto-starts on login. + * Writes the plist to ~/Library/LaunchAgents/ and loads it via launchctl. + * RunAtLoad causes launchd to start the process immediately on load. + */ +export function installWebRuntimeLaunchAgent(params: { + stateDir: string; + port: number; + gatewayPort: number; +}): StartManagedWebRuntimeResult { + const runtimeServerPath = resolveManagedWebRuntimeServerPath(params.stateDir); + if (!existsSync(runtimeServerPath)) { + return { started: false, runtimeServerPath, reason: "runtime-missing" }; + } + + const appDir = path.dirname(runtimeServerPath); + const logsDir = path.join(params.stateDir, "logs"); + mkdirSync(logsDir, { recursive: true }); + + uninstallWebRuntimeLaunchAgent(); + + const plistPath = resolveLaunchAgentPlistPath(); + mkdirSync(path.dirname(plistPath), { recursive: true }); + + const plistXml = buildPlistXml({ + nodePath: process.execPath, + serverPath: runtimeServerPath, + workingDirectory: appDir, + port: params.port, + gatewayPort: params.gatewayPort, + stdoutPath: path.join(logsDir, "web-app.log"), + stderrPath: path.join(logsDir, "web-app.err.log"), + }); + + writeFileSync(plistPath, plistXml, "utf-8"); + + try { + execFileSync("launchctl", ["load", "-w", plistPath], { + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + rmSync(plistPath, { force: true }); + return { started: false, runtimeServerPath, reason: "launchctl-load-failed" }; + } + + const pid = resolveLaunchAgentPid() ?? -1; + + writeManagedWebRuntimeProcess(params.stateDir, { + pid, + port: params.port, + gatewayPort: params.gatewayPort, + startedAt: new Date().toISOString(), + runtimeAppDir: appDir, + }); + updateManifestLastPort(params.stateDir, params.port, params.gatewayPort); + + return { started: true, pid, runtimeServerPath }; +} diff --git a/src/cli/web-runtime.ts b/src/cli/web-runtime.ts index 5b18a9c82ca..fb614ae987c 100644 --- a/src/cli/web-runtime.ts +++ b/src/cli/web-runtime.ts @@ -84,7 +84,7 @@ export type StartManagedWebRuntimeResult = | { started: false; runtimeServerPath: string; - reason: "runtime-missing"; + reason: string; }; export type WebPortListenerOwnership = "managed" | "legacy-standalone" | "foreign"; @@ -279,7 +279,7 @@ function writeManagedWebRuntimeManifest( return manifest; } -function writeManagedWebRuntimeProcess( +export function writeManagedWebRuntimeProcess( stateDir: string, processMeta: ManagedWebRuntimeProcess, ): void { @@ -290,7 +290,7 @@ function clearManagedWebRuntimeProcess(stateDir: string): void { rmSync(resolveManagedWebRuntimeProcessPath(stateDir), { force: true }); } -function updateManifestLastPort( +export function updateManifestLastPort( stateDir: string, webPort: number, gatewayPort: number, @@ -839,6 +839,11 @@ export async function ensureManagedWebRuntime(params: { denchVersion: string; port: number; gatewayPort: number; + startFn?: (p: { + stateDir: string; + port: number; + gatewayPort: number; + }) => StartManagedWebRuntimeResult; }): Promise<{ ready: boolean; reason: string }> { const install = installManagedWebRuntime({ stateDir: params.stateDir, @@ -869,7 +874,8 @@ export async function ensureManagedWebRuntime(params: { }; } - const start = startManagedWebRuntime({ + const doStart = params.startFn ?? startManagedWebRuntime; + const start = doStart({ stateDir: params.stateDir, port: params.port, gatewayPort: params.gatewayPort,