Merge pull request #92 from DenchHQ/kumareth/web-ui-launchagent

feat(cli): persist web UI across reboots via macOS LaunchAgent
This commit is contained in:
Kumar Abhirup 2026-03-11 21:17:07 -07:00 committed by GitHub
commit ac033c1b21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 295 additions and 16 deletions

View File

@ -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,

View File

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

View File

@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
`<plist version="1.0">`,
`<dict>`,
` <key>Label</key>`,
` <string>${escapeXml(LAUNCH_AGENT_LABEL)}</string>`,
` <key>ProgramArguments</key>`,
` <array>`,
` <string>${escapeXml(params.nodePath)}</string>`,
` <string>${escapeXml(params.serverPath)}</string>`,
` </array>`,
` <key>WorkingDirectory</key>`,
` <string>${escapeXml(params.workingDirectory)}</string>`,
` <key>EnvironmentVariables</key>`,
` <dict>`,
` <key>PORT</key>`,
` <string>${params.port}</string>`,
` <key>HOSTNAME</key>`,
` <string>127.0.0.1</string>`,
` <key>OPENCLAW_GATEWAY_PORT</key>`,
` <string>${params.gatewayPort}</string>`,
` <key>NODE_ENV</key>`,
` <string>production</string>`,
` <key>PATH</key>`,
` <string>${escapeXml(envPath)}</string>`,
` </dict>`,
` <key>RunAtLoad</key>`,
` <true/>`,
` <key>StandardOutPath</key>`,
` <string>${escapeXml(params.stdoutPath)}</string>`,
` <key>StandardErrorPath</key>`,
` <string>${escapeXml(params.stderrPath)}</string>`,
`</dict>`,
`</plist>`,
``,
].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 };
}

View File

@ -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,