import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { sleepWithAbort } from "@openclaw/retry"; import { tryListenOnPort } from "../../infra/ports-probe.js"; import { registerSecretValueForRedaction } from "../../logging/secret-redaction-registry.js"; import { runCommandBuffered } from "../../process/exec.js"; import { getProcessSupervisor } from "../../process/supervisor/index.js"; import type { ManagedRun, ProcessSupervisor, RunExit } from "../../process/supervisor/types.js"; import { createDeferredCore } from "../../shared/deferred.js"; import { getHostDesktopGuidance } from "./host-guidance.js"; import { probeRfbServer } from "./rfb-probe.js"; const MANAGED_DISPLAY_FIRST = 99; const MANAGED_DISPLAY_LAST = 199; const MANAGED_RESTART_LIMIT = 3; const MANAGED_RESTART_WINDOW_MS = 5 * 60_000; const MANAGED_READINESS_TIMEOUT_MS = 15_000; const MANAGED_READINESS_POLL_MS = 100; const STDERR_TAIL_CHARS = 4_096; type ManagedResources = { tempDir: string; passwordFile: string; password: string; display: number; port: number; env: NodeJS.ProcessEnv; }; type ManagedPair = { current: boolean; vnc: ManagedRun; bus: ManagedRun; session: ManagedRun; vncExit: ReturnType; busExit: ReturnType; sessionExit: ReturnType; computerLeases: Set<{ onStop(): Promise }>; stopPromise?: Promise; }; export type DesktopComputerLease = { env: NodeJS.ProcessEnv; isCurrent(): boolean; release(): void; }; export type ManagedLinuxDesktopStatus = | { state: "not-started" } | { state: "starting"; display?: number; port?: number } | { state: "running"; display: number; port: number } | { state: "failed"; error: string; display?: number; port?: number }; export type ManagedLinuxDesktop = { acquire(): Promise<{ attachment: { kind: "tcp"; host: "127.0.0.1"; port: number }; auth: "vnc-password"; vncPassword: string; }>; acquireComputer(params: { onStop(): Promise }): Promise; stop(): Promise; status(): ManagedLinuxDesktopStatus; }; function buildTigerVncArgv(resources: ManagedResources): string[] { return [ "Xtigervnc", `:${resources.display}`, "-geometry", "1920x1080", "-depth", "24", "-localhost", "yes", "-rfbport", String(resources.port), "-SecurityTypes", "VncAuth", "-PasswordFile", resources.passwordFile, "-AlwaysShared", "-AcceptSetDesktopSize", "-nolisten", "tcp", "-ac", ]; } function buildDesktopSessionArgv(): string[] { return ["startxfce4"]; } function chooseDisplayNumber(socketNames: readonly string[]): number { const occupied = new Set( socketNames.flatMap((name) => { const match = /^X(\d+)$/u.exec(name); return match ? [Number.parseInt(match[1] ?? "", 10)] : []; }), ); for (let display = MANAGED_DISPLAY_FIRST; display <= MANAGED_DISPLAY_LAST; display += 1) { if (!occupied.has(display)) { return display; } } throw new Error( `managed Linux desktop could not find an unused X display between :${MANAGED_DISPLAY_FIRST} and :${MANAGED_DISPLAY_LAST}`, ); } function createVncPassword(random: Buffer): string { return random.toString("base64url").slice(0, 8); } function appendTail(current: string, chunk: string): string { const next = current + chunk; return next.length <= STDERR_TAIL_CHARS ? next : next.slice(-STDERR_TAIL_CHARS); } function lastStderrLine(stderr: string): string | undefined { const lines = stderr.split(/\r?\n/u); for (let index = lines.length - 1; index >= 0; index -= 1) { const line = lines[index]?.trim(); if (line) { return line; } } return undefined; } async function readDisplaySocketNames(socketDir: string): Promise { try { return await fs.readdir(socketDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return []; } throw error; } } function binaryError( binary: "Xtigervnc" | "tigervncpasswd" | "startxfce4" | "dbus-daemon", error: unknown, ) { const reason = error instanceof Error ? error.message : String(error); return new Error( `managed Linux desktop could not start ${binary}: ${reason}. ${getHostDesktopGuidance("linux")}`, { cause: error }, ); } export function createManagedLinuxDesktop( params: { supervisor?: ProcessSupervisor; onFailed?: (error: string) => void; runtime?: { nowMs?: () => number; probeRfb?: typeof probeRfbServer; randomBytes?: typeof crypto.randomBytes; readinessPollMs?: number; readinessTimeoutMs?: number; runPasswordTool?: typeof runCommandBuffered; sleep?: (ms: number) => Promise; tempRoot?: string; tryListenOnPort?: (params: { port: 0; host: "127.0.0.1"; exclusive: true; }) => Promise; x11SocketDir?: string; }; } = {}, ): ManagedLinuxDesktop { const supervisor = params.supervisor ?? getProcessSupervisor(); const nowMs = params.runtime?.nowMs ?? Date.now; const probeRfb = params.runtime?.probeRfb ?? probeRfbServer; const randomBytes = params.runtime?.randomBytes ?? crypto.randomBytes; const readinessPollMs = params.runtime?.readinessPollMs ?? MANAGED_READINESS_POLL_MS; const readinessTimeoutMs = params.runtime?.readinessTimeoutMs ?? MANAGED_READINESS_TIMEOUT_MS; const runPasswordTool = params.runtime?.runPasswordTool ?? runCommandBuffered; // ref:false keeps readiness polling from pinning an otherwise idle process alive. const wait = params.runtime?.sleep ?? ((ms: number) => sleepWithAbort(ms, undefined, { ref: false })); const tempRoot = params.runtime?.tempRoot ?? os.tmpdir(); const pickPort = params.runtime?.tryListenOnPort ?? tryListenOnPort; const x11SocketDir = params.runtime?.x11SocketDir ?? "/tmp/.X11-unix"; const scopeKey = `host-desktop-managed-linux:${crypto.randomUUID()}`; let status: ManagedLinuxDesktopStatus = { state: "not-started" }; let resources: ManagedResources | undefined; let pair: ManagedPair | undefined; let startPromise: Promise | undefined; let stopPromise: Promise | undefined; let stopProcesses: (() => Promise) | undefined; let epoch = 0; let stopping = false; let stderrTail = ""; let restartTimes: number[] = []; const activeWaits = new Set>(); const monitorTasks = new Set>(); const isPairCurrent = (current: ManagedPair) => !stopping && pair === current && current.current && !current.vnc.activity.resultSettled && !current.bus.activity.resultSettled && !current.session.activity.resultSettled; const publicResult = (active: ManagedResources) => ({ attachment: { kind: "tcp" as const, host: "127.0.0.1" as const, port: active.port, }, auth: "vnc-password" as const, vncPassword: active.password, }); const removeResources = async () => { const current = resources; resources = undefined; if (current) { await fs.rm(current.tempDir, { recursive: true, force: true }); } }; const markFailed = (error: Error) => { const coordinates = resources ? { display: resources.display, port: resources.port } : status.state === "starting" || status.state === "failed" ? { display: status.display, port: status.port } : {}; status = { state: "failed", error: error.message, ...coordinates }; params.onFailed?.(error.message); }; const prepareResources = async (): Promise => { const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-managed-desktop-")); await fs.chmod(tempDir, 0o700); const plaintextFile = path.join(tempDir, "password.txt"); const passwordFile = path.join(tempDir, "passwd"); try { const password = createVncPassword(randomBytes(12)); registerSecretValueForRedaction(password); await fs.writeFile(plaintextFile, password, { mode: 0o600, flag: "wx" }); const passwordInput = await fs.readFile(plaintextFile); const filtered = await runPasswordTool(["tigervncpasswd", "-f"], { input: passwordInput, maxOutputBytes: { stdout: 64, stderr: 4_096 }, timeoutMs: 10_000, }); if (filtered.termination !== "exit" || filtered.code !== 0 || filtered.stdout.length === 0) { const detail = filtered.error?.message ?? filtered.stderr.toString("utf8").trim(); throw binaryError("tigervncpasswd", detail || `exit code ${filtered.code ?? "none"}`); } await fs.writeFile(passwordFile, filtered.stdout, { mode: 0o600, flag: "wx" }); await fs.rm(plaintextFile, { force: true }); const port = await pickPort({ port: 0, host: "127.0.0.1", exclusive: true }); const display = chooseDisplayNumber(await readDisplaySocketNames(x11SocketDir)); const env: NodeJS.ProcessEnv = { ...process.env, DISPLAY: `:${display}`, DBUS_SESSION_BUS_ADDRESS: `unix:path=${path.join(tempDir, "bus")}`, XDG_SESSION_TYPE: "x11", }; delete env.WAYLAND_DISPLAY; delete env.SESSION_MANAGER; delete env.AT_SPI_BUS_ADDRESS; delete env.DBUS_SESSION_BUS_PID; return { tempDir, passwordFile, password, display, port, env: Object.freeze(env) }; } catch (error) { await fs.rm(tempDir, { recursive: true, force: true }); throw error; } }; const waitUntilReady = async (active: ManagedResources, activeEpoch: number) => { const deadline = nowMs() + readinessTimeoutMs; let lastProbe = "unreachable"; for (;;) { if (activeEpoch !== epoch || stopping) { break; } const probe = await probeRfb({ host: "127.0.0.1", port: active.port, timeoutMs: Math.min(1_000, readinessTimeoutMs), }); lastProbe = probe.kind; if (probe.kind === "rfb" && probe.securityTypes.includes(2)) { return; } if (nowMs() >= deadline) { break; } await wait(readinessPollMs); } if (activeEpoch !== epoch || stopping) { throw new Error("managed Linux desktop stopped during startup"); } throw new Error( `managed Linux desktop did not become ready on 127.0.0.1:${active.port} within ${readinessTimeoutMs}ms (last probe: ${lastProbe})`, ); }; const stopPair = (current: ManagedPair | undefined): Promise => { if (current?.stopPromise) { return current.stopPromise; } if (current) { current.current = false; } const stopped = Promise.resolve().then(async () => { // Native users must finish cleanup before their X11 and D-Bus session disappears. const outcomes = await Promise.allSettled( [...(current?.computerLeases ?? [])].map(async (lease) => { await lease.onStop(); current?.computerLeases.delete(lease); }), ); const failure = outcomes.find((outcome) => outcome.status === "rejected"); if (failure) { throw failure.reason; } supervisor.cancelScope(scopeKey, "manual-cancel"); const cleanup = stopProcesses; await cleanup?.(); await Promise.allSettled(activeWaits); if (stopProcesses === cleanup) { stopProcesses = undefined; } if (pair === current) { pair = undefined; } }); if (current) { current.stopPromise = stopped; void stopped.catch(() => { current.stopPromise = undefined; }); } return stopped; }; const waitForRun = (run: ManagedRun): Promise => { const pending = run.wait().catch((error: unknown): RunExit => ({ reason: "spawn-error", exitCode: null, exitSignal: null, durationMs: Math.max(0, nowMs() - run.startedAtMs), stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut: false, noOutputTimedOut: false, })); activeWaits.add(pending); void pending.finally(() => activeWaits.delete(pending)); return pending; }; const spawnRun = async ( binary: "Xtigervnc" | "startxfce4" | "dbus-daemon", argv: string[], activeEpoch: number, env?: NodeJS.ProcessEnv, onStdout?: (chunk: string) => void, ) => { try { return await supervisor.spawn({ scopeKey, mode: "child", argv, ...(env ? { env } : {}), assertCurrent: () => { if (activeEpoch !== epoch || stopping) { throw new Error("managed Linux desktop stopped during startup"); } }, ...(onStdout ? { onStdout } : {}), stdinMode: "pipe-closed", maxCapturedOutputChars: STDERR_TAIL_CHARS, onStderr: (chunk) => { stderrTail = appendTail(stderrTail, chunk); }, }); } catch (error) { throw binaryError(binary, error); } }; const describeExit = (binary: string, exit: Awaited>) => { const stderr = lastStderrLine(exit.stderr) ?? lastStderrLine(stderrTail); return stderr ?? `${binary} exited with code ${exit.exitCode ?? "none"}`; }; const startPair = async (active: ManagedResources, activeEpoch: number): Promise => { status = { state: "starting", display: active.display, port: active.port }; stopProcesses = supervisor.acquireScopeCleanup(scopeKey, { processTree: "required-all" }); const vnc = await spawnRun("Xtigervnc", buildTigerVncArgv(active), activeEpoch); const vncExit = waitForRun(vnc); try { await Promise.race([ waitUntilReady(active, activeEpoch), vncExit.then((exit) => { throw new Error(describeExit("Xtigervnc", exit)); }), ]); if (activeEpoch !== epoch || stopping) { throw new Error("managed Linux desktop stopped during startup"); } const busReady = createDeferredCore(); let busOutput = ""; await fs.rm(path.join(active.tempDir, "bus"), { force: true }); const bus = await spawnRun( "dbus-daemon", [ "dbus-daemon", "--session", "--nofork", "--nopidfile", "--print-address=1", `--address=${active.env.DBUS_SESSION_BUS_ADDRESS}`, ], activeEpoch, active.env, (chunk) => { busOutput = appendTail(busOutput, chunk); if (busOutput.includes("\n")) { busReady.resolve(); } }, ); const busExit = waitForRun(bus); const busTimeout = setTimeout( () => busReady.reject(new Error("managed Linux desktop D-Bus session did not become ready")), readinessTimeoutMs, ); try { await Promise.race([ busReady.promise, busExit.then((exit) => { throw new Error(describeExit("dbus-daemon", exit)); }), vncExit.then((exit) => { throw new Error(describeExit("Xtigervnc", exit)); }), ]); } finally { clearTimeout(busTimeout); } const session = await spawnRun( "startxfce4", buildDesktopSessionArgv(), activeEpoch, active.env, ); const nextPair: ManagedPair = { current: true, vnc, bus, session, vncExit, busExit, sessionExit: waitForRun(session), computerLeases: new Set(), }; pair = nextPair; status = { state: "running", display: active.display, port: active.port }; return nextPair; } catch (error) { await stopPair(undefined); throw error; } }; const monitorPair = (current: ManagedPair, active: ManagedResources, activeEpoch: number) => { const task = Promise.race([ current.vncExit.then((exit) => ({ binary: "Xtigervnc", exit })), current.busExit.then((exit) => ({ binary: "dbus-daemon", exit })), current.sessionExit.then((exit) => ({ binary: "startxfce4", exit })), ]).then(async ({ binary, exit }) => { if (pair !== current || activeEpoch !== epoch || stopping) { return; } const failure = describeExit(binary, exit); status = { state: "starting", display: active.display, port: active.port }; try { await stopPair(current); } catch (error) { if (activeEpoch === epoch && !stopping) { markFailed(error instanceof Error ? error : new Error(String(error))); } return; } if (activeEpoch !== epoch || stopping) { return; } const now = nowMs(); restartTimes = restartTimes.filter( (startedAt) => now - startedAt < MANAGED_RESTART_WINDOW_MS, ); if (restartTimes.length >= MANAGED_RESTART_LIMIT) { markFailed( new Error( `managed Linux desktop failed after ${MANAGED_RESTART_LIMIT} restarts within 5 minutes: ${failure}`, ), ); return; } restartTimes.push(now); try { const restarted = await startPair(active, activeEpoch); monitorPair(restarted, active, activeEpoch); } catch (error) { if (activeEpoch === epoch && !stopping) { markFailed(error instanceof Error ? error : new Error(String(error))); } } }); monitorTasks.add(task); void task.finally(() => monitorTasks.delete(task)).catch(() => undefined); }; const start = async (activeEpoch: number): Promise => { try { resources = await prepareResources(); if (activeEpoch !== epoch || stopping) { throw new Error("managed Linux desktop stopped during startup"); } const started = await startPair(resources, activeEpoch); monitorPair(started, resources, activeEpoch); return resources; } catch (error) { if (activeEpoch === epoch && !stopping) { markFailed(error instanceof Error ? error : new Error(String(error))); } throw error; } }; return { async acquire() { if (stopping || stopPromise) { throw new Error("managed Linux desktop is stopping"); } if (status.state === "failed") { throw new Error(status.error); } if (status.state === "running" && resources && pair && isPairCurrent(pair)) { return publicResult(resources); } if (monitorTasks.size > 0) { throw new Error("managed Linux desktop is restarting; retry when it is ready"); } if (!startPromise) { stopping = false; restartTimes = []; stderrTail = ""; const activeEpoch = ++epoch; startPromise = start(activeEpoch).finally(() => { startPromise = undefined; }); } return publicResult(await startPromise); }, async acquireComputer(request) { const current = pair; if (status.state !== "running" || !resources || !current || !isPairCurrent(current)) { throw new Error("managed Linux desktop is unavailable for computer control"); } const lease = { onStop: () => request.onStop() }; current.computerLeases.add(lease); return { env: resources.env, isCurrent: () => isPairCurrent(current) && current.computerLeases.has(lease), release: () => { current.computerLeases.delete(lease); }, }; }, stop() { if (stopPromise) { return stopPromise; } stopping = true; ++epoch; const failed = status.state === "failed" ? status : undefined; const stopped = Promise.resolve().then(async () => { await stopPair(pair); await startPromise?.catch(() => undefined); await Promise.all(monitorTasks); await stopPair(pair); await removeResources(); startPromise = undefined; restartTimes = []; status = failed ?? { state: "not-started" }; stopping = false; }); stopPromise = stopped; void stopped .finally(() => { stopPromise = undefined; }) .catch(() => undefined); return stopped; }, status() { return { ...status }; }, }; }