diff --git a/scripts/bundle-server.mjs b/scripts/bundle-server.mjs index 58696a30fa..16256ba9e6 100644 --- a/scripts/bundle-server.mjs +++ b/scripts/bundle-server.mjs @@ -29,6 +29,7 @@ const ENTRY_POINTS = [ "index.ts", "computer-proxy.ts", "container-mcp.ts", + "vps-container-mcp.ts", "permission-proxy.ts", "connector-proxy.ts", "drivers/agents-proxy.ts", diff --git a/server/branching.test.ts b/server/branching.test.ts index 1fd2c15a7e..cf33c672b8 100644 --- a/server/branching.test.ts +++ b/server/branching.test.ts @@ -187,6 +187,12 @@ 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 queues (steer-queue) — never a parallel // turn: the words land in the transcript, the live turn keeps running const parallel = await api("POST", `/api/bots/${created.id}/messages`, { text: "sneaky second" }); diff --git a/server/cloud-backend.test.ts b/server/cloud-backend.test.ts new file mode 100644 index 0000000000..7807553b1a --- /dev/null +++ b/server/cloud-backend.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { + CLOUD_BACKEND_CHANGE_ERROR, + VPS_ALIAS_CHANGE_ERROR, + cloudBackendChangeError, + vpsAliasChangeError, +} 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(); + }); + + it("keeps an active VPS turn on its original SSH host", () => { + expect(vpsAliasChangeError("old-vps", "new-vps", true)).toBe(VPS_ALIAS_CHANGE_ERROR); + expect(vpsAliasChangeError("old-vps", "old-vps", true)).toBeNull(); + expect(vpsAliasChangeError("old-vps", "new-vps", false)).toBeNull(); + }); +}); diff --git a/server/cloud-backend.ts b/server/cloud-backend.ts new file mode 100644 index 0000000000..f8be215da1 --- /dev/null +++ b/server/cloud-backend.ts @@ -0,0 +1,10 @@ +export const CLOUD_BACKEND_CHANGE_ERROR = "stop the active turn before changing the cloud backend"; +export const VPS_ALIAS_CHANGE_ERROR = "stop the active VPS turn before changing the SSH config alias"; + +export function cloudBackendChangeError(botBusy: boolean, activeVpsThread: boolean): string | null { + return botBusy || activeVpsThread ? CLOUD_BACKEND_CHANGE_ERROR : null; +} + +export function vpsAliasChangeError(currentAlias: string | null, nextAlias: string | null, activeVpsThread: boolean): string | null { + return activeVpsThread && currentAlias !== nextAlias ? VPS_ALIAS_CHANGE_ERROR : null; +} diff --git a/server/config.test.ts b/server/config.test.ts index 3da54f2ff7..9c62e49899 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { instanceConfigs, + isValidSshAlias, parseConfigPatch, parseStoredConfig, + vpsSshAlias, withInstanceCli, type AppConfig, } from "./config.ts"; @@ -27,6 +29,17 @@ describe("configuration boundaries", () => { expect(() => parseConfigPatch({ opencodeGo: { apiKey: 42 } })).toThrow("opencodeGo.apiKey"); expect(() => parseConfigPatch({ profile: [] })).toThrow("profile"); }); + + it("accepts only a simple VPS SSH config alias and exposes no credentials", () => { + expect(isValidSshAlias("production-vps")).toBe(true); + expect(isValidSshAlias("prod; reboot")).toBe(false); + expect(() => parseConfigPatch({ vps: { sshAlias: "prod; reboot" } })).toThrow("vps.sshAlias"); + expect(parseConfigPatch({ vps: { sshAlias: "production-vps" } })).toEqual({ + vps: { sshAlias: "production-vps" }, + }); + expect(vpsSshAlias({ vps: { sshAlias: "production-vps" } })).toBe("production-vps"); + expect(vpsSshAlias({ vps: { sshAlias: "-bad" } })).toBeNull(); + }); }); describe("default fleet", () => { diff --git a/server/config.ts b/server/config.ts index c21d5ace40..10557d67d8 100644 --- a/server/config.ts +++ b/server/config.ts @@ -11,6 +11,31 @@ import type { InstanceConfigMap } from "./contracts.ts"; import { parseJson, schemaIssue, type JsonObject, type JsonValue } from "./schema.ts"; const optionalText = z.string().optional(); +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 }; +} + +const vpsConfigSchema = z.object({ + sshAlias: z.string().refine((value) => value === "" || isValidSshAlias(value), { + message: "must be a simple SSH config alias", + }).optional(), +}); const instanceConfigSchema = z.object({ driver: z.string().min(1), displayName: optionalText, @@ -26,6 +51,7 @@ const appConfigSchema = z.object({ * are non-secret local identifiers used to reuse one Composio Session. */ composio: z.object({ apiKey: optionalText, userId: optionalText, sessionId: optionalText }).optional(), box: z.object({ token: optionalText }).optional(), + vps: vpsConfigSchema.optional(), /** OpenCode Go key; persisted write-only and passed only to its child. */ opencodeGo: z.object({ apiKey: optionalText }).optional(), /** Voice credentials and the selected voice id. */ @@ -41,6 +67,8 @@ export interface AppConfig { xai?: { key?: string; url?: string }; composio?: { apiKey?: string; userId?: string; sessionId?: string }; box?: { token?: string }; + /** A named host from the user's SSH config. Authentication stays with SSH. */ + vps?: { sshAlias?: string }; opencodeGo?: { apiKey?: string }; tts?: { key?: string; voice?: string }; profile?: { name?: string; email?: string }; @@ -62,6 +90,10 @@ export function parseConfigPatch(value: JsonValue): ConfigPatch { return parsed.data; } +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"); @@ -117,6 +149,7 @@ export function saveConfig(patch: Partial): void { Object.assign(merged, section); disk[key] = merged; } + if (checkedPatch.vps !== undefined) disk.vps = normalizeVpsConfig(checkedPatch.vps); if (checkedPatch.instances) { const currentInstances = jsonObjectSchema.safeParse(disk.instances); const diskInstances: JsonObject = currentInstances.success ? currentInstances.data : {}; diff --git a/server/contracts.ts b/server/contracts.ts index eb03bfd26d..f72fd8d0e4 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"; export type ProviderErrorCode = | "missing_cli" @@ -154,7 +155,7 @@ export interface SendTurnInput { composio?: { command: string; args: string[]; env: Record }; /** 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 d489cbfe92..7274f70cb9 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -604,6 +604,26 @@ 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 invalid = await api("PATCH", `/api/bots/${bot.id}`, { cloudBackend: "daytona" }); + expect(invalid.status).toBe(400); + }); + it("validates a Composio project key, creates a Session, and keeps externally stored secrets off disk", async () => { const oldKey = await api("PUT", "/api/config", { composio: { apiKey: "old_key" } }); expect(oldKey.status).toBe(400); diff --git a/server/index.ts b/server/index.ts index ee3fe842e0..6a39b30b16 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,6 +13,7 @@ import { approvalKey, autoDecision } from "./auto-approve.ts"; import { validateBotCwd } from "./bot-cwd.ts"; import { groupTurnCwd } from "./room-cwd.ts"; import * as box from "./box.ts"; +import { cloudBackendChangeError, vpsAliasChangeError } from "./cloud-backend.ts"; import * as composio from "./composio.ts"; import { chiefOfStaffSystemPrompt } from "./chief-of-staff.ts"; import { @@ -30,6 +31,7 @@ import { parseConfigPatch, saveConfig, withInstanceCli, + vpsSshAlias, EVENTS_DIR, NATIVE_DIR, } from "./config.ts"; @@ -72,6 +74,7 @@ import { readCuaConnection } from "./local-computer.ts"; import { LocalVmIdleTimer } from "./local-vm-idle.ts"; import { LocalVmLease } from "./local-vm-lease.ts"; import { RepeatDetector, callKey } from "./repeat-detector.ts"; +import * as vps from "./vps-computer.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.ts"; import { createTeamManifest, parseTeamManifest } from "./team-manifest.ts"; @@ -481,6 +484,7 @@ const watchdog = new TurnWatchdog({ const currentBot = store.bot(turn.botId); if (currentBot?.busy) { stopScreenPoller(currentBot.id); + if (activeVpsThreads.get(currentBot.id) === turn.threadId) activeVpsThreads.delete(currentBot.id); store.setActivity(currentBot.id, "idle"); } }, 6_000); @@ -540,6 +544,7 @@ const localVmLease = new LocalVmLease(30 * 60_000); const localVmOwnerBusy = (botId: string) => store.bot(botId)?.busy === true; let localVmLifecycleBusy = false; let localVmActiveThread: string | null = null; +const activeVpsThreads = new Map(); const LOCAL_VM_IDLE_MS = 8 * 60 * 60_000; const localVmIdle = new LocalVmIdleTimer( LOCAL_VM_IDLE_MS, @@ -760,6 +765,10 @@ bus.subscribe((event: RuntimeEvent) => { // tally is not the right home for a shared room's spend, so only // 1:1 task turns are tallied for now. if (bot) { + const vpsTurn = activeVpsThreads.get(bot.id) === event.threadId; + const clearVpsTurn = () => { + if (activeVpsThreads.get(bot.id) === event.threadId) activeVpsThreads.delete(bot.id); + }; // bank what this turn spent before the bot broadcast carries the // task list to every window. The driver's own per-turn figure // (turn.completed.usage) is authoritative; a driver that only @@ -784,7 +793,9 @@ bus.subscribe((event: RuntimeEvent) => { if (frame && store.bot(bot.id)) { pushMessage({ role: "bot", kind: "screen", png: frame.png, mime: frame.mime }); } - }); + }).finally(clearVpsTurn); + } else if (vpsTurn) { + clearVpsTurn(); } } const speaker = groupSpeakers.get(event.threadId); @@ -957,7 +968,7 @@ function drainQueuedSends() { ); } -// ── 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 }; @@ -986,8 +997,12 @@ const SCREEN_MIN_GAP_MS = 3000; /** `screenIsTheWork` starts the turn already counting as screen usage: a * boxAgent's whole session runs ON the box, so every tool it calls acts on * that screen even though none of them is named like a computer tool. */ -function startScreenPoller(botId: string, boxId?: string, { screenIsTheWork = false } = {}) { - if (screenPollers.has(botId) || !box.boxConfigured(cfg)) return; +function startScreenPoller( + botId: string, + capture: () => Promise<{ png: string; format: string }>, + { screenIsTheWork = false } = {}, +) { + 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 @@ -1001,9 +1016,7 @@ function startScreenPoller(botId: string, boxId?: string, { screenIsTheWork = fa if (!current && 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 }); @@ -1214,10 +1227,13 @@ async function startTurn( const dwebUrl = process.env.DWEB_URL?.trim(); if (dwebUrl) integrations.dweb = { url: dwebUrl }; const wants = opts?.runOn === "cloud" ? "cloud" : bot.computer; // cloud routine overrides the MAUS default + // Cloud routines always use Box/BoxAgent. The per-bot backend applies + // only to ordinary turns that mount a computer into the local agent. + const cloudBackend = opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps"; 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; + 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. @@ -1252,9 +1268,34 @@ 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 && wants === "cloud") throw new Error(unsupported); + if (!unsupported) { + activeVpsThreads.set(bot.id, threadId); + const remote = wants === "cloud" + ? await vps.vpsComputerAction("provision", cfg, bot.id) + : await vps.reuseVps(cfg, bot.id); + if (remote?.ready && remote.sshAlias) { + const targetCfg = { ...cfg, vps: { sshAlias: remote.sshAlias } }; + integrations.localComputer = vps.vpsComputerMcp(targetCfg, bot.id, remote.container_id ?? undefined); + computerKind = "vps"; + previewCapture = () => vps.vpsComputerScreenshot(targetCfg, bot.id); + } else { + activeVpsThreads.delete(bot.id); + if (wants === "cloud") { + throw new Error(remote?.problem ?? "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"); } @@ -1275,17 +1316,17 @@ 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"); } @@ -1327,6 +1368,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); watchdog.watch(threadId, bot.id); await instance.adapter.sendTurn({ threadId, @@ -1344,6 +1386,8 @@ async function startTurn( ? " You have a shared, isolated Cua sandbox: a Linux desktop in a container on this machine. Only /home/cua/workspace is durable; save downloads, repositories, working files, and browser profiles there because everything else inside the VM is disposable. No other host folder is 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. In Chrome, prefer browser_snapshot with browser_click/browser_fill for semantic, trusted actions; use screenshot/click/type_text for visual or non-browser UI, open_url for navigation, and computer_exec for Linux tasks. Every action already returns the resulting screen, so don't follow it with screenshot; batch predictable pixel actions 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." : "") + @@ -1376,12 +1420,13 @@ async function startTurn( // after its own turn.completed would never be torn down — it would // keep polling the box forever, carrying dead per-turn state. busy // is flipped false in the fold, so it is the honest "still running". - if (previewBoxId && store.bot(bot.id)?.busy) { - startScreenPoller(bot.id, previewBoxId, { screenIsTheWork: instance.driverKind === "boxAgent" }); + if (previewCapture && store.bot(bot.id)?.busy) { + startScreenPoller(bot.id, previewCapture, { screenIsTheWork: instance.driverKind === "boxAgent" }); } } catch (e) { localVmLease.release(threadId); if (localVmActiveThread === threadId) localVmActiveThread = null; + if (activeVpsThreads.get(bot.id) === threadId) activeVpsThreads.delete(bot.id); watchdog.settle(threadId); turnUsage.delete(threadId); const message = e instanceof Error ? e.message : String(e); @@ -1888,6 +1933,7 @@ function configStatus() { mode: composio.connectionMode(cfg), }, box: { configured: Boolean(cfg.box?.token) }, + vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" }, opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey) }, // the chosen voice is a setting, not a secret; the key is reported the // same configured-or-not way as every other credential @@ -1914,6 +1960,7 @@ async function reloadProviders() { if (localVmActiveThread === vmLease.threadId) localVmActiveThread = null; } stopScreenPoller(b.id); + activeVpsThreads.delete(b.id); finalizeDelegationWatch( b.threadId, false, @@ -2788,7 +2835,11 @@ const server = createServer(async (req, res) => { if (field === "name" && !value.trim()) return json(res, 400, { error: "name must not be empty" }); } 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 (key === "computer" && body.computer === null) { + patch.computer = undefined; + continue; + } if (body[key] !== undefined) patch[key] = body[key]; } // per-bot gate on the workspace's connected apps (Composio) @@ -2798,13 +2849,21 @@ const server = createServer(async (req, res) => { } if ( body.computer !== undefined && + body.computer !== null && !["cloud", "vm", "local", "off"].includes(String(body.computer)) ) { 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" }); } + if (body.cloudBackend !== undefined) { + const backendError = cloudBackendChangeError(Boolean(existing?.busy), activeVpsThreads.has(m[1])); + if (backendError) return json(res, 409, { error: backendError }); + } if (body.cwd !== undefined) { const checked = validateBotCwd(body.cwd); if (!checked.ok) return json(res, 400, { error: checked.error }); @@ -2852,6 +2911,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); webhooks.disableForBot(bot.id); lastReply.delete(bot.threadId); @@ -3249,6 +3309,12 @@ const server = createServer(async (req, res) => { const patch = parseConfigPatch(body); if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" }); if (providerConfigBusy) return json(res, 409, { error: "provider settings are already being updated" }); + if (patch.vps !== undefined) { + const currentAlias = vpsSshAlias(cfg); + const nextAlias = vpsSshAlias({ ...cfg, vps: patch.vps }); + const aliasError = vpsAliasChangeError(currentAlias, nextAlias, activeVpsThreads.size > 0); + if (aliasError) return json(res, 409, { error: aliasError }); + } providerConfigBusy = true; try { // A project key is useful only if it can create/reuse the Session that @@ -3300,7 +3366,9 @@ 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(); + // The VPS alias is consumed by lifecycle commands, not provider + // engines. Saving it must not interrupt an in-flight turn. + 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); @@ -3421,12 +3489,38 @@ const server = createServer(async (req, res) => { // ── the bot's cloud computer (Box) ── 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 bot.cloudBackend === "vps" + ? json(res, 200, { backend: "vps", ...(await vps.vpsComputerStatus(cfg, bot.id)) }) + : json(res, 200, { backend: "box", ...(await box.boxStatus(cfg, bot.id)) }); + } m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot)$/); if (m && method === "POST") { const botId = m[1]; const bot = store.bot(botId); if (!bot) return json(res, 404, { error: "no such bot" }); + if (bot.cloudBackend === "vps") { + if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { + return json(res, 415, { error: "content-type must be application/json" }); + } + if (m[2] === "join" || m[2] === "exec") { + return json(res, 409, { error: "interactive VPS desktop access is not supported" }); + } + 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/proxy-paths.ts b/server/proxy-paths.ts index d4a02857f5..68ca376ffa 100644 --- a/server/proxy-paths.ts +++ b/server/proxy-paths.ts @@ -37,6 +37,7 @@ export const SPAWNED_PROXIES = { computer: resolveProxy("computer-proxy"), permission: resolveProxy("permission-proxy"), containerMcp: resolveProxy("container-mcp"), + vpsContainerMcp: resolveProxy("vps-container-mcp"), agents: resolveProxy("drivers/agents-proxy"), dweb: resolveProxy("drivers/dweb-proxy"), connectors: resolveProxy("connector-proxy"), diff --git a/server/store.test.ts b/server/store.test.ts index 4ceeca13f0..2baf0c89ca 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -110,6 +110,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("migrates unambiguous legacy peer grants without guessing duplicate names", () => { const store = new Store(selection); const requester = store.createBot(); diff --git a/server/store.ts b/server/store.ts index ca2d0f8e95..045e156b76 100644 --- a/server/store.ts +++ b/server/store.ts @@ -10,7 +10,7 @@ import { peerAllowKey, type PeerAction } from "./peer-approval-key.ts"; import { DATA_DIR } from "./config.ts"; import * as mdb from "./message-db.ts"; import { workspaceDir } from "./workspace.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"; import { redactSecretsInText } from "./redact.ts"; @@ -246,6 +246,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; /** where NEW tasks run their shell tools; each task pins its own copy * on its first turn (TaskRecord.cwd). Absent = the home folder. */ cwd?: string; @@ -424,6 +426,10 @@ export class Store { if (b.busy || (b.activity !== undefined && b.activity !== "idle")) botsMigrated = true; b.busy = false; b.activity = "idle"; + if (b.cloudBackend !== undefined && b.cloudBackend !== "box" && b.cloudBackend !== "vps") { + delete b.cloudBackend; + botsMigrated = true; + } } for (const b of this.bots) { if (!b.chiefOfStaff) continue; diff --git a/server/testing/setup.ts b/server/testing/setup.ts index e8cc6fe122..197d31d494 100644 --- a/server/testing/setup.ts +++ b/server/testing/setup.ts @@ -12,6 +12,11 @@ import { removeTempDir } from "./cleanup.ts"; const home = mkdtempSync(join(tmpdir(), "omb-test-home-")); process.env.HOME = home; process.env.USERPROFILE = home; +// OMB_DATA_DIR is an intentional production override, but tests must never +// let it escape the throwaway home they are about to delete. +delete process.env.OMB_DATA_DIR; +// Do not let a developer's Hermes global config path leak into per-test homes. +delete process.env.HERMES_HOME; // The companion keeps its paired devices in its own directory, and resolves // it from homedir() the same way — so the redirect above already covers it. // Named explicitly all the same: the device tests delete this directory 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 new file mode 100644 index 0000000000..45127e61ef --- /dev/null +++ b/server/vps-computer.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, it } from "vitest"; + +import { + BASE_IMAGE, + BASE_IMAGE_DIGEST, + BASE_IMAGE_LABEL, + CUA_DRIVER_VERSION, + DRIVER_LABEL, + DISPLAY, + IMAGE_LAYER_LABEL, + IMAGE_LAYER_VERSION, + 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, + vpsContainerRunArgs, + 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, + rebuiltImageId, + containerId = CONTAINER_ID, + privileged = false, + pidMode = "", + ipcMode, + capAdd = ["CAP_SETUID", "CAP_SETGID"], + screenshotValid = true, + screenshotCaptureFails = false, + securityOpt = [], + memory = 4 * 1024 * 1024 * 1024, + restartPolicyName = "no", + cgroupnsMode, + imageLabelsMatch = true, +}: { + image?: boolean; + container?: boolean; + running?: boolean; + managed?: boolean; + mounts?: boolean; + publicPorts?: boolean; + publishAllPorts?: boolean; + deviceRequests?: boolean; + networkMode?: string; + containerImageId?: string; + inspectedImageId?: string; + rebuiltImageId?: string; + containerId?: string; + privileged?: boolean; + pidMode?: string; + ipcMode?: string; + capAdd?: string[]; + screenshotValid?: boolean; + screenshotCaptureFails?: boolean; + securityOpt?: string[]; + 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, imageLabelsMatch, inspectedImageId }; + 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: state.imageLabelsMatch ? { + [MANAGED_LABEL]: "1", + [DRIVER_LABEL]: CUA_DRIVER_VERSION, + [BASE_IMAGE_LABEL]: BASE_IMAGE_DIGEST, + [IMAGE_LAYER_LABEL]: IMAGE_LAYER_VERSION, + } : { [MANAGED_LABEL]: "0" } }, + Id: state.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, + [IMAGE_LAYER_LABEL]: IMAGE_LAYER_VERSION, + }, + }, + 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 ?? argValue("--ipc"), + UTSMode: "", + ShmSize: 512 * 1024 * 1024, + Devices: [], + DeviceRequests: deviceRequests ? [{ Driver: "nvidia" }] : [], + SecurityOpt: securityOpt, + UsernsMode: "", + CgroupnsMode: cgroupnsMode ?? argValue("--cgroupns"), + 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: "" }; + if (args.includes("health_report")) { + return { stdout: JSON.stringify({ schema_version: "1", overall: "ok", checks: [] }), stderr: "" }; + } + if (args.includes("get_desktop_state")) return { stdout: "{}\n", stderr: "" }; + return { stdout: "{}\n", stderr: "" }; + } + 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: "" }; + } + 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}}"]); + const probes = fake.calls.filter( + ({ args }) => + args[2] === "exec" && + (args.at(-1) === "--version" || args.includes("status") || args.includes("health_report") || args.includes("get_desktop_state")), + ); + expect(probes).toHaveLength(4); + expect(probes.every(({ args }) => args.includes(CONTAINER_ID))).toBe(true); + expect(probes.every(({ args }) => !args.includes(fake.name))).toBe(true); + expect(probes.every(({ args }) => args.includes(`DISPLAY=${DISPLAY}`))).toBe(true); + expect(probes.every(({ args }) => args.includes("CUA_DRIVER_RS_TELEMETRY_ENABLED=0"))).toBe(true); + }); + + 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).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.join(" ")).toContain(`--label ${IMAGE_LAYER_LABEL}=${IMAGE_LAYER_VERSION}`); + expect(run).not.toContain("--mount"); + expect(run).not.toContain("-p"); + 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); + 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", + "-e", + "CUA_DRIVER_RS_TELEMETRY_ENABLED=0", + 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..de9ec2836a --- /dev/null +++ b/server/vps-computer.ts @@ -0,0 +1,762 @@ +// 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 { spawn } from "node:child_process"; + +import { + BASE_IMAGE, + BASE_IMAGE_DIGEST, + BASE_IMAGE_LABEL, + CUA_DRIVER_VERSION, + CUA_EXECUTABLE, + CUA_SOCKET, + DISPLAY, + DRIVER_LABEL, + IMAGE as CUA_IMAGE, + IMAGE_LAYER_LABEL, + IMAGE_LAYER_VERSION, + MANAGED_LABEL, + managedImageDockerfile, +} from "./container-computer.ts"; +import { isValidSshAlias, vpsSshAlias, type AppConfig } from "./config.ts"; +import { augmentedPath } from "./env-path.ts"; +import { SPAWNED_PROXIES } from "./proxy-paths.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 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; +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; + desktop_error: string | null; + 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]; +} + +export 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; + let timedOut = false; + let killTimer: ReturnType | undefined; + let timeout: ReturnType; + const settle = (finish: () => void) => { + if (settled) return; + settled = true; + 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?.(); + + 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.stdin.on("error", (error) => { + if (timedOut) return; + settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error.message}`))); + }); + child.on("error", (error) => { + settle(() => reject(new Error(`Docker-over-SSH could not start: ${error.message}`))); + }); + child.on("close", (code, signal) => { + 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"}`)); + }); + }); + try { + child.stdin.end(options.input); + } catch (error) { + settle(() => reject(new Error(`Docker-over-SSH stdin failed: ${error instanceof Error ? error.message : String(error)}`))); + } + }); +} + +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, + desktop_error: null, + 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 && + labels?.[IMAGE_LAYER_LABEL] === IMAGE_LAYER_VERSION + ); +} + +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)); + const restartPolicy = config.RestartPolicy?.Name; + 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 && + (restartPolicy === undefined || restartPolicy === "" || restartPolicy === "no") + ); +} + +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.desktop_error) return `The VPS Cua desktop failed to start: ${status.desktop_error}`; + 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 && status.container_id) { + const readinessPath = "/tmp/openmausbot-vps-readiness.png"; + const exec = [ + "exec", + "-u", + "cua", + "-e", + "HOME=/home/cua", + "-e", + `DISPLAY=${DISPLAY}`, + "-e", + "CUA_DRIVER_INSTALL_CHANNEL=python_package", + "-e", + "CUA_DRIVER_RS_TELEMETRY_ENABLED=0", + status.container_id, + 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]); + const health = await run( + [...exec, "call", "health_report", "{}", "--socket", CUA_SOCKET], + 15_000, + ); + const report = JSON.parse(health.stdout) as { + schema_version?: string; + overall?: string; + checks?: unknown[]; + }; + if ( + report.schema_version !== "1" || + !Array.isArray(report.checks) || + (report.overall !== "ok" && report.overall !== "degraded") + ) { + throw new Error(`Cua health report is ${report.overall ?? "invalid"}`); + } + await run( + [ + ...exec, + "call", + "get_desktop_state", + "{}", + "--socket", + CUA_SOCKET, + "--screenshot-out-file", + readinessPath, + ], + 20_000, + ); + const captured = await run( + [ + "exec", + "-u", + "cua", + "-e", + "HOME=/home/cua", + "-e", + `DISPLAY=${DISPLAY}`, + "-e", + "CUA_DRIVER_RS_TELEMETRY_ENABLED=0", + status.container_id, + "base64", + "-w0", + readinessPath, + ], + 20_000, + ); + if (!validImage(Buffer.from(captured.stdout.trim(), "base64"))) { + throw new Error("Cua Driver returned an incomplete VPS readiness screenshot"); + } + status.desktopReady = true; + } catch (error) { + status.desktopReady = false; + status.desktop_error = error instanceof Error ? error.message.slice(0, 320) : null; + } finally { + await run(["exec", "-u", "cua", status.container_id, "rm", "-f", readinessPath], 10_000).catch(() => {}); + } + } + } 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}`, + "--label", + `${IMAGE_LAYER_LABEL}=${IMAGE_LAYER_VERSION}`, + "--memory", + "4g", + "--memory-swap", + "4g", + "--cpus", + "2", + "--pids-limit", + String(PIDS_LIMIT), + "--network", + "bridge", + "--ipc", + "private", + "--cgroupns", + "private", + "--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") { + 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 { + 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; +} + +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=${DISPLAY}`, + "-e", + "CUA_DRIVER_INSTALL_CHANNEL=python_package", + "-e", + "CUA_DRIVER_RS_TELEMETRY_ENABLED=0", + 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: [SPAWNED_PROXIES.vpsContainerMcp, 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=${DISPLAY}`, + "-e", + "CUA_DRIVER_INSTALL_CHANNEL=python_package", + "-e", + "CUA_DRIVER_RS_TELEMETRY_ENABLED=0", + 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..c7abdf66ba --- /dev/null +++ b/server/vps-container-mcp.test.ts @@ -0,0 +1,72 @@ +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 }))); +}); + +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.on("error", () => {}); + 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-")); + 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","data":"${"x".repeat(2 * 1024 * 1024)}"}\n`; + const result = await runBridge(bin, 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 -e CUA_DRIVER_RS_TELEMETRY_ENABLED=0 ${vpsContainerName("bridge-test")} ` + + "/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 new file mode 100644 index 0000000000..8eb51153da --- /dev/null +++ b/server/vps-container-mcp.ts @@ -0,0 +1,46 @@ +// 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"], +}); + +// 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.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`); + // 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) { + process.on(signal, () => child.kill(signal)); +} diff --git a/src/components/ApiKeys.tsx b/src/components/ApiKeys.tsx index 303b92359c..834bdd1225 100644 --- a/src/components/ApiKeys.tsx +++ b/src/components/ApiKeys.tsx @@ -209,3 +209,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 88ab28e1ee..de50208587 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -38,6 +38,8 @@ type Phase = | "ready" | "vm" | "vm-unavailable" + | "vps-unconfigured" + | "vps-stopped" | "local" | "local-unavailable" | "off" @@ -80,7 +82,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 +96,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 +113,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,7 +121,7 @@ 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 @@ -166,6 +172,61 @@ export function ComputerPanel({ bot }: { bot: Bot }) { return; } if (bot.computer !== "cloud" && !capabilitiesReady) return; + if (cloudBackend === "vps") { + const autoLocal = bot.computer !== "cloud" && capabilitiesReady && localAvailable && computerToolSupported; + if (!vpsSupported) { + if (autoLocal) setPhase("local"); + else { + 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; + } + api(`/api/bots/${bot.id}/computer`) + .then((status) => { + if (!alive) return; + 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 ?? null); + 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 +255,7 @@ 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 +350,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 === "join" && result.joinUrl) window.open(result.joinUrl, "_blank", "noopener"); + 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 +379,16 @@ export function ComputerPanel({ bot }: { bot: Bot }) { dispatch({ type: "toggleAppSettings", open: true }); }; + const openConnectionSettings = () => { + dispatch({ type: "toggleAppSettings", open: true, section: "connections" }); + }; + const emptyState = { 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 +426,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 +467,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 +505,38 @@ export function ComputerPanel({ bot }: { bot: Bot }) { /> )} + {phase === "vps-unconfigured" && ( +
+
+ Configure the VPS SSH alias in App Settings → Connections. Auto only reuses an existing ready container. +
+ +
+ )} {/* 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 55462b09ff..87db5b82d3 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { Coins, KeyRound, Monitor, Smartphone, Terminal, 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 { EnginesSettings } from "./EnginesSettings"; import { LocalComputerSection } from "./LocalComputerSection"; @@ -218,6 +218,7 @@ export function SettingsModal() { ) : null} +
Self-host connected apps diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 1c6418bf88..6e7cb4ae3f 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -325,8 +325,9 @@ export function SettingsPanel({ bot }: { bot: Bot }) { | "name" | "title" | "description" - | "notifications" - | "computer" + | "notifications" + | "computer" + | "cloudBackend" | "color" | "mascotExpression" | "autoApprove" @@ -344,6 +345,7 @@ export function SettingsPanel({ bot }: { bot: Bot }) { const engine = state.instances.find((instance) => instance.instanceId === bot.modelSelection.instanceId); const canCoordinate = engine?.capabilities?.agentsMcp === true; const canUseConnectedApps = engine?.capabilities?.composioMcp === true; + const canUseVps = engine?.capabilities?.computerMcp === true && engine.driverKind !== "boxAgent"; const connectedAppsConfigured = state.config?.composio?.configured === true; const connectedAppsEnabled = bot.composio !== false; const currentChief = state.bots.find((candidate) => candidate.chiefOfStaff); @@ -645,22 +647,58 @@ export function SettingsPanel({ bot }: { bot: Bot }) { Where this bot's computer runs{bot.computer ? "" : " (currently: auto)"}
- {(["cloud", "local", "off"] as const).map((mode, i) => ( + {([ + ["auto", "Auto"], + ["cloud", "Cloud"], + ["vm", "Local VM"], + ["local", "This computer"], + ["off", "Off"], + ] as const).map(([mode, label], i) => ( ))}
+ {(!bot.computer || bot.computer === "cloud") && ( +
+
Cloud backend
+
+ {bot.cloudBackend === "vps" + ? "Auto reuses a ready VPS container only. Choose Cloud to provision or start it." + : "Box is the default hosted computer. Self-hosted VPS uses your SSH-configured Linux Docker host."} +
+
+ {(["box", "vps"] as const).map((backend, i) => ( + + ))} +
+
+ )} diff --git a/src/state/store.tsx b/src/state/store.tsx index fd2b724cd3..6e14aef065 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -13,7 +13,7 @@ import { useState, type ReactNode, } from "react"; -import type { EffortLevel } from "../../server/contracts.ts"; +import type { CloudBackend, EffortLevel } from "../../server/contracts.ts"; import type { MausColor, MausMotion } from "@/lib/mascot"; import type { Routine, RoutineInput, RoutineRun } from "@/lib/routines"; import type { WebhookAttempt, WebhookIngressStatus, WebhookTrigger } from "@/lib/webhooks"; @@ -153,6 +153,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 means Box. */ + cloudBackend?: CloudBackend; /** where new tasks run their shell tools; absent = the private bot workspace */ cwd?: string; /** auto mode: the bot approves its own tool permissions */ @@ -210,6 +212,7 @@ export interface ConfigStatus { xai?: { configured: boolean }; composio: { configured: boolean; mode?: "managed" | "self-hosted" | "unavailable" }; box: { configured: boolean }; + vps: { configured: boolean; sshAlias: string }; opencodeGo?: { configured: boolean }; /** 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 @@ -392,9 +395,10 @@ export type Action = | "name" | "title" | "description" - | "notifications" - | "computer" - | "color" + | "notifications" + | "computer" + | "cloudBackend" + | "color" | "mascotExpression" | "autoApprove" | "speakReplies" @@ -1097,8 +1101,9 @@ export function StoreProvider({ children }: { children: ReactNode }) { title: source.title, description: source.description, notifications: source.notifications, - modelSelection: source.modelSelection, - ...(source.computer ? { computer: source.computer } : {}), + 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 } }), @@ -1201,7 +1206,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { patch, timer: setTimeout(() => { timers.delete(action.botId); - api(`/api/bots/${action.botId}`, { method: "PATCH", body: JSON.stringify(patch) }).catch(showError); + const requestPatch = + Object.prototype.hasOwnProperty.call(patch, "computer") && patch.computer === undefined + ? { ...patch, computer: null } + : patch; + api(`/api/bots/${action.botId}`, { method: "PATCH", body: JSON.stringify(requestPatch) }).catch(showError); }, 400), }); break; @@ -1409,6 +1418,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, },