From 5411dadc4cccef0cfd3c918163c57fdea383a25f Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Fri, 14 Aug 2026 22:28:01 +0100 Subject: [PATCH 1/6] feat: add BYO VPS computer backend --- server/config.test.ts | 41 ++ server/config.ts | 27 ++ server/contracts.ts | 3 +- server/index.test.ts | 25 ++ server/index.ts | 158 ++++++-- server/store.ts | 4 +- server/vps-computer.test.ts | 385 ++++++++++++++++++ server/vps-computer.ts | 668 +++++++++++++++++++++++++++++++ server/vps-container-mcp.test.ts | 55 +++ server/vps-container-mcp.ts | 39 ++ src/components/ApiKeys.tsx | 70 ++++ src/components/ComputerPanel.tsx | 199 +++++++-- src/components/SettingsModal.tsx | 5 +- src/components/SettingsPanel.tsx | 30 ++ src/state/store.tsx | 6 + 15 files changed, 1660 insertions(+), 55 deletions(-) create mode 100644 server/config.test.ts create mode 100644 server/vps-computer.test.ts create mode 100644 server/vps-computer.ts create mode 100644 server/vps-container-mcp.test.ts create mode 100644 server/vps-container-mcp.ts diff --git a/server/config.test.ts b/server/config.test.ts new file mode 100644 index 0000000000..0874695e21 --- /dev/null +++ b/server/config.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { DATA_DIR, loadConfig, normalizeVpsConfig, saveConfig, vpsSshAlias } from "./config.ts"; + +describe("VPS config", () => { + beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + }); + + it("accepts a simple SSH config alias and rejects command-shaped targets", () => { + expect(normalizeVpsConfig({ sshAlias: "production-vps" })).toEqual({ sshAlias: "production-vps" }); + expect(() => normalizeVpsConfig({ sshAlias: "production-vps; touch /tmp/pwned" })).toThrow(/SSH config alias/); + expect(() => normalizeVpsConfig({ sshAlias: "ssh://production-vps" })).toThrow(/SSH config alias/); + expect(() => normalizeVpsConfig({ sshAlias: "user@production-vps" })).toThrow(/SSH config alias/); + }); + + it("keeps old config data and persists only the non-secret alias", () => { + mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync( + join(DATA_DIR, "config.json"), + JSON.stringify({ box: { token: "legacy-box-token" }, instances: { claude: { driver: "claudeAgent" } } }), + ); + + saveConfig({ vps: { sshAlias: "production-vps", privateKey: "must-not-persist" } as never }); + + const disk = JSON.parse(readFileSync(join(DATA_DIR, "config.json"), "utf8")); + expect(disk.box).toEqual({ token: "legacy-box-token" }); + expect(disk.instances).toEqual({ claude: { driver: "claudeAgent" } }); + expect(disk.vps).toEqual({ sshAlias: "production-vps" }); + expect(JSON.stringify(disk)).not.toContain("must-not-persist"); + expect(vpsSshAlias(loadConfig())).toBe("production-vps"); + }); + + it("supports clearing the optional VPS config", () => { + saveConfig({ vps: { sshAlias: "production-vps" } }); + saveConfig({ vps: { sshAlias: "" } }); + expect(vpsSshAlias(loadConfig())).toBeNull(); + }); +}); diff --git a/server/config.ts b/server/config.ts index 96feb51a4e..100298c0d2 100644 --- a/server/config.ts +++ b/server/config.ts @@ -15,6 +15,8 @@ export interface AppConfig { * catalog with official logos in the plugins marketplace. */ composio?: { key?: string; apiKey?: string; url?: string }; box?: { token?: string }; + /** A named host from the user's SSH config. Authentication stays with SSH. */ + vps?: { sshAlias?: string }; /** Voice (ElevenLabs). `key` is the credential and is never echoed back; * `voice` is the chosen voice id, which is a setting, not a secret. */ tts?: { key?: string; voice?: string }; @@ -24,6 +26,30 @@ export interface AppConfig { instances?: InstanceConfigMap; } +const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; + +export function isValidSshAlias(value: unknown): value is string { + return typeof value === "string" && SSH_ALIAS.test(value); +} + +/** Keep the persisted VPS shape deliberately smaller than an SSH connection. */ +export function normalizeVpsConfig(raw: unknown): { sshAlias?: string } { + if (raw === undefined || raw === null) return {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error("vps must be an object containing an SSH config alias"); + } + const alias = (raw as Record).sshAlias; + if (alias === undefined || alias === "") return {}; + if (!isValidSshAlias(alias)) { + throw new Error("vps.sshAlias must be a simple SSH config alias (letters, numbers, dot, dash, or underscore)"); + } + return { sshAlias: alias }; +} + +export function vpsSshAlias(cfg: AppConfig): string | null { + return isValidSshAlias(cfg.vps?.sshAlias) ? cfg.vps.sshAlias : null; +} + // OMB_DATA_DIR isolates test/soak rigs from the user's real fleet. export const DATA_DIR = process.env.OMB_DATA_DIR ?? join(homedir(), ".openmausbot"); const LEGACY_DATA_DIR = join(homedir(), ".opengrokbot"); @@ -72,6 +98,7 @@ export function saveConfig(patch: Partial): void { disk[key] = { ...(disk[key] as object), ...patch[key] }; } } + if (patch.vps !== undefined) disk.vps = normalizeVpsConfig(patch.vps); mkdirSync(DATA_DIR, { recursive: true }); writeFileAtomic(p, JSON.stringify(disk, null, 2)); } diff --git a/server/contracts.ts b/server/contracts.ts index 0257904399..f6d125947e 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -9,6 +9,7 @@ export type DriverKind = string; export type InstanceId = string; export type ThreadId = string; export type TurnId = string; +export type CloudBackend = "box" | "vps"; // ── model selection ──────────────────────────────────────────────────── // "Which model" is a data value carried on the request, never a service @@ -102,7 +103,7 @@ export interface SendTurnInput { composio?: { url?: string; key: string }; /** Cloud computer, reached through OpenMausBot's REST-to-MCP adapter. */ computer?: { kind?: "box"; boxId: string; token: string }; - /** Direct stdio connection to a Cua Driver MCP server (host or sandbox). */ + /** Direct stdio connection to a Cua Driver MCP server (host, sandbox, or VPS). */ localComputer?: { command: string; args: string[]; env: Record }; /** Peer-agent comms: an MCP proxy (list_bots / ask_bot) that routes back * through the harness so this bot can message other bots. The harness diff --git a/server/index.test.ts b/server/index.test.ts index d8ffab4401..81500a6eca 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -268,6 +268,31 @@ describe("harness HTTP API", () => { expect(nothing.status).toBe(400); }); + it("validates the non-secret VPS alias and keeps old bots on Box by default", async () => { + const before = await api("GET", "/api/bots"); + const bot = before.body.bots[0]; + expect(bot.cloudBackend).toBeUndefined(); + + const bad = await api("PUT", "/api/config", { vps: { sshAlias: "prod; reboot" } }); + expect(bad.status).toBe(400); + + const saved = await api("PUT", "/api/config", { vps: { sshAlias: "production-vps" } }); + expect(saved.status).toBe(200); + expect(saved.body.vps).toEqual({ configured: true, sshAlias: "production-vps" }); + expect(JSON.stringify(saved.body)).not.toContain("privateKey"); + + const patched = await api("PATCH", `/api/bots/${bot.id}`, { cloudBackend: "vps" }); + expect(patched.status).toBe(200); + expect(patched.body.bot.cloudBackend).toBe("vps"); + const csrf = await api("POST", `/api/bots/${bot.id}/computer/provision`); + expect(csrf.status).toBe(415); + const autoProvision = await api("POST", `/api/bots/${bot.id}/computer/provision`, {}); + expect(autoProvision.status).toBe(409); + expect(autoProvision.body.error).toContain("Auto mode will not provision"); + const invalid = await api("PATCH", `/api/bots/${bot.id}`, { cloudBackend: "daytona" }); + expect(invalid.status).toBe(400); + }); + it("stores and echoes the user profile (not write-only, unlike keys)", async () => { const put = await api("PUT", "/api/config", { profile: { name: "Ada Lovelace", email: "Ada@Example.com" } }); expect(put.status).toBe(200); diff --git a/server/index.ts b/server/index.ts index 70c7daf5e5..5b052bf644 100644 --- a/server/index.ts +++ b/server/index.ts @@ -19,7 +19,16 @@ import { setupCommands, type LifecycleAction, } from "./container-computer.ts"; -import { ensureDirs, instanceConfigs, loadConfig, saveConfig, EVENTS_DIR, NATIVE_DIR } from "./config.ts"; +import { + ensureDirs, + instanceConfigs, + loadConfig, + normalizeVpsConfig, + saveConfig, + vpsSshAlias, + EVENTS_DIR, + NATIVE_DIR, +} from "./config.ts"; import { resetPathCache } from "./env-path.ts"; import type { RuntimeEvent } from "./contracts.ts"; @@ -31,6 +40,7 @@ import * as tts from "./tts/index.ts"; import { narrateTool, toUtterances } from "./tts/speech-text.ts"; import { readCuaConnection } from "./local-computer.ts"; import { RoutineManager, type RoutineRunOn } from "./routines.ts"; +import * as vps from "./vps-computer.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const STATIC_DIR = process.env.OMB_STATIC_DIR || null; @@ -170,6 +180,7 @@ let routines: RoutineManager | null = null; // so only one thread may lease it at a time. let activeVmThreadId: string | null = null; let localVmLifecycleBusy = false; +const activeVpsThreads = new Map(); bus.subscribe((event: RuntimeEvent) => { broadcast({ kind: "runtime", event }); @@ -328,9 +339,14 @@ bus.subscribe((event: RuntimeEvent) => { case "turn.completed": { if (activeVmThreadId === event.threadId) activeVmThreadId = null; if (bot) { - store.patchBot(bot.id, { busy: false, unread: true }); - broadcast({ kind: "bot", bot: store.bot(bot.id) }); - if (screenPollers.has(bot.id)) { + const vpsTurn = activeVpsThreads.get(bot.id) === event.threadId; + const settle = () => { + if (activeVpsThreads.get(bot.id) === event.threadId) activeVpsThreads.delete(bot.id); + if (!store.bot(bot.id)) return; + store.patchBot(bot.id, { busy: false, unread: true }); + broadcast({ kind: "bot", bot: store.bot(bot.id) }); + }; + if (vpsTurn && screenPollers.has(bot.id)) { // the last live frame becomes a settled inline screen message — // the screenshot-in-chat moment. One fresh capture first, so the // frame shows the turn's END state (the final tool's poke may @@ -340,7 +356,18 @@ bus.subscribe((event: RuntimeEvent) => { if (frame && store.bot(bot.id)) { pushMessage({ role: "bot", kind: "screen", png: frame.png, mime: frame.mime }); } - }); + }).finally(settle); + } else { + settle(); + if (screenPollers.has(bot.id)) { + // Box behavior remains unchanged: its final preview is best effort + // after the turn has settled because the Box lease is independent. + void finalScreenFrame(bot.id).then((frame) => { + if (frame && store.bot(bot.id)) { + pushMessage({ role: "bot", kind: "screen", png: frame.png, mime: frame.mime }); + } + }); + } } } // group busy/unread settle in the group turn engine, which knows @@ -350,13 +377,13 @@ bus.subscribe((event: RuntimeEvent) => { } }); -// ── live screen: poll the bot's box while it works ──────────────────── +// ── live screen: poll the bot's computer while it works ─────────────── // Frames stream to clients as SSE {kind:'screen'} (the "Bot's screen" // panel); the final frame is folded into the transcript on turn end. type Frame = { png: string; mime: string }; const screenPollers = new Map< string, - { timer: ReturnType | null; capture: () => Promise; last: Frame | null } + { timer: ReturnType | null; capture: (force?: boolean) => Promise; last: Frame | null } >(); /** The preview shares the box's single command endpoint with the agent's @@ -366,8 +393,8 @@ const screenPollers = new Map< const SCREEN_POLL_MS = 6000; const SCREEN_MIN_GAP_MS = 3000; -function startScreenPoller(botId: string, boxId?: string) { - if (screenPollers.has(botId) || !box.boxConfigured(cfg)) return; +function startScreenPoller(botId: string, capture: () => Promise<{ png: string; format: string }>) { + if (screenPollers.has(botId)) return; // One capture at a time, shared by the interval, the pokes, and the // turn-end grab: awaiting the in-flight promise (rather than dropping the // call) is what lets the final frame be the settled one. The min-gap keeps @@ -377,13 +404,11 @@ function startScreenPoller(botId: string, boxId?: string) { let lastAt = 0; const entry = { timer: null as ReturnType | null, - capture: (): Promise => { - if (!current && Date.now() - lastAt < SCREEN_MIN_GAP_MS) return Promise.resolve(); + capture: (force = false): Promise => { + if (!current && !force && Date.now() - lastAt < SCREEN_MIN_GAP_MS) return Promise.resolve(); current ??= (async () => { try { - // boxId is resolved once per turn — re-resolving per frame cost a - // full LIST of the account's boxes - const { png, format } = await box.screenshotBox(cfg, botId, boxId); + const { png, format } = await capture(); const frame = { png, mime: format === "jpeg" ? "image/jpeg" : "image/png" }; entry.last = frame; broadcast({ kind: "screen", botId, ...frame }); @@ -425,7 +450,7 @@ async function finalScreenFrame(botId: string): Promise { if (!entry) return null; if (entry.timer) clearInterval(entry.timer); screenPollers.delete(botId); - await entry.capture(); + await entry.capture(true); return entry.last; } @@ -526,8 +551,14 @@ async function startTurn( const wants = opts?.runOn === "cloud" ? "cloud" : bot.computer; // cloud routine overrides the MAUS default const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true; const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent"; - let previewBoxId: string | null = null; - let computerKind: "box" | "vm" | "local" | null = null; + // Cloud routines intentionally retain their existing BoxAgent meaning; + // the per-bot backend applies to ordinary local-agent turns. + const cloudBackend = opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps"; + const computerConfig = cloudBackend === "vps" + ? { ...cfg, vps: cfg.vps ? { ...cfg.vps } : undefined } + : cfg; + let previewCapture: (() => Promise<{ png: string; format: string }>) | null = null; + let computerKind: "box" | "vps" | "vm" | "local" | null = null; // Explicit destinations are strict. In particular, Local VM must never // fall through to host CUA and accidentally click on the user's Mac. @@ -555,9 +586,32 @@ async function startTurn( computerKind = "local"; } + // A VPS is a local-agent computer mount, never a remote agent runner. + // Explicit Cloud may prepare/start it; Auto is read-only and can only + // attach to an already-running, verified container. + if ((wants === "cloud" || wants === undefined) && cloudBackend === "vps") { + const unsupported = vps.vpsDriverError(instance.driverKind, mountsComputerMcp); + if (unsupported) throw new Error(unsupported); + const remote = wants === "cloud" + ? await vps.vpsComputerAction("provision", computerConfig, bot.id) + : await vps.reuseVps(computerConfig, bot.id); + if (remote) { + if (!remote.ready) { + if (wants === "cloud") throw new Error(remote.problem ?? "the VPS computer is not ready"); + } else { + integrations.localComputer = vps.vpsComputerMcp(computerConfig, bot.id, remote.container_id ?? undefined); + computerKind = "vps"; + previewCapture = () => vps.vpsComputerScreenshot(computerConfig, bot.id); + } + } + if (wants === "cloud" && !integrations.localComputer) { + throw new Error("the VPS computer could not be created or reached"); + } + } + // Cloud is also strict when explicitly selected. Auto (unset) reuses an // existing cloud box, then falls back to host CUA without provisioning. - if ((wants === "cloud" || wants === undefined) && box.boxConfigured(cfg)) { + if ((wants === "cloud" || wants === undefined) && cloudBackend === "box" && box.boxConfigured(cfg)) { if (!mountsCloudComputer && wants === "cloud") { throw new Error("this model engine cannot use computer tools — choose Claude, an ACP engine, or the Computer engine"); } @@ -578,23 +632,28 @@ async function startTurn( b = (await box.readyBox(cfg, bot.id).catch(() => null)) ?? b; } if (b) { - previewBoxId = b.id; + previewCapture = () => box.screenshotBox(cfg, bot.id, b!.id); if (mountsCloudComputer) { integrations.computer = { kind: "box", boxId: b.id, token: cfg.box!.token! }; computerKind = "box"; } } } - if (wants === "cloud" && !box.boxConfigured(cfg)) { + if (wants === "cloud" && cloudBackend === "box" && !box.boxConfigured(cfg)) { throw new Error("Cloud box is not configured — add a Box API key or choose Local VM"); } - if (wants === "cloud" && !integrations.computer) { + if (wants === "cloud" && cloudBackend === "box" && !integrations.computer) { throw new Error("the cloud computer could not be created or reached"); } // Auto-only host fallback. Electron owns cua-driver/TCC attribution; // the harness only reads its already-running connection descriptor. - if (!integrations.computer && !integrations.localComputer && wants === undefined && mountsComputerMcp) { + if ( + !integrations.computer && + !integrations.localComputer && + wants === undefined && + mountsComputerMcp + ) { const cua = readCuaConnection(); if (cua) { integrations.localComputer = cua; @@ -630,6 +689,7 @@ async function startTurn( ? "You can work with the user's other bots through the agents tools — list_bots shows who's available, ask_bot sends one of them a message and returns their reply." : ""; + if (computerKind === "vps") activeVpsThreads.set(bot.id, threadId); await instance.adapter.sendTurn({ threadId, text: turnText, @@ -645,6 +705,8 @@ async function startTurn( ? " You have a shared, isolated Cua sandbox: a Linux desktop in a container on this machine with no host folders mounted. Use the computer tools for desktop, accessibility, window, and shell work. Inspect the desktop state before acting, prefer accessibility targets over raw coordinates, and work carefully." : computerKind === "box" && instance.driverKind !== "boxAgent" ? " You have your own cloud computer — use screenshot, click, type_text, open_url and computer_exec whenever a desktop helps. Every action already returns the resulting screen, so don't follow it with screenshot; batch predictable sequences with computer_batch." + : computerKind === "vps" + ? " You have your own self-hosted remote Linux computer through the official Cua tools. Inspect the desktop state before acting, prefer accessibility targets over raw coordinates, and act carefully." : computerKind === "local" ? " You can act on the user's computer through the computer tools — take a screenshot or read the desktop state first, prefer accessibility actions over raw coordinates, and act carefully." : "") + @@ -658,9 +720,10 @@ async function startTurn( }); // dispatched: the rewind is spent, and the old cursors are dead if (rewound) store.patchBot(bot.id, { rewound: false, resumeCursors: {} }); - if (previewBoxId) startScreenPoller(bot.id, previewBoxId); + if (previewCapture) startScreenPoller(bot.id, previewCapture); } catch (e) { if (activeVmThreadId === threadId) activeVmThreadId = null; + if (activeVpsThreads.get(bot.id) === threadId) activeVpsThreads.delete(bot.id); const message = e instanceof Error ? e.message : String(e); const failure = store.appendMessage(threadId, { role: "bot", @@ -859,6 +922,7 @@ function configStatus() { xai: { configured: Boolean(cfg.xai?.key) }, composio: { configured: Boolean(cfg.composio?.key), apiKeyConfigured: Boolean(cfg.composio?.apiKey) }, box: { configured: Boolean(cfg.box?.token) }, + vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" }, // the chosen voice is a setting, not a secret; the key is reported the // same configured-or-not way as every other credential tts: tts.describeVoice(cfg), @@ -879,6 +943,7 @@ async function reloadProviders() { // forever. Settle anything still marked busy. for (const b of store.bots.filter((b) => b.busy)) { stopScreenPoller(b.id); + activeVpsThreads.delete(b.id); const note = store.appendMessage(b.threadId, { role: "bot", kind: "activity", @@ -1203,7 +1268,7 @@ const server = createServer(async (req, res) => { if (m && method === "PATCH") { const body = await readBody(req); const patch: Record = {}; - for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "color", "mascotExpression", "pinned", "hidden", "speakReplies", "voice"] as const) { + for (const key of ["name", "title", "description", "notifications", "modelSelection", "unread", "computer", "cloudBackend", "color", "mascotExpression", "pinned", "hidden", "speakReplies", "voice"] as const) { if (body[key] !== undefined) patch[key] = body[key]; } if ( @@ -1212,6 +1277,9 @@ const server = createServer(async (req, res) => { ) { return json(res, 400, { error: "computer must be cloud, vm, local, or off" }); } + if (body.cloudBackend !== undefined && !["box", "vps"].includes(String(body.cloudBackend))) { + return json(res, 400, { error: "cloudBackend must be box or vps" }); + } if (body.chiefOfStaff !== undefined && typeof body.chiefOfStaff !== "boolean") { return json(res, 400, { error: "chiefOfStaff must be true or false" }); } @@ -1253,6 +1321,7 @@ const server = createServer(async (req, res) => { // a running turn dies with its bot await registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(bot.threadId).catch(() => {}); stopScreenPoller(bot.id); + activeVpsThreads.delete(bot.id); routines!.disableForBot(bot.id); store.deleteBot(bot.id); for (const dir of [EVENTS_DIR, NATIVE_DIR]) { @@ -1496,6 +1565,13 @@ const server = createServer(async (req, res) => { for (const key of ["xai", "composio", "box", "tts", "profile"] as const) { if (body[key] && typeof body[key] === "object") patch[key] = body[key]; } + if (body.vps !== undefined) { + try { + patch.vps = normalizeVpsConfig(body.vps); + } catch (e) { + return json(res, 400, { error: e instanceof Error ? e.message : String(e) }); + } + } if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" }); // check a box token against the provider before storing it: a // rejected token used to save happily and only surface as a 401 in @@ -1518,7 +1594,7 @@ const server = createServer(async (req, res) => { // provider keys change the fleet; a profile or voice edit must not // kill in-flight turns with a pointless reload — no driver reads // either, and picking a voice mid-turn should be free - if (Object.keys(patch).some((k) => k !== "profile" && k !== "tts")) await reloadProviders(); + if (Object.keys(patch).some((k) => k !== "profile" && k !== "tts" && k !== "vps")) await reloadProviders(); const status = configStatus(); broadcast({ kind: "config", ...status }); return json(res, 200, status); @@ -1583,14 +1659,42 @@ const server = createServer(async (req, res) => { m = path.match(/^\/api\/connectors\/([\w-]+)$/); if (m && method === "DELETE") return json(res, 200, await composio.removeService(cfg, m[1])); - // ── the bot's cloud computer (Box) ── + // ── the bot's cloud computer (Box or VPS) ── m = path.match(/^\/api\/bots\/([\w-]+)\/computer$/); - if (m && method === "GET") return json(res, 200, await box.boxStatus(cfg, m[1])); + if (m && method === "GET") { + const bot = store.bot(m[1]); + if (!bot) return json(res, 404, { error: "no such bot" }); + return json( + res, + 200, + bot.cloudBackend === "vps" ? { backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) } : { backend: "box", ...(await box.boxStatus(cfg, bot.id)) }, + ); + } m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot)$/); if (m && method === "POST") { + if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { + return json(res, 415, { error: "content-type must be application/json" }); + } const botId = m[1]; const bot = store.bot(botId); if (!bot) return json(res, 404, { error: "no such bot" }); + if (bot.cloudBackend === "vps") { + if (m[2] === "join" || m[2] === "exec") { + return json(res, 409, { error: "interactive VPS desktop access is not supported yet" }); + } + if (m[2] === "provision" && bot.computer !== "cloud") { + return json(res, 409, { error: "Auto mode will not provision a VPS; choose Cloud for this bot first" }); + } + if (m[2] === "sleep" && (bot.busy || activeVpsThreads.has(botId))) { + return json(res, 409, { error: "the VPS computer is being used by this bot — interrupt the turn first" }); + } + if (m[2] === "screenshot") return json(res, 200, await vps.vpsComputerScreenshot(cfg, botId)); + return json( + res, + 200, + await vps.vpsComputerAction(m[2] === "provision" ? "provision" : "stop", cfg, botId), + ); + } switch (m[2]) { case "provision": return json(res, 200, await box.provisionBox(cfg, botId, bot.name)); diff --git a/server/store.ts b/server/store.ts index 1530e41d96..9e9b58068f 100644 --- a/server/store.ts +++ b/server/store.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { writeFileAtomic } from "./atomic.ts"; import { DATA_DIR } from "./config.ts"; -import { newId, type ModelSelection, type ThreadId } from "./contracts.ts"; +import { newId, type CloudBackend, type ModelSelection, type ThreadId } from "./contracts.ts"; import { pickBotName } from "./names.ts"; export type MausColor = @@ -144,6 +144,8 @@ export interface BotRecord { /** which computer the bot acts on: its cloud box, this Mac (local CUA), * or none. Unset = auto (box when it exists, else local when available). */ computer?: "cloud" | "vm" | "local" | "off"; + /** Which cloud computer backs `computer: "cloud"`; absent means Box. */ + cloudBackend?: CloudBackend; /** Auto mode: the bot approves its own tool permissions and keeps * working instead of stopping to ask. Questions it asks YOU still come * through, and a short list of destructive commands still stops it. */ diff --git a/server/vps-computer.test.ts b/server/vps-computer.test.ts new file mode 100644 index 0000000000..203c4401fb --- /dev/null +++ b/server/vps-computer.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it } from "vitest"; + +import { + BASE_IMAGE, + BASE_IMAGE_DIGEST, + BASE_IMAGE_LABEL, + CUA_DRIVER_VERSION, + DRIVER_LABEL, + MANAGED_LABEL, +} from "./container-computer.ts"; +import type { AppConfig } from "./config.ts"; +import { + VPS_CONTAINER_LABEL, + VPS_IMAGE, + VPS_MANAGED_LABEL, + vpsComputerAction, + vpsComputerScreenshot, + vpsComputerStatus, + vpsComputerMcp, + vpsContainerMcpArgs, + vpsContainerName, + vpsDockerArgs, + vpsDriverError, + reuseVps, + type VpsCommandRunner, +} from "./vps-computer.ts"; + +const BOT_ID = "bot-1234-abcd"; +const CONFIG: AppConfig = { vps: { sshAlias: "production-vps" } }; +const IMAGE_ID = `sha256:${"a".repeat(64)}`; +const CONTAINER_ID = "b".repeat(64); +const screenshot = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(600), + Buffer.from("IEND", "ascii"), +]); + +function fixture({ + image = true, + container = true, + running = true, + managed = true, + mounts = false, + publicPorts = false, + publishAllPorts = false, + deviceRequests = false, + networkMode = "default", + containerImageId = IMAGE_ID, + inspectedImageId = IMAGE_ID, + containerId = CONTAINER_ID, + privileged = false, + pidMode = "", + ipcMode = "private", + capAdd = ["CAP_SETUID", "CAP_SETGID"], + screenshotValid = true, + screenshotCaptureFails = false, + securityOpt = [], + memory = 4 * 1024 * 1024 * 1024, + restartPolicyName = "", + cgroupnsMode = "private", +}: { + image?: boolean; + container?: boolean; + running?: boolean; + managed?: boolean; + mounts?: boolean; + publicPorts?: boolean; + publishAllPorts?: boolean; + deviceRequests?: boolean; + networkMode?: string; + containerImageId?: string; + inspectedImageId?: string; + containerId?: string; + privileged?: boolean; + pidMode?: string; + ipcMode?: string; + capAdd?: string[]; + screenshotValid?: boolean; + screenshotCaptureFails?: boolean; + securityOpt?: string[]; + memory?: number; + restartPolicyName?: string; + cgroupnsMode?: string; +} = {}) { + const name = vpsContainerName(BOT_ID); + const calls: Array<{ args: string[]; options?: { input?: string; timeoutMs?: number } }> = []; + const state = { image, container, running }; + const runner: VpsCommandRunner = async (args, options) => { + calls.push({ args, options }); + const command = args[2]; + if (command === "info") return { stdout: "29\n", stderr: "" }; + if (command === "image") { + if (!state.image) throw new Error("missing image"); + return { + stdout: JSON.stringify([{ + Config: { Labels: { + [MANAGED_LABEL]: "1", + [DRIVER_LABEL]: CUA_DRIVER_VERSION, + [BASE_IMAGE_LABEL]: BASE_IMAGE_DIGEST, + } }, + Id: inspectedImageId, + }]), + stderr: "", + }; + } + if (command === "inspect") { + if (!state.container) throw new Error("missing container"); + return { + stdout: JSON.stringify([{ + Config: { + Image: state.image ? VPS_IMAGE : "old-image", + Labels: { + [VPS_MANAGED_LABEL]: managed ? "1" : "0", + [VPS_CONTAINER_LABEL]: managed ? name : "other-container", + [MANAGED_LABEL]: "1", + [DRIVER_LABEL]: CUA_DRIVER_VERSION, + [BASE_IMAGE_LABEL]: BASE_IMAGE_DIGEST, + }, + }, + Id: containerId, + Image: state.image ? containerImageId : "old-image-id", + HostConfig: { + Binds: mounts ? ["/host:/container"] : [], + VolumesFrom: [], + NetworkMode: networkMode, + PortBindings: publicPorts ? { "6901/tcp": [{ HostIp: "0.0.0.0" }] } : {}, + PublishAllPorts: publishAllPorts, + Memory: memory, + MemorySwap: 4 * 1024 * 1024 * 1024, + NanoCpus: 2_000_000_000, + PidsLimit: 512, + CapDrop: ["ALL"], + CapAdd: capAdd, + Privileged: privileged, + PidMode: pidMode, + IpcMode: ipcMode, + UTSMode: "", + ShmSize: 512 * 1024 * 1024, + Devices: [], + DeviceRequests: deviceRequests ? [{ Driver: "nvidia" }] : [], + SecurityOpt: securityOpt, + UsernsMode: "", + CgroupnsMode: cgroupnsMode, + OomKillDisable: false, + AutoRemove: false, + RestartPolicy: { Name: restartPolicyName, MaximumRetryCount: 0 }, + }, + NetworkSettings: { + Networks: { [networkMode === "default" ? "bridge" : networkMode]: {} }, + }, + Mounts: mounts ? [{ Source: "/host", Destination: "/container" }] : [], + State: { Running: state.running }, + }]), + stderr: "", + }; + } + if (command === "exec") { + if (screenshotCaptureFails && args.includes("get_desktop_state")) throw new Error("capture failed"); + if (args.includes("base64")) return { stdout: screenshotValid ? screenshot.toString("base64") : "not-an-image", stderr: "" }; + if (args.at(-1) === "--version") return { stdout: `cua-driver ${CUA_DRIVER_VERSION}\n`, stderr: "" }; + if (args.includes("status")) return { stdout: "running\n", stderr: "" }; + return { stdout: "{}\n", stderr: "" }; + } + if (command === "pull") return { stdout: "pulled\n", stderr: "" }; + if (command === "build") { + state.image = true; + expect(options?.input).toContain(`FROM ${BASE_IMAGE}`); + return { stdout: "built\n", stderr: "" }; + } + if (command === "run") { + state.container = true; + state.running = true; + return { stdout: `${name}\n`, stderr: "" }; + } + if (command === "start") { + state.running = true; + return { stdout: `${name}\n`, stderr: "" }; + } + if (command === "stop") { + state.running = false; + return { stdout: `${name}\n`, stderr: "" }; + } + throw new Error(`unexpected Docker command ${command}`); + }; + return { calls, runner, state, name }; +} + +describe("VPS computer", () => { + it("uses a deterministic, bot-id-derived managed container name", () => { + expect(vpsContainerName(BOT_ID)).toBe(vpsContainerName(BOT_ID)); + expect(vpsContainerName(BOT_ID)).not.toBe(vpsContainerName("another-bot")); + expect(vpsContainerName(BOT_ID)).toMatch(/^openmausbot-vps-[a-z0-9-]+$/); + }); + + it("passes the SSH target as one validated Docker argv value", () => { + expect(vpsDockerArgs("production-vps", ["info"])).toEqual(["-H", "ssh://production-vps", "info"]); + for (const alias of ["production-vps;touch", "ssh://production-vps", "-H", "--host=evil", "prod vps", "prod\n-v"] ) { + expect(() => vpsDockerArgs(alias, ["info"])).toThrow(/alias/); + } + expect(() => vpsContainerMcpArgs("production-vps", "not a container")).toThrow(/connection/); + }); + + it("reports a ready container only when image, labels, limits, mounts, network, and Cua pass", async () => { + const fake = fixture(); + const status = await vpsComputerStatus(CONFIG, BOT_ID, fake.runner); + expect(status).toMatchObject({ + configured: true, + daemonUp: true, + image: true, + imageMatches: true, + managed: true, + network: "private", + mounts: "none", + security: "hardened", + desktopReady: true, + ready: true, + problem: null, + }); + expect(fake.calls[0]?.args).toEqual(["-H", "ssh://production-vps", "info", "--format", "{{.ServerVersion}}"]); + }); + + it("refuses host mounts, public ports, and unowned containers", async () => { + const mounted = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ mounts: true }).runner); + expect(mounted.ready).toBe(false); + expect(mounted.mounts).toBe("unsafe"); + + const publicPorts = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ publicPorts: true }).runner); + expect(publicPorts.ready).toBe(false); + expect(publicPorts.network).toBe("unsafe"); + + const publishedAll = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ publishAllPorts: true }).runner); + expect(publishedAll.ready).toBe(false); + expect(publishedAll.network).toBe("unsafe"); + + const devices = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ deviceRequests: true }).runner); + expect(devices.ready).toBe(false); + expect(devices.security).toBe("unsafe"); + + const sharedNetwork = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ networkMode: "shared-net" }).runner); + expect(sharedNetwork.ready).toBe(false); + expect(sharedNetwork.network).toBe("unsafe"); + + const hostNetwork = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ networkMode: "host" }).runner); + expect(hostNetwork.ready).toBe(false); + expect(hostNetwork.network).toBe("unsafe"); + + const privileged = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ privileged: true }).runner); + expect(privileged.ready).toBe(false); + expect(privileged.security).toBe("unsafe"); + + const hostNamespaces = await vpsComputerStatus( + CONFIG, + BOT_ID, + fixture({ pidMode: "host", ipcMode: "host" }).runner, + ); + expect(hostNamespaces.ready).toBe(false); + expect(hostNamespaces.security).toBe("unsafe"); + + const extraCapability = await vpsComputerStatus( + CONFIG, + BOT_ID, + fixture({ capAdd: ["CAP_SETUID", "CAP_SETGID", "CAP_SYS_ADMIN"] }).runner, + ); + expect(extraCapability.ready).toBe(false); + expect(extraCapability.security).toBe("unsafe"); + + const unsafeProfile = await vpsComputerStatus( + CONFIG, + BOT_ID, + fixture({ securityOpt: ["seccomp=unconfined"], memory: 1024, restartPolicyName: "always", cgroupnsMode: "host" }).runner, + ); + expect(unsafeProfile.ready).toBe(false); + expect(unsafeProfile.security).toBe("unsafe"); + + const wrongImage = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ containerImageId: "c".repeat(64) }).runner); + expect(wrongImage.ready).toBe(false); + expect(wrongImage.imageMatches).toBe(false); + + const malformedImageId = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ inspectedImageId: "--help" }).runner); + expect(malformedImageId.ready).toBe(false); + expect(malformedImageId.image).toBe(false); + + const malformedContainerId = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ containerId: "--help" }).runner); + expect(malformedContainerId.ready).toBe(false); + expect(malformedContainerId.container_id).toBeNull(); + + const unowned = await vpsComputerStatus(CONFIG, BOT_ID, fixture({ managed: false }).runner); + expect(unowned.ready).toBe(false); + expect(unowned.managed).toBe(false); + }); + + it("lets explicit provisioning build and run the pinned container, but Auto only reuses", async () => { + const auto = fixture({ image: false, container: false }); + expect(await reuseVps(CONFIG, BOT_ID, auto.runner)).toBeNull(); + expect(auto.calls.some(({ args }) => ["run", "start", "build", "pull"].includes(args[2]!))).toBe(false); + + const provision = fixture({ image: false, container: false }); + const status = await vpsComputerAction("provision", CONFIG, BOT_ID, provision.runner); + expect(status.ready).toBe(true); + const run = provision.calls.find(({ args }) => args[2] === "run")?.args ?? []; + expect(run).toContain("--memory"); + expect(run).toContain("--pids-limit"); + expect(run.at(-1)).toBe(IMAGE_ID); + expect(run.join(" ")).toContain(`--label ${VPS_MANAGED_LABEL}=1`); + expect(run).not.toContain("--mount"); + expect(run).not.toContain("-p"); + expect(provision.calls.some(({ args }) => args[2] === "build")).toBe(true); + }); + + it("starts and sleeps only the managed container, never the VPS", async () => { + const start = fixture({ running: false }); + const started = await vpsComputerAction("start", CONFIG, BOT_ID, start.runner); + expect(started.container).toBe("running"); + expect(start.calls.some(({ args }) => args[2] === "start")).toBe(true); + expect(start.calls.some(({ args }) => ["rm", "system", "reboot", "shutdown"].includes(args[2]!))).toBe(false); + + const stop = fixture(); + const stopped = await vpsComputerAction("stop", CONFIG, BOT_ID, stop.runner); + expect(stopped.container).toBe("stopped"); + expect(stop.calls.some(({ args }) => args[2] === "stop" && args[3] === CONTAINER_ID)).toBe(true); + expect(stop.calls.some(({ args }) => ["rm", "system", "reboot", "shutdown"].includes(args[2]!))).toBe(false); + }); + + it("serializes concurrent provisioning for the same bot", async () => { + const fake = fixture({ image: false, container: false }); + const results = await Promise.all([ + vpsComputerAction("provision", CONFIG, BOT_ID, fake.runner), + vpsComputerAction("provision", CONFIG, BOT_ID, fake.runner), + ]); + expect(results.every((status) => status.ready)).toBe(true); + expect(fake.calls.filter(({ args }) => args[2] === "run")).toHaveLength(1); + }); + + it("mounts the official Cua MCP server through the tiny remote exec bridge", () => { + const connection = vpsComputerMcp(CONFIG, BOT_ID); + expect(connection.command).toBe(process.execPath); + expect(connection.args.slice(-2)).toEqual(["production-vps", vpsContainerName(BOT_ID)]); + expect(connection.env).toEqual({ ELECTRON_RUN_AS_NODE: "1" }); + expect(vpsComputerMcp(CONFIG, BOT_ID, CONTAINER_ID).args.slice(-2)).toEqual(["production-vps", CONTAINER_ID]); + expect(vpsContainerMcpArgs("production-vps", vpsContainerName(BOT_ID))).toEqual([ + "-H", + "ssh://production-vps", + "exec", + "-i", + "-u", + "cua", + "-e", + "HOME=/home/cua", + "-e", + "DISPLAY=:1", + "-e", + "CUA_DRIVER_INSTALL_CHANNEL=python_package", + vpsContainerName(BOT_ID), + "/usr/local/libexec/openmausbot/cua-driver", + "mcp", + "--socket", + "/run/user/1000/openmausbot-cua.sock", + ]); + }); + + it("captures screenshots through Cua Driver and validates the returned image", async () => { + const fake = fixture(); + const frame = await vpsComputerScreenshot(CONFIG, BOT_ID, fake.runner); + expect(frame).toEqual({ png: screenshot.toString("base64"), format: "png" }); + expect(fake.calls.some(({ args }) => args.includes("get_desktop_state"))).toBe(true); + expect(fake.calls.some(({ args }) => args.includes("base64") && args.includes("-u") && args.includes("cua"))).toBe(true); + expect(fake.calls.some(({ args }) => args.includes("rm") && args.includes("-f"))).toBe(true); + + await expect(vpsComputerScreenshot(CONFIG, BOT_ID, fixture({ screenshotValid: false }).runner)).rejects.toThrow(/incomplete/); + + const failedCapture = fixture({ screenshotCaptureFails: true }); + await expect(vpsComputerScreenshot(CONFIG, BOT_ID, failedCapture.runner)).rejects.toThrow(/capture failed/); + expect(failedCapture.calls.some(({ args }) => args.includes("rm") && args.includes("-f"))).toBe(true); + }); + + it("fails clearly for BoxAgent and engines without computer MCP", () => { + expect(vpsDriverError("boxAgent", true)).toMatch(/cannot use a self-hosted VPS/); + expect(vpsDriverError("codex", false)).toMatch(/cannot mount/); + expect(vpsDriverError("claudeAgent", true)).toBeNull(); + }); + + it("fails cleanly when no VPS alias is configured", async () => { + await expect(vpsComputerAction("provision", {}, BOT_ID, fixture().runner)).rejects.toThrow(/not configured/); + }); +}); diff --git a/server/vps-computer.ts b/server/vps-computer.ts new file mode 100644 index 0000000000..16c650d813 --- /dev/null +++ b/server/vps-computer.ts @@ -0,0 +1,668 @@ +// BYO Linux VPS computer. The agent process stays local; Docker's own SSH +// transport reaches the user's daemon and the official Cua MCP server stays +// inside one managed container per bot. +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + BASE_IMAGE, + BASE_IMAGE_DIGEST, + BASE_IMAGE_LABEL, + CUA_DRIVER_VERSION, + CUA_EXECUTABLE, + CUA_SOCKET, + DRIVER_LABEL, + IMAGE as CUA_IMAGE, + MANAGED_LABEL, + managedImageDockerfile, +} from "./container-computer.ts"; +import { isValidSshAlias, vpsSshAlias, type AppConfig } from "./config.ts"; +import { augmentedPath } from "./env-path.ts"; + +export const VPS_IMAGE = CUA_IMAGE; +export const VPS_MANAGED_LABEL = "com.openmausbot.vps"; +export const VPS_CONTAINER_LABEL = "com.openmausbot.container"; +export const VPS_CONTAINER_PREFIX = "openmausbot-vps"; + +const CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/; +const CONTAINER_ID = /^[a-f0-9]{12,64}$/i; +const IMAGE_ID = /^sha256:[a-f0-9]{64}$/i; +const MEMORY_BYTES = 4 * 1024 * 1024 * 1024; +const NANO_CPUS = 2_000_000_000; +const PIDS_LIMIT = 512; +const SHM_BYTES = 512 * 1024 * 1024; +const SCREENSHOT_PATH = "/tmp/openmausbot-vps-preview.png"; +const lifecycleLocks = new Map>(); + +export interface VpsCommandOptions { + input?: string; + timeoutMs?: number; +} + +export type VpsCommandRunner = ( + args: string[], + options?: VpsCommandOptions, +) => Promise<{ stdout: string; stderr: string }>; + +export type VpsLifecycleAction = "provision" | "start" | "stop"; + +export interface VpsComputerStatus { + configured: boolean; + sshAlias: string | null; + daemonUp: boolean; + image: boolean; + imageMatches: boolean; + managed: boolean; + container: "running" | "stopped" | "missing"; + network: "private" | "unsafe" | "unknown"; + mounts: "none" | "unsafe" | "unknown"; + security: "hardened" | "unsafe" | "unknown"; + desktopReady: boolean; + ready: boolean; + problem: string | null; + image_ref: string; + base_image_ref: string; + driver_version: string; + container_name: string; + container_id: string | null; + image_id: string | null; +} + +function containerNamePart(botId: string): string { + return botId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12) || "bot"; +} + +/** Stable across restarts and independent of the bot's editable display name. */ +export function vpsContainerName(botId: string): string { + const hash = createHash("sha256").update(botId).digest("hex").slice(0, 12); + return `${VPS_CONTAINER_PREFIX}-${containerNamePart(botId)}-${hash}`; +} + +export function vpsDockerArgs(alias: string, args: string[]): string[] { + if (!isValidSshAlias(alias)) { + throw new Error("invalid VPS SSH config alias"); + } + return ["-H", `ssh://${alias}`, ...args]; +} + +function defaultRunner(args: string[], options: VpsCommandOptions = {}): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("docker", args, { + shell: false, + env: { ...process.env, PATH: augmentedPath() }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + reject(new Error("Docker-over-SSH command timed out")); + }, options.timeoutMs ?? 120_000); + timeout.unref?.(); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout = `${stdout}${chunk}`.slice(-16 * 1024 * 1024); + }); + child.stderr.on("data", (chunk: string) => { + stderr = `${stderr}${chunk}`.slice(-16 * 1024 * 1024); + }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(new Error(`Docker-over-SSH could not start: ${error.message}`)); + }); + child.on("close", (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (code === 0) return resolve({ stdout, stderr }); + const detail = stderr.trim().slice(-1000); + reject(new Error(detail || `Docker-over-SSH exited ${code ?? signal ?? "without a status"}`)); + }); + child.stdin.end(options.input); + }); +} + +function emptyStatus(botId: string, alias: string | null): VpsComputerStatus { + return { + configured: Boolean(alias), + sshAlias: alias, + daemonUp: false, + image: false, + imageMatches: false, + managed: false, + container: "missing", + network: "unknown", + mounts: "unknown", + security: "unknown", + desktopReady: false, + ready: false, + problem: alias ? "Docker over SSH is not reachable" : "Configure a VPS SSH alias in App Settings → Connections", + image_ref: VPS_IMAGE, + base_image_ref: BASE_IMAGE, + driver_version: CUA_DRIVER_VERSION, + container_name: vpsContainerName(botId), + container_id: null, + image_id: null, + }; +} + +function imageLabelsMatch(labels: Record | undefined): boolean { + return ( + labels?.[MANAGED_LABEL] === "1" && + labels?.[DRIVER_LABEL] === CUA_DRIVER_VERSION && + labels?.[BASE_IMAGE_LABEL] === BASE_IMAGE_DIGEST + ); +} + +function dockerSecurityIsHardened(config: { + Memory?: number; + MemorySwap?: number; + NanoCpus?: number; + PidsLimit?: number | null; + CapDrop?: string[] | null; + CapAdd?: string[] | null; + Privileged?: boolean; + PidMode?: string; + IpcMode?: string; + UTSMode?: string; + ShmSize?: number; + Devices?: unknown[] | null; + DeviceRequests?: unknown[] | null; + SecurityOpt?: string[] | null; + UsernsMode?: string; + CgroupnsMode?: string; + OomKillDisable?: boolean | null; + AutoRemove?: boolean; + RestartPolicy?: { Name?: string; MaximumRetryCount?: number }; +} | undefined): boolean { + if (!config) return false; + const capDrop = (config.CapDrop ?? []).map((cap) => cap.toLowerCase()); + const capAdd = (config.CapAdd ?? []) + .map((cap) => cap.toLowerCase().replace(/^cap_/, "")) + .sort(); + const unsafeSecurityOption = (config.SecurityOpt ?? []).some((option) => /(?:^|=)(?:unconfined|disable)$/i.test(option)); + return ( + config.Memory === MEMORY_BYTES && + (config.MemorySwap ?? 0) === MEMORY_BYTES && + (config.NanoCpus ?? 0) === NANO_CPUS && + config.PidsLimit === PIDS_LIMIT && + capDrop.includes("all") && + capAdd.join(",") === "setgid,setuid" && + config.Privileged === false && + !config.PidMode && + config.IpcMode === "private" && + !config.UTSMode && + config.ShmSize === SHM_BYTES && + (!config.Devices || config.Devices.length === 0) && + (!config.DeviceRequests || config.DeviceRequests.length === 0) && + !unsafeSecurityOption && + !config.UsernsMode && + config.CgroupnsMode === "private" && + config.OomKillDisable !== true && + config.AutoRemove !== true && + !config.RestartPolicy?.Name + ); +} + +function hasNoHostMounts(detail: { + Mounts?: unknown; + HostConfig?: { Binds?: string[] | null; VolumesFrom?: string[] | null }; +}): boolean { + return ( + Array.isArray(detail.Mounts) && + detail.Mounts.length === 0 && + (!detail.HostConfig?.Binds || detail.HostConfig.Binds.length === 0) && + (!detail.HostConfig?.VolumesFrom || detail.HostConfig.VolumesFrom.length === 0) + ); +} + +function hasNoPublishedPorts(config: { + NetworkMode?: string; + PortBindings?: Record | null; + PublishAllPorts?: boolean; +} | undefined, networks?: Record | null): boolean { + if (!config) return false; + const networkMode = (config.NetworkMode ?? "").toLowerCase(); + if (!["default", "bridge"].includes(networkMode)) return false; + const attached = Object.keys(networks ?? {}).map((name) => name.toLowerCase()); + if (attached.length !== 1 || !["default", "bridge"].includes(attached[0]!)) return false; + return ( + config.PublishAllPorts !== true && + !Object.values(config.PortBindings ?? {}).some((value) => Array.isArray(value) && value.length > 0) + ); +} + +function statusProblem(status: VpsComputerStatus): string | null { + if (!status.configured) return "Configure a VPS SSH alias in App Settings → Connections"; + if (!status.daemonUp) return "Docker over SSH could not reach the VPS; check the SSH alias and Docker on the VPS"; + if (!status.image) return `Prepare the pinned OpenMausBot Cua image on the VPS (Driver ${CUA_DRIVER_VERSION})`; + if (status.container === "missing") return "No OpenMausBot container exists for this bot on the VPS"; + if (!status.imageMatches) return "The VPS container uses an incompatible or untrusted OpenMausBot image"; + if (!status.managed) return "The VPS container name is occupied by a container OpenMausBot did not create"; + if (status.network === "unsafe") return "The VPS container uses an unapproved network or publishes ports; refusing to use it"; + if (status.mounts === "unsafe") return "The VPS container has host mounts; refusing to use it"; + if (status.security === "unsafe") return "The VPS container is missing OpenMausBot safety limits"; + if (status.container === "stopped") return "The OpenMausBot VPS container is stopped"; + if (!status.desktopReady) return "The VPS container started, but Cua Driver is not ready yet"; + return null; +} + +export async function vpsComputerStatus( + cfg: AppConfig, + botId: string, + runner: VpsCommandRunner = defaultRunner, +): Promise { + const alias = vpsSshAlias(cfg); + const status = emptyStatus(botId, alias); + if (!alias) return status; + const run = (args: string[], timeoutMs = 10_000, input?: string) => + runner(vpsDockerArgs(alias, args), { timeoutMs, input }); + + try { + await run(["info", "--format", "{{.ServerVersion}}"]); + status.daemonUp = true; + } catch { + status.problem = statusProblem(status); + return status; + } + + let inspectedImageId: string | null = null; + try { + const inspected = JSON.parse((await run(["image", "inspect", VPS_IMAGE])).stdout) as Array<{ + Id?: string; + id?: string; + Config?: { Labels?: Record }; + config?: { Labels?: Record; labels?: Record }; + }>; + const image = inspected[0]; + const labels = image?.Config?.Labels ?? image?.config?.Labels ?? image?.config?.labels; + const imageId = image?.Id ?? image?.id; + inspectedImageId = imageId && IMAGE_ID.test(imageId) ? imageId : null; + status.image_id = inspectedImageId; + status.image = Boolean(inspectedImageId) && imageLabelsMatch(labels); + } catch { + status.image = false; + } + + try { + const inspected = JSON.parse((await run(["inspect", status.container_name])).stdout) as Array<{ + Config?: { Image?: string; Labels?: Record }; + HostConfig?: { + Binds?: string[] | null; + VolumesFrom?: string[] | null; + NetworkMode?: string; + PortBindings?: Record | null; + PublishAllPorts?: boolean; + Memory?: number; + MemorySwap?: number; + NanoCpus?: number; + PidsLimit?: number | null; + CapDrop?: string[] | null; + CapAdd?: string[] | null; + Privileged?: boolean; + PidMode?: string; + IpcMode?: string; + UTSMode?: string; + ShmSize?: number; + Devices?: unknown[] | null; + DeviceRequests?: unknown[] | null; + SecurityOpt?: string[] | null; + UsernsMode?: string; + CgroupnsMode?: string; + OomKillDisable?: boolean | null; + AutoRemove?: boolean; + RestartPolicy?: { Name?: string; MaximumRetryCount?: number }; + }; + Id?: string; + id?: string; + Image?: string; + NetworkSettings?: { Networks?: Record | null }; + Mounts?: unknown; + State?: { Running?: boolean }; + }>; + const detail = inspected[0]; + const labels = detail?.Config?.Labels; + const containerId = detail?.Id ?? detail?.id; + status.container_id = containerId && CONTAINER_ID.test(containerId) ? containerId : null; + status.container = detail?.State?.Running ? "running" : "stopped"; + status.imageMatches = + status.image && + Boolean(status.container_id) && + (detail?.Config?.Image === VPS_IMAGE || detail?.Config?.Image === inspectedImageId) && + Boolean(inspectedImageId) && + detail?.Image === inspectedImageId && + imageLabelsMatch(labels); + status.managed = + labels?.[VPS_MANAGED_LABEL] === "1" && labels?.[VPS_CONTAINER_LABEL] === status.container_name; + status.network = hasNoPublishedPorts(detail?.HostConfig, detail?.NetworkSettings?.Networks) ? "private" : "unsafe"; + status.mounts = hasNoHostMounts(detail ?? {}) ? "none" : "unsafe"; + status.security = dockerSecurityIsHardened(detail?.HostConfig) ? "hardened" : "unsafe"; + + const canProbe = + status.container === "running" && + status.image && + status.imageMatches && + status.managed && + status.network === "private" && + status.mounts === "none" && + status.security === "hardened"; + if (canProbe) { + const exec = [ + "exec", + "-u", + "cua", + "-e", + "HOME=/home/cua", + "-e", + "DISPLAY=:1", + "-e", + "CUA_DRIVER_INSTALL_CHANNEL=python_package", + status.container_name, + CUA_EXECUTABLE, + ]; + try { + const version = await run([...exec, "--version"]); + if (version.stdout.trim() !== `cua-driver ${CUA_DRIVER_VERSION}`) throw new Error("unexpected Cua Driver version"); + await run([...exec, "status", "--socket", CUA_SOCKET]); + status.desktopReady = true; + } catch { + status.desktopReady = false; + } + } + } catch { + status.container = "missing"; + } + + status.problem = statusProblem(status); + status.ready = status.problem === null; + return status; +} + +export function vpsContainerRunArgs(containerName: string, imageRef = VPS_IMAGE): string[] { + if (!CONTAINER_NAME.test(containerName) || (imageRef !== VPS_IMAGE && !IMAGE_ID.test(imageRef))) { + throw new Error("invalid managed VPS container or image reference"); + } + return [ + "run", + "-d", + "--name", + containerName, + "--label", + `${VPS_MANAGED_LABEL}=1`, + "--label", + `${VPS_CONTAINER_LABEL}=${containerName}`, + "--label", + `${MANAGED_LABEL}=1`, + "--label", + `${DRIVER_LABEL}=${CUA_DRIVER_VERSION}`, + "--label", + `${BASE_IMAGE_LABEL}=${BASE_IMAGE_DIGEST}`, + "--memory", + "4g", + "--memory-swap", + "4g", + "--cpus", + "2", + "--pids-limit", + String(PIDS_LIMIT), + "--network", + "bridge", + "--cap-drop", + "ALL", + "--cap-add", + "SETUID", + "--cap-add", + "SETGID", + "--shm-size", + "512m", + imageRef, + ]; +} + +function assertUsableContainer(status: VpsComputerStatus) { + if ( + !status.image || + !status.imageMatches || + !status.managed || + status.network !== "private" || + status.mounts !== "none" || + status.security !== "hardened" + ) { + throw Object.assign(new Error(status.problem ?? "The existing VPS container is unsafe or incompatible"), { + status: 409, + }); + } +} + +async function prepareVpsImage(alias: string, runner: VpsCommandRunner) { + await runner(vpsDockerArgs(alias, ["pull", BASE_IMAGE]), { timeoutMs: 10 * 60_000 }); + await runner(vpsDockerArgs(alias, ["build", "-t", VPS_IMAGE, "-"]), { + input: managedImageDockerfile(), + timeoutMs: 10 * 60_000, + }); +} + +async function waitForVpsReady( + cfg: AppConfig, + botId: string, + runner: VpsCommandRunner, + budgetMs = 60_000, +): Promise { + const deadline = Date.now() + budgetMs; + let status = await vpsComputerStatus(cfg, botId, runner); + while (!status.ready && Date.now() < deadline) { + if ( + !status.daemonUp || + !status.image || + !status.imageMatches || + !status.managed || + status.container !== "running" || + status.network !== "private" || + status.mounts !== "none" || + status.security !== "hardened" + ) { + return status; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + status = await vpsComputerStatus(cfg, botId, runner); + } + return status; +} + +async function withVpsLifecycleLock(key: string, operation: () => Promise): Promise { + const previous = lifecycleLocks.get(key); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + lifecycleLocks.set(key, current); + if (previous) await previous; + try { + return await operation(); + } finally { + release(); + if (lifecycleLocks.get(key) === current) lifecycleLocks.delete(key); + } +} + +function vpsLockKey(cfg: AppConfig, botId: string): string | null { + const alias = vpsSshAlias(cfg); + return alias ? `${alias}:${vpsContainerName(botId)}` : null; +} + +export async function vpsComputerAction( + action: VpsLifecycleAction, + cfg: AppConfig, + botId: string, + runner: VpsCommandRunner = defaultRunner, +): Promise { + const alias = vpsSshAlias(cfg); + if (!alias) throw Object.assign(new Error("VPS is not configured — add an SSH config alias in App Settings → Connections"), { status: 409 }); + const operation = async () => { + const before = await vpsComputerStatus(cfg, botId, runner); + if (!before.daemonUp) throw Object.assign(new Error(before.problem ?? "Docker over SSH is not reachable"), { status: 409 }); + const run = (args: string[], timeoutMs = 2 * 60_000) => runner(vpsDockerArgs(alias, args), { timeoutMs }); + + const containerRef = before.container_id ?? before.container_name; + if (action === "provision") { + if (before.container === "missing") { + if (!before.image) await prepareVpsImage(alias, runner); + const imageRef = before.image_id ?? (await vpsComputerStatus(cfg, botId, runner)).image_id; + if (!imageRef) throw Object.assign(new Error("The prepared VPS image could not be identified"), { status: 409 }); + await run(vpsContainerRunArgs(before.container_name, imageRef)); + } else { + assertUsableContainer(before); + if (before.container === "stopped") await run(["start", containerRef]); + } + } else if (action === "start") { + if (before.container === "missing") throw Object.assign(new Error("No VPS container exists for this bot"), { status: 409 }); + if (before.container === "running") throw Object.assign(new Error("The VPS container is already running"), { status: 409 }); + assertUsableContainer(before); + await run(["start", containerRef]); + } else { + if (before.container !== "running") throw Object.assign(new Error("The VPS container is not running"), { status: 409 }); + assertUsableContainer(before); + await run(["stop", containerRef]); + } + return action === "stop" ? vpsComputerStatus(cfg, botId, runner) : waitForVpsReady(cfg, botId, runner); + }; + return withVpsLifecycleLock(vpsLockKey(cfg, botId)!, operation); +} + +/** Auto is intentionally read-only: it can attach only to an existing ready container. */ +export async function reuseVps( + cfg: AppConfig, + botId: string, + runner: VpsCommandRunner = defaultRunner, +): Promise { + const key = vpsLockKey(cfg, botId); + const status = await (key ? withVpsLifecycleLock(key, () => vpsComputerStatus(cfg, botId, runner)) : vpsComputerStatus(cfg, botId, runner)); + return status.ready ? status : null; +} + +function bridgePath(): string { + const ts = join(dirname(fileURLToPath(import.meta.url)), "vps-container-mcp.ts"); + return existsSync(ts) ? ts : ts.replace(/\.ts$/, ".js"); +} + +export function vpsContainerMcpArgs(alias: string, containerName: string): string[] { + if (!isValidSshAlias(alias) || (!CONTAINER_NAME.test(containerName) && !CONTAINER_ID.test(containerName))) { + throw new Error("invalid VPS MCP connection"); + } + return vpsDockerArgs(alias, [ + "exec", + "-i", + "-u", + "cua", + "-e", + "HOME=/home/cua", + "-e", + "DISPLAY=:1", + "-e", + "CUA_DRIVER_INSTALL_CHANNEL=python_package", + containerName, + CUA_EXECUTABLE, + "mcp", + "--socket", + CUA_SOCKET, + ]); +} + +export function vpsComputerMcp(cfg: AppConfig, botId: string, containerRef?: string): { + command: string; + args: string[]; + env: Record; +} { + const alias = vpsSshAlias(cfg); + if (!alias) throw new Error("VPS is not configured — add an SSH config alias first"); + return { + command: process.execPath, + args: [bridgePath(), alias, containerRef ?? vpsContainerName(botId)], + env: { ELECTRON_RUN_AS_NODE: "1" }, + }; +} + +export function vpsDriverError(driverKind: string, computerMcp: boolean): string | null { + if (driverKind === "boxAgent") { + return "The Computer engine runs its agent on Box and cannot use a self-hosted VPS — choose Claude or an ACP engine"; + } + if (!computerMcp) { + return "This model engine cannot mount a self-hosted VPS computer — choose Claude or an ACP engine"; + } + return null; +} + +function validImage(bytes: Buffer): "image/png" | "image/jpeg" | null { + if (bytes.length < 512) return null; + const png = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47; + if (png && bytes.subarray(Math.max(0, bytes.length - 12)).includes(Buffer.from("IEND", "ascii"))) return "image/png"; + const jpeg = bytes[0] === 0xff && bytes[1] === 0xd8; + return jpeg && bytes.subarray(Math.max(0, bytes.length - 32)).includes(Buffer.from([0xff, 0xd9])) + ? "image/jpeg" + : null; +} + +export async function vpsComputerScreenshot( + cfg: AppConfig, + botId: string, + runner: VpsCommandRunner = defaultRunner, +): Promise<{ png: string; format: "png" | "jpeg" }> { + const alias = vpsSshAlias(cfg); + if (!alias) throw Object.assign(new Error("VPS is not configured"), { status: 409 }); + return withVpsLifecycleLock(vpsLockKey(cfg, botId)!, async () => { + const status = await vpsComputerStatus(cfg, botId, runner); + if (!status.ready) throw Object.assign(new Error(status.problem ?? "The VPS computer is not ready"), { status: 409 }); + const containerRef = status.container_id ?? status.container_name; + const exec = [ + "exec", + "-u", + "cua", + "-e", + "HOME=/home/cua", + "-e", + "DISPLAY=:1", + "-e", + "CUA_DRIVER_INSTALL_CHANNEL=python_package", + containerRef, + CUA_EXECUTABLE, + "call", + "get_desktop_state", + "{}", + "--socket", + CUA_SOCKET, + "--screenshot-out-file", + SCREENSHOT_PATH, + ]; + try { + await runner(vpsDockerArgs(alias, exec), { timeoutMs: 30_000 }); + const encoded = (await runner(vpsDockerArgs(alias, [ + "exec", + "-u", + "cua", + "-e", + "HOME=/home/cua", + containerRef, + "base64", + "-w0", + SCREENSHOT_PATH, + ]), { timeoutMs: 30_000 })).stdout.trim(); + const mime = validImage(Buffer.from(encoded, "base64")); + if (!mime) throw Object.assign(new Error("Cua Driver returned an incomplete VPS screenshot"), { status: 502 }); + return { png: encoded, format: mime === "image/jpeg" ? "jpeg" : "png" }; + } finally { + await runner(vpsDockerArgs(alias, ["exec", "-u", "cua", containerRef, "rm", "-f", SCREENSHOT_PATH]), { + timeoutMs: 10_000, + }).catch(() => {}); + } + }); +} diff --git a/server/vps-container-mcp.test.ts b/server/vps-container-mcp.test.ts new file mode 100644 index 0000000000..16624be31c --- /dev/null +++ b/server/vps-container-mcp.test.ts @@ -0,0 +1,55 @@ +import { spawn } from "node:child_process"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +import { vpsContainerName } from "./vps-computer.ts"; + +const temporary: string[] = []; + +afterEach(async () => { + await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe.skipIf(process.platform === "win32")("VPS Cua MCP bridge", () => { + it("passes MCP bytes unchanged to docker exec over the validated SSH target", async () => { + const bin = await mkdtemp(join(tmpdir(), "openmausbot-vps-mcp-")); + temporary.push(bin); + const fakeDocker = join(bin, "docker"); + await writeFile(fakeDocker, "#!/bin/sh\nprintf 'ARGS:%s\\n' \"$*\" >&2\ncat\n", { mode: 0o700 }); + await chmod(fakeDocker, 0o700); + + const input = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; + const result = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL("./vps-container-mcp.ts", import.meta.url)), + "production-vps", + vpsContainerName("bridge-test"), + ], + { + env: { ...process.env, OMB_EXTRA_PATH: bin, NODE_NO_WARNINGS: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); + + expect(result.code).toBe(0); + expect(result.stdout).toBe(input); + expect(result.stderr).toContain( + `ARGS:-H ssh://production-vps exec -i -u cua -e HOME=/home/cua -e DISPLAY=:1 ` + + `-e CUA_DRIVER_INSTALL_CHANNEL=python_package ${vpsContainerName("bridge-test")} ` + + "/usr/local/libexec/openmausbot/cua-driver mcp --socket /run/user/1000/openmausbot-cua.sock", + ); + }); +}); diff --git a/server/vps-container-mcp.ts b/server/vps-container-mcp.ts new file mode 100644 index 0000000000..f30d0704bf --- /dev/null +++ b/server/vps-container-mcp.ts @@ -0,0 +1,39 @@ +// Transparent stdio bridge to the official Cua MCP server in a VPS +// container. Docker's SSH transport handles authentication through the +// user's normal SSH config and agent; this process stores no credentials. +import { spawn } from "node:child_process"; + +import { augmentedPath } from "./env-path.ts"; +import { vpsContainerMcpArgs } from "./vps-computer.ts"; + +const [alias, containerName] = process.argv.slice(2); +let args: string[]; +try { + args = vpsContainerMcpArgs(alias ?? "", containerName ?? ""); +} catch { + process.stderr.write("invalid VPS MCP connection\n"); + process.exit(2); +} + +const child = spawn("docker", args, { + shell: false, + env: { ...process.env, PATH: augmentedPath() }, + stdio: ["pipe", "pipe", "pipe"], +}); + +process.stdin.pipe(child.stdin); +child.stdout.pipe(process.stdout); +child.stderr.pipe(process.stderr); + +child.on("error", (error) => { + process.stderr.write(`could not connect to VPS Cua Driver: ${error.message}\n`); + process.exit(1); +}); +child.on("close", (code, signal) => { + if (signal) process.stderr.write(`VPS Cua Driver connection ended with ${signal}\n`); + process.exit(code ?? 1); +}); + +for (const signal of ["SIGTERM", "SIGINT"] as const) { + process.on(signal, () => child.kill(signal)); +} diff --git a/src/components/ApiKeys.tsx b/src/components/ApiKeys.tsx index ac08f5d43e..26bdb49241 100644 --- a/src/components/ApiKeys.tsx +++ b/src/components/ApiKeys.tsx @@ -206,3 +206,73 @@ export function ApiKeyRow({ ); } + +/** Non-secret Docker-over-SSH target. Keys and passwords stay with SSH. */ +export function VpsConnection() { + const { state, dispatch } = useStore(); + const [alias, setAlias] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const configured = Boolean(state.config?.vps?.configured); + + useEffect(() => { + setAlias(state.config?.vps?.sshAlias ?? ""); + }, [state.config?.vps?.sshAlias]); + + const save = () => { + if (saving || (!alias.trim() && !configured)) return; + setSaving(true); + setError(null); + api("/api/config", { + method: "PUT", + body: JSON.stringify({ vps: { sshAlias: alias.trim() } }), + }) + .then((status: ConfigStatus) => { + dispatch({ type: "configStatus", config: status }); + setAlias(status.vps?.sshAlias ?? ""); + }) + .catch((e) => setError(e.message)) + .finally(() => setSaving(false)); + }; + + return ( +
+
+ + Self-hosted VPS + + Optional + + {configured && Connected} +
+
+ SSH config alias for the Linux VPS. OpenMausBot uses your normal SSH config and agent; it does not store keys or passwords. +
+
+ setAlias(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && save()} + placeholder="my-vps" + aria-label="Self-hosted VPS SSH config alias" + autoComplete="off" + className="w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" + /> + +
+ {error &&
{error}
} +
+ ); +} diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 23715f0484..bf1e32da47 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -1,9 +1,10 @@ // The bot's computer, in the right-side slot. Where it runs decides the -// whole flow: cloud → provision the box on open (idempotent) and preview +// whole flow: Box → provision on open (idempotent), VPS → use an existing +// container in Auto or provision only for explicit Cloud, and preview // via SSE frames or a ~4s screenshot poll; local ("This Mac") → frames // come from the Electron main process (desktopCapturer over the preload // bridge — box endpoints are never touched); off → parked. Auto (unset) -// prefers the cloud box when one exists, else local inside the app. +// prefers the selected cloud backend when one exists, else local inside the app. import { useEffect, useRef, useState } from "react"; import { CalendarDays, @@ -38,6 +39,8 @@ type Phase = | "ready" | "vm" | "vm-unavailable" + | "vps-unconfigured" + | "vps-stopped" | "local" | "local-unavailable" | "off" @@ -80,7 +83,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const [polledFrame, setPolledFrame] = useState<{ png: string; mime: string } | null>(null); const [vmFrame, setVmFrame] = useState(null); const [localFrame, setLocalFrame] = useState(null); - const [pending, setPending] = useState<"join" | "sleep" | null>(null); + const [pending, setPending] = useState<"join" | "sleep" | "provision" | null>(null); const [error, setError] = useState(null); const [creatingRoutine, setCreatingRoutine] = useState(false); // bumped when a Box API key is saved inline, to re-run the spin-up flow @@ -94,7 +97,11 @@ export function ComputerPanel({ bot }: { bot: Bot }) { selectedInstance.driverKind !== "boxAgent", ); const computerToolSupported = selectedInstance?.capabilities?.computerMcp === true; - const cloudSupported = computerToolSupported || selectedInstance?.driverKind === "boxAgent"; + const vpsSupported = Boolean(computerToolSupported && selectedInstance?.driverKind !== "boxAgent"); + const cloudBackend = bot.cloudBackend ?? "box"; + const cloudSupported = cloudBackend === "vps" + ? vpsSupported + : computerToolSupported || selectedInstance?.driverKind === "boxAgent"; const botRoutines = state.routines .filter((routine) => routine.botId === bot.id) .sort((a, b) => Number(b.enabled) - Number(a.enabled) || (a.nextRunAt ?? Infinity) - (b.nextRunAt ?? Infinity)); @@ -107,7 +114,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { ); const computerDestination = bot.computer === "cloud" - ? "this cloud box" + ? cloudBackend === "vps" ? "this self-hosted VPS" : "this cloud box" : bot.computer === "vm" ? "the Local VM" : bot.computer === "local" @@ -115,11 +122,11 @@ export function ComputerPanel({ bot }: { bot: Bot }) { : bot.computer === "off" ? null : phase === "ready" - ? "the cloud box selected by Auto" + ? cloudBackend === "vps" ? "the self-hosted VPS selected by Auto" : "the cloud box selected by Auto" : "this computer selected by Auto"; - // resolve the mode on open; box endpoints are only ever hit on the - // cloud path, so local/off can never render a JSON error as an image + // Resolve the mode on open. Local and Local VM remain separate from the + // cloud backends; a VPS Auto check is read-only. useEffect(() => { let alive = true; setPhase("checking"); @@ -165,7 +172,59 @@ export function ComputerPanel({ bot }: { bot: Bot }) { setPhase("error"); return; } + if (cloudBackend === "vps" && !vpsSupported) { + setError("This model engine cannot use a self-hosted VPS. Choose Claude or an ACP engine, or switch the cloud backend to Box."); + setPhase("error"); + return; + } if (bot.computer !== "cloud" && !capabilitiesReady) return; + if (cloudBackend === "vps") { + api(`/api/bots/${bot.id}/computer`) + .then((status) => { + if (!alive) return; + const autoLocal = bot.computer !== "cloud" && capabilitiesReady && localAvailable && computerToolSupported; + if (!status.configured) { + if (autoLocal) setPhase("local"); + else { + setError("Add the VPS SSH config alias in App Settings → Connections."); + setPhase("vps-unconfigured"); + } + return; + } + if (status.ready) { + setBoxState(status.container); + setPhase("ready"); + return; + } + if (bot.computer === "cloud") { + setPhase("starting"); + return api(`/api/bots/${bot.id}/computer/provision`, { method: "POST" }).then((result) => { + if (!alive) return; + setBoxState(result.container ?? null); + if (result.ready) setPhase("ready"); + else { + setError(result.problem ?? "The VPS Cua desktop is not ready yet"); + setPhase("error"); + } + }); + } + if (autoLocal) { + setPhase("local"); + return; + } + setBoxState(status.container ?? null); + setError(`${status.problem ?? "No ready VPS container"}. Auto will not create or start it; choose Cloud to provision it.`); + setPhase(status.container === "stopped" ? "vps-stopped" : "vps-unconfigured"); + }) + .catch((e) => { + if (!alive) return; + setError(e.message); + setPhase("error"); + }); + return () => { + alive = false; + }; + } // cloud, or auto (cloud box wins when one exists, else local in-app) api(`/api/bots/${bot.id}/computer`) .then((status) => { @@ -194,7 +253,19 @@ export function ComputerPanel({ bot }: { bot: Bot }) { return () => { alive = false; }; - }, [bot.id, bot.computer, retry, capabilitiesReady, localAvailable, vmSupported, computerToolSupported, cloudSupported]); + }, [ + bot.id, + bot.computer, + retry, + capabilitiesReady, + localAvailable, + vmSupported, + computerToolSupported, + cloudSupported, + cloudBackend, + vpsSupported, + state.config?.vps?.sshAlias, + ]); // cloud preview: SSE frames win while the bot works; otherwise poll const live = state.screens[bot.id]; @@ -289,14 +360,25 @@ export function ComputerPanel({ bot }: { bot: Bot }) { ? cloudFrame && `data:${cloudFrame.mime};base64,${cloudFrame.png}` : null; - const run = (kind: "join" | "sleep") => { + const run = (kind: "join" | "sleep" | "provision") => { setPending(kind); setError(null); api(`/api/bots/${bot.id}/computer/${kind}`, { method: "POST" }) .then((result) => { // the join URL's stream token rotates — always freshly minted, never cached if (kind === "join" && result.joinUrl) window.open(result.joinUrl); - if (kind === "sleep") setBoxState("archived"); + if (kind === "provision") { + setBoxState(result.container ?? null); + if (result.ready) setPhase("ready"); + else { + setError(result.problem ?? "The VPS Cua desktop is not ready yet"); + setPhase("error"); + } + } + if (kind === "sleep") { + setBoxState(cloudBackend === "vps" ? "stopped" : "archived"); + if (cloudBackend === "vps") setPhase("vps-stopped"); + } }) .catch((e) => setError(e.message)) .finally(() => setPending(null)); @@ -307,10 +389,16 @@ export function ComputerPanel({ bot }: { bot: Bot }) { dispatch({ type: "toggleAppSettings", open: true }); }; + const openConnectionSettings = () => { + dispatch({ type: "toggleAppSettings", open: true, section: "connections" }); + }; + const emptyState: Record, string> = { checking: "Checking…", starting: "Starting your bot's computer…", unconfigured: "No cloud computer configured", + "vps-unconfigured": "No managed VPS computer is configured for this bot", + "vps-stopped": "The managed VPS computer is stopped", "local-unavailable": capabilities.host.platform === "linux" ? "Local computer control isn't available on Linux yet. Use a cloud box instead." @@ -348,6 +436,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {bot.name}'s screen {phase === "local" && this computer} {phase === "vm" && Local VM} + {cloudBackend === "vps" && (phase === "ready" || phase === "starting") && self-hosted VPS}
{frameSrc ? ( @@ -388,6 +477,24 @@ export function ComputerPanel({ bot }: { bot: Bot }) { Open Local VM setup )} + {(phase === "vps-unconfigured" || phase === "vps-stopped") && ( + + )} + {phase === "vps-stopped" && bot.computer === "cloud" && ( + + )}
)} @@ -408,23 +515,38 @@ export function ComputerPanel({ bot }: { bot: Bot }) { /> )} + {phase === "vps-unconfigured" && ( +
+
+ Configure the VPS SSH alias in App Settings → Connections. Auto will reuse an existing managed container but will not create one. +
+ +
+ )} {/* Cloud-only actions */} {phase === "ready" && (
- - {boxState !== "archived" && ( + {cloudBackend === "box" && ( + + )} + {(cloudBackend === "vps" || boxState !== "archived") && ( + ); + })} +
+ + )} {/* Routines */} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 19bb690d9c..a0e57b8983 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { KeyRound, Monitor, User, Volume2, X } from "lucide-react"; import { useStore, type AppSettingsSection } from "@/state/store"; -import { ApiKeyRow } from "./ApiKeys"; +import { ApiKeyRow, VpsConnection } from "./ApiKeys"; import { useUpdaterState } from "@/lib/updater"; import { LocalComputerSection } from "./LocalComputerSection"; import { Card } from "./SettingsPrimitives"; @@ -203,12 +203,13 @@ export function SettingsModal() { {section === "connections" && (
+
)} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 30539455d8..956a4141b0 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -42,6 +42,7 @@ export function SettingsPanel({ bot }: { bot: Bot }) { | "description" | "notifications" | "computer" + | "cloudBackend" | "color" | "mascotExpression" | "autoApprove" @@ -55,6 +56,7 @@ export function SettingsPanel({ bot }: { bot: Bot }) { const mascotMotion = state.mascotMotion?.botId === bot.id ? state.mascotMotion : null; const engine = state.instances.find((instance) => instance.instanceId === bot.modelSelection.instanceId); const canCoordinate = engine?.capabilities?.agentsMcp === true; + const canUseVps = engine?.capabilities?.computerMcp === true && engine.driverKind !== "boxAgent"; const currentChief = state.bots.find((candidate) => candidate.chiefOfStaff); useEffect(() => { @@ -263,6 +265,34 @@ export function SettingsPanel({ bot }: { bot: Bot }) { ))} + {(!bot.computer || bot.computer === "cloud" || bot.cloudBackend === "vps") && ( +
+
Cloud backend
+
+ Choose hosted Box or your SSH-configured Linux VPS. VPS has no interactive desktop tunnel yet. +
+
+ {(["box", "vps"] as const).map((backend, i) => ( + + ))} +
+
+ )}
diff --git a/src/state/store.tsx b/src/state/store.tsx index 175851bf13..a225a7e721 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -111,6 +111,8 @@ export interface Bot { modelSelection: ModelSelection; /** Where this bot's computer runs; unset = auto (cloud box if one exists, else local). */ computer?: "cloud" | "vm" | "local" | "off"; + /** Which cloud computer backs `computer: "cloud"`; absent = Box. */ + cloudBackend?: "box" | "vps"; /** auto mode: the bot approves its own tool permissions */ autoApprove?: boolean; /** tools this bot may always use without asking */ @@ -160,6 +162,7 @@ export interface ConfigStatus { xai?: { configured: boolean }; composio: { configured: boolean; apiKeyConfigured?: boolean }; box: { configured: boolean }; + vps?: { configured: boolean; sshAlias: string }; /** Voice (ElevenLabs). `configured` = a key is saved; `ready` = a key AND * a voice, which is what it takes to actually speak. The key itself is * never echoed back. */ @@ -302,6 +305,7 @@ type Action = | "description" | "notifications" | "computer" + | "cloudBackend" | "color" | "mascotExpression" | "autoApprove" @@ -922,6 +926,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { notifications: source.notifications, modelSelection: source.modelSelection, ...(source.computer ? { computer: source.computer } : {}), + ...(source.cloudBackend ? { cloudBackend: source.cloudBackend } : {}), }), }).then(({ bot: patched }) => rawDispatch({ type: "botAdded", bot: { ...bot, ...patched, messages: bot.messages } }), @@ -1180,6 +1185,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { xai: frame.xai, composio: frame.composio, box: frame.box, + vps: frame.vps, tts: frame.tts, profile: frame.profile, }, From 87ed7d2550c819179979ec0ee7930f1005e0c066 Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Fri, 14 Aug 2026 23:48:07 +0100 Subject: [PATCH 2/6] fix: accept Docker's disabled restart policy --- server/vps-computer.test.ts | 2 +- server/vps-computer.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/vps-computer.test.ts b/server/vps-computer.test.ts index 203c4401fb..58b24527ad 100644 --- a/server/vps-computer.test.ts +++ b/server/vps-computer.test.ts @@ -56,7 +56,7 @@ function fixture({ screenshotCaptureFails = false, securityOpt = [], memory = 4 * 1024 * 1024 * 1024, - restartPolicyName = "", + restartPolicyName = "no", cgroupnsMode = "private", }: { image?: boolean; diff --git a/server/vps-computer.ts b/server/vps-computer.ts index 16c650d813..d61504cc78 100644 --- a/server/vps-computer.ts +++ b/server/vps-computer.ts @@ -191,6 +191,7 @@ function dockerSecurityIsHardened(config: { .map((cap) => cap.toLowerCase().replace(/^cap_/, "")) .sort(); const unsafeSecurityOption = (config.SecurityOpt ?? []).some((option) => /(?:^|=)(?:unconfined|disable)$/i.test(option)); + const restartPolicy = config.RestartPolicy?.Name; return ( config.Memory === MEMORY_BYTES && (config.MemorySwap ?? 0) === MEMORY_BYTES && @@ -210,7 +211,7 @@ function dockerSecurityIsHardened(config: { config.CgroupnsMode === "private" && config.OomKillDisable !== true && config.AutoRemove !== true && - !config.RestartPolicy?.Name + (restartPolicy === undefined || restartPolicy === "" || restartPolicy === "no") ); } From 294dc221c6b88710a4714682f90ffd095381debc Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Sat, 15 Aug 2026 09:49:17 +0100 Subject: [PATCH 3/6] fix: address VPS review findings --- server/config.test.ts | 5 ++ server/store.test.ts | 26 +++++++++++ server/store.ts | 5 ++ server/testing/setup.ts | 2 + server/vps-computer.runner.test.ts | 75 ++++++++++++++++++++++++++++++ server/vps-computer.test.ts | 50 ++++++++++++++++---- server/vps-computer.ts | 65 +++++++++++++++++++------- server/vps-container-mcp.test.ts | 60 +++++++++++++++--------- server/vps-container-mcp.ts | 11 ++++- src/components/ComputerPanel.tsx | 2 +- 10 files changed, 251 insertions(+), 50 deletions(-) create mode 100644 server/vps-computer.runner.test.ts diff --git a/server/config.test.ts b/server/config.test.ts index 0874695e21..f4dbce076a 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -9,6 +9,11 @@ describe("VPS config", () => { rmSync(DATA_DIR, { recursive: true, force: true }); }); + it("keeps test data in the throwaway home", () => { + expect(process.env.OMB_DATA_DIR).toBeUndefined(); + expect(DATA_DIR).toContain("omb-test-home-"); + }); + it("accepts a simple SSH config alias and rejects command-shaped targets", () => { expect(normalizeVpsConfig({ sshAlias: "production-vps" })).toEqual({ sshAlias: "production-vps" }); expect(() => normalizeVpsConfig({ sshAlias: "production-vps; touch /tmp/pwned" })).toThrow(/SSH config alias/); diff --git a/server/store.test.ts b/server/store.test.ts index 4b78d94cc3..3eac6127ab 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -77,6 +77,32 @@ describe("Store", () => { expect(messages.at(-1)).toMatchObject({ role: "user", text: "hi there" }); }); + it("normalizes persisted cloud backends without changing valid or absent values", () => { + const store = new Store(selection); + const box = store.createBot(); + const vps = store.createBot(); + const invalid = store.createBot(); + const absent = store.createBot(); + const raw: BotRecord[] = JSON.parse(readFileSync(join(DATA_DIR, "bots.json"), "utf8")); + raw.find((bot) => bot.id === box.id)!.cloudBackend = "box"; + raw.find((bot) => bot.id === vps.id)!.cloudBackend = "vps"; + (raw.find((bot) => bot.id === invalid.id) as unknown as { cloudBackend: string }).cloudBackend = "daytona"; + delete raw.find((bot) => bot.id === absent.id)!.cloudBackend; + writeFileSync(join(DATA_DIR, "bots.json"), JSON.stringify(raw)); + + const reloaded = new Store(selection); + expect(reloaded.bot(box.id)?.cloudBackend).toBe("box"); + expect(reloaded.bot(vps.id)?.cloudBackend).toBe("vps"); + expect(reloaded.bot(invalid.id)?.cloudBackend).toBeUndefined(); + expect(reloaded.bot(absent.id)?.cloudBackend).toBeUndefined(); + + const saved: BotRecord[] = JSON.parse(readFileSync(join(DATA_DIR, "bots.json"), "utf8")); + expect(saved.find((bot) => bot.id === box.id)?.cloudBackend).toBe("box"); + expect(saved.find((bot) => bot.id === vps.id)?.cloudBackend).toBe("vps"); + expect(saved.find((bot) => bot.id === invalid.id)).not.toHaveProperty("cloudBackend"); + expect(saved.find((bot) => bot.id === absent.id)).not.toHaveProperty("cloudBackend"); + }); + it("keeps exactly one persisted Chief of Staff and supports handoff", () => { const store = new Store(selection); const first = store.createBot(); diff --git a/server/store.ts b/server/store.ts index 9e9b58068f..c438ac7f4e 100644 --- a/server/store.ts +++ b/server/store.ts @@ -299,6 +299,11 @@ export class Store { let groupsMigrated = false; for (const b of this.bots) b.busy = false; for (const b of this.bots) { + // Older or hand-edited files can bypass the API validation. + if (b.cloudBackend !== undefined && b.cloudBackend !== "box" && b.cloudBackend !== "vps") { + delete b.cloudBackend; + botsMigrated = true; + } if (!b.chiefOfStaff) continue; if (!chiefSeen) { chiefSeen = true; diff --git a/server/testing/setup.ts b/server/testing/setup.ts index f961df95fd..7a785fd0b1 100644 --- a/server/testing/setup.ts +++ b/server/testing/setup.ts @@ -10,6 +10,8 @@ import { afterAll } from "vitest"; const home = mkdtempSync(join(tmpdir(), "omb-test-home-")); process.env.HOME = home; process.env.USERPROFILE = home; +// A caller-supplied override must not escape the test sandbox. +delete process.env.OMB_DATA_DIR; afterAll(async () => { // Windows holds a directory that is a live process's cwd, and a diff --git a/server/vps-computer.runner.test.ts b/server/vps-computer.runner.test.ts new file mode 100644 index 0000000000..b87ec16a7e --- /dev/null +++ b/server/vps-computer.runner.test.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from "node:events"; +import { PassThrough, Writable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +vi.mock("node:child_process", async () => ({ + ...(await vi.importActual("node:child_process")), + spawn: spawnMock, +})); + +import { defaultRunner } from "./vps-computer.ts"; + +type FakeChild = EventEmitter & { + stdin: Writable; + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; +}; + +function fakeChild(): FakeChild { + const child = new EventEmitter() as FakeChild; + child.stdin = new Writable({ write: (_chunk, _encoding, callback) => callback() }); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(() => true); + spawnMock.mockReturnValue(child); + return child; +} + +describe("default VPS command runner", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("collects output and resolves after the child closes", async () => { + const child = fakeChild(); + const result = defaultRunner(["info"], { input: "request" }); + + child.stdout.write("out"); + child.stderr.write("err"); + child.emit("close", 0, null); + + await expect(result).resolves.toEqual({ stdout: "out", stderr: "err" }); + expect(spawnMock).toHaveBeenCalledWith("docker", ["info"], expect.objectContaining({ shell: false })); + }); + + it("turns stdin EPIPE into a rejected command instead of an unhandled error", async () => { + const child = fakeChild(); + const result = defaultRunner(["build", "-"], { input: "Dockerfile" }); + + child.stdin.emit("error", new Error("write EPIPE")); + + await expect(result).rejects.toThrow("Docker-over-SSH stdin failed: write EPIPE"); + child.emit("close", 1, null); + }); + + it("escalates a timed-out command from SIGTERM to SIGKILL", async () => { + vi.useFakeTimers(); + const child = fakeChild(); + const result = defaultRunner(["info"], { timeoutMs: 100 }); + const rejection = expect(result).rejects.toThrow("Docker-over-SSH command timed out"); + + await vi.advanceTimersByTimeAsync(100); + expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM"); + + await vi.advanceTimersByTimeAsync(1_000); + await rejection; + expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); + }); +}); diff --git a/server/vps-computer.test.ts b/server/vps-computer.test.ts index 58b24527ad..f034aa8909 100644 --- a/server/vps-computer.test.ts +++ b/server/vps-computer.test.ts @@ -19,6 +19,7 @@ import { vpsComputerMcp, vpsContainerMcpArgs, vpsContainerName, + vpsContainerRunArgs, vpsDockerArgs, vpsDriverError, reuseVps, @@ -47,17 +48,19 @@ function fixture({ networkMode = "default", containerImageId = IMAGE_ID, inspectedImageId = IMAGE_ID, + rebuiltImageId, containerId = CONTAINER_ID, privileged = false, pidMode = "", - ipcMode = "private", + ipcMode, capAdd = ["CAP_SETUID", "CAP_SETGID"], screenshotValid = true, screenshotCaptureFails = false, securityOpt = [], memory = 4 * 1024 * 1024 * 1024, restartPolicyName = "no", - cgroupnsMode = "private", + cgroupnsMode, + imageLabelsMatch = true, }: { image?: boolean; container?: boolean; @@ -70,6 +73,7 @@ function fixture({ networkMode?: string; containerImageId?: string; inspectedImageId?: string; + rebuiltImageId?: string; containerId?: string; privileged?: boolean; pidMode?: string; @@ -81,10 +85,17 @@ function fixture({ memory?: number; restartPolicyName?: string; cgroupnsMode?: string; + imageLabelsMatch?: boolean; } = {}) { const name = vpsContainerName(BOT_ID); + const provisioningArgs = vpsContainerRunArgs(name); + const argValue = (flag: string) => { + const index = provisioningArgs.indexOf(flag); + if (index >= 0) return provisioningArgs[index + 1] ?? ""; + return provisioningArgs.find((arg) => arg.startsWith(`${flag}=`))?.slice(flag.length + 1) ?? ""; + }; const calls: Array<{ args: string[]; options?: { input?: string; timeoutMs?: number } }> = []; - const state = { image, container, running }; + const state = { image, container, running, imageLabelsMatch, inspectedImageId }; const runner: VpsCommandRunner = async (args, options) => { calls.push({ args, options }); const command = args[2]; @@ -93,12 +104,12 @@ function fixture({ if (!state.image) throw new Error("missing image"); return { stdout: JSON.stringify([{ - Config: { Labels: { + Config: { Labels: state.imageLabelsMatch ? { [MANAGED_LABEL]: "1", [DRIVER_LABEL]: CUA_DRIVER_VERSION, [BASE_IMAGE_LABEL]: BASE_IMAGE_DIGEST, - } }, - Id: inspectedImageId, + } : { [MANAGED_LABEL]: "0" } }, + Id: state.inspectedImageId, }]), stderr: "", }; @@ -133,14 +144,14 @@ function fixture({ CapAdd: capAdd, Privileged: privileged, PidMode: pidMode, - IpcMode: ipcMode, + IpcMode: ipcMode ?? argValue("--ipc"), UTSMode: "", ShmSize: 512 * 1024 * 1024, Devices: [], DeviceRequests: deviceRequests ? [{ Driver: "nvidia" }] : [], SecurityOpt: securityOpt, UsernsMode: "", - CgroupnsMode: cgroupnsMode, + CgroupnsMode: cgroupnsMode ?? argValue("--cgroupns"), OomKillDisable: false, AutoRemove: false, RestartPolicy: { Name: restartPolicyName, MaximumRetryCount: 0 }, @@ -164,6 +175,8 @@ function fixture({ if (command === "pull") return { stdout: "pulled\n", stderr: "" }; if (command === "build") { state.image = true; + state.imageLabelsMatch = true; + state.inspectedImageId = rebuiltImageId ?? state.inspectedImageId; expect(options?.input).toContain(`FROM ${BASE_IMAGE}`); return { stdout: "built\n", stderr: "" }; } @@ -300,6 +313,10 @@ describe("VPS computer", () => { const run = provision.calls.find(({ args }) => args[2] === "run")?.args ?? []; expect(run).toContain("--memory"); expect(run).toContain("--pids-limit"); + expect(run).toContain("--ipc"); + expect(run[run.indexOf("--ipc") + 1]).toBe("private"); + expect(run).toContain("--cgroupns"); + expect(run[run.indexOf("--cgroupns") + 1]).toBe("private"); expect(run.at(-1)).toBe(IMAGE_ID); expect(run.join(" ")).toContain(`--label ${VPS_MANAGED_LABEL}=1`); expect(run).not.toContain("--mount"); @@ -307,6 +324,23 @@ describe("VPS computer", () => { expect(provision.calls.some(({ args }) => args[2] === "build")).toBe(true); }); + it("uses the image id produced by a rebuild", async () => { + const staleImageId = `sha256:${"b".repeat(64)}`; + const provision = fixture({ + image: true, + imageLabelsMatch: false, + container: false, + inspectedImageId: staleImageId, + rebuiltImageId: IMAGE_ID, + }); + + const status = await vpsComputerAction("provision", CONFIG, BOT_ID, provision.runner); + expect(status.ready).toBe(true); + const run = provision.calls.find(({ args }) => args[2] === "run")?.args ?? []; + expect(run.at(-1)).toBe(IMAGE_ID); + expect(run.at(-1)).not.toBe(staleImageId); + }); + it("starts and sleeps only the managed container, never the VPS", async () => { const start = fixture({ running: false }); const started = await vpsComputerAction("start", CONFIG, BOT_ID, start.runner); diff --git a/server/vps-computer.ts b/server/vps-computer.ts index d61504cc78..c8bf25fe53 100644 --- a/server/vps-computer.ts +++ b/server/vps-computer.ts @@ -26,6 +26,7 @@ export const VPS_IMAGE = CUA_IMAGE; export const VPS_MANAGED_LABEL = "com.openmausbot.vps"; export const VPS_CONTAINER_LABEL = "com.openmausbot.container"; export const VPS_CONTAINER_PREFIX = "openmausbot-vps"; +const COMMAND_TIMEOUT_KILL_GRACE_MS = 1_000; const CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/; const CONTAINER_ID = /^[a-f0-9]{12,64}$/i; @@ -88,7 +89,7 @@ export function vpsDockerArgs(alias: string, args: string[]): string[] { return ["-H", `ssh://${alias}`, ...args]; } -function defaultRunner(args: string[], options: VpsCommandOptions = {}): Promise<{ stdout: string; stderr: string }> { +export function defaultRunner(args: string[], options: VpsCommandOptions = {}): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn("docker", args, { shell: false, @@ -98,11 +99,26 @@ function defaultRunner(args: string[], options: VpsCommandOptions = {}): Promise let stdout = ""; let stderr = ""; let settled = false; - const timeout = setTimeout(() => { + let timedOut = false; + let killTimer: ReturnType | undefined; + let timeout: ReturnType; + const settle = (finish: () => void) => { if (settled) return; settled = true; - child.kill(); - reject(new Error("Docker-over-SSH command timed out")); + clearTimeout(timeout); + if (killTimer) clearTimeout(killTimer); + finish(); + }; + timeout = setTimeout(() => { + if (settled) return; + timedOut = true; + killTimer = setTimeout(() => { + if (settled) return; + child.kill("SIGKILL"); + settle(() => reject(new Error("Docker-over-SSH command timed out"))); + }, COMMAND_TIMEOUT_KILL_GRACE_MS); + killTimer.unref?.(); + child.kill("SIGTERM"); }, options.timeoutMs ?? 120_000); timeout.unref?.(); @@ -114,21 +130,29 @@ function defaultRunner(args: string[], options: VpsCommandOptions = {}): Promise child.stderr.on("data", (chunk: string) => { stderr = `${stderr}${chunk}`.slice(-16 * 1024 * 1024); }); + child.stdin.on("error", (error) => { + if (timedOut) return; + settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error.message}`))); + }); child.on("error", (error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(new Error(`Docker-over-SSH could not start: ${error.message}`)); + settle(() => reject(new Error(`Docker-over-SSH could not start: ${error.message}`))); }); child.on("close", (code, signal) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (code === 0) return resolve({ stdout, stderr }); - const detail = stderr.trim().slice(-1000); - reject(new Error(detail || `Docker-over-SSH exited ${code ?? signal ?? "without a status"}`)); + if (timedOut) { + settle(() => reject(new Error("Docker-over-SSH command timed out"))); + return; + } + settle(() => { + if (code === 0) return resolve({ stdout, stderr }); + const detail = stderr.trim().slice(-1000); + reject(new Error(detail || `Docker-over-SSH exited ${code ?? signal ?? "without a status"}`)); + }); }); - child.stdin.end(options.input); + try { + child.stdin.end(options.input); + } catch (error) { + settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error instanceof Error ? error.message : String(error)}`))); + } }); } @@ -418,6 +442,10 @@ export function vpsContainerRunArgs(containerName: string, imageRef = VPS_IMAGE) String(PIDS_LIMIT), "--network", "bridge", + "--ipc", + "private", + "--cgroupns", + "private", "--cap-drop", "ALL", "--cap-add", @@ -517,8 +545,11 @@ export async function vpsComputerAction( const containerRef = before.container_id ?? before.container_name; if (action === "provision") { if (before.container === "missing") { - if (!before.image) await prepareVpsImage(alias, runner); - const imageRef = before.image_id ?? (await vpsComputerStatus(cfg, botId, runner)).image_id; + let imageRef = before.image ? before.image_id : null; + if (!before.image) { + await prepareVpsImage(alias, runner); + imageRef = (await vpsComputerStatus(cfg, botId, runner)).image_id; + } if (!imageRef) throw Object.assign(new Error("The prepared VPS image could not be identified"), { status: 409 }); await run(vpsContainerRunArgs(before.container_name, imageRef)); } else { diff --git a/server/vps-container-mcp.test.ts b/server/vps-container-mcp.test.ts index 16624be31c..e51a561349 100644 --- a/server/vps-container-mcp.test.ts +++ b/server/vps-container-mcp.test.ts @@ -13,6 +13,30 @@ afterEach(async () => { await Promise.all(temporary.splice(0).map((path) => rm(path, { recursive: true, force: true }))); }); +function runBridge(bin: string, input: string) { + return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL("./vps-container-mcp.ts", import.meta.url)), + "production-vps", + vpsContainerName("bridge-test"), + ], + { + env: { ...process.env, OMB_EXTRA_PATH: bin, NODE_NO_WARNINGS: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + describe.skipIf(process.platform === "win32")("VPS Cua MCP bridge", () => { it("passes MCP bytes unchanged to docker exec over the validated SSH target", async () => { const bin = await mkdtemp(join(tmpdir(), "openmausbot-vps-mcp-")); @@ -21,28 +45,8 @@ describe.skipIf(process.platform === "win32")("VPS Cua MCP bridge", () => { await writeFile(fakeDocker, "#!/bin/sh\nprintf 'ARGS:%s\\n' \"$*\" >&2\ncat\n", { mode: 0o700 }); await chmod(fakeDocker, 0o700); - const input = '{"jsonrpc":"2.0","id":1,"method":"tools/list"}\n'; - const result = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { - const child = spawn( - process.execPath, - [ - fileURLToPath(new URL("./vps-container-mcp.ts", import.meta.url)), - "production-vps", - vpsContainerName("bridge-test"), - ], - { - env: { ...process.env, OMB_EXTRA_PATH: bin, NODE_NO_WARNINGS: "1" }, - stdio: ["pipe", "pipe", "pipe"], - }, - ); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); - child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); - child.on("error", reject); - child.on("close", (code) => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); + const input = `{"jsonrpc":"2.0","id":1,"method":"tools/list","data":"${"x".repeat(2 * 1024 * 1024)}"}\n`; + const result = await runBridge(bin, input); expect(result.code).toBe(0); expect(result.stdout).toBe(input); @@ -52,4 +56,16 @@ describe.skipIf(process.platform === "win32")("VPS Cua MCP bridge", () => { "/usr/local/libexec/openmausbot/cua-driver mcp --socket /run/user/1000/openmausbot-cua.sock", ); }); + + it("survives docker closing stdin before consuming the request", async () => { + const bin = await mkdtemp(join(tmpdir(), "openmausbot-vps-mcp-")); + temporary.push(bin); + const fakeDocker = join(bin, "docker"); + await writeFile(fakeDocker, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + await chmod(fakeDocker, 0o700); + + const result = await runBridge(bin, "x".repeat(4 * 1024 * 1024)); + + expect(result.code).toBe(0); + }); }); diff --git a/server/vps-container-mcp.ts b/server/vps-container-mcp.ts index f30d0704bf..8eb51153da 100644 --- a/server/vps-container-mcp.ts +++ b/server/vps-container-mcp.ts @@ -21,17 +21,24 @@ const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"], }); +// docker may exit before it drains stdin; pipe() leaves this error unhandled. +child.stdin.on("error", () => {}); process.stdin.pipe(child.stdin); child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr); child.on("error", (error) => { process.stderr.write(`could not connect to VPS Cua Driver: ${error.message}\n`); - process.exit(1); + process.exitCode = 1; + process.stdin.unpipe(child.stdin); + process.stdin.pause(); }); child.on("close", (code, signal) => { if (signal) process.stderr.write(`VPS Cua Driver connection ended with ${signal}\n`); - process.exit(code ?? 1); + // Let stdout and stderr drain before the bridge exits. + process.exitCode = code ?? 1; + process.stdin.unpipe(child.stdin); + process.stdin.pause(); }); for (const signal of ["SIGTERM", "SIGINT"] as const) { diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index bf1e32da47..c9686ae622 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -366,7 +366,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { api(`/api/bots/${bot.id}/computer/${kind}`, { method: "POST" }) .then((result) => { // the join URL's stream token rotates — always freshly minted, never cached - if (kind === "join" && result.joinUrl) window.open(result.joinUrl); + if (kind === "join" && result.joinUrl) window.open(result.joinUrl, "_blank", "noopener"); if (kind === "provision") { setBoxState(result.container ?? null); if (result.ready) setPhase("ready"); From 39af194abe72a9be16ee660206f032bb520d32e7 Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Sun, 16 Aug 2026 14:12:59 +0100 Subject: [PATCH 4/6] fix: guard VPS backend switches and readiness probes --- server/branching.test.ts | 4 ++++ server/cloud-backend.test.ts | 18 ++++++++++++++++++ server/cloud-backend.ts | 5 +++++ server/index.ts | 5 +++++ server/vps-computer.test.ts | 6 ++++++ server/vps-computer.ts | 4 ++-- 6 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 server/cloud-backend.test.ts create mode 100644 server/cloud-backend.ts diff --git a/server/branching.test.ts b/server/branching.test.ts index a3c67af08b..9f8d4ad860 100644 --- a/server/branching.test.ts +++ b/server/branching.test.ts @@ -184,6 +184,10 @@ posixOnly("conversation branching e2e (fake ACP fleet)", () => { expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "first try" })).status).toBe(202); await waitFor(async () => (await getBot(created.id)).busy === true, "the hung turn to start"); + const backendChange = await api("PATCH", `/api/bots/${created.id}`, { cloudBackend: "vps" }); + expect(backendChange.status).toBe(409); + expect(backendChange.body.error).toContain("stop the active turn"); + // a second send while busy is refused — never a parallel turn const parallel = await api("POST", `/api/bots/${created.id}/messages`, { text: "sneaky second" }); expect(parallel.status).toBe(409); diff --git a/server/cloud-backend.test.ts b/server/cloud-backend.test.ts new file mode 100644 index 0000000000..c4d3c56616 --- /dev/null +++ b/server/cloud-backend.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { CLOUD_BACKEND_CHANGE_ERROR, cloudBackendChangeError } from "./cloud-backend.ts"; + +describe("cloud backend switching", () => { + const activeTurnCases: Array<[string, boolean, boolean]> = [ + ["a busy bot", true, false], + ["an active VPS thread", false, true], + ]; + + it.each(activeTurnCases)("rejects changes during %s", (_reason, busy, activeVpsThread) => { + expect(cloudBackendChangeError(busy, activeVpsThread)).toBe(CLOUD_BACKEND_CHANGE_ERROR); + }); + + it("allows changes while idle", () => { + expect(cloudBackendChangeError(false, false)).toBeNull(); + }); +}); diff --git a/server/cloud-backend.ts b/server/cloud-backend.ts new file mode 100644 index 0000000000..3f201df7ef --- /dev/null +++ b/server/cloud-backend.ts @@ -0,0 +1,5 @@ +export const CLOUD_BACKEND_CHANGE_ERROR = "stop the active turn before changing the cloud backend"; + +export function cloudBackendChangeError(botBusy: boolean, activeVpsThread: boolean): string | null { + return botBusy || activeVpsThread ? CLOUD_BACKEND_CHANGE_ERROR : null; +} diff --git a/server/index.ts b/server/index.ts index 53f8f6e92f..33154f0c58 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { approvalKey, autoDecision } from "./auto-approve.ts"; import * as box from "./box.ts"; +import { cloudBackendChangeError } from "./cloud-backend.ts"; import * as composio from "./composio.ts"; import { chiefOfStaffSystemPrompt } from "./chief-of-staff.ts"; import { @@ -1789,6 +1790,10 @@ const server = createServer(async (req, res) => { return json(res, 400, { error: "chiefOfStaff must be true or false" }); } const existing = store.bot(m[1]); + if (body.cloudBackend !== undefined) { + const backendError = cloudBackendChangeError(Boolean(existing?.busy), activeVpsThreads.has(m[1])); + if (backendError) return json(res, 409, { error: backendError }); + } if (body.hidden === true && existing?.chiefOfStaff && body.chiefOfStaff !== false) { return json(res, 400, { error: "choose another Chief of Staff before hiding this bot" }); } diff --git a/server/vps-computer.test.ts b/server/vps-computer.test.ts index f034aa8909..79920fddde 100644 --- a/server/vps-computer.test.ts +++ b/server/vps-computer.test.ts @@ -230,6 +230,12 @@ describe("VPS computer", () => { problem: null, }); expect(fake.calls[0]?.args).toEqual(["-H", "ssh://production-vps", "info", "--format", "{{.ServerVersion}}"]); + const probes = fake.calls.filter( + ({ args }) => args[2] === "exec" && (args.at(-1) === "--version" || args.includes("status")), + ); + expect(probes).toHaveLength(2); + expect(probes.every(({ args }) => args.includes(CONTAINER_ID))).toBe(true); + expect(probes.every(({ args }) => !args.includes(fake.name))).toBe(true); }); it("refuses host mounts, public ports, and unowned containers", async () => { diff --git a/server/vps-computer.ts b/server/vps-computer.ts index c8bf25fe53..03237fcf4d 100644 --- a/server/vps-computer.ts +++ b/server/vps-computer.ts @@ -381,7 +381,7 @@ export async function vpsComputerStatus( status.network === "private" && status.mounts === "none" && status.security === "hardened"; - if (canProbe) { + if (canProbe && status.container_id) { const exec = [ "exec", "-u", @@ -392,7 +392,7 @@ export async function vpsComputerStatus( "DISPLAY=:1", "-e", "CUA_DRIVER_INSTALL_CHANNEL=python_package", - status.container_name, + status.container_id, CUA_EXECUTABLE, ]; try { From 2e8e5168e2a7d75006f8ab4c18c355d3d72f85d7 Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Sun, 16 Aug 2026 14:24:45 +0100 Subject: [PATCH 5/6] test: handle expected VPS bridge disconnects --- server/vps-container-mcp.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/vps-container-mcp.test.ts b/server/vps-container-mcp.test.ts index e51a561349..f7adbfae92 100644 --- a/server/vps-container-mcp.test.ts +++ b/server/vps-container-mcp.test.ts @@ -33,6 +33,7 @@ function runBridge(bin: string, input: string) { child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); child.on("error", reject); child.on("close", (code) => resolve({ code, stdout, stderr })); + child.stdin.on("error", () => {}); child.stdin.end(input); }); } From c42259f62fdc9caedea31e6c05413dbf50145435 Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Sun, 16 Aug 2026 14:33:30 +0100 Subject: [PATCH 6/6] test: assert rejected backend changes do not persist --- server/branching.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/branching.test.ts b/server/branching.test.ts index 9f8d4ad860..bc9aaf7c3e 100644 --- a/server/branching.test.ts +++ b/server/branching.test.ts @@ -184,9 +184,11 @@ posixOnly("conversation branching e2e (fake ACP fleet)", () => { expect((await api("POST", `/api/bots/${created.id}/messages`, { text: "first try" })).status).toBe(202); await waitFor(async () => (await getBot(created.id)).busy === true, "the hung turn to start"); + const backendBefore = (await getBot(created.id)).cloudBackend; const backendChange = await api("PATCH", `/api/bots/${created.id}`, { cloudBackend: "vps" }); expect(backendChange.status).toBe(409); expect(backendChange.body.error).toContain("stop the active turn"); + expect((await getBot(created.id)).cloudBackend).toBe(backendBefore); // a second send while busy is refused — never a parallel turn const parallel = await api("POST", `/api/bots/${created.id}/messages`, { text: "sneaky second" });