From 207d9a82f708ebcec7a10a55a3a30589c164bb5a Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Sat, 22 Aug 2026 08:59:55 +0100 Subject: [PATCH 1/6] Add support for user-managed Existing VMs --- companion/src/wire.ts | 8 +- companion/test/proxy-response.test.ts | 9 +- companion/test/proxy.test.ts | 28 +- companion/test/wire.test.ts | 20 +- scripts/bundle-server.mjs | 1 + server/config.test.ts | 14 + server/config.ts | 23 +- server/existing-vm-mcp.ts | 26 + server/existing-vm.test.ts | 141 ++++++ server/existing-vm.ts | 599 ++++++++++++++++++++++++ server/index.test.ts | 67 ++- server/index.ts | 166 +++++-- server/proxy-paths.ts | 1 + src/components/ComputerPanel.tsx | 109 +++-- src/components/LocalComputerSection.tsx | 300 ++++++++++-- src/state/store.test.ts | 18 +- src/state/store.tsx | 4 +- 17 files changed, 1407 insertions(+), 127 deletions(-) create mode 100644 server/existing-vm-mcp.ts create mode 100644 server/existing-vm.test.ts create mode 100644 server/existing-vm.ts diff --git a/companion/src/wire.ts b/companion/src/wire.ts index 7cc5272e2..f5be96e9c 100644 --- a/companion/src/wire.ts +++ b/companion/src/wire.ts @@ -12,10 +12,10 @@ // either way. // // `sshAlias` is the same story with a different payload: the harness's config -// status echoes the self-hosted VPS alias — a label naming one of the user's -// servers — inside `vps`, on both GET /api/config and the `config` SSE frame. -// The phone only ever renders configured-or-not, so it gets exactly that: -// `{configured: true}` survives, the host label does not. +// status echoes self-hosted VPS and user-managed Local VM aliases — labels +// naming one of the user's servers — inside `vps` or `localVm`, on both GET +// /api/config and the `config` SSE frame. The phone only ever renders +// configured-or-not, so it gets the surrounding status without the host label. /** Keys that are the harness's business, never a device's. */ const WITHHELD_KEYS = new Set(["resumeCursors", "sshAlias"]); diff --git a/companion/test/proxy-response.test.ts b/companion/test/proxy-response.test.ts index 8711894ff..54b8b94fd 100644 --- a/companion/test/proxy-response.test.ts +++ b/companion/test/proxy-response.test.ts @@ -144,12 +144,17 @@ describe("preparing a harness response for a device", () => { it("scrubs a well-formed body and re-frames it", async () => { respond = (res) => { res.writeHead(200, { "content-type": "application/json", "transfer-encoding": "chunked" }); - res.end(JSON.stringify({ bots: [{ id: "b1" }], resumeCursors: { agent: "cursor-value" } })); + res.end(JSON.stringify({ + bots: [{ id: "b1" }], + resumeCursors: { agent: "cursor-value" }, + localVm: { source: "existing", configured: true, sshAlias: "personal-linux-vm" }, + })); }; const { status, text } = await device(); expect(status).toBe(200); - expect(JSON.parse(text)).toEqual({ bots: [{ id: "b1" }] }); + expect(JSON.parse(text)).toEqual({ bots: [{ id: "b1" }], localVm: { source: "existing", configured: true } }); expect(text).not.toContain("cursor-value"); + expect(text).not.toContain("personal-linux-vm"); }); }); diff --git a/companion/test/proxy.test.ts b/companion/test/proxy.test.ts index 56de9be27..c6e0f6627 100644 --- a/companion/test/proxy.test.ts +++ b/companion/test/proxy.test.ts @@ -130,7 +130,10 @@ beforeAll(async () => { mkdirSync(join(home, ".openmausbot"), { recursive: true }); writeFileSync( join(home, ".openmausbot", "config.json"), - JSON.stringify({ instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" } } }), + JSON.stringify({ + instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" } }, + localVm: { source: "existing", sshAlias: "personal-linux-vm" }, + }), ); harness = spawn(process.execPath, [join(ROOT, "server", "index.ts")], { @@ -257,6 +260,14 @@ describe("the sidecar in front of an unmodified harness", () => { expect((await device("PATCH", `/api/groups/not-a-room`, { body: { unread: false } })).status).toBe(404); }); + it("scrubs the Existing VM SSH alias from a paired device's config response", async () => { + const response = await device("GET", "/api/config"); + expect(response.status).toBe(200); + expect(response.body.localVm).toMatchObject({ source: "existing" }); + expect(response.body.localVm).not.toHaveProperty("sshAlias"); + expect(JSON.stringify(response.body)).not.toContain("personal-linux-vm"); + }); + it("lets a device answer an approval, and manage its own chats", async () => { // The approval path is the product: a card raised on the computer, // answered on the phone, and the bot carries on. What is checked here is @@ -359,6 +370,21 @@ describe("the sidecar in front of an unmodified harness", () => { expect(frame).not.toBeNull(); expect(frame).toMatch(/^id: [0-9a-f]+:\d+\n/); expect(frame).not.toContain("resumeCursors"); + + // Change config through the loopback harness API, then observe the + // resulting config SSE frame through the paired-device proxy. This + // exercises the real HTTP and SSE scrub paths with an Existing VM alias. + await fetch(`${HARNESS}/api/config`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ localVm: { source: "existing", sshAlias: "another-private-vm" } }), + }); + const configFrame = await nextEvent((e) => e.includes('"kind":"config"')); + expect(configFrame).not.toBeNull(); + expect(configFrame).not.toContain("another-private-vm"); + const configData = JSON.parse(configFrame!.split("\n").find((line) => line.startsWith("data:"))!.slice(5)); + expect(configData.localVm).toMatchObject({ source: "existing" }); + expect(configData.localVm).not.toHaveProperty("sshAlias"); } finally { controller.abort(); } diff --git a/companion/test/wire.test.ts b/companion/test/wire.test.ts index a920780b2..59fc2178a 100644 --- a/companion/test/wire.test.ts +++ b/companion/test/wire.test.ts @@ -30,19 +30,24 @@ describe("scrub", () => { }); }); - it("withholds the VPS host label but keeps the configured signal", () => { - // GET /api/config and the `config` SSE frame echo the VPS SSH alias — a - // label naming one of the user's servers. The phone renders - // configured-or-not, so that is all it may receive. + it("withholds every SSH host label but keeps configured signals", () => { + // GET /api/config and the `config` SSE frame echo SSH aliases for both + // self-hosted VPS and user-managed Local VM connections. The phone only + // renders configured-or-not, so that is all it may receive. const status = { box: { configured: false }, vps: { configured: true, sshAlias: "prod-vps" }, + localVm: { source: "existing", sshAlias: "personal-linux-vm", configured: true }, }; const cleaned = scrub(status); expect(JSON.stringify(cleaned)).not.toContain("sshAlias"); expect(JSON.stringify(cleaned)).not.toContain("prod-vps"); - expect(cleaned).toEqual({ box: { configured: false }, vps: { configured: true } }); + expect(cleaned).toEqual({ + box: { configured: false }, + vps: { configured: true }, + localVm: { source: "existing", configured: true }, + }); }); it("leaves values it does not own alone", () => { @@ -85,13 +90,14 @@ describe("createSseScrubber", () => { }); it("scrubs the payload but never the id: line", () => { - const frame = 'id: abc123:7\ndata: {"kind":"bot","bot":{"id":"b1","resumeCursors":{"g":"s"}}}\n\n'; + const frame = 'id: abc123:7\ndata: {"kind":"config","localVm":{"source":"existing","sshAlias":"personal-linux-vm"},"bot":{"id":"b1","resumeCursors":{"g":"s"}}}\n\n'; const out = createSseScrubber()(frame); expect(out).toContain("id: abc123:7\n"); expect(out).not.toContain("resumeCursors"); + expect(out).not.toContain("personal-linux-vm"); const data = JSON.parse(out.split("\n").find((l) => l.startsWith("data:"))!.slice(5)); - expect(data).toEqual({ kind: "bot", bot: { id: "b1" } }); + expect(data).toEqual({ kind: "config", localVm: { source: "existing" }, bot: { id: "b1" } }); }); it("emits an event as soon as it is complete, not when the chunk ends", () => { diff --git a/scripts/bundle-server.mjs b/scripts/bundle-server.mjs index ef9c43443..eab55ae76 100644 --- a/scripts/bundle-server.mjs +++ b/scripts/bundle-server.mjs @@ -37,6 +37,7 @@ const ENTRY_POINTS = [ "computer-proxy.ts", "container-mcp.ts", "vps-container-mcp.ts", + "existing-vm-mcp.ts", "permission-proxy.ts", "connector-proxy.ts", "drivers/agents-proxy.ts", diff --git a/server/config.test.ts b/server/config.test.ts index 0d40215d9..0d132fae6 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -10,6 +10,8 @@ import { loadConfig, localVmMaxInstances, localVmMode, + localVmSource, + localVmSshAlias, parseConfigPatch, parseStoredConfig, roomTurnTimeoutMinutes, @@ -72,6 +74,8 @@ describe("configuration boundaries", () => { it("preserves shared Local VM behavior by default and accepts bounded per-bot mode", () => { expect(localVmMode({})).toBe("shared"); expect(localVmMaxInstances({})).toBe(2); + expect(localVmSource({})).toBe("managed"); + expect(localVmSshAlias({})).toBeNull(); expect(parseConfigPatch({ localVm: { mode: "per-bot", maxInstances: 4 } })).toEqual({ localVm: { mode: "per-bot", maxInstances: 4 }, }); @@ -79,6 +83,16 @@ describe("configuration boundaries", () => { expect(localVmMaxInstances({ localVm: { maxInstances: 3 } })).toBe(3); }); + it("accepts an Existing VM source without persisting anything except its SSH alias", () => { + expect( + parseConfigPatch({ localVm: { source: "existing", sshAlias: "linux-vm" } }), + ).toEqual({ localVm: { source: "existing", sshAlias: "linux-vm" } }); + expect(localVmSource({ localVm: { source: "existing" } })).toBe("existing"); + expect(localVmSshAlias({ localVm: { source: "existing", sshAlias: "linux-vm" } })).toBe("linux-vm"); + expect(() => parseConfigPatch({ localVm: { sshAlias: "linux-vm; id" } })).toThrow("localVm.sshAlias"); + expect(() => parseConfigPatch({ localVm: { sshAlias: "-linux-vm" } })).toThrow("localVm.sshAlias"); + }); + it.each([0, 1.5, 5, "2", null])("rejects an invalid per-bot VM limit: %j", (maxInstances) => { expect(() => parseConfigPatch({ localVm: { maxInstances } })).toThrow("localVm.maxInstances"); }); diff --git a/server/config.ts b/server/config.ts index d20beddcf..d300c63ac 100644 --- a/server/config.ts +++ b/server/config.ts @@ -52,6 +52,7 @@ const roomConfigSchema = z.object({ .max(MAX_ROOM_TURN_TIMEOUT_MINUTES), }); const localVmConfigSchema = z.object({ + source: z.enum(["managed", "existing"]).optional(), mode: z.enum(["shared", "per-bot"]).optional(), maxInstances: z .number() @@ -59,6 +60,9 @@ const localVmConfigSchema = z.object({ .min(MIN_LOCAL_VM_MAX_INSTANCES) .max(MAX_LOCAL_VM_MAX_INSTANCES) .optional(), + 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), @@ -102,9 +106,14 @@ export interface AppConfig { imageGen?: { key?: string }; profile?: { name?: string; email?: string }; rooms?: { turnTimeoutMinutes: number }; - /** Shared preserves the historical singleton. Per-bot gives every bot a - * separate container, durable workspace, viewer and lease. */ - localVm?: { mode?: "shared" | "per-bot"; maxInstances?: number }; + /** Managed preserves the historical container-backed Local VM. Existing is + * one user-owned Linux VM reached through a validated SSH alias. */ + localVm?: { + source?: "managed" | "existing"; + mode?: "shared" | "per-bot"; + maxInstances?: number; + sshAlias?: string; + }; instances?: InstanceConfigMap; } export type ConfigPatch = z.output; @@ -139,6 +148,14 @@ export function localVmMaxInstances(cfg: AppConfig): number { return cfg.localVm?.maxInstances ?? DEFAULT_LOCAL_VM_MAX_INSTANCES; } +export function localVmSource(cfg: AppConfig): "managed" | "existing" { + return cfg.localVm?.source === "existing" ? "existing" : "managed"; +} + +export function localVmSshAlias(cfg: AppConfig): string | null { + return isValidSshAlias(cfg.localVm?.sshAlias) ? cfg.localVm.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"); diff --git a/server/existing-vm-mcp.ts b/server/existing-vm-mcp.ts new file mode 100644 index 000000000..3cd784aba --- /dev/null +++ b/server/existing-vm-mcp.ts @@ -0,0 +1,26 @@ +// Transparent stdio bridge to the official Cua MCP server in a user-managed +// Linux VM. The SSH command is fixed and the alias is the only user value. +import { runMcpBridge, type BridgeOptions } from "./mcp-bridge.ts"; +import { existingVmLivenessArgs, existingVmMcpArgs } from "./existing-vm.ts"; + +const [alias] = process.argv.slice(2); +try { + const args = existingVmMcpArgs(alias ?? ""); + const liveness = existingVmLivenessArgs(alias ?? ""); + const controlUrl = process.env.OMB_CONTROL_URL ?? ""; + const controlToken = process.env.OMB_CONTROL_TOKEN ?? ""; + const options: BridgeOptions = { + command: "ssh", + args, + label: "Existing VM Cua Driver", + // Probe SSH itself, not the desktop. A slow or busy CUA call is traffic + // on this bridge; only a dead SSH peer should terminate the transport. + liveness: { command: "ssh", args: liveness }, + }; + if (controlUrl && controlToken) options.gate = { url: controlUrl, token: controlToken }; + + runMcpBridge(options); +} catch { + process.stderr.write("invalid Existing VM SSH connection\n"); + process.exit(2); +} diff --git a/server/existing-vm.test.ts b/server/existing-vm.test.ts new file mode 100644 index 000000000..5af4f4c0c --- /dev/null +++ b/server/existing-vm.test.ts @@ -0,0 +1,141 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { CUA_DRIVER_VERSION } from "./container-computer.ts"; +import { + existingVmComputerMcp, + existingVmLivenessArgs, + existingVmMcpArgs, + existingVmScreenshot, + existingVmStatus, + type ExistingVmOptions, +} from "./existing-vm.ts"; +import type { AppConfig } from "./config.ts"; + +const FIXED_SSH_OPTIONS = [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=5", + "-o", + "ServerAliveCountMax=2", +]; + +const fakeSshSource = String.raw`import { Buffer } from "node:buffer"; + +const args = process.argv.slice(2); +const alias = args[9]; +const remote = args.slice(10).join(" "); + +if (remote === "uname -s") { + process.stdout.write(alias === "vm-windows" ? "Windows_NT\n" : "Linux\n"); + process.exit(0); +} +if (remote === "cua-driver --version") { + process.stdout.write(alias === "vm-bad-version" ? "cua-driver 0.19.0\n" : "cua-driver 0.20.0\n"); + process.exit(0); +} +if (remote !== "cua-driver mcp") process.exit(2); + +const image = Buffer.alloc(512); +image.set(Buffer.from([0x89, 0x50, 0x4e, 0x47]), 0); +image.set(Buffer.from("IEND"), image.length - 4); +const tools = ["get_desktop_state", "list_apps", "click", "type_text", "press_key", "scroll"]; +if (alias === "vm-missing-tool") tools.pop(); +let pending = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + pending += chunk; + let newline; + while ((newline = pending.indexOf("\n")) !== -1) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (!line.trim()) continue; + const message = JSON.parse(line); + if (message.id === undefined) continue; + let result; + if (message.method === "initialize") result = { protocolVersion: "2024-11-05" }; + else if (message.method === "tools/list") result = { tools: tools.map((name) => ({ name })) }; + else if (message.method === "tools/call" && alias === "vm-no-image") result = { content: [{ type: "text", text: "no image" }] }; + else if (message.method === "tools/call") result = { content: [{ type: "image", data: image.toString("base64"), mimeType: "image/png" }] }; + else result = {}; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\n"); + } +}); +`; + +describe("Existing VM transport", () => { + let temp: string; + let fakeSsh: string; + let options: ExistingVmOptions; + + beforeAll(() => { + temp = mkdtempSync(join(tmpdir(), "openmausbot-existing-vm-")); + fakeSsh = join(temp, "fake-ssh.mjs"); + writeFileSync(fakeSsh, fakeSshSource, "utf8"); + options = { sshCommand: process.execPath, sshCommandPrefix: [fakeSsh] }; + }); + + afterAll(() => rmSync(temp, { recursive: true, force: true })); + + const config = (sshAlias: string): AppConfig => ({ localVm: { source: "existing", sshAlias } }); + + it("uses a fixed SSH command and rejects shell-like aliases", () => { + expect(existingVmMcpArgs("my-vm")).toEqual([...FIXED_SSH_OPTIONS, "my-vm", "cua-driver", "mcp"]); + expect(existingVmLivenessArgs("my-vm")).toEqual([...FIXED_SSH_OPTIONS, "my-vm", "true"]); + expect(() => existingVmMcpArgs("vm; reboot")).toThrow("invalid Existing VM SSH config alias"); + expect(() => existingVmLivenessArgs("$(id)")).toThrow("invalid Existing VM SSH config alias"); + }); + + it("requires Linux, the pinned driver, MCP tools, and a complete desktop image", async () => { + const status = await existingVmStatus(config("vm-good"), options); + + expect(status).toMatchObject({ + source: "existing", + configured: true, + ssh: "connected", + os: "linux", + driver: "compatible", + mcp: "ready", + desktopReady: true, + ready: true, + driver_version: CUA_DRIVER_VERSION, + viewer_url: "", + watch_only: true, + }); + expect(status.tools).toEqual(expect.arrayContaining(["get_desktop_state", "click", "type_text", "press_key", "scroll"])); + expect(status).not.toHaveProperty("mode"); + expect(status).not.toHaveProperty("max_instances"); + + const frame = await existingVmScreenshot(config("vm-good"), options); + expect(frame.format).toBe("png"); + expect(frame.png).toBeTruthy(); + }); + + it.each([ + ["vm-windows", "unsupported", "remote-os"], + ["vm-bad-version", "incompatible", "cua-version"], + ["vm-missing-tool", "failed", "mcp"], + ["vm-no-image", "failed", "desktop"], + ] as const)("reports the failing readiness stage for %s", async (alias, stage, errorCode) => { + const status = await existingVmStatus(config(alias), options); + expect(status.ready).toBe(false); + if (stage === "unsupported") expect(status.os).toBe(stage); + if (stage === "incompatible") expect(status.driver).toBe(stage); + if (stage === "failed") expect(status.mcp).toBe(stage); + expect(status.errorCode).toBe(errorCode); + }); + + it("does not expose a viewer or a managed lifecycle through the MCP spawn contract", () => { + const mcp = existingVmComputerMcp(config("my-vm")); + expect(mcp.command).toBe(process.execPath); + expect(mcp.args.at(-1)).toBe("my-vm"); + expect(mcp.env).toEqual({ ELECTRON_RUN_AS_NODE: "1" }); + }); +}); diff --git a/server/existing-vm.ts b/server/existing-vm.ts new file mode 100644 index 000000000..c4d2e8c06 --- /dev/null +++ b/server/existing-vm.ts @@ -0,0 +1,599 @@ +// User-managed Existing VM transport and readiness. +// +// This is deliberately not a LocalVmTarget. LocalVmTarget represents a +// container OpenMausBot owns; an Existing VM has no OpenMausBot lifecycle, +// filesystem, image, or isolation contract. The only persisted connection +// detail is a validated SSH config alias. +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +import { CUA_DRIVER_VERSION, wholeScreenshot } from "./container-computer.ts"; +import { isValidSshAlias, localVmSshAlias, type AppConfig } from "./config.ts"; +import { augmentedPath } from "./env-path.ts"; +import { SPAWNED_PROXIES } from "./proxy-paths.ts"; + +export const EXISTING_VM_LEASE_KEY = "existing-vm"; +export const EXISTING_VM_REQUIRED_TOOLS = [ + "get_desktop_state", + "list_apps", + "click", + "type_text", + "press_key", + "scroll", +] as const; + +const SSH_COMMAND = "ssh"; +const SSH_OPTIONS = [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=5", + "-o", + "ServerAliveCountMax=2", +] as const; +const PROBE_TIMEOUT_MS = 10_000; +const MCP_REQUEST_TIMEOUT_MS = 15_000; +const SCREENSHOT_TIMEOUT_MS = 20_000; +const STATUS_CACHE_TTL_MS = 10_000; +const MAX_PROBE_OUTPUT = 64 * 1024; +const MAX_MCP_LINE_CHARS = 16 * 1024 * 1024; +const MCP_CLOSE_GRACE_MS = 1_500; + +export type ExistingVmErrorCode = + | "ssh-unreachable" + | "remote-os" + | "cua-missing" + | "cua-version" + | "mcp" + | "desktop" + | "timeout"; + +export class ExistingVmError extends Error { + readonly code: ExistingVmErrorCode; + + constructor(code: ExistingVmErrorCode, message: string) { + super(message); + this.name = "ExistingVmError"; + this.code = code; + } +} + +export type ExistingVmStatus = { + source: "existing"; + configured: boolean; + sshAlias: string | null; + ssh: "not-configured" | "connected" | "unreachable"; + os: "unknown" | "linux" | "unsupported"; + driver: "unknown" | "compatible" | "missing" | "incompatible"; + mcp: "unknown" | "ready" | "failed"; + tools: string[]; + desktopReady: boolean; + ready: boolean; + problem: string | null; + errorCode: ExistingVmErrorCode | "not-configured" | null; + driver_version: string; + viewer_url: ""; + watch_only: true; +}; + +export type ExistingVmOptions = { + /** Test-only executable override; user config never supplies this. */ + sshCommand?: string; + /** Test-only argv prefix for running a fake SSH executable. */ + sshCommandPrefix?: string[]; + /** Bypass the short status cache for an explicit user re-check. */ + force?: boolean; +}; + +type CommandResult = { stdout: string; stderr: string }; + +type CommandFailure = Error & { + code?: string; + stderr?: string; +}; + +function commandFailure(message: string, code?: string, stderr?: string): CommandFailure { + const error = new Error(message) as CommandFailure; + if (code) error.code = code; + if (stderr) error.stderr = stderr; + return error; +} + +/** The only SSH argv used by the Existing VM path. No user-provided options + * or remote command fragments can reach this function. */ +function sshArgs(alias: string, remote: readonly string[]): string[] { + if (!isValidSshAlias(alias)) throw new Error("invalid Existing VM SSH config alias"); + return [...SSH_OPTIONS, alias, ...remote]; +} + +function spawnedSshArgs(alias: string, remote: readonly string[], options: ExistingVmOptions): string[] { + return [...(options.sshCommandPrefix ?? []), ...sshArgs(alias, remote)]; +} + +export function existingVmMcpArgs(alias: string): string[] { + return sshArgs(alias, ["cua-driver", "mcp"]); +} + +export function existingVmLivenessArgs(alias: string): string[] { + return sshArgs(alias, ["true"]); +} + +function collectBounded(target: { value: string; size: number }, chunk: string): void { + target.size += Buffer.byteLength(chunk, "utf8"); + if (target.size > MAX_PROBE_OUTPUT) throw new Error("SSH probe output exceeded its limit"); + target.value += chunk; +} + +function runSshCommand( + alias: string, + remote: readonly string[], + options: ExistingVmOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const { sshCommand = SSH_COMMAND } = options; + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(sshCommand, spawnedSshArgs(alias, remote, options), { + shell: false, + windowsHide: true, + env: { ...process.env, PATH: augmentedPath() }, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + reject(error); + return; + } + + const stdout = { value: "", size: 0 }; + const stderr = { value: "", size: 0 }; + let settled = false; + let timedOut = false; + let outputExceeded = false; + let killTimer: ReturnType | undefined; + const timeout = setTimeout(() => { + if (settled) return; + timedOut = true; + try { + child.kill("SIGTERM"); + } catch {} + killTimer = setTimeout(() => { + if (settled) return; + try { + child.kill("SIGKILL"); + } catch {} + settleReject(new ExistingVmError("timeout", "SSH command timed out")); + }, MCP_CLOSE_GRACE_MS); + killTimer.unref?.(); + }, PROBE_TIMEOUT_MS); + timeout.unref?.(); + + const clearTimers = () => { + clearTimeout(timeout); + if (killTimer) clearTimeout(killTimer); + }; + const settle = (finish: () => void) => { + if (settled) return; + settled = true; + clearTimers(); + finish(); + }; + const settleReject = (error: Error) => settle(() => reject(error)); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + if (settled || outputExceeded) return; + try { + collectBounded(stdout, chunk); + } catch { + outputExceeded = true; + settleReject(new ExistingVmError("mcp", "SSH probe output exceeded its limit")); + try { + child.kill("SIGKILL"); + } catch {} + } + }); + child.stderr.on("data", (chunk: string) => { + if (settled || outputExceeded) return; + try { + collectBounded(stderr, chunk); + } catch { + outputExceeded = true; + settleReject(new ExistingVmError("mcp", "SSH probe output exceeded its limit")); + try { + child.kill("SIGKILL"); + } catch {} + } + }); + child.stdin.on("error", () => {}); + child.on("error", (error) => { + settleReject(commandFailure(`SSH could not start: ${error.message}`, (error as NodeJS.ErrnoException).code)); + }); + child.on("close", (code, signal) => { + if (settled) return; + if (timedOut) { + settleReject(new ExistingVmError("timeout", "SSH command timed out")); + return; + } + if (code !== 0) { + const detail = stderr.value.trim().slice(-800); + settleReject(commandFailure(detail || `SSH exited ${code ?? signal ?? "without a status"}`, "SSH_EXIT", stderr.value)); + return; + } + settle(() => resolve({ stdout: stdout.value, stderr: stderr.value })); + }); + try { + child.stdin.end(); + } catch (error) { + settleReject(commandFailure(`SSH stdin failed: ${error instanceof Error ? error.message : String(error)}`)); + } + }); +} + +type JsonRpcResponse = { + id?: number; + result?: unknown; + error?: { message?: unknown }; +}; + +class ExistingVmMcpClient { + private readonly child: ChildProcessWithoutNullStreams; + private readonly pending = new Map void; reject: (error: Error) => void; timer: ReturnType }>(); + private readonly exited: Promise; + private buffer = ""; + private nextId = 1; + private closed = false; + private stderr = ""; + private closePromise: Promise | null = null; + + constructor(alias: string, options: ExistingVmOptions = {}) { + const { sshCommand = SSH_COMMAND } = options; + this.child = spawn(sshCommand, spawnedSshArgs(alias, ["cua-driver", "mcp"], options), { + shell: false, + windowsHide: true, + env: { ...process.env, PATH: augmentedPath() }, + stdio: ["pipe", "pipe", "pipe"], + }); + this.exited = new Promise((resolve) => this.child.once("close", () => resolve())); + this.child.stdin.on("error", () => {}); + this.child.stdout.setEncoding("utf8"); + this.child.stderr.setEncoding("utf8"); + this.child.stdout.on("data", (chunk: string) => this.read(chunk)); + this.child.stderr.on("data", (chunk: string) => { + this.stderr = `${this.stderr}${chunk}`.slice(-4_096); + }); + this.child.on("error", (error) => this.fail(new ExistingVmError("mcp", `SSH MCP transport could not start: ${error.message}`))); + this.child.on("close", (code, signal) => { + this.closed = true; + const detail = this.stderr.trim(); + this.fail( + new ExistingVmError( + "mcp", + detail || `SSH MCP transport exited ${code ?? signal ?? "without a status"}`, + ), + ); + }); + } + + private read(chunk: string): void { + if (this.closed) return; + this.buffer += chunk; + if (this.buffer.length > MAX_MCP_LINE_CHARS) { + this.fail(new ExistingVmError("mcp", "CUA MCP response exceeded its output limit")); + return; + } + let newline: number; + while ((newline = this.buffer.indexOf("\n")) !== -1) { + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + let message: JsonRpcResponse; + try { + message = JSON.parse(line) as JsonRpcResponse; + } catch { + this.fail(new ExistingVmError("mcp", "CUA MCP returned invalid JSON")); + return; + } + if (typeof message.id !== "number") continue; + const waiting = this.pending.get(message.id); + if (!waiting) continue; + this.pending.delete(message.id); + clearTimeout(waiting.timer); + if (message.error) { + waiting.reject(new ExistingVmError("mcp", String(message.error.message ?? "CUA MCP request failed"))); + } else { + waiting.resolve(message.result); + } + } + } + + private fail(error: Error): void { + for (const [id, waiting] of this.pending) { + this.pending.delete(id); + clearTimeout(waiting.timer); + waiting.reject(error); + } + } + + request(method: string, params: Record, timeoutMs = MCP_REQUEST_TIMEOUT_MS): Promise { + if (this.closed) return Promise.reject(new ExistingVmError("mcp", "CUA MCP transport is closed")); + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new ExistingVmError("timeout", `CUA MCP ${method} timed out`)); + }, timeoutMs); + timer.unref?.(); + this.pending.set(id, { resolve, reject, timer }); + try { + this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + } catch (error) { + clearTimeout(timer); + this.pending.delete(id); + reject(new ExistingVmError("mcp", `CUA MCP request failed: ${error instanceof Error ? error.message : String(error)}`)); + } + }); + } + + notify(method: string, params: Record = {}): void { + if (this.closed) return; + try { + this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); + } catch { + // The request that follows reports the closed transport to the caller. + } + } + + async close(): Promise { + if (this.closePromise) return this.closePromise; + this.closePromise = (async () => { + if (!this.closed) { + try { + this.child.stdin.end(); + } catch {} + await Promise.race([this.exited, delay(MCP_CLOSE_GRACE_MS)]); + } + if (this.child.exitCode === null && this.child.signalCode === null) { + try { + this.child.kill("SIGTERM"); + } catch {} + await Promise.race([this.exited, delay(500)]); + } + if (this.child.exitCode === null && this.child.signalCode === null) { + try { + this.child.kill("SIGKILL"); + } catch {} + } + this.closed = true; + this.fail(new ExistingVmError("mcp", "CUA MCP transport closed")); + })(); + return this.closePromise; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} + +function desktopImage(result: unknown): { png: string; format: "png" | "jpeg" } { + const content = result && typeof result === "object" && Array.isArray((result as { content?: unknown }).content) + ? (result as { content: unknown[] }).content + : []; + if (result && typeof result === "object" && (result as { isError?: unknown }).isError === true) { + const first = content[0]; + const message = first && typeof first === "object" && typeof (first as { text?: unknown }).text === "string" + ? (first as { text: string }).text + : "get_desktop_state reported an error"; + throw new ExistingVmError("desktop", message); + } + const image = content.find( + (item): item is { type: "image"; data: string; mimeType?: string } => + Boolean(item) && + typeof item === "object" && + (item as { type?: unknown }).type === "image" && + typeof (item as { data?: unknown }).data === "string", + ); + if (!image) throw new ExistingVmError("desktop", "get_desktop_state returned no desktop image"); + const bytes = Buffer.from(image.data, "base64"); + const checked = wholeScreenshot(bytes); + if (!checked.ok) throw new ExistingVmError("desktop", "get_desktop_state returned an incomplete desktop image"); + return { png: image.data, format: checked.mime === "image/jpeg" ? "jpeg" : "png" }; +} + +async function runMcpProbe( + alias: string, + options: ExistingVmOptions, +): Promise<{ tools: string[]; screenshot: { png: string; format: "png" | "jpeg" } }> { + const client = new ExistingVmMcpClient(alias, options); + try { + const initialized = await client.request("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "openmausbot-existing-vm", version: "1" }, + }); + if (!initialized || typeof initialized !== "object" || typeof (initialized as { protocolVersion?: unknown }).protocolVersion !== "string") { + throw new ExistingVmError("mcp", "CUA MCP initialize returned an invalid response"); + } + client.notify("notifications/initialized"); + const listed = await client.request("tools/list", {}); + const tools = listed && typeof listed === "object" && Array.isArray((listed as { tools?: unknown }).tools) + ? (listed as { tools: unknown[] }).tools + .map((tool) => tool && typeof tool === "object" ? (tool as { name?: unknown }).name : undefined) + .filter((name): name is string => typeof name === "string") + : []; + const missing = EXISTING_VM_REQUIRED_TOOLS.filter((name) => !tools.includes(name)); + if (missing.length) throw new ExistingVmError("mcp", `CUA MCP is missing required tools: ${missing.join(", ")}`); + const result = await client.request( + "tools/call", + { name: "get_desktop_state", arguments: {} }, + SCREENSHOT_TIMEOUT_MS, + ); + return { tools, screenshot: desktopImage(result) }; + } finally { + await client.close(); + } +} + +function emptyStatus(alias: string | null): ExistingVmStatus { + return { + source: "existing", + configured: Boolean(alias), + sshAlias: alias, + ssh: alias ? "unreachable" : "not-configured", + os: "unknown", + driver: "unknown", + mcp: "unknown", + tools: [], + desktopReady: false, + ready: false, + problem: alias + ? "SSH could not reach the Existing VM" + : "Configure an SSH config alias for the Existing VM in App Settings → Local VM", + errorCode: alias ? "ssh-unreachable" : "not-configured", + driver_version: CUA_DRIVER_VERSION, + viewer_url: "", + watch_only: true, + }; +} + +function safeDetail(error: unknown, alias: string): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replaceAll(alias, "the configured SSH host") + .replace(/\s+/g, " ") + .trim() + .slice(0, 240); +} + +async function computeStatus(cfg: AppConfig, options: ExistingVmOptions): Promise { + const alias = localVmSshAlias(cfg); + const status = emptyStatus(alias); + if (!alias) return status; + + try { + const os = await runSshCommand(alias, ["uname", "-s"], options); + status.ssh = "connected"; + if (os.stdout.trim() !== "Linux") { + status.os = "unsupported"; + status.errorCode = "remote-os"; + status.problem = `Existing VM requires a Linux guest; SSH reported ${os.stdout.trim().slice(0, 80) || "an unknown OS"}`; + return status; + } + status.os = "linux"; + } catch (error) { + status.ssh = "unreachable"; + status.errorCode = error instanceof ExistingVmError && error.code === "timeout" ? "timeout" : "ssh-unreachable"; + status.problem = status.errorCode === "timeout" + ? "SSH timed out while reaching the Existing VM" + : "SSH could not reach the Existing VM; check the alias, host key, and SSH agent"; + return status; + } + + let version: CommandResult; + try { + version = await runSshCommand(alias, ["cua-driver", "--version"], options); + } catch (error) { + status.driver = "missing"; + status.errorCode = "cua-missing"; + status.problem = `CUA Driver is missing or unavailable on the Existing VM${safeDetail(error, alias) ? `: ${safeDetail(error, alias)}` : ""}`; + return status; + } + const match = /^cua-driver\s+([^\s]+)$/m.exec(version.stdout.trim()); + if (!match || match[1] !== CUA_DRIVER_VERSION) { + status.driver = "incompatible"; + status.errorCode = "cua-version"; + status.problem = `Existing VM needs CUA Driver ${CUA_DRIVER_VERSION}; found ${match?.[1] ?? "an unknown version"}`; + return status; + } + status.driver = "compatible"; + + try { + const probe = await runMcpProbe(alias, options); + status.mcp = "ready"; + status.tools = probe.tools; + status.desktopReady = true; + status.ready = true; + status.problem = null; + status.errorCode = null; + } catch (error) { + const code = error instanceof ExistingVmError ? error.code : "mcp"; + status.mcp = "failed"; + status.errorCode = code; + status.problem = code === "desktop" + ? `SSH reached CUA Driver, but it could not reach the graphical desktop${safeDetail(error, alias) ? `: ${safeDetail(error, alias)}` : ""}` + : `SSH-launched CUA MCP transport failed${safeDetail(error, alias) ? `: ${safeDetail(error, alias)}` : ""}`; + } + return status; +} + +const statusCache = new Map(); +const statusInFlight = new Map>(); + +export async function existingVmStatus( + cfg: AppConfig, + options: ExistingVmOptions = {}, +): Promise { + const alias = localVmSshAlias(cfg); + if (!alias) return emptyStatus(null); + const cacheable = !options.sshCommand; + if (options.force) statusCache.delete(alias); + if (cacheable) { + if (!options.force) { + const cached = statusCache.get(alias); + if (cached && cached.expiresAt > Date.now()) return cached.status; + const inFlight = statusInFlight.get(alias); + if (inFlight) return inFlight; + } + } + const promise = computeStatus(cfg, options); + if (!cacheable) return promise; + statusInFlight.set(alias, promise); + try { + const status = await promise; + statusCache.set(alias, { status, expiresAt: Date.now() + STATUS_CACHE_TTL_MS }); + return status; + } finally { + if (statusInFlight.get(alias) === promise) statusInFlight.delete(alias); + } +} + +export async function existingVmScreenshot( + cfg: AppConfig, + options: ExistingVmOptions = {}, +): Promise<{ png: string; format: "png" | "jpeg" }> { + const status = await existingVmStatus(cfg, options); + const alias = status.sshAlias; + if (!alias || !status.ready) { + throw Object.assign(new Error(status.problem ?? "The Existing VM is not ready"), { status: 409 }); + } + try { + return (await runMcpProbe(alias, options)).screenshot; + } catch (error) { + if (!options.sshCommand) statusCache.delete(alias); + const detail = safeDetail(error, alias); + throw Object.assign(new Error(detail || "The Existing VM did not return a valid desktop image"), { + status: error instanceof ExistingVmError && error.code === "timeout" ? 504 : 502, + }); + } +} + +export function existingVmComputerMcp( + cfg: AppConfig, + control?: { url: string; token: string }, +): { command: string; args: string[]; env: Record } { + const alias = localVmSshAlias(cfg); + if (!alias) throw new Error("Existing VM is not configured — add an SSH config alias first"); + return { + command: process.execPath, + args: [SPAWNED_PROXIES.existingVmMcp, alias], + env: { + ELECTRON_RUN_AS_NODE: "1", + ...(control ? { OMB_CONTROL_URL: control.url, OMB_CONTROL_TOKEN: control.token } : {}), + }, + }; +} diff --git a/server/index.test.ts b/server/index.test.ts index 3819f5b63..e245ddfb8 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -1209,7 +1209,7 @@ describe("harness HTTP API", () => { const first = (await api("POST", "/api/bots")).body.bot; const second = (await api("POST", "/api/bots")).body.bot; const before = await api("GET", "/api/config"); - expect(before.body.localVm).toEqual({ mode: "shared", maxInstances: 2 }); + expect(before.body.localVm).toEqual({ source: "managed", mode: "shared", maxInstances: 2, sshAlias: "" }); const shared = await api("GET", `/api/bots/${first.id}/local-computer`); expect(shared.status).toBe(200); @@ -1219,7 +1219,7 @@ describe("harness HTTP API", () => { localVm: { mode: "per-bot", maxInstances: 3 }, }); expect(saved.status).toBe(200); - expect(saved.body.localVm).toEqual({ mode: "per-bot", maxInstances: 3 }); + expect(saved.body.localVm).toEqual({ source: "managed", mode: "per-bot", maxInstances: 3, sshAlias: "" }); const [firstStatus, secondStatus] = await Promise.all([ api("GET", `/api/bots/${first.id}/local-computer`), @@ -1240,6 +1240,69 @@ describe("harness HTTP API", () => { await api("PATCH", "/api/config", { localVm: { mode: "shared", maxInstances: 2 } }); }); + it("persists the Existing VM source without exposing lifecycle or Take Control actions", async () => { + const created = await api("POST", "/api/bots", {}); + const botId = created.body.bot.id; + await api("PATCH", `/api/bots/${botId}`, { name: "Existing VM Guard Fixture" }); + try { + const saved = await api("PATCH", "/api/config", { + localVm: { source: "existing", sshAlias: "fixture-existing-vm" }, + }); + expect(saved.status).toBe(200); + expect(saved.body.localVm).toEqual({ + source: "existing", + sshAlias: "fixture-existing-vm", + }); + expect((await api("GET", "/api/config")).body.localVm).toEqual({ + source: "existing", + sshAlias: "fixture-existing-vm", + }); + + const invalid = await api("PATCH", "/api/config", { localVm: { sshAlias: "vm; reboot" } }); + expect(invalid.status).toBe(400); + expect(invalid.body.error).toContain("localVm.sshAlias"); + + const lifecycle = await api("POST", "/api/local-computer/start", {}); + expect(lifecycle.status).toBe(409); + expect(lifecycle.body.error).toContain("user-managed"); + + const selected = await api("PATCH", `/api/bots/${botId}`, { computer: "vm" }); + expect(selected.status).toBe(200); + const engine = await api("PATCH", `/api/bots/${botId}`, { + modelSelection: { instanceId: "claude", model: "claude-sonnet-5" }, + }); + expect(engine.status).toBe(200); + const started = await api("POST", `/api/bots/${botId}/messages`, { text: "hold the Existing VM turn" }); + expect(started.status).toBe(202); + await expect.poll(async () => { + const state = (await api("GET", "/api/bots")).body; + return state.bots.find((bot: { id: string }) => bot.id === botId)?.busy; + }, { timeout: 5_000 }).toBe(true); + const destination = await api("PATCH", `/api/bots/${botId}`, { computer: "cloud" }); + expect(destination.status).toBe(409); + expect(destination.body.error).toContain("active Existing VM turn"); + await api("POST", `/api/bots/${botId}/interrupt`); + await expect.poll(async () => { + const state = (await api("GET", "/api/bots")).body; + return state.bots.find((bot: { id: string }) => bot.id === botId)?.busy; + }, { timeout: 5_000 }).toBe(false); + const take = await api("POST", `/api/bots/${botId}/computer/control`, { action: "take" }); + expect(take.status).toBe(409); + expect(take.body.error).toContain("watch-only"); + const viewer = await api("POST", `/api/bots/${botId}/computer/join`, {}); + expect(viewer.status).toBe(409); + expect(viewer.body.error).toContain("watch-only"); + await api("PATCH", "/api/config", { localVm: { sshAlias: "" } }); + const status = await api("GET", "/api/local-computer"); + expect(status.body).toMatchObject({ source: "existing", ready: false, ssh: "not-configured" }); + expect(status.body).not.toHaveProperty("mode"); + expect(status.body).not.toHaveProperty("max_instances"); + } finally { + await api("PATCH", "/api/config", { localVm: { source: "managed", sshAlias: "" } }); + await api("DELETE", `/api/bots/${botId}`); + } + }); + it("keeps an active turn alive when only the room timeout changes", async () => { const created = await api("POST", "/api/bots", {}); const botId = created.body.bot.id; diff --git a/server/index.ts b/server/index.ts index 15f278122..07c5d966f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -46,6 +46,8 @@ import { loadConfig, localVmMaxInstances, localVmMode, + localVmSource, + localVmSshAlias, parseConfigPatch, roomTurnTimeoutMinutes, saveConfig, @@ -97,6 +99,12 @@ import { LocalVmIdleTimer } from "./local-vm-idle.ts"; import { LocalVmLease, LocalVmLeasePool } from "./local-vm-lease.ts"; import { RepeatDetector, callKey } from "./repeat-detector.ts"; import * as vps from "./vps-computer.ts"; +import { + EXISTING_VM_LEASE_KEY, + existingVmComputerMcp, + existingVmScreenshot, + existingVmStatus, +} from "./existing-vm.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; import { fetchGithubTeam, fetchLibraryTeam, fetchTeamCatalog } from "./team-library.ts"; import { createTeamManifest, importedMemberProfile, parseTeamManifest } from "./team-manifest.ts"; @@ -596,6 +604,7 @@ const watchdog = new TurnWatchdog({ if (currentBot?.busy) { stopScreenPoller(currentBot.id); if (activeVpsThreads.get(currentBot.id) === turn.threadId) activeVpsThreads.delete(currentBot.id); + releaseExistingVmThread(turn.threadId); store.setActivity(currentBot.id, "idle"); } }, 6_000); @@ -657,6 +666,9 @@ let localVmImageBusy = false; let localVmProvisionBusy = false; let localVmModeChangeBusy = false; const activeVpsThreads = new Map(); +const existingVmLease = localVmLeases.forTarget(EXISTING_VM_LEASE_KEY); +const existingVmThreadIds = new Map(); +const existingVmActiveThreads = new Map(); const LOCAL_VM_IDLE_MS = 8 * 60 * 60_000; const localVmIdles = new Map(); @@ -701,9 +713,18 @@ function releaseLocalVmThread(threadId: string): void { localVmThreadTargets.delete(threadId); } +function releaseExistingVmThread(threadId: string): void { + const botId = existingVmThreadIds.get(threadId); + if (!botId) return; + existingVmLease.release(threadId); + if (existingVmActiveThreads.get(botId) === threadId) existingVmActiveThreads.delete(botId); + existingVmThreadIds.delete(threadId); +} + // A running VM may have survived an app/server restart. Start its idle // backstop even if nobody opens Settings or begins a turn this session. void (async () => { + if (localVmSource(cfg) !== "managed") return; const targets = localVmMode(cfg) === "per-bot" ? store.bots.filter((bot) => bot.computer === "vm").map((bot) => perBotLocalVmTarget(bot.id)) : [SHARED_LOCAL_VM_TARGET]; @@ -719,8 +740,10 @@ bus.subscribe((event: RuntimeEvent) => { localVmLeaseFor(localVmTarget).touch(event.threadId); localVmIdleFor(localVmTarget).touch(); } + if (existingVmThreadIds.has(event.threadId)) existingVmLease.touch(event.threadId); if (event.type === "turn.completed") { releaseLocalVmThread(event.threadId); + releaseExistingVmThread(event.threadId); } broadcast({ kind: "runtime", event }); const routineRun = routines?.handleRuntimeEvent(event) ?? null; @@ -1445,7 +1468,7 @@ async function startTurn( const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent"; const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true; let previewCapture: (() => Promise<{ png: string; format: string }>) | null = null; - let computerKind: "box" | "vps" | "vm" | "local" | null = null; + let computerKind: "box" | "vps" | "vm" | "existing-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. @@ -1453,29 +1476,47 @@ async function startTurn( if (!mountsComputerMcp || instance.driverKind === "boxAgent") { throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination"); } - const localVmTarget = localVmTargetForBot(bot.id); - if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) { - throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish"); - } - // Claim before the first await. The lifecycle route performs its - // matching check synchronously, so neither side can enter while the - // other is between inspection and mutation. - if (!localVmLeaseFor(localVmTarget).claim(threadId, bot.id, localVmOwnerBusy)) { - throw new Error("this Local VM is already being used by another turn — wait for that turn to finish"); - } - localVmThreadTargets.set(threadId, localVmTarget); - localVmActiveThreads.set(localVmTarget.key, threadId); - localVmIdleFor(localVmTarget).touch(); - const localVm = await containerComputerStatus(undefined, undefined, localVmTarget); - if (!localVm.ready || !localVm.runtime) { - throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Local VM)`); + if (localVmSource(cfg) === "existing") { + // Existing VM is one user-owned desktop, regardless of the managed + // Local VM's shared/per-bot policy. Keep a separate stable lease lane + // so switching sources never lets two bots drive it at once. + if (!existingVmLease.claim(threadId, bot.id, localVmOwnerBusy)) { + throw new Error("this Existing VM is already being used by another turn — wait for that turn to finish"); + } + existingVmThreadIds.set(threadId, bot.id); + existingVmActiveThreads.set(bot.id, threadId); + const existing = await existingVmStatus(cfg); + if (!existing.ready) { + throw new Error(`${existing.problem ?? "the Existing VM is not ready"} (App Settings → Local VM)`); + } + integrations.localComputer = existingVmComputerMcp(cfg, controlIntegration(bot.id)); + previewCapture = async () => existingVmScreenshot(cfg); + computerKind = "existing-vm"; + } else { + const localVmTarget = localVmTargetForBot(bot.id); + if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) { + throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish"); + } + // Claim before the first await. The lifecycle route performs its + // matching check synchronously, so neither side can enter while the + // other is between inspection and mutation. + if (!localVmLeaseFor(localVmTarget).claim(threadId, bot.id, localVmOwnerBusy)) { + throw new Error("this Local VM is already being used by another turn — wait for that turn to finish"); + } + localVmThreadTargets.set(threadId, localVmTarget); + localVmActiveThreads.set(localVmTarget.key, threadId); + localVmIdleFor(localVmTarget).touch(); + const localVm = await containerComputerStatus(undefined, undefined, localVmTarget); + if (!localVm.ready || !localVm.runtime) { + throw new Error(`${localVm.problem ?? "the Local VM is not ready"} (App Settings → Local VM)`); + } + integrations.localComputer = containerComputerMcp( + localVm.runtime, + controlIntegration(bot.id), + localVmTarget, + ); + computerKind = "vm"; } - integrations.localComputer = containerComputerMcp( - localVm.runtime, - controlIntegration(bot.id), - localVmTarget, - ); - computerKind = "vm"; } else if (wants === "local") { if (!shouldMountLocalComputer({ requested: "local", @@ -1632,6 +1673,8 @@ async function startTurn( ? " 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. Its filesystem is disposable: everything on it is wiped whenever its container is recreated, so keep long-lived work somewhere durable — push it to a remote, or hand the results back in chat — instead of leaving it only on that computer. Inspect the desktop state before acting, prefer accessibility targets over raw coordinates, and act carefully." + : computerKind === "existing-vm" + ? " You have a user-managed Linux VM through the official Cua tools. Its filesystem may be persistent and is controlled by the user outside OpenMausBot; do not assume it is disposable, isolated, or owned by OpenMausBot. 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." : "") + @@ -1670,6 +1713,7 @@ async function startTurn( } } catch (e) { releaseLocalVmThread(threadId); + releaseExistingVmThread(threadId); if (activeVpsThreads.get(bot.id) === threadId) activeVpsThreads.delete(bot.id); watchdog.settle(threadId); turnUsage.delete(threadId); @@ -2192,9 +2236,13 @@ function stderrOf(err: unknown): string { return typeof s === "string" ? s : Buffer.isBuffer(s) ? s.toString("utf8") : ""; } -async function localVmPayload(target: LocalVmTarget) { +async function localVmPayload(target: LocalVmTarget, forceExistingRefresh = false) { + if (localVmSource(cfg) === "existing") { + return existingVmStatus(cfg, { force: forceExistingRefresh }); + } const status = await containerComputerStatus(undefined, undefined, target); return { + source: "managed" as const, ...status, commands: setupCommands(status.runtime, process.platform, target), idle_timeout_ms: LOCAL_VM_IDLE_MS, @@ -2226,6 +2274,7 @@ async function perBotLocalVmCountForModeChange(): Promise { } function configStatus() { + const source = localVmSource(cfg); return { xai: { configured: Boolean(cfg.xai?.key) }, composio: { @@ -2242,10 +2291,9 @@ function configStatus() { // not a secret — the sidebar shows it profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" }, rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) }, - localVm: { - mode: localVmMode(cfg), - maxInstances: localVmMaxInstances(cfg), - }, + localVm: source === "existing" + ? { source, sshAlias: localVmSshAlias(cfg) ?? "" } + : { source, mode: localVmMode(cfg), maxInstances: localVmMaxInstances(cfg), sshAlias: localVmSshAlias(cfg) ?? "" }, }; } @@ -2264,6 +2312,8 @@ async function reloadProviders() { localVmLeaseFor(target).current(localVmOwnerBusy)?.botId === b.id )?.[0]; if (vmThread) releaseLocalVmThread(vmThread); + const existingThread = [...existingVmThreadIds.entries()].find(([, ownerBotId]) => ownerBotId === b.id)?.[0]; + if (existingThread) releaseExistingVmThread(existingThread); stopScreenPoller(b.id); activeVpsThreads.delete(b.id); finalizeDelegationWatch( @@ -3313,6 +3363,14 @@ const server = createServer(async (req, res) => { const nextSelection = (body as Record).modelSelection as | { instanceId?: string; effort?: string } | undefined; + const existingVmTurnActive = existingVmActiveThreads.has(m[1]) || ( + existingBot?.busy === true && + existingBot.computer === "vm" && + localVmSource(cfg) === "existing" + ); + if (body.computer !== undefined && existingVmTurnActive && body.computer !== existingBot?.computer) { + return json(res, 409, { error: "stop the active Existing VM turn before changing its computer destination" }); + } if (nextSelection?.effort !== undefined) { if (!isEffortLevel(nextSelection.effort)) { return json(res, 400, { error: `effort "${String(nextSelection.effort)}" is not recognized` }); @@ -3461,7 +3519,7 @@ const server = createServer(async (req, res) => { if (m && method === "DELETE") { const bot = store.bot(m[1]); if (!bot) return json(res, 404, { error: "no such bot" }); - if (localVmMode(cfg) === "per-bot") { + if (localVmSource(cfg) === "managed" && localVmMode(cfg) === "per-bot") { const target = perBotLocalVmTarget(bot.id); if (localVmActiveThreads.has(target.key) || localVmLifecycleBusy.has(target.key)) { return json(res, 409, { error: "stop this bot's Local VM turn or setup action before deleting the bot" }); @@ -3480,6 +3538,9 @@ const server = createServer(async (req, res) => { await registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(bot.threadId).catch(() => {}); stopScreenPoller(bot.id); activeVpsThreads.delete(bot.id); + for (const [threadId, ownerBotId] of existingVmThreadIds) { + if (ownerBotId === bot.id) releaseExistingVmThread(threadId); + } routines!.disableForBot(bot.id); webhooks.disableForBot(bot.id); lastReply.delete(bot.threadId); @@ -3755,7 +3816,7 @@ const server = createServer(async (req, res) => { // what the user's machine can host: which runtime is installed, whether // its daemon is up, and whether the desktop image and container exist if (method === "GET" && path === "/api/local-computer") { - return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET)); + return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET, url.searchParams.get("refresh") === "1")); } m = path.match(/^\/api\/local-computer\/(pull|run|start|stop|remove)$/); if (m && method === "POST") { @@ -3767,6 +3828,11 @@ const server = createServer(async (req, res) => { return json(res, 415, { error: "content-type must be application/json" }); } const action = z.enum(["pull", "run", "start", "stop", "remove"]).parse(m[1]); + if (localVmSource(cfg) === "existing") { + return json(res, 409, { + error: "Existing VM is user-managed; OpenMausBot does not create, start, stop, replace, or delete it", + }); + } if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(SHARED_LOCAL_VM_TARGET.key)) { return json(res, 409, { error: "another Local VM setup action is still running" }); } @@ -3784,6 +3850,7 @@ const server = createServer(async (req, res) => { if (action === "run" || action === "start") localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch(); if (action === "stop" || action === "remove") localVmIdleFor(SHARED_LOCAL_VM_TARGET).cancel(); return json(res, 200, { + source: "managed" as const, ...status, commands: setupCommands(status.runtime, process.platform, SHARED_LOCAL_VM_TARGET), idle_timeout_ms: LOCAL_VM_IDLE_MS, @@ -3796,6 +3863,10 @@ const server = createServer(async (req, res) => { } } if (method === "POST" && path === "/api/local-computer/screenshot") { + if (localVmSource(cfg) === "existing") { + const frame = await existingVmScreenshot(cfg); + return json(res, 200, { image: `data:image/${frame.format};base64,${frame.png}` }); + } localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch(); return json(res, 200, { image: await containerComputerScreenshot(undefined, undefined, SHARED_LOCAL_VM_TARGET), @@ -3806,7 +3877,7 @@ const server = createServer(async (req, res) => { if (m && method === "GET") { const bot = store.bot(m[1]); if (!bot) return json(res, 404, { error: "no such bot" }); - return json(res, 200, await localVmPayload(localVmTargetForBot(bot.id))); + return json(res, 200, await localVmPayload(localVmTargetForBot(bot.id), url.searchParams.get("refresh") === "1")); } m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/); if (m && method === "POST") { @@ -3816,6 +3887,11 @@ const server = createServer(async (req, res) => { const bot = store.bot(m[1]); if (!bot) return json(res, 404, { error: "no such bot" }); const action = z.enum(["run", "stop", "remove"]).parse(m[2]); + if (localVmSource(cfg) === "existing") { + return json(res, 409, { + error: "Existing VM is user-managed; OpenMausBot does not create, start, stop, replace, or delete it", + }); + } const target = localVmTargetForBot(bot.id); if (target.key === SHARED_LOCAL_VM_TARGET.key) { return json(res, 409, { error: "Shared mode manages this desktop in App Settings → Local VM" }); @@ -3849,6 +3925,7 @@ const server = createServer(async (req, res) => { if (action === "run") localVmIdleFor(target).touch(); if (action === "stop" || action === "remove") localVmIdleFor(target).cancel(); return json(res, 200, { + source: "managed" as const, ...status, commands: setupCommands(status.runtime, process.platform, target), idle_timeout_ms: LOCAL_VM_IDLE_MS, @@ -3864,6 +3941,10 @@ const server = createServer(async (req, res) => { if (m && method === "POST") { const bot = store.bot(m[1]); if (!bot) return json(res, 404, { error: "no such bot" }); + if (localVmSource(cfg) === "existing") { + const frame = await existingVmScreenshot(cfg); + return json(res, 200, { image: `data:image/${frame.format};base64,${frame.png}` }); + } const target = localVmTargetForBot(bot.id); localVmIdleFor(target).touch(); return json(res, 200, { @@ -4001,6 +4082,20 @@ const server = createServer(async (req, res) => { const aliasError = vpsAliasChangeError(currentAlias, nextAlias, activeVpsThreads.size > 0); if (aliasError) return json(res, 409, { error: aliasError }); } + if (patch.localVm !== undefined) { + const nextLocalVmConfig = { + ...cfg, + localVm: { ...cfg.localVm, ...patch.localVm }, + }; + const sourceChanged = localVmSource(nextLocalVmConfig) !== localVmSource(cfg); + const aliasChanged = localVmSshAlias(nextLocalVmConfig) !== localVmSshAlias(cfg); + if ((sourceChanged || aliasChanged) && existingVmActiveThreads.size > 0) { + return json(res, 409, { error: "stop the active Existing VM turn before changing its source or SSH config alias" }); + } + if (sourceChanged && localVmActiveThreads.size > 0) { + return json(res, 409, { error: "stop the active Local VM turn before changing its source" }); + } + } providerConfigBusy = true; const changingLocalVmMode = patch.localVm?.mode !== undefined && patch.localVm.mode !== localVmMode(cfg); if (changingLocalVmMode) localVmModeChangeBusy = true; @@ -4248,6 +4343,12 @@ const server = createServer(async (req, res) => { } const body = await readBody(req); const action = String(body.action ?? ""); + if (action === "take" && ( + existingVmActiveThreads.has(bot.id) || + (bot.computer === "vm" && localVmSource(cfg) === "existing") + )) { + return json(res, 409, { error: "Existing VM is watch-only; Take control is not available" }); + } if (action === "take") return json(res, 200, computerControl.take(bot.id)); if (action === "release") return json(res, 200, computerControl.release(bot.id)); if (action === "dismiss-help") return json(res, 200, computerControl.dismissHelp(bot.id)); @@ -4268,6 +4369,9 @@ const server = createServer(async (req, res) => { if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { return json(res, 415, { error: "content-type must be application/json" }); } + if (bot.computer === "vm" && localVmSource(cfg) === "existing") { + return json(res, 409, { error: "Existing VM is watch-only; live viewer and cloud computer actions are unavailable" }); + } if (bot.cloudBackend === "vps") { if (m[2] === "join" || m[2] === "exec") { return json(res, 409, { error: "interactive VPS desktop access is not supported" }); diff --git a/server/proxy-paths.ts b/server/proxy-paths.ts index f6bd30c33..3925e3c27 100644 --- a/server/proxy-paths.ts +++ b/server/proxy-paths.ts @@ -38,6 +38,7 @@ export const SPAWNED_PROXIES = { permission: resolveProxy("permission-proxy"), containerMcp: resolveProxy("container-mcp"), vpsContainerMcp: resolveProxy("vps-container-mcp"), + existingVmMcp: resolveProxy("existing-vm-mcp"), agents: resolveProxy("drivers/agents-proxy"), dweb: resolveProxy("drivers/dweb-proxy"), connectors: resolveProxy("connector-proxy"), diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index ab42106c7..b039897cc 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -59,7 +59,8 @@ type Phase = | "off" | "error"; -interface LocalVmStatus { +interface ManagedLocalVmStatus { + source: "managed"; mode: "shared" | "per-bot"; max_instances: number; image: boolean; @@ -76,6 +77,26 @@ interface LocalVmStatus { viewer_url: string; } +interface ExistingLocalVmStatus { + source: "existing"; + configured: boolean; + sshAlias: string | null; + ssh: "not-configured" | "connected" | "unreachable"; + os: "unknown" | "linux" | "unsupported"; + driver: "unknown" | "compatible" | "missing" | "incompatible"; + mcp: "unknown" | "ready" | "failed"; + tools: string[]; + desktopReady: boolean; + ready: boolean; + problem: string | null; + errorCode: string | null; + driver_version: string; + viewer_url: ""; + watch_only: true; +} + +type LocalVmStatus = ManagedLocalVmStatus | ExistingLocalVmStatus; + function routineScheduleLabel(routine: Routine) { if (routine.schedule.type === "once") { return new Date(routine.schedule.at).toLocaleString([], { @@ -139,6 +160,9 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const selectedInstance = state.instances.find( (instance) => instance.instanceId === bot.modelSelection.instanceId, ); + const managedVmStatus = vmStatus?.source === "managed" ? vmStatus : null; + const vmWatchOnly = phase === "vm" && vmStatus?.source === "existing" && vmStatus.watch_only; + const localComputerLabel = state.config?.localVm.source === "existing" ? "Existing VM" : "Local VM"; useEffect(() => { return window.ogb?.desktopViewer?.onState((viewer) => { @@ -177,7 +201,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { bot.computer === "cloud" ? cloudBackend === "vps" ? "this self-hosted VPS" : "this cloud box" : bot.computer === "vm" - ? "the Local VM" + ? state.config?.localVm.source === "existing" ? "the Existing VM" : "the Local VM" : bot.computer === "local" ? "this computer" : bot.computer === "off" @@ -218,7 +242,9 @@ export function ComputerPanel({ bot }: { bot: Bot }) { api(`/api/bots/${bot.id}/local-computer`) .then((rawStatus) => { if (!alive) return; - const status: LocalVmStatus = rawStatus; + const status: LocalVmStatus = rawStatus.source === "existing" + ? rawStatus as ExistingLocalVmStatus + : { source: "managed", ...rawStatus } as ManagedLocalVmStatus; setVmStatus(status); // parse at the boundary: our own status endpoint sends a string or nothing const viewerUrl = String(status.viewer_url ?? ""); @@ -226,6 +252,9 @@ export function ComputerPanel({ bot }: { bot: Bot }) { if (status.ready) { vmReadinessAttempts.current = 0; setPhase("vm"); + } else if (status.source === "existing") { + setError(`${status.problem ?? "The Existing VM is not ready"}. Open App Settings → Local VM.`); + setPhase("vm-unavailable"); } else if ( status.container === "running" && status.imageMatches && @@ -369,6 +398,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { cloudSupported, vpsSupported, state.config?.vps?.sshAlias, + state.config?.localVm.source, ]); // cloud preview: SSE frames win while the bot works; otherwise poll @@ -507,6 +537,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { }; const openDesktop = async () => { + if (vmWatchOnly) { + setError("Existing VM is watch-only; OpenMausBot cannot open a live viewer or take control."); + return; + } setPending("join"); setControlPending(true); setError(null); @@ -598,14 +632,17 @@ export function ComputerPanel({ bot }: { bot: Bot }) { }); } if (action !== "vm-delete") { - const status: LocalVmStatus = await api(`/api/bots/${bot.id}/local-computer/run`, { + const rawStatus = await api(`/api/bots/${bot.id}/local-computer/run`, { method: "POST", body: "{}", }); + const status: LocalVmStatus = rawStatus.source === "existing" + ? rawStatus as ExistingLocalVmStatus + : { source: "managed", ...rawStatus } as ManagedLocalVmStatus; setVmStatus(status); setPhase(status.ready ? "vm" : "checking"); } else { - setVmStatus((current) => current ? { ...current, container: "missing", ready: false } : current); + setVmStatus((current) => current?.source === "managed" ? { ...current, container: "missing", ready: false } : current); setPhase("vm-unavailable"); } } catch (e) { @@ -694,7 +731,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
{bot.name}'s screen {phase === "local" && this computer} - {phase === "vm" && Local VM} + {phase === "vm" && {vmWatchOnly ? "Existing VM · watch-only" : "Local VM"}} {cloudBackend === "vps" && (phase === "ready" || phase === "starting") && self-hosted VPS}
@@ -703,7 +740,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { src={frameSrc} alt={`${bot.name}'s screen`} className="h-full w-full object-contain" - title={phase === "vm" ? "Watch-only preview — use Open desktop to click and type" : undefined} + title={phase === "vm" ? vmWatchOnly ? "Watch-only preview — live viewer and Take Control are unavailable" : "Watch-only preview — use Open desktop to click and type" : undefined} /> ) : (
@@ -718,7 +755,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {phase === "ready" ? "Waiting for the first frame…" : phase === "vm" - ? "Capturing the Local VM screen…" + ? vmWatchOnly ? "Showing the Existing VM screen (watch-only)…" : "Capturing the Local VM screen…" : phase === "local" ? isLinux ? "Ready for approved bot actions. Start the separate preview below when you want to watch the screen." @@ -736,16 +773,16 @@ export function ComputerPanel({ bot }: { bot: Bot }) { )} {phase === "vm-unavailable" && ( - vmStatus?.mode === "per-bot" && vmStatus.image && vmStatus.create_supported ? ( + managedVmStatus?.mode === "per-bot" && managedVmStatus.image && managedVmStatus.create_supported ? ( ) : (
- + {vmWatchOnly ? ( +
+ Existing VM is watch-only; Take Control is unavailable. +
+ ) : ( + + )}
)} - {phase === "vm" && vmViewerUrl && control.held && ( + {phase === "vm" && !vmWatchOnly && vmViewerUrl && control.held && ( )} - {phase === "vm" && !control.held && !control.helpReason && ( + {phase === "vm" && !vmWatchOnly && !control.held && !control.helpReason && ( )} - {phase === "vm" && vmStatus?.mode === "per-bot" && ( + {phase === "vm" && managedVmStatus?.mode === "per-bot" && (
{( [ ["cloud", "Cloud"], - ["vm", "Local VM"], + ["vm", localComputerLabel], ["local", "This computer"], ["off", "Off"], ] as const @@ -961,7 +1010,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { (mode === "local" && !localSelectable); const unavailableTitle = mode === "vm" && !vmSupported - ? "This model engine cannot use the Local VM" + ? `This model engine cannot use the ${localComputerLabel}` : mode === "cloud" && !cloudSupported ? "This model engine cannot use cloud computer tools" : mode === "local" && !localSelectable diff --git a/src/components/LocalComputerSection.tsx b/src/components/LocalComputerSection.tsx index a7c27c99c..506af9dab 100644 --- a/src/components/LocalComputerSection.tsx +++ b/src/components/LocalComputerSection.tsx @@ -13,10 +13,12 @@ import { } from "lucide-react"; import { Card, CommandLine } from "./SettingsPrimitives"; import { cn } from "@/lib/cn"; +import { useStore } from "@/state/store"; type Action = "pull" | "run" | "start" | "stop" | "remove" | "recreate"; -interface Status { +interface ManagedStatus { + source: "managed"; platform: string; runtime: string | null; available: string[]; @@ -53,6 +55,26 @@ interface Status { }; } +interface ExistingStatus { + source: "existing"; + configured: boolean; + sshAlias: string | null; + ssh: "not-configured" | "connected" | "unreachable"; + os: "unknown" | "linux" | "unsupported"; + driver: "unknown" | "compatible" | "missing" | "incompatible"; + mcp: "unknown" | "ready" | "failed"; + tools: string[]; + desktopReady: boolean; + ready: boolean; + problem: string | null; + errorCode: string | null; + driver_version: string; + viewer_url: ""; + watch_only: true; +} + +type Status = ManagedStatus | ExistingStatus; + function Step({ n, title, done, children }: { n: number; title: string; done: boolean; children?: React.ReactNode }) { return (
@@ -101,29 +123,42 @@ function ActionButton({ } export function LocalComputerSection() { + const { state, dispatch } = useStore(); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [pending, setPending] = useState(null); const [error, setError] = useState(null); const [policyPending, setPolicyPending] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const [source, setSource] = useState<"managed" | "existing">("managed"); + const [alias, setAlias] = useState(""); - const refresh = useCallback(async (signal?: AbortSignal) => { - const response = await fetch("/api/local-computer", { signal }); + const refresh = useCallback(async (signal?: AbortSignal, force = false) => { + const response = await fetch(`/api/local-computer${force ? "?refresh=1" : ""}`, { signal }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? `Status request failed (${response.status})`); - setStatus(body as Status); + setStatus(body.source === "existing" ? body as ExistingStatus : { source: "managed", ...body } as ManagedStatus); setError(null); }, []); + useEffect(() => { + const configuredSource = state.config?.localVm.source ?? status?.source; + if (configuredSource) setSource(configuredSource); + }, [state.config?.localVm.source, status?.source]); + + useEffect(() => { + if (state.config?.localVm.sshAlias !== undefined) setAlias(state.config.localVm.sshAlias); + else if (status?.source === "existing") setAlias(status.sshAlias ?? ""); + }, [state.config?.localVm.sshAlias, status?.source, status?.source === "existing" ? status.sshAlias : null]); + useEffect(() => { let active = true; let timer: number | undefined; let controller: AbortController | undefined; - const poll = async () => { + const poll = async (force = false) => { controller = new AbortController(); try { - await refresh(controller.signal); + await refresh(controller.signal, force); } catch (e) { if (active && !(e instanceof DOMException && e.name === "AbortError")) { setStatus(null); @@ -136,7 +171,7 @@ export function LocalComputerSection() { } } }; - void poll(); + void poll(refreshKey > 0); return () => { active = false; controller?.abort(); @@ -183,7 +218,7 @@ export function LocalComputerSection() { } }; - const savePolicy = async (mode: Status["mode"], maxInstances: number) => { + const savePolicy = async (mode: ManagedStatus["mode"], maxInstances: number) => { setPolicyPending(true); setError(null); try { @@ -203,23 +238,183 @@ export function LocalComputerSection() { } }; - const c = status?.commands; - const ready = status?.ready === true; - const existing = status?.container !== "missing"; + const saveSource = async (nextSource: "managed" | "existing") => { + if (policyPending || nextSource === source) return; + setPolicyPending(true); + setError(null); + try { + const response = await fetch("/api/config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ localVm: { source: nextSource } }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Could not change the Local VM source"); + dispatch({ type: "configStatus", config: body }); + setSource(nextSource); + await refresh(undefined, true); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setPolicyPending(false); + } + }; + + const saveAlias = async () => { + if (policyPending) return; + setPolicyPending(true); + setError(null); + try { + const response = await fetch("/api/config", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ localVm: { sshAlias: alias.trim() } }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Could not save the Existing VM SSH alias"); + dispatch({ type: "configStatus", config: body }); + setAlias(body.localVm?.sshAlias ?? alias.trim()); + await refresh(undefined, true); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setPolicyPending(false); + } + }; + + const existingStatus = status?.source === "existing" ? status : null; + if (source === "existing") { + const savedAlias = Boolean(alias.trim() || existingStatus?.sshAlias); + return ( + <> + +
+ {(["managed", "existing"] as const).map((value, index) => ( + + ))} +
+ {error &&
{error}
} +
+ + +
+ setAlias(event.target.value)} + onKeyDown={(event) => event.key === "Enter" && void saveAlias()} + placeholder="my-linux-vm" + aria-label="Existing VM 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" + /> + +
+
+ Alias names may contain letters, numbers, dots, dashes, and underscores. The remote command is fixed to cua-driver mcp. +
+
+ + +
+ + {loading ? : existingStatus?.ready ? : } + {loading ? "Checking…" : existingStatus?.ready ? "Ready · watch-only preview" : existingStatus?.problem ?? "Not ready"} + + +
+
+ +
Enter the alias for the Linux VM above, then save it.
+
+ + {existingStatus?.ssh === "unreachable" &&
Check the SSH alias, host key, and SSH agent, then re-check.
} +
+ + {existingStatus?.os === "unsupported" &&
The Existing VM must report Linux.
} +
+ + {existingStatus?.driver === "missing" &&
Install the pinned CUA Driver in the VM and re-check.
} + {existingStatus?.driver === "incompatible" &&
The VM has a different CUA Driver version than this OpenMausBot build.
} +
+ + {existingStatus?.mcp === "failed" &&
The CUA MCP bridge did not become ready.
} +
+ + {existingStatus?.desktopReady === false && existingStatus?.mcp === "ready" &&
CUA could not return a complete desktop image.
} +
+
+ {existingStatus?.ready && ( +
+ The bot Computer panel can show a watch-only preview. Live viewer access and Take Control are intentionally unavailable for an Existing VM. +
+ )} +
+ + ); + } + + const managedStatus = status?.source === "managed" ? status : null; + const c = managedStatus?.commands; + const ready = managedStatus?.ready === true; + const existing = managedStatus?.container !== "missing"; const needsRecreate = Boolean( existing && - (status?.container === "stopped" || - !status?.imageMatches || - !status?.managed || - status?.network === "unsafe" || - status?.security === "unsafe" || - status?.persistence === "unsafe"), + (managedStatus?.container === "stopped" || + !managedStatus?.imageMatches || + !managedStatus?.managed || + managedStatus?.network === "unsafe" || + managedStatus?.security === "unsafe" || + managedStatus?.persistence === "unsafe"), ); const unavailable = !loading && !status; - const host = status?.platform === "darwin" ? "Mac" : "computer"; - const perBot = status?.mode === "per-bot"; - const perBotRuntimeUnsupported = perBot && status?.runtime === "container"; - const headerReady = perBot ? Boolean(status?.daemonUp && status?.image && !perBotRuntimeUnsupported) : ready; + const host = managedStatus?.platform === "darwin" ? "Mac" : "computer"; + const perBot = managedStatus?.mode === "per-bot"; + const perBotRuntimeUnsupported = perBot && managedStatus?.runtime === "container"; + const headerReady = perBot ? Boolean(managedStatus?.daemonUp && managedStatus?.image && !perBotRuntimeUnsupported) : ready; return ( <> @@ -229,6 +424,23 @@ export function LocalComputerSection() { ? `Private Cua Linux desktops on this ${host}, with one container and durable workspace per bot. Distinct bots can work concurrently and idle desktops stop after 8 hours.` : `A shared Cua Linux sandbox on this ${host} for bots to browse and work in — isolated, backed by one durable workspace, and automatically recycled after 8 hours without activity.`} > +
+ {(["managed", "existing"] as const).map((value, index) => ( + + ))} +