openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
621 lines (620 loc) • 21.9 kB
JavaScript
import { s as sleepWithAbort } from "./src-BQ327IOM.js";
import { i as registerSecretValueForRedaction } from "./secret-redaction-registry-BOLC6DkF.js";
import { t as runCommandBuffered } from "./exec-BIE-3oLG.js";
import { r as tryListenOnPort } from "./ports-probe-funEe1wt.js";
import { t as getProcessSupervisor } from "./supervisor--Gozeeeu.js";
import { i as probeRfbServer, t as classifyRfbSecurity } from "./rfb-probe-BHZn-QhU.js";
import { n as HostDesktopCredentialsRequiredError } from "./host-source-errors-46uOYNUn.js";
import { r as mintDesktopObserverToken } from "./observe-bridge-DyRZ6Fs5.js";
import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import crypto from "node:crypto";
//#region src/gateway/desktop/host-guidance.ts
/** Platform-specific next steps for preparing a loopback-only host VNC server. */
const HOST_DESKTOP_GUIDANCE = {
darwin: "Enable System Settings -> General -> Sharing -> Screen Sharing, or run `sudo launchctl enable system/com.apple.screensharing && sudo launchctl kickstart -k system/com.apple.screensharing`.",
linux: "Install the managed desktop binaries with `apt install tigervnc-standalone-server tigervnc-tools xfce4-session`, enable desktop.host.managed, or run a loopback-only VNC server yourself. gnome-remote-desktop uses unsupported VeNCrypt.",
win32: "Install TightVNC with `SET_USEVNCAUTHENTICATION=1 SET_ALLOWLOOPBACK=1 ACCEPTHTTPCONNECTIONS=0` and listen on 127.0.0.1:5900. Locked or UAC sessions may render black."
};
/** Resolves guidance for supported gateway platforms, falling back to Linux-style setup. */
function getHostDesktopGuidance(platform) {
return HOST_DESKTOP_GUIDANCE[platform] ?? HOST_DESKTOP_GUIDANCE.linux;
}
//#endregion
//#region src/gateway/desktop/managed-linux.ts
const MANAGED_DISPLAY_FIRST = 99;
const MANAGED_DISPLAY_LAST = 199;
const MANAGED_RESTART_LIMIT = 3;
const MANAGED_RESTART_WINDOW_MS = 3e5;
const MANAGED_READINESS_TIMEOUT_MS = 15e3;
const MANAGED_READINESS_POLL_MS = 100;
const STDERR_TAIL_CHARS = 4096;
function buildTigerVncArgv(resources) {
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() {
return ["startxfce4"];
}
function chooseDisplayNumber(socketNames) {
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) {
return random.toString("base64url").slice(0, 8);
}
function appendTail(current, chunk) {
const next = current + chunk;
return next.length <= STDERR_TAIL_CHARS ? next : next.slice(-4096);
}
function lastStderrLine(stderr) {
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;
}
}
async function readDisplaySocketNames(socketDir) {
try {
return await fs.readdir(socketDir);
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
}
function binaryError(binary, error) {
const reason = error instanceof Error ? error.message : String(error);
return new Error(`managed Linux desktop could not start ${binary}: ${reason}. ${getHostDesktopGuidance("linux")}`, { cause: error });
}
function createManagedLinuxDesktop(params = {}) {
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;
const wait = params.runtime?.sleep ?? ((ms) => sleepWithAbort(ms, void 0, { 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 = { state: "not-started" };
let resources;
let pair;
let startPromise;
let epoch = 0;
let stopping = false;
let stderrTail = "";
let restartTimes = [];
const activeWaits = /* @__PURE__ */ new Set();
const publicResult = (active) => ({
attachment: {
kind: "tcp",
host: "127.0.0.1",
port: active.port
},
auth: "vnc-password",
vncPassword: active.password
});
const removeResources = async () => {
const current = resources;
resources = void 0;
if (current) await fs.rm(current.tempDir, {
recursive: true,
force: true
});
};
const markFailed = (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 () => {
const tempDir = await fs.mkdtemp(path.join(tempRoot, "openclaw-managed-desktop-"));
await fs.chmod(tempDir, 448);
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: 384,
flag: "wx"
});
const passwordInput = await fs.readFile(plaintextFile);
const filtered = await runPasswordTool(["tigervncpasswd", "-f"], {
input: passwordInput,
maxOutputBytes: {
stdout: 64,
stderr: 4096
},
timeoutMs: 1e4
});
if (filtered.termination !== "exit" || filtered.code !== 0 || filtered.stdout.length === 0) throw binaryError("tigervncpasswd", (filtered.error?.message ?? filtered.stderr.toString("utf8").trim()) || `exit code ${filtered.code ?? "none"}`);
await fs.writeFile(passwordFile, filtered.stdout, {
mode: 384,
flag: "wx"
});
await fs.rm(plaintextFile, { force: true });
const port = await pickPort({
port: 0,
host: "127.0.0.1",
exclusive: true
});
return {
tempDir,
passwordFile,
password,
display: chooseDisplayNumber(await readDisplaySocketNames(x11SocketDir)),
port
};
} catch (error) {
await fs.rm(tempDir, {
recursive: true,
force: true
});
throw error;
}
};
const waitUntilReady = async (active, activeEpoch) => {
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(1e3, 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 = async (current) => {
supervisor.cancelScope(scopeKey, "manual-cancel");
await Promise.allSettled(activeWaits);
if (pair === current) pair = void 0;
};
const waitForRun = (run) => {
const pending = run.wait().catch((error) => ({
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);
pending.finally(() => activeWaits.delete(pending));
return pending;
};
const spawnRun = async (binary, argv, env) => {
try {
return await supervisor.spawn({
sessionId: "host-desktop-managed-linux",
backendId: binary === "Xtigervnc" ? "managed-vnc" : "managed-session",
scopeKey,
mode: "child",
argv,
...env ? { env } : {},
stdinMode: "pipe-closed",
maxCapturedOutputChars: STDERR_TAIL_CHARS,
onStderr: (chunk) => {
stderrTail = appendTail(stderrTail, chunk);
}
});
} catch (error) {
throw binaryError(binary, error);
}
};
const describeExit = (binary, exit) => {
return lastStderrLine(exit.stderr) ?? lastStderrLine(stderrTail) ?? `${binary} exited with code ${exit.exitCode ?? "none"}`;
};
const startPair = async (active, activeEpoch) => {
status = {
state: "starting",
display: active.display,
port: active.port
};
const vnc = await spawnRun("Xtigervnc", buildTigerVncArgv(active));
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 session = await spawnRun("startxfce4", buildDesktopSessionArgv(), {
...process.env,
DISPLAY: `:${active.display}`
});
const nextPair = {
vnc,
session,
vncExit,
sessionExit: waitForRun(session)
};
pair = nextPair;
status = {
state: "running",
display: active.display,
port: active.port
};
return nextPair;
} catch (error) {
vnc.cancel("manual-cancel");
await Promise.allSettled([vncExit]);
throw error;
}
};
const monitorPair = (current, active, activeEpoch) => {
Promise.race([current.vncExit.then((exit) => ({
binary: "Xtigervnc",
exit
})), current.sessionExit.then((exit) => ({
binary: "startxfce4",
exit
}))]).then(async ({ binary, exit }) => {
if (pair !== current || activeEpoch !== epoch || stopping) return;
const failure = describeExit(binary, exit);
await stopPair(current);
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(/* @__PURE__ */ 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)));
}
});
};
const start = async (activeEpoch) => {
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 (status.state === "failed") throw new Error(status.error);
if (status.state === "running" && resources) return publicResult(resources);
if (!startPromise) {
stopping = false;
restartTimes = [];
stderrTail = "";
const activeEpoch = ++epoch;
startPromise = start(activeEpoch).finally(() => {
startPromise = void 0;
});
}
return publicResult(await startPromise);
},
async stop() {
stopping = true;
++epoch;
const failed = status.state === "failed" ? status : void 0;
await stopPair(pair);
await removeResources();
startPromise = void 0;
restartTimes = [];
status = failed ?? { state: "not-started" };
stopping = false;
},
status() {
return { ...status };
}
};
}
//#endregion
//#region src/gateway/desktop/host-source.ts
const DEFAULT_HOST_DESKTOP_PORT = 5900;
const HOST_DESKTOP_PROBE_TIMEOUT_MS = 1500;
function nonRfbError(port) {
return `desktop.host.port ${port} is occupied by a non-VNC service; configure desktop.host.port for the loopback VNC server, then restart the gateway`;
}
function unavailableError(port, platform) {
return `gateway host desktop is unavailable at 127.0.0.1:${port}. ${getHostDesktopGuidance(platform)}`;
}
function managedPlatformError(platform) {
return `desktop.host.managed is available only on Linux; disable it on ${platform} or configure desktop.host.port for an existing loopback VNC server`;
}
function managedInspection(managedStatus) {
if (managedStatus.state === "running") return {
status: {
enabled: true,
state: "managed",
managedState: "running",
display: managedStatus.display,
port: managedStatus.port,
security: "VncAuth"
},
detail: `managed (running, display :${managedStatus.display}, port ${managedStatus.port}, security: VncAuth)`
};
if (managedStatus.state === "failed") return {
status: {
enabled: true,
state: "managed",
managedState: "failed",
port: managedStatus.port ?? DEFAULT_HOST_DESKTOP_PORT,
...managedStatus.display !== void 0 ? { display: managedStatus.display } : {},
error: managedStatus.error
},
detail: `managed (failed: ${managedStatus.error})`,
unavailableReason: "unsupported"
};
const startingCoordinates = managedStatus.state === "starting" ? {
port: managedStatus.port ?? DEFAULT_HOST_DESKTOP_PORT,
...managedStatus.display !== void 0 ? { display: managedStatus.display } : {}
} : { port: DEFAULT_HOST_DESKTOP_PORT };
return {
status: {
enabled: true,
state: "managed",
managedState: managedStatus.state,
...startingCoordinates
},
detail: managedStatus.state === "starting" ? "managed (starting)" : "managed (not started)"
};
}
function configuredManagedInspection() {
return {
status: {
enabled: true,
state: "managed",
managedState: "unknown",
port: DEFAULT_HOST_DESKTOP_PORT
},
detail: "managed (configured; runtime state is available from the running Gateway status)"
};
}
function securityLabel(probe) {
const auth = classifyRfbSecurity(probe.securityTypes);
if (auth === "vnc-password") return "VncAuth";
if (auth === "ard-account") return "ARD";
if (auth === "none") return "None";
return probe.securityTypes.includes(19) ? "VeNCrypt" : "unsupported";
}
/** Probes the configured host desktop without reading or exposing password material. */
async function inspectHostDesktop(params) {
const port = params.config?.port ?? DEFAULT_HOST_DESKTOP_PORT;
if (params.config?.enabled !== true) return {
status: {
enabled: false,
state: "disabled",
port
},
detail: "disabled; enable the Desktop lab with desktop.host.enabled=true, then restart the gateway"
};
const platform = params.platform ?? process.platform;
const probe = await (params.probeRfb ?? probeRfbServer)({
host: "127.0.0.1",
port,
timeoutMs: HOST_DESKTOP_PROBE_TIMEOUT_MS
});
if (probe.kind === "unreachable" || probe.kind === "timeout") {
if (params.config.port === void 0 && params.config.managed === true) {
if (platform !== "linux") return {
status: {
enabled: true,
state: "unavailable",
port
},
detail: managedPlatformError(platform),
unavailableReason: "unsupported"
};
return params.managedDesktop ? managedInspection(params.managedDesktop.status()) : configuredManagedInspection();
}
return {
status: {
enabled: true,
state: "unavailable",
port
},
detail: unavailableError(port, platform),
unavailableReason: "not-listening"
};
}
if (probe.kind === "not-rfb") return {
status: {
enabled: true,
state: "unavailable",
port
},
detail: nonRfbError(port),
unavailableReason: "not-rfb"
};
const security = securityLabel(probe);
const auth = classifyRfbSecurity(probe.securityTypes);
if (auth === "vnc-password" || auth === "ard-account") return {
status: {
enabled: true,
state: "attached",
port,
security
},
detail: `attached (127.0.0.1:${port}, security: ${security})`
};
const detail = auth === "none" ? `unavailable: unauthenticated VNC server at 127.0.0.1:${port}; require a password-protected VncAuth server, then retry` : `unavailable: ${security} security is not supported; configure a VncAuth server and desktop.host.passwordFile, then retry`;
return {
status: {
enabled: true,
state: "unavailable",
port,
security
},
detail,
unavailableReason: "unsupported"
};
}
/** Creates the host acquisition hook consumed by the source-agnostic desktop registry. */
function createHostDesktopSource(params) {
const port = params.config.port ?? DEFAULT_HOST_DESKTOP_PORT;
const platform = params.platform ?? process.platform;
const probeRfb = params.probeRfb ?? probeRfbServer;
const managedDesktop = params.managedDesktop ?? (params.config.managed === true && platform === "linux" ? createManagedLinuxDesktop() : void 0);
const acquireAttached = async (probe) => {
const security = classifyRfbSecurity(probe.securityTypes);
if (security === "none") throw new Error(`refusing unauthenticated VNC server on 127.0.0.1:${port}; require a password-protected VncAuth server, then retry`);
if (security === "unsupported") {
const name = probe.securityTypes.includes(19) ? "VeNCrypt" : "the offered VNC security";
throw new Error(`${name} is not supported; configure a VncAuth server and desktop.host.passwordFile, then retry`);
}
let vncPassword;
if (params.config.passwordFile) {
try {
vncPassword = (await fs.readFile(params.config.passwordFile, "utf8")).replace(/[\r\n]+$/u, "");
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`could not read desktop.host.passwordFile ${params.config.passwordFile}: ${reason}; fix the absolute path or remove desktop.host.passwordFile so the UI can prompt`, { cause: error });
}
if (!vncPassword) throw new Error("desktop.host.passwordFile is empty; write the VNC password or remove desktop.host.passwordFile so the UI can prompt");
registerSecretValueForRedaction(vncPassword);
}
return {
attachment: {
kind: "tcp",
host: "127.0.0.1",
port
},
auth: security,
...vncPassword ? { vncPassword } : {}
};
};
const acquire = async () => {
const probe = await probeRfb({
host: "127.0.0.1",
port,
timeoutMs: HOST_DESKTOP_PROBE_TIMEOUT_MS
});
if (probe.kind === "unreachable" || probe.kind === "timeout") {
if (params.config.port === void 0 && params.config.managed === true) {
if (platform !== "linux") throw new Error(managedPlatformError(platform));
if (!managedDesktop) throw new Error("managed Linux desktop lifecycle is unavailable; restart the gateway");
return await managedDesktop.acquire();
}
throw new Error(unavailableError(port, platform));
}
if (probe.kind === "not-rfb") throw new Error(nonRfbError(port));
return await acquireAttached(probe);
};
return {
acquire,
teardown: managedDesktop ? () => managedDesktop.stop() : void 0,
inspect: () => inspectHostDesktop({
config: params.config,
platform,
managedDesktop,
probeRfb
})
};
}
/** Combines host acquisition, registry ownership, and observer-token minting. */
function createHostDesktopService(params) {
const platform = params.platform ?? process.platform;
const managedDesktop = params.managedDesktop ?? (params.config.managed === true && platform === "linux" ? createManagedLinuxDesktop({ onFailed: () => {
params.registry.stop("host", 0);
} }) : void 0);
const source = createHostDesktopSource({
config: params.config,
platform,
...managedDesktop ? { managedDesktop } : {}
});
return {
async observe(observeParams) {
const acquired = await params.registry.acquire({
sourceKey: "host",
ownerEpoch: 0,
start: source.acquire,
...source.teardown ? { teardown: source.teardown } : {}
});
const auth = acquired.auth;
if (!auth) throw new Error("gateway host desktop authentication state is unavailable; retry observe");
let preauth;
if (auth === "ard-account") {
const username = observeParams.credentials?.username?.trim() ?? "";
const password = observeParams.credentials?.password ?? "";
if (!username || !password) throw new HostDesktopCredentialsRequiredError();
registerSecretValueForRedaction(password);
preauth = {
auth: "ard-account",
credentials: {
username,
password
}
};
}
const minted = mintDesktopObserverToken({
sourceKey: "host",
ownerEpoch: 0,
control: observeParams.control,
attachment: acquired.attachment,
...preauth ? { preauth } : {}
});
return {
transport: "rfb",
wsPath: `/desktop/observe?token=${minted.token}`,
expiresAtMs: minted.expiresAtMs,
control: observeParams.control,
auth,
...auth === "vnc-password" && acquired.vncPassword ? { vncPassword: acquired.vncPassword } : {}
};
},
async status() {
return (await source.inspect()).status;
}
};
}
//#endregion
export { createHostDesktopSource as n, inspectHostDesktop as r, createHostDesktopService as t };