import { timingSafeEqual } from "node:crypto"; import path from "node:path"; import { canReleaseScreenLease, canTakeScreenLease } from "@rakazo/core"; import { z } from "zod"; import { type SandboxInput, screenPorts, TEAM_SCREEN_LIMIT, xdotoolCommand, } from "./computer-spec.js"; export const computerActionSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("key"), key: z.string(), modifiers: z.array(z.string()).optional() }), z.object({ kind: z.literal("pointer"), x: z.number(), y: z.number(), button: z.enum(["left", "right"]).optional(), type: z.enum(["move", "down", "up", "click"]), }), z.object({ kind: z.literal("clipboard"), text: z.string() }), z.object({ kind: z.literal("scroll"), direction: z.enum(["up", "down"]), amount: z.number().optional(), }), z.object({ kind: z.literal("wait"), ms: z.number() }), z.object({ kind: z.literal("open"), path: z.string() }), z.object({ kind: z.literal("launch"), application: z.string(), uri: z.string().optional() }), ]); export const DOCKER_BROWSER_ALIASES = new Set([ "browser", "chrome", "chromium", "chromium-browser", "firefox", "google-chrome", "google-chrome-stable", "rakazo-browser", ]); export function assertRequestIdentity( botId: string | undefined, spaceId: string | undefined, expected: { botId: string; spaceId: string }, ) { if (botId !== expected.botId || spaceId !== expected.spaceId) { throw new Error("computer identity mismatch"); } } export function hasComputerIdentity( labels: Record | undefined, botId: string, spaceId: string, ) { const labeledSpaceId = labels?.["rakazo.spaceId"] ?? labels?.["rakazo.workspaceId"]; return labels?.["rakazo.botId"] === botId && labeledSpaceId === spaceId; } export function hasValidBearerToken(authorization: string | undefined, expectedToken: string) { const supplied = authorization?.startsWith("Bearer ") ? authorization.slice(7) : ""; const actual = Buffer.from(expectedToken); const candidate = Buffer.from(supplied); return actual.length === candidate.length && timingSafeEqual(actual, candidate); } /** Prefer the HTTP control fast path; on failure use the docker-exec fallback. */ export async function preferComputerControl( run: (() => Promise) | undefined, fallback: () => Promise, ): Promise { if (!run) return fallback(); try { return await run(); } catch { return fallback(); } } export class ComputerControlUnavailableError extends Error { constructor(message = "computer control unavailable") { super(message); this.name = "ComputerControlUnavailableError"; } } function errorText(error: unknown) { if (!(error instanceof Error)) return String(error).toLowerCase(); const cause = error.cause instanceof Error ? ` ${error.cause.message}` : ""; return `${error.message}${cause}`.toLowerCase(); } /** True when the control service was never reached, so actions were not applied. */ export function isComputerControlUnavailable(error: unknown) { if (error instanceof ComputerControlUnavailableError) return true; if (!(error instanceof Error)) return false; if (error.name === "TimeoutError" || error.name === "AbortError") return false; const text = errorText(error); // Only pre-connect failures prove no actions ran. Mid-flight resets/hang-ups can // happen after the service already applied steps. return ( text.includes("econnrefused") || text.includes("enotfound") || text.includes("ehostunreach") || text.includes("enetunreach") || text.includes("eai_again") ); } export type ComputerControlAttempt = | { status: "ok"; value: T } | { status: "unavailable" } | { status: "failed"; error: Error }; /** * Try the HTTP control fast path. * `unavailable` means the service was never reached (safe to fall back for actions). * `failed` means the request may have partially applied actions (do not replay). */ export async function attemptComputerControl( run: (() => Promise) | undefined, ): Promise> { if (!run) return { status: "unavailable" }; try { return { status: "ok", value: await run() }; } catch (error) { if (isComputerControlUnavailable(error)) return { status: "unavailable" }; return { status: "failed", error: error instanceof Error ? error : new Error(String(error)), }; } } /** Replay actions via docker-exec only when control was never reached. */ export function shouldReplayComputerActions(attempt: ComputerControlAttempt) { return attempt.status === "unavailable"; } const CONTROL_BASE_TIMEOUT_MS = 15_000; const CONTROL_MAX_TIMEOUT_MS = 60_000; /** Bound the HTTP control deadline by mapped waits and settle time. */ export function computerControlTimeoutMs( actions: Array>, settleMs = 0, ) { let waits = 0; for (const action of actions) { if (action.kind === "wait") waits += Math.min(Math.max(action.ms, 0), 5_000); } return Math.min( CONTROL_MAX_TIMEOUT_MS, CONTROL_BASE_TIMEOUT_MS + waits + Math.min(Math.max(settleMs, 0), 5_000), ); } export function toSandboxInput(input: { kind: "key" | "pointer" | "clipboard"; key?: string; modifiers?: string[]; x?: number; y?: number; button?: "left" | "right"; type?: "move" | "down" | "up" | "click"; text?: string; }): SandboxInput { if (input.kind === "key") { return { kind: "key", key: input.key ?? "", modifiers: input.modifiers }; } if (input.kind === "clipboard") return { kind: "clipboard", text: input.text ?? "" }; return { kind: "pointer", x: input.x ?? 0, y: input.y ?? 0, button: input.button, type: input.type ?? "click", }; } export function nextScreenIndex( assigned: Map, screenId: string, leaseId?: string, limit = TEAM_SCREEN_LIMIT, ): number { const existing = assigned.get(screenId); if (existing) { if (existing.releasing) { throw new Error("This Team Computer screen is still being released."); } if (leaseId) { if ( existing.leaseId && existing.leaseId !== leaseId && !canTakeScreenLease(existing.leaseId, leaseId) ) { throw new Error("This Team Computer screen is owned by a newer execution."); } if (canTakeScreenLease(existing.leaseId, leaseId)) existing.leaseId = leaseId; } return existing.index; } const used = new Set([...assigned.values()].map((slot) => slot.index)); for (let index = 0; index < limit; index += 1) { if (!used.has(index)) { assigned.set(screenId, { index, leaseId }); return index; } } throw new Error(`This Team Computer cannot allocate another screen (limit ${limit}).`); } export function releaseAssignedScreen( assigned: Map, screenId: string, leaseId?: string, ): number | undefined { const slot = assigned.get(screenId); if (!slot || slot.releasing || (leaseId && !canReleaseScreenLease(slot.leaseId, leaseId))) { return undefined; } slot.releasing = true; return slot.index; } export function completeReleasedScreen( assigned: Map, screenId: string, index: number, ): void { const slot = assigned.get(screenId); if (slot?.releasing && slot.index === index) assigned.delete(screenId); } export interface ScreenAssignment { index: number; leaseId?: string; releasing?: boolean; } export function clearComputerScreenRegistry( registry: Map>, containerId: string, ) { registry.delete(containerId); } export function stopExtraScreenCommand(index: number) { if (index <= 0) return ""; const layout = screenPorts(index); const fluxHome = `/tmp/fluxbox-home-${layout.displayNumber}`; const profile = `/home/rakazo/.browser-profiles/chromium-screen-${layout.displayNumber}`; const tokenFile = `/tmp/rakazo/control-token-${layout.displayNumber}`; return [ `pkill -f 'Xvfb ${layout.display} -screen' || true`, `pkill -f 'HOME=${fluxHome} DISPLAY=${layout.display} fluxbox' || true`, `pkill -f -- '--user-data-dir=${profile}' || true`, `pkill -f '^x11vnc .* -rfbport ${layout.viewVncPort}' || true`, `pkill -f '^x11vnc .* -rfbport ${layout.controlVncPort}' || true`, `pkill -f '^/usr/bin/python3 .*websockify.*${layout.viewPort}' || true`, `pkill -f '^/usr/bin/python3 .*websockify.*${layout.controlPort}' || true`, `rm -f /tmp/.X${layout.displayNumber}-lock /tmp/.X11-unix/X${layout.displayNumber} ${tokenFile}`, ].join("; "); } export function ensureScreenCommand(index: number) { const layout = screenPorts(index); if (index === 0) { return `for i in $(seq 1 100); do xdpyinfo -display ${layout.display} >/dev/null 2>&1 && exit 0; sleep 0.1; done; exit 1`; } const fluxHome = `/tmp/fluxbox-home-${layout.displayNumber}`; const log = `/tmp/rakazo/screen-${layout.displayNumber}`; const profile = `/home/rakazo/.browser-profiles/chromium-screen-${layout.displayNumber}`; return [ `xdpyinfo -display ${layout.display} >/dev/null 2>&1 && exit 0 || true`, `mkdir -p /tmp/rakazo ${fluxHome}/.fluxbox /tmp/.X11-unix ${profile}`, `rm -f /tmp/.X${layout.displayNumber}-lock /tmp/.X11-unix/X${layout.displayNumber}`, `Xvfb ${layout.display} -screen 0 1280x800x24 -ac +extension RANDR +render -noreset >${log}-xvfb.log 2>&1 &`, `for i in $(seq 1 100); do xdpyinfo -display ${layout.display} >/dev/null 2>&1 && break; sleep 0.1; done`, `xdpyinfo -display ${layout.display} >/dev/null 2>&1 || exit 1`, `cp /etc/rakazo/fluxbox/init ${fluxHome}/.fluxbox/init`, `cp /etc/rakazo/fluxbox/apps ${fluxHome}/.fluxbox/apps 2>/dev/null || true`, `cp /etc/rakazo/fluxbox/menu ${fluxHome}/.fluxbox/menu 2>/dev/null || true`, `HOME=${fluxHome} DISPLAY=${layout.display} fluxbox -rc ${fluxHome}/.fluxbox/init >${log}-fluxbox.log 2>&1 &`, `if [ -d /home/rakazo/.browser-profiles/chromium ]; then cp -a /home/rakazo/.browser-profiles/chromium/. ${profile}/; rm -f ${profile}/SingletonLock ${profile}/SingletonCookie ${profile}/SingletonSocket; fi`, `DISPLAY=${layout.display} HOME=/home/rakazo rakazo-browser --user-data-dir=${profile} >${log}-browser.log 2>&1 &`, `x11vnc -display ${layout.display} -forever -shared -viewonly -nopw -listen 127.0.0.1 -rfbport ${layout.viewVncPort} -xkb -ncache 0 >${log}-x11vnc.log 2>&1 &`, `websockify --heartbeat=30 --web=/usr/share/novnc 0.0.0.0:${layout.viewPort} 127.0.0.1:${layout.viewVncPort} >${log}-novnc.log 2>&1 &`, `for i in $(seq 1 50); do (echo >/dev/tcp/127.0.0.1/${layout.viewPort}) >/dev/null 2>&1 && exit 0; sleep 0.1; done`, "exit 1", ].join("\n"); } export function containerActionStep( action: z.infer, display = ":1", ): { argv: string[] } | { waitMs: number } { if (action.kind === "wait") { return { waitMs: Math.min(Math.max(action.ms, 0), 5_000) }; } let argv: string[]; if (action.kind === "key" || action.kind === "pointer" || action.kind === "clipboard") { argv = ["env", `DISPLAY=${display}`, ...xdotoolCommand(toSandboxInput(action))]; } else if (action.kind === "scroll") { argv = [ "env", `DISPLAY=${display}`, "xdotool", "click", "--repeat", String(Math.min(Math.max(Math.round(action.amount ?? 3), 1), 20)), action.direction === "up" ? "4" : "5", ]; } else if (action.kind === "open") { const target = /^https?:\/\//i.test(action.path) ? action.path : workspaceTarget(normalizeWorkspaceRelative(action.path)); argv = ["env", `DISPLAY=${display}`, "xdg-open", target]; } else { const application = DOCKER_BROWSER_ALIASES.has(action.application.toLowerCase()) ? "rakazo-browser" : action.application; argv = ["env", `DISPLAY=${display}`, application, ...(action.uri ? [action.uri] : [])]; } return { argv }; } export function containerActionSteps( actions: Array>, display = ":1", ) { return actions.map((action) => containerActionStep(action, display)); } export function normalizeWorkspaceRelative(value: string) { const normalized = value.replace(/\\/g, "/").replace(/^\/+/, ""); const segments = normalized.split("/").filter(Boolean); if (segments.some((segment) => segment === "." || segment === "..")) { throw new Error("path escapes the computer workspace"); } return segments.join("/"); } export function workspaceTarget(relative: string) { return relative ? path.posix.join("/home/rakazo", relative) : "/home/rakazo"; } export function sandboxTimeoutCommand(argv: string[], timeoutMs: number, completionMarker: string) { return [ "timeout", "--kill-after=1s", `${timeoutMs / 1_000}s`, "sh", "-c", '"$@"; status=$?; if [ "$status" -eq 124 ]; then : > "$0"; fi; exit "$status"', completionMarker, ...argv, ]; } export function sandboxCommandTimedOut(exitCode: number, completedWithExit124: boolean) { return exitCode === 124 && !completedWithExit124; } export function interactiveScreenCommand( interactive: boolean, controlToken?: string, layout = screenPorts(0), ) { const tokenFile = layout.displayNumber === 1 ? "/tmp/rakazo/control-token" : `/tmp/rakazo/control-token-${layout.displayNumber}`; const stopProcesses = `pkill -f '^x11vnc .* -rfbport ${layout.controlVncPort}' || true; ` + `pkill -f '^/usr/bin/python3 .*websockify.*${layout.controlPort}' || true; ` + `rm -f ${tokenFile}`; const stop = controlToken ? `[ -f ${tokenFile} ] && [ "$(cat ${tokenFile})" != ${shellQuote(controlToken)} ] || { ${stopProcesses}; }` : stopProcesses; if (!interactive) return stop; if (!controlToken) throw new Error("interactive screen requires a control token"); return [ `[ -f ${tokenFile} ] && [ "$(cat ${tokenFile})" = ${shellQuote(controlToken)} ] && pgrep -f '^x11vnc .* -rfbport ${layout.controlVncPort}' >/dev/null && pgrep -f '^/usr/bin/python3 .*websockify.*${layout.controlPort}' >/dev/null && exit 0 || true`, stopProcesses, `printf %s ${shellQuote(controlToken)} > ${tokenFile}`, `export DISPLAY=${layout.display}`, `(x11vnc -display ${layout.display} -forever -shared -nopw -listen 127.0.0.1 -rfbport ${layout.controlVncPort} -xkb -ncache 0 >/tmp/rakazo/x11vnc-control-${layout.displayNumber}.log 2>&1 &)`, `(websockify --heartbeat=30 --web=/usr/share/novnc 0.0.0.0:${layout.controlPort} 127.0.0.1:${layout.controlVncPort} >/tmp/rakazo/novnc-control-${layout.displayNumber}.log 2>&1 &)`, `for i in $(seq 1 50); do (echo >/dev/tcp/127.0.0.1/${layout.controlPort}) >/dev/null 2>&1 && exit 0; sleep 0.1; done`, "exit 1", ].join("; "); } function shellQuote(value: string) { return `'${value.replaceAll("'", `'"'"'`)}'`; } export function parseObservation(output: string) { const geometry = output.match(/^GEOM\s+(\d+)\s+(\d+)$/m); const cursorLine = output.match(/^CURSOR\s+(.+)$/m)?.[1] ?? ""; const cursorX = Number(cursorLine.match(/X=(\d+)/)?.[1]); const cursorY = Number(cursorLine.match(/Y=(\d+)/)?.[1]); const windowId = output.match(/^WINDOW\s*(.*)$/m)?.[1]?.trim(); const title = output.match(/^TITLE\s*(.*)$/m)?.[1]?.trim(); const image = output.match(/^IMAGE\s+([A-Za-z0-9+/=]+)$/m)?.[1]; if (!image) throw new Error("screen capture returned no image"); return { image, mimeType: "image/png" as const, width: Number(geometry?.[1] ?? 1280), height: Number(geometry?.[2] ?? 800), ...(Number.isFinite(cursorX) && Number.isFinite(cursorY) ? { cursor: { x: cursorX, y: cursorY } } : {}), ...(windowId ? { activeWindow: { id: windowId, ...(title ? { title } : {}) } } : {}), }; } /** * True when `buffer` is a complete Docker multiplexed stream: every frame has * type 0/1/2, its payload fits in the remaining bytes, and parsing ends exactly * at the buffer length. Otherwise the buffer is treated as raw (TTY) stdout. */ function isCompleteDockerMultiplexedStream(buffer: Buffer): boolean { let offset = 0; while (offset < buffer.length) { if (offset + 8 > buffer.length) return false; const type = buffer[offset]; if (type !== 0 && type !== 1 && type !== 2) return false; // Bytes 1–3 are reserved padding and must be zero in Docker's multiplex format. if (buffer[offset + 1] !== 0 || buffer[offset + 2] !== 0 || buffer[offset + 3] !== 0) { return false; } const size = buffer.readUInt32BE(offset + 4); if (offset + 8 + size > buffer.length) return false; offset += 8 + size; } return offset === buffer.length; } /** * Split a Docker exec stream into stdout and stderr. Without a TTY the stream is * multiplexed: each frame is an 8-byte header (type byte, 3 reserved bytes, big-endian * length) followed by the payload, and type 2 is stderr. A raw (TTY) stream has no * headers and is all stdout. Only demux when the buffer validates as a complete * multiplexed sequence; otherwise return the whole buffer as stdout. */ export function demuxDockerStream(buffer: Buffer): { stdout: string; stderr: string } { if (!isCompleteDockerMultiplexedStream(buffer)) { return { stdout: buffer.toString("utf8"), stderr: "" }; } const stdout: Buffer[] = []; const stderr: Buffer[] = []; let offset = 0; while (offset < buffer.length) { const size = buffer.readUInt32BE(offset + 4); const payload = buffer.subarray(offset + 8, offset + 8 + size); (buffer[offset] === 2 ? stderr : stdout).push(payload); offset += 8 + size; } return { stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), }; }