diff --git a/apps/docs/content/docs/computers/local-computer.mdx b/apps/docs/content/docs/computers/local-computer.mdx index 57cb19f94..cba85ae72 100644 --- a/apps/docs/content/docs/computers/local-computer.mdx +++ b/apps/docs/content/docs/computers/local-computer.mdx @@ -14,6 +14,17 @@ The packaged app can request Accessibility and Screen Recording permission. Afte Ubuntu 24.04 GNOME host control is temporarily disabled while the real-seat input-safety blocker in issue #345 is resolved. Packaged builds retain the pinned runtime for reproducible review, but OpenMausBot does not start it and clears legacy opt-ins. Chat, screen preview, Cloud, and Local VM remain available. Do not start the bundled driver manually as a workaround. +## Existing VM + +Existing VM connects OpenMausBot to a Linux desktop that you already manage. It does not create, provision, isolate, delete, or manage the VM filesystem. + +1. Install the OpenSSH client on the computer running OpenMausBot and make sure `ssh` is available in its `PATH`. +2. Add an SSH config alias for the VM and make sure the alias works with your normal SSH key or agent authentication. +3. In **App Settings > Local VM**, choose **Existing VM**, enter the alias, and save it. +4. Install the pinned CUA Driver version shown in the readiness card on the VM. OpenMausBot checks Linux, the driver version, the required CUA MCP tools, and a complete desktop image before reporting Ready. + +OpenMausBot uses the saved alias and your SSH configuration; it does not ask for or store an SSH password or private key. The Computer panel provides a watch-only preview for an Existing VM. Live viewer access and **Take Control** are intentionally unavailable, while the bot can use the validated CUA connection for computer actions. + ## Safety model - Enabling local control globally does not assign it to a bot. 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 b84630a37..6b0dab9dd 100644 --- a/companion/test/proxy-response.test.ts +++ b/companion/test/proxy-response.test.ts @@ -172,13 +172,18 @@ describe("preparing a harness response for a device", () => { "transfer-encoding": "chunked", "cache-control": "public, max-age=3600", }); - 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, headers } = 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"); expect(headers.get("cache-control")).toBe("private, no-store"); expect(headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); }); diff --git a/companion/test/proxy.test.ts b/companion/test/proxy.test.ts index 817f2d016..128433486 100644 --- a/companion/test/proxy.test.ts +++ b/companion/test/proxy.test.ts @@ -131,7 +131,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")], { @@ -271,6 +274,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 @@ -374,6 +385,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 db58e4580..0e8354e48 100644 --- a/scripts/bundle-server.mjs +++ b/scripts/bundle-server.mjs @@ -51,6 +51,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 7eba56aed..5c6c8e59d 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -10,6 +10,8 @@ import { loadConfig, localVmMaxInstances, localVmMode, + localVmSource, + localVmSshAlias, parseConfigPatch, parseStoredConfig, roomTurnTimeoutMinutes, @@ -73,6 +75,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 }, }); @@ -91,6 +95,16 @@ describe("configuration boundaries", () => { ); }); + 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 3ee8693ef..44da7c3a9 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 featureConfigSchema = z.object({ /** Experimental desktop workflow recorder. Hidden unless explicitly enabled. */ @@ -109,9 +113,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; + }; /** Opt-in product experiments. Every flag defaults to disabled. */ features?: { skillRecorder?: boolean }; instances?: InstanceConfigMap; @@ -148,6 +157,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; +} + export function skillRecorderEnabled(cfg: AppConfig): boolean { return cfg.features?.skillRecorder === true; } diff --git a/server/container-computer.test.ts b/server/container-computer.test.ts index aae0f73e9..08388ad9c 100644 --- a/server/container-computer.test.ts +++ b/server/container-computer.test.ts @@ -20,6 +20,7 @@ import { computerProxyEnv, containerComputerAction, containerComputerMcp, + containerComputerManagedPerBotNames, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, @@ -28,9 +29,11 @@ import { perBotLocalVmTarget, podmanSecurityIsHardened, setupCommands, + wholeScreenshot, type CommandRunner, type LocalVmTarget, } from "./container-computer.ts"; +import { validPngFixture } from "./testing/png-fixture.ts"; function runner(responses: Record) { const calls: string[] = []; @@ -56,11 +59,36 @@ const readinessProbe = `${driverExec} call get_desktop_state {} --socket ${CUA_SOCKET} ` + "--screenshot-out-file /tmp/openmausbot-readiness.png"; const readinessRead = `docker exec ${CONTAINER} base64 -w0 /tmp/openmausbot-readiness.png`; -const validPng = Buffer.concat([ - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), - Buffer.alloc(600), - Buffer.from("IEND", "ascii"), -]); +const validPng = validPngFixture(); + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const chunk = Buffer.alloc(data.length + 12); + chunk.writeUInt32BE(data.length, 0); + body.copy(chunk, 4); + chunk.writeUInt32BE(crc32(body), data.length + 8); + return chunk; +} + +it("rejects a CRC-valid PNG chunk before IHDR", () => { + const signatureLength = 8; + const malformed = Buffer.concat([ + validPng.subarray(0, signatureLength), + pngChunk("tEXt", Buffer.from("before-header")), + validPng.subarray(signatureLength), + ]); + expect(wholeScreenshot(validPng).ok).toBe(true); + expect(wholeScreenshot(malformed).ok).toBe(false); +}); function preparedImageInspect() { return JSON.stringify([ @@ -136,6 +164,16 @@ function perBotReadyInspect(botId: string, viewerPort: number, targetLabel?: str } describe("containerComputerStatus", () => { + it("enumerates managed per-bot containers and fails closed on unknown names", async () => { + const suffix = "a".repeat(16); + const command = `docker ps -a --filter label=${MANAGED_LABEL}=1 --format {{.Names}}`; + const listed = runner({ [command]: `${CONTAINER}\n${CONTAINER}-${suffix}\n` }); + expect(await containerComputerManagedPerBotNames("docker", listed.run)).toEqual([`${CONTAINER}-${suffix}`]); + + const unknown = runner({ [command]: "openmausbot-computer-legacy\n" }); + expect(await containerComputerManagedPerBotNames("docker", unknown.run)).toBeNull(); + }); + it("prefers the supported Podman image store when Docker is also healthy on Windows", async () => { const fake = runner({ "where.exe podman": "C:\\Program Files\\RedHat\\Podman\\podman.exe\n", diff --git a/server/container-computer.ts b/server/container-computer.ts index f6eb236ef..f83e12f20 100644 --- a/server/container-computer.ts +++ b/server/container-computer.ts @@ -50,6 +50,7 @@ export const VM_WORKSPACE_GUEST = "/home/cua/workspace"; export const DISPLAY = ":1"; export const CUA_SOCKET = "/run/user/1000/openmausbot-cua.sock"; export const CUA_EXECUTABLE = "/usr/local/libexec/openmausbot/cua-driver"; +export const PER_BOT_VM_HOMES_DIR = join(DATA_DIR, "vm-homes"); const RUNTIMES = ["docker", "podman", "container"] as const; export type Runtime = (typeof RUNTIMES)[number]; @@ -89,7 +90,7 @@ export function perBotLocalVmTarget(botId: string): LocalVmTarget { return { key: `bot:${digest}`, containerName: `${CONTAINER}-${short}`, - workspaceDir: join(DATA_DIR, "vm-homes", short), + workspaceDir: join(PER_BOT_VM_HOMES_DIR, short), viewerPort: null, label: digest, }; @@ -971,13 +972,74 @@ export async function containerComputerExists( } } +/** Enumerate managed per-bot names so source changes cannot hide a container + * whose bot record no longer exists. Unknown managed names fail closed. */ +export async function containerComputerManagedPerBotNames( + runtime: Runtime, + runner: CommandRunner = sh, +): Promise { + try { + const { stdout } = await runner( + runtime, + ["ps", "-a", "--filter", `label=${MANAGED_LABEL}=1`, "--format", "{{.Names}}"], + 8_000, + ); + const names = [...new Set(stdout.split(/\r?\n/).map((name) => name.trim()).filter(Boolean))]; + const pattern = new RegExp(`^${CONTAINER}-[0-9a-f]{16}$`); + const perBotNames = names.filter((name) => name !== CONTAINER); + return perBotNames.every((name) => pattern.test(name)) ? perBotNames : null; + } catch { + return null; + } +} + export type ScreenshotCheck = { ok: boolean; mime: "image/png" | "image/jpeg" }; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function pngCrc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function structurallyValidPng(bytes: Buffer): boolean { + if (bytes.length < PNG_SIGNATURE.length || !bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return false; + let offset = PNG_SIGNATURE.length; + let hasHeader = false; + let hasData = false; + while (offset + 12 <= bytes.length) { + const length = bytes.readUInt32BE(offset); + const end = offset + 12 + length; + if (end > bytes.length) return false; + const type = bytes.subarray(offset + 4, offset + 8); + const data = bytes.subarray(offset + 8, offset + 8 + length); + const expectedCrc = bytes.readUInt32BE(offset + 8 + length); + if (pngCrc32(Buffer.concat([type, data])) !== expectedCrc) return false; + const name = type.toString("ascii"); + if (!hasHeader && name !== "IHDR") return false; + if (name === "IHDR") { + if (hasHeader || length !== 13 || data.readUInt32BE(0) === 0 || data.readUInt32BE(4) === 0) return false; + hasHeader = true; + } else if (name === "IDAT") { + if (!hasHeader) return false; + hasData = true; + } else if (name === "IEND") { + return hasHeader && hasData && length === 0 && end === bytes.length; + } + offset = end; + } + return false; +} + /** Shared with the BYO-VPS backend: a truncated base64 transfer must never * become a "successful" preview frame on either transport. */ export function wholeScreenshot(bytes: Buffer): ScreenshotCheck { if (bytes.length < 512) return { ok: false, mime: "image/png" }; - const png = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47; + const png = structurallyValidPng(bytes); if (png) { return { ok: bytes.subarray(Math.max(0, bytes.length - 12)).includes(Buffer.from("IEND", "ascii")), diff --git a/server/container-mcp.ts b/server/container-mcp.ts index 7bc48cd7f..d0c71b674 100644 --- a/server/container-mcp.ts +++ b/server/container-mcp.ts @@ -3,7 +3,7 @@ // piping, drain-safe exit, and watchdog live in mcp-bridge.ts, shared with // the VPS entry point. import { cuaExecArgs } from "./container-computer.ts"; -import { runMcpBridge } from "./mcp-bridge.ts"; +import { controlGateFromEnv, runMcpBridge } from "./mcp-bridge.ts"; const [runtime, container, socket] = process.argv.slice(2); if (!runtime || !["docker", "podman", "container"].includes(runtime)) { @@ -15,10 +15,15 @@ if (!container || !/^[a-zA-Z0-9_.-]+$/.test(container) || !socket?.startsWith("/ process.exit(2); } -// The who-is-driving pair rides in env, not argv — argv is world-readable -// through `ps`, and the token guards a loopback endpoint. -const controlUrl = process.env.OMB_CONTROL_URL ?? ""; -const controlToken = process.env.OMB_CONTROL_TOKEN ?? ""; +let gate: ReturnType; +try { + // The who-is-driving pair rides in env, not argv — argv is world-readable + // through `ps`, and the token guards a loopback endpoint. + gate = controlGateFromEnv("Local VM"); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : "incomplete Local VM control configuration"}\n`); + process.exit(2); +} runMcpBridge({ command: runtime, @@ -26,5 +31,5 @@ runMcpBridge({ label: "Cua Driver", // No liveness watchdog: the runtime CLI talks to a local daemon and fails // fast on its own — there is no silent WAN peer to wedge on. - ...(controlUrl && controlToken ? { gate: { url: controlUrl, token: controlToken } } : {}), + gate, }); diff --git a/server/existing-vm-mcp.test.ts b/server/existing-vm-mcp.test.ts new file mode 100644 index 000000000..2906b0413 --- /dev/null +++ b/server/existing-vm-mcp.test.ts @@ -0,0 +1,51 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +function runBridge( + entrypoint: string, + args: string[], + env: { OMB_CONTROL_URL?: string; OMB_CONTROL_TOKEN?: string }, +) { + return new Promise<{ code: number | null; stderr: string }>((resolve, reject) => { + const childEnv: NodeJS.ProcessEnv = { ...process.env, NODE_NO_WARNINGS: "1" }; + delete childEnv.OMB_CONTROL_URL; + delete childEnv.OMB_CONTROL_TOKEN; + const child = spawn( + process.execPath, + [fileURLToPath(new URL(`./${entrypoint}`, import.meta.url)), ...args], + { env: { ...childEnv, ...env }, stdio: ["pipe", "ignore", "pipe"] }, + ); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stderr })); + child.stdin.on("error", () => {}); + child.stdin.end(); + }); +} + +describe("Existing VM MCP bridge", () => { + it.each([ + { + entrypoint: "existing-vm-mcp.ts", + args: ["test-vm"], + label: "Existing VM", + }, + { + entrypoint: "container-mcp.ts", + args: ["docker", "openmausbot-computer", "/run/user/1000/openmausbot-cua.sock"], + label: "Local VM", + }, + { + entrypoint: "vps-container-mcp.ts", + args: ["test-vps", "openmausbot-computer"], + label: "VPS", + }, + ])("rejects partial control configuration for $label", async ({ entrypoint, args, label }) => { + const result = await runBridge(entrypoint, args, { OMB_CONTROL_URL: "http://127.0.0.1:1/control" }); + expect(result.code).toBe(2); + expect(result.stderr).toContain(`incomplete ${label} control configuration`); + }); +}); diff --git a/server/existing-vm-mcp.ts b/server/existing-vm-mcp.ts new file mode 100644 index 000000000..79b728df6 --- /dev/null +++ b/server/existing-vm-mcp.ts @@ -0,0 +1,34 @@ +// 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 { + controlGateFromEnv, + IncompleteControlConfigError, + 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 gate = controlGateFromEnv("Existing VM"); + 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 }, + gate, + }; + + runMcpBridge(options); +} catch (error) { + if (error instanceof IncompleteControlConfigError) { + process.stderr.write(`${error.message}\n`); + process.exit(2); + } + 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..9a5ad6e6e --- /dev/null +++ b/server/existing-vm.test.ts @@ -0,0 +1,260 @@ +import { mkdtempSync, readFileSync, 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, + closeExistingVmScreenshotSessions, + type ExistingVmOptions, +} from "./existing-vm.ts"; +import type { AppConfig } from "./config.ts"; +import { validPngFixture } from "./testing/png-fixture.ts"; + +const FIXED_SSH_OPTIONS = [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=5", + "-o", + "ServerAliveCountMax=2", +]; + +const validPng = validPngFixture(); +const malformedPng = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(600), + Buffer.from("IEND", "ascii"), +]); +const fakeSshSource = String.raw`import { appendFileSync, readFileSync } from "node:fs"; +import { Buffer } from "node:buffer"; + +const args = process.argv.slice(2); +const alias = args[9]; +const remote = args.slice(10).join(" "); +const validImage = Buffer.from(${JSON.stringify(validPng.toString("base64"))}, "base64"); +const malformedImage = Buffer.from(${JSON.stringify(malformedPng.toString("base64"))}, "base64"); + +const trace = process.env.EXISTING_VM_TEST_TRACE; +const traceNumber = trace && remote === "cua-driver mcp" + ? readFileSync(trace, "utf8").split("\n").filter(Boolean).length + 1 + : 0; +if (trace && remote === "cua-driver mcp") appendFileSync(trace, alias + "\n"); + +if (alias === "vm-unreachable") { + process.stderr.write("Connection refused\n"); + process.exit(255); +} +if (alias === "vm-overflow" && remote === "cua-driver mcp") { + process.stdout.write("x".repeat(2048)); + setInterval(() => {}, 1000); +} else if (alias === "vm-invalid-json" && remote === "cua-driver mcp") { + process.stdout.write("not-json\n"); + setInterval(() => {}, 1000); +} else if (alias === "vm-timeout") { + setInterval(() => {}, 1000); +} else if (remote === "uname -s") { + const output = alias === "vm-windows" ? "Windows_NT\n" : "Linux\n"; + if (alias === "vm-slow") setTimeout(() => { process.stdout.write(output); process.exit(0); }, 30); + else { + process.stdout.write(output); + process.exit(0); + } +} else 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); +} else if (remote !== "cua-driver mcp") process.exit(2); + +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") { + const image = alias === "vm-invalid-image" || (alias === "vm-session-failure" && traceNumber % 2 === 0) + ? malformedImage + : validImage; + 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(() => { + closeExistingVmScreenshotSessions(); + 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("rejects a PNG-shaped response with invalid chunk structure", async () => { + const status = await existingVmStatus(config("vm-invalid-image"), options); + expect(status.ready).toBe(false); + expect(status.errorCode).toBe("desktop"); + }); + + it("reuses and recreates the bounded screenshot MCP session", async () => { + const trace = join(temp, "screenshot-trace.log"); + writeFileSync(trace, "", "utf8"); + process.env.EXISTING_VM_TEST_TRACE = trace; + const sessionOptions = { ...options, screenshotSessionIdleMs: 5_000 }; + try { + await existingVmScreenshot(config("vm-reusable"), sessionOptions); + await existingVmScreenshot(config("vm-reusable"), sessionOptions); + const reusedCount = readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length; + expect(reusedCount).toBe(3); + + await new Promise((resolve) => setTimeout(resolve, 5_100)); + await existingVmScreenshot(config("vm-reusable"), sessionOptions); + const recreatedCount = readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length; + expect(recreatedCount).toBe(5); + } finally { + delete process.env.EXISTING_VM_TEST_TRACE; + closeExistingVmScreenshotSessions(); + } + }); + + it("recreates a screenshot session after a transport or image failure", async () => { + const trace = join(temp, "failure-trace.log"); + writeFileSync(trace, "", "utf8"); + process.env.EXISTING_VM_TEST_TRACE = trace; + try { + await expect(existingVmScreenshot(config("vm-session-failure"), options)).rejects.toThrow(); + await expect(existingVmScreenshot(config("vm-session-failure"), options)).rejects.toThrow(); + const count = readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length; + expect(count).toBe(4); + } finally { + delete process.env.EXISTING_VM_TEST_TRACE; + closeExistingVmScreenshotSessions(); + } + }); + + it("closes an MCP client after output-limit overflow", async () => { + const status = await existingVmStatus(config("vm-overflow"), { ...options, mcpLineLimit: 64 }); + expect(status.errorCode).toBe("mcp"); + expect(status.problem).toContain("output limit"); + }); + + it("closes an MCP client after invalid JSON", async () => { + const status = await existingVmStatus(config("vm-invalid-json"), options); + expect(status.errorCode).toBe("mcp"); + expect(status.problem).toContain("invalid JSON"); + }); + + it("deduplicates simultaneous forced readiness probes", async () => { + const trace = join(temp, "force-trace.log"); + writeFileSync(trace, "", "utf8"); + process.env.EXISTING_VM_TEST_TRACE = trace; + try { + const forced = { ...options, cacheStatus: true, force: true }; + await Promise.all([existingVmStatus(config("vm-slow"), forced), existingVmStatus(config("vm-slow"), forced)]); + const count = readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length; + expect(count).toBe(1); + } finally { + delete process.env.EXISTING_VM_TEST_TRACE; + closeExistingVmScreenshotSessions(); + } + }); + + it("distinguishes a missing SSH executable, an unreachable VM, and a timeout", async () => { + const missing = await existingVmStatus(config("vm-missing-ssh"), { + sshCommand: join(temp, "ssh-not-installed"), + }); + expect(missing.errorCode).toBe("ssh-missing"); + expect(missing.problem).toContain("OpenSSH (ssh) is not installed"); + + const unreachable = await existingVmStatus(config("vm-unreachable"), options); + expect(unreachable.errorCode).toBe("ssh-unreachable"); + + const timedOut = await existingVmStatus(config("vm-timeout"), { ...options, sshTimeoutMs: 20 }); + expect(timedOut.errorCode).toBe("timeout"); + }); + + 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..657591ce8 --- /dev/null +++ b/server/existing-vm.ts @@ -0,0 +1,762 @@ +// 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"; +import { z } from "zod"; + +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 TEST_SSH_COMMAND = process.env.OMB_TEST_SSH_COMMAND; +const TEST_SSH_PREFIX = (() => { + const raw = process.env.OMB_TEST_SSH_PREFIX; + if (!raw) return []; + try { + const parsed = z.array(z.string()).safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : []; + } catch { + return []; + } +})(); +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 SCREENSHOT_SESSION_IDLE_MS = 30_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-missing" + | "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[]; + /** Test-only command timeout override. */ + sshTimeoutMs?: number; + /** Test-only MCP line limit override. */ + mcpLineLimit?: number; + /** Test-only screenshot session idle timeout override. */ + screenshotSessionIdleMs?: number; + /** Test-only status cache enablement for injected SSH commands. */ + cacheStatus?: boolean; + /** Bypass the short status cache for an explicit user re-check. */ + force?: boolean; +}; + +type ExistingVmEnvironment = { + ELECTRON_RUN_AS_NODE: string; + OMB_CONTROL_URL?: string; + OMB_CONTROL_TOKEN?: string; +}; +type ExistingVmComputerMcp = { command: string; args: string[]; env: ExistingVmEnvironment }; + +type CommandResult = { stdout: string; stderr: string }; + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +type JsonObject = { [key: string]: JsonValue }; +type JsonRpcError = { message?: JsonValue }; +type JsonRpcResponse = { id?: number; result?: JsonValue; error?: JsonRpcError }; +type DesktopImage = { png: string; format: "png" | "jpeg" }; + +const jsonObjectSchema = z.record(z.string(), z.json()); +const jsonNumberSchema = z.number(); +const jsonStringSchema = z.string(); + +class CommandFailure extends Error { + readonly code?: string; + readonly stderr?: string; + + constructor(message: string, code?: string, stderr?: string) { + super(message); + this.name = "CommandFailure"; + this.code = code; + this.stderr = stderr; + } +} + +function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return jsonObjectSchema.safeParse(value).success; +} + +function isJsonNumber(value: JsonValue | undefined): value is number { + return jsonNumberSchema.safeParse(value).success; +} + +function isJsonString(value: JsonValue | undefined): value is string { + return jsonStringSchema.safeParse(value).success; +} + +function spawnErrorCode(error: Error): string | undefined { + if (!("code" in error)) return undefined; + return error.code === "ENOENT" ? "ENOENT" : undefined; +} + +function commandFailure(message: string, code?: string, stderr?: string): CommandFailure { + return new CommandFailure(message, code, stderr); +} + +/** 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 ?? TEST_SSH_PREFIX), ...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 = TEST_SSH_COMMAND ?? 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) { + const cause = error instanceof Error ? error : new Error(String(error)); + reject(commandFailure(`SSH could not start: ${cause.message}`, spawnErrorCode(cause))); + 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?.(); + }, options.sshTimeoutMs ?? 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}`, spawnErrorCode(error))); + }); + 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)}`)); + } + }); +} + +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 = TEST_SSH_COMMAND ?? 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, options.mcpLineLimit ?? MAX_MCP_LINE_CHARS)); + this.child.stderr.on("data", (chunk: string) => { + this.stderr = `${this.stderr}${chunk}`.slice(-4_096); + }); + this.child.on("error", (error) => this.fail(new ExistingVmError( + spawnErrorCode(error) === "ENOENT" ? "ssh-missing" : "mcp", + spawnErrorCode(error) === "ENOENT" + ? "OpenSSH (ssh) is not installed or is not available in PATH on the computer running OpenMausBot" + : `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"}`, + ), + ); + }); + } + + isClosed(): boolean { + return this.closed; + } + + private read(chunk: string, lineLimit: number): void { + if (this.closed) return; + this.buffer += chunk; + const failOutputLimit = () => { + this.buffer = ""; + this.closed = true; + this.fail(new ExistingVmError("mcp", "CUA MCP response exceeded its output limit")); + void this.close().catch(() => {}); + }; + const failTransport = (error: Error) => { + this.buffer = ""; + this.closed = true; + this.fail(error); + void this.close().catch(() => {}); + }; + let newline: number; + while ((newline = this.buffer.indexOf("\n")) !== -1) { + const rawLine = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + if (rawLine.length > lineLimit) { + failOutputLimit(); + return; + } + const line = rawLine.trim(); + if (!line) continue; + let message: JsonRpcResponse; + try { + const parsed: JsonValue = JSON.parse(line); + if (!isJsonObject(parsed)) throw new Error("not an object"); + message = {}; + if (isJsonNumber(parsed.id)) message.id = parsed.id; + if (parsed.result !== undefined) message.result = parsed.result; + if (isJsonObject(parsed.error)) message.error = { message: parsed.error.message }; + } catch { + failTransport(new ExistingVmError("mcp", "CUA MCP returned invalid JSON")); + return; + } + if (message.id === undefined) 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 ?? null); + } + } + if (this.buffer.length > lineLimit) failOutputLimit(); + } + + 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: JsonObject, 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: JsonObject = {}): 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 isImageContent(value: JsonValue): value is { type: "image"; data: string; mimeType?: string } { + return isJsonObject(value) && value.type === "image" && isJsonString(value.data) && + (value.mimeType === undefined || isJsonString(value.mimeType)); +} + +function desktopImage(result: JsonValue): DesktopImage { + const content = isJsonObject(result) && Array.isArray(result.content) ? result.content : []; + if (isJsonObject(result) && result.isError === true) { + const first = content[0]; + const message = isJsonObject(first) && isJsonString(first.text) + ? first.text + : "get_desktop_state reported an error"; + throw new ExistingVmError("desktop", message); + } + const image = content.find(isImageContent); + 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 tools = await initializeMcpClient(client); + const result = await client.request( + "tools/call", + { name: "get_desktop_state", arguments: {} }, + SCREENSHOT_TIMEOUT_MS, + ); + return { tools, screenshot: desktopImage(result) }; + } finally { + await client.close(); + } +} + +async function initializeMcpClient(client: ExistingVmMcpClient): Promise { + const initialized = await client.request("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "openmausbot-existing-vm", version: "1" }, + }); + if (!isJsonObject(initialized) || !isJsonString(initialized.protocolVersion)) { + throw new ExistingVmError("mcp", "CUA MCP initialize returned an invalid response"); + } + client.notify("notifications/initialized"); + const listed = await client.request("tools/list", {}); + const tools = isJsonObject(listed) && Array.isArray(listed.tools) + ? listed.tools.flatMap((tool) => isJsonObject(tool) && isJsonString(tool.name) ? [tool.name] : []) + : []; + 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(", ")}`); + return tools; +} + +type ExistingVmScreenshotSession = { + client: ExistingVmMcpClient; + initialized: Promise; + tail: Promise; + idleTimer: ReturnType | null; +}; + +const screenshotSessions = new Map(); + +function discardScreenshotSession(alias: string, session: ExistingVmScreenshotSession): void { + if (screenshotSessions.get(alias) !== session) return; + screenshotSessions.delete(alias); + if (session.idleTimer) clearTimeout(session.idleTimer); + void session.client.close().catch(() => {}); +} + +function armScreenshotSessionIdle(alias: string, session: ExistingVmScreenshotSession, idleMs: number): void { + if (session.idleTimer) clearTimeout(session.idleTimer); + session.idleTimer = setTimeout(() => { + if (screenshotSessions.get(alias) !== session) return; + discardScreenshotSession(alias, session); + }, idleMs); + session.idleTimer.unref?.(); +} + +function screenshotSessionFor(alias: string, options: ExistingVmOptions): ExistingVmScreenshotSession { + const current = screenshotSessions.get(alias); + if (current && !current.client.isClosed()) return current; + if (current) discardScreenshotSession(alias, current); + const client = new ExistingVmMcpClient(alias, options); + const session = { + client, + initialized: Promise.resolve([]), + tail: Promise.resolve(), + idleTimer: null, + } satisfies ExistingVmScreenshotSession; + session.initialized = initializeMcpClient(client).catch((error) => { + discardScreenshotSession(alias, session); + throw error; + }); + screenshotSessions.set(alias, session); + return session; +} + +async function screenshotFromSession(alias: string, options: ExistingVmOptions): Promise<{ png: string; format: "png" | "jpeg" }> { + const session = screenshotSessionFor(alias, options); + const operation = session.tail.then(async () => { + await session.initialized; + const result = await session.client.request( + "tools/call", + { name: "get_desktop_state", arguments: {} }, + SCREENSHOT_TIMEOUT_MS, + ); + armScreenshotSessionIdle(alias, session, options.screenshotSessionIdleMs ?? SCREENSHOT_SESSION_IDLE_MS); + return desktopImage(result); + }); + session.tail = operation.then(() => undefined, () => undefined); + try { + return await operation; + } catch (error) { + discardScreenshotSession(alias, session); + throw error; + } +} + +export function closeExistingVmScreenshotSessions(): void { + for (const [alias, session] of screenshotSessions) discardScreenshotSession(alias, session); +} + +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: Error, alias: string): string { + const message = error.message; + 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) { + const cause = error instanceof Error ? error : new Error(String(error)); + const missingSsh = spawnErrorCode(cause) === "ENOENT"; + const timedOut = cause instanceof ExistingVmError && cause.code === "timeout"; + status.ssh = "unreachable"; + status.errorCode = timedOut ? "timeout" : missingSsh ? "ssh-missing" : "ssh-unreachable"; + status.problem = timedOut + ? "SSH timed out while reaching the Existing VM" + : missingSsh + ? "OpenSSH (ssh) is not installed or is not available in PATH on the computer running OpenMausBot" + : "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) { + const cause = error instanceof Error ? error : new Error(String(error)); + const detail = safeDetail(cause, alias); + status.driver = "missing"; + status.errorCode = "cua-missing"; + status.problem = `CUA Driver is missing or unavailable on the Existing VM${detail ? `: ${detail}` : ""}`; + 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"; + const detail = error instanceof Error ? safeDetail(error, alias) : ""; + status.mcp = "failed"; + status.errorCode = code; + status.problem = code === "desktop" + ? `SSH reached CUA Driver, but it could not reach the graphical desktop${detail ? `: ${detail}` : ""}` + : `SSH-launched CUA MCP transport failed${detail ? `: ${detail}` : ""}`; + } + 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 || options.cacheStatus === true; + if (options.force) statusCache.delete(alias); + if (cacheable) { + const inFlight = statusInFlight.get(alias); + if (inFlight) return inFlight; + if (!options.force) { + const cached = statusCache.get(alias); + if (cached && cached.expiresAt > Date.now()) return cached.status; + } + } + 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 screenshotFromSession(alias, options); + } catch (error) { + if (!options.sshCommand) statusCache.delete(alias); + const detail = error instanceof Error ? 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 }, +): ExistingVmComputerMcp { + const alias = localVmSshAlias(cfg); + if (!alias) throw new Error("Existing VM is not configured — add an SSH config alias first"); + const env: ExistingVmEnvironment = { ELECTRON_RUN_AS_NODE: "1" }; + if (control) { + env.OMB_CONTROL_URL = control.url; + env.OMB_CONTROL_TOKEN = control.token; + } + return { + command: process.execPath, + args: [SPAWNED_PROXIES.existingVmMcp, alias], + env, + }; +} diff --git a/server/index.test.ts b/server/index.test.ts index 367859c8b..aa046cd0b 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -5,22 +5,25 @@ // the shadow-instance behavior end to end while it's at it. import { spawn, type ChildProcess } from "node:child_process"; import { createServer, request, type Server } from "node:http"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { removeTempDir, waitForExit } from "./testing/cleanup.ts"; import { openSse } from "./testing/sse.ts"; import { IMAGE_MAX_BYTES } from "./attachments.ts"; +import { validPngFixture } from "./testing/png-fixture.ts"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const ROOT = join(SERVER_DIR, ".."); const FAKE_CLAUDE_CLI = join(SERVER_DIR, "testing", "fake-claude-cli.ts"); -const PORT = 18800 + Math.floor(Math.random() * 10_000); +// Several integration files boot real harnesses in parallel. Derive these +// ports from the worker pid instead of competing for the same random range. +const PORT = 50_000 + (process.pid % 10_000); const BASE = `http://127.0.0.1:${PORT}`; -const WEBHOOK_PORT = 39000 + Math.floor(Math.random() * 10_000); +const WEBHOOK_PORT = 60_000 + (process.pid % 5_000); const WEBHOOK_BASE = `http://127.0.0.1:${WEBHOOK_PORT}`; let child: ChildProcess; @@ -30,6 +33,7 @@ let boxStubPort = 0; let home: string; let staticDir: string; let fakeClaudeDump: string; +let fakeSshBin: string; let stderr = ""; const api = async (method: string, path: string, body?: unknown): Promise<{ status: number; body: any }> => { @@ -68,11 +72,57 @@ beforeAll(async () => { home = mkdtempSync(join(tmpdir(), "omb-api-test-")); staticDir = join(home, "static"); fakeClaudeDump = join(home, "fake-claude-dump.json"); + fakeSshBin = join(home, "fake-ssh-bin"); // a fleet of exactly one unknown driver: no CLI probes, no network mkdirSync(join(home, ".openmausbot"), { recursive: true }); mkdirSync(join(staticDir, "assets"), { recursive: true }); + mkdirSync(fakeSshBin, { recursive: true }); writeFileSync(join(staticDir, "index.html"), "Packaged OpenMausBot"); writeFileSync(join(staticDir, "assets", "smoke.css"), "body { color: white; }"); + const fakeSshScript = join(home, "fake-existing-vm-ssh.mjs"); + const fakeImage = validPngFixture().toString("base64"); + writeFileSync( + fakeSshScript, + `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("Linux\\n"); process.exit(0); } +if (remote === "cua-driver --version") { process.stdout.write("cua-driver 0.20.0\\n"); process.exit(0); } +if (remote === "true") process.exit(0); +if (remote !== "cua-driver mcp") process.exit(2); +const image = Buffer.from(${JSON.stringify(fakeImage)}, "base64"); +const tools = ["get_desktop_state", "list_apps", "click", "type_text", "press_key", "scroll"]; +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; + const result = message.method === "initialize" + ? { protocolVersion: "2024-11-05" } + : message.method === "tools/list" + ? { tools: tools.map((name) => ({ name })) } + : { content: [{ type: "image", data: image.toString("base64"), mimeType: "image/png" }] }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }) + "\\n"); + } +}); +`, + "utf8", + ); + // On Windows the server still uses shell:false, so the fixture runs through + // OMB_TEST_SSH_COMMAND plus a script prefix instead of a .cmd shim, which + // CreateProcess cannot resolve without a shell. + if (process.platform !== "win32") { + const fakeSsh = join(fakeSshBin, "ssh"); + writeFileSync(fakeSsh, `#!/bin/sh\nexec "${process.execPath}" "${fakeSshScript}" "$@"\n`, "utf8"); + chmodSync(fakeSsh, 0o755); + } writeFileSync( join(home, ".openmausbot", "config.json"), JSON.stringify({ @@ -213,7 +263,7 @@ beforeAll(async () => { child = spawn(process.execPath, [join(SERVER_DIR, "index.ts")], { cwd: ROOT, env: { - ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + PATH: [fakeSshBin, process.env.PATH].filter(Boolean).join(delimiter), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), HOME: home, USERPROFILE: home, @@ -224,6 +274,9 @@ beforeAll(async () => { OMB_STATIC_DIR: staticDir, FAKE_CLAUDE_MODE: "hang", FAKE_CLAUDE_DUMP: fakeClaudeDump, + ...(process.platform === "win32" + ? { OMB_TEST_SSH_COMMAND: process.execPath, OMB_TEST_SSH_PREFIX: JSON.stringify([fakeSshScript]) } + : {}), }, stdio: ["ignore", "pipe", "pipe"], }); @@ -1693,7 +1746,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); @@ -1703,7 +1756,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`), @@ -1724,6 +1777,108 @@ describe("harness HTTP API", () => { await api("PATCH", "/api/config", { localVm: { mode: "shared", maxInstances: 2 } }); }); + it("does not hide managed per-bot workspaces when switching source or deleting a bot", async () => { + const botId = (await api("POST", "/api/bots", {})).body.bot.id; + let workspacePath = ""; + try { + expect((await api("PATCH", "/api/config", { localVm: { mode: "per-bot" } })).status).toBe(200); + workspacePath = (await api("GET", `/api/bots/${botId}/local-computer`)).body.workspace_path; + mkdirSync(workspacePath, { recursive: true }); + + const refused = await api("PATCH", "/api/config", { + localVm: { source: "existing", sshAlias: "fixture-existing-vm" }, + }); + expect(refused.status).toBe(409); + expect(refused.body.error).toMatch(/per-bot Local VM/i); + + rmSync(workspacePath, { recursive: true, force: true }); + const switched = await api("PATCH", "/api/config", { + localVm: { source: "existing", sshAlias: "fixture-existing-vm" }, + }); + expect(switched.status).toBe(200); + + mkdirSync(workspacePath, { recursive: true }); + const deletion = await api("DELETE", `/api/bots/${botId}`); + expect(deletion.status).toBe(409); + expect(deletion.body.error).toMatch(/Local VM/i); + rmSync(workspacePath, { recursive: true, force: true }); + expect((await api("DELETE", `/api/bots/${botId}`)).status).toBe(200); + } finally { + if (workspacePath) rmSync(workspacePath, { recursive: true, force: true }); + await api("PATCH", "/api/config", { localVm: { source: "managed", mode: "shared", maxInstances: 2, sshAlias: "" } }); + await api("DELETE", `/api/bots/${botId}`); + } + }); + + 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 sourceChange = await api("PATCH", "/api/config", { localVm: { source: "managed" } }); + expect(sourceChange.status).toBe(409); + expect(sourceChange.body.error).toContain("active Existing VM turn"); + const aliasChange = await api("PATCH", "/api/config", { localVm: { sshAlias: "another-existing-vm" } }); + expect(aliasChange.status).toBe(409); + expect(aliasChange.body.error).toContain("active Existing VM turn"); + 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; @@ -1773,6 +1928,44 @@ describe("harness HTTP API", () => { } }); + it("clears active room ownership when provider settings reload", async () => { + const bot = (await api("POST", "/api/bots", {})).body.bot; + const room = (await api("POST", "/api/groups", { + name: "Provider reload room", + memberIds: [bot.id], + })).body.group; + const ready = await api("PATCH", `/api/groups/${room.id}/setup`, { action: "skip" }); + expect(ready.status).toBe(200); + try { + expect((await api("PATCH", `/api/bots/${bot.id}`, { + modelSelection: { instanceId: "claude", model: "claude-sonnet-5" }, + })).status).toBe(200); + expect((await api("POST", `/api/groups/${room.id}/messages`, { text: "hold for reload" })).status).toBe(202); + await expect.poll(async () => { + const state = (await api("GET", "/api/bots")).body; + return { + botBusy: state.bots.find((candidate: { id: string }) => candidate.id === bot.id)?.busy, + roomBusyBotId: state.groups.find((candidate: { id: string }) => candidate.id === room.id)?.busyBotId, + }; + }, { timeout: 5_000 }).toEqual({ botBusy: true, roomBusyBotId: bot.id }); + + const reload = await api("PATCH", "/api/instances/claude", { cli: FAKE_CLAUDE_CLI }); + expect(reload.status).toBe(200); + await expect.poll(async () => { + const state = (await api("GET", "/api/bots")).body; + return { + botBusy: state.bots.find((candidate: { id: string }) => candidate.id === bot.id)?.busy, + roomBusyBotId: state.groups.find((candidate: { id: string }) => candidate.id === room.id)?.busyBotId, + }; + }, { timeout: 5_000 }).toEqual({ botBusy: false, roomBusyBotId: null }); + expect((await api("DELETE", `/api/groups/${room.id}`)).status).toBe(200); + } finally { + await api("POST", `/api/groups/${room.id}/interrupt`); + await api("DELETE", `/api/groups/${room.id}`); + await api("DELETE", `/api/bots/${bot.id}`); + } + }); + 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]; diff --git a/server/index.ts b/server/index.ts index 03d74acf5..95da2a9de 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2,7 +2,7 @@ // (upstream rule): the React app dispatches typed commands over HTTP and // folds one SSE event stream; every provider process runs here. import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { isIP } from "node:net"; import { extname, join } from "node:path"; @@ -42,6 +42,8 @@ import { containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, + containerComputerManagedPerBotNames, + PER_BOT_VM_HOMES_DIR, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, @@ -54,6 +56,8 @@ import { loadConfig, localVmMaxInstances, localVmMode, + localVmSource, + localVmSshAlias, parseConfigPatch, roomTurnTimeoutMinutes, saveConfig, @@ -69,7 +73,7 @@ import { ComputerControl } from "./computer-control.ts"; import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.ts"; import { describeSpawnFailure, execCli } from "./procs.ts"; import { buildNotification, type Notification } from "./notify.ts"; -import { isEffortLevel, type RequestOutcome, type RuntimeEvent } from "./contracts.ts"; +import { isEffortLevel, type ProviderInstance, type RequestOutcome, type RuntimeEvent } from "./contracts.ts"; import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.ts"; import { BUILT_IN_DRIVERS } from "./drivers/builtIn.ts"; @@ -129,6 +133,13 @@ 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, + closeExistingVmScreenshotSessions, + existingVmComputerMcp, + existingVmScreenshot, + existingVmStatus, +} from "./existing-vm.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; import { fetchBotDirectory, matchDirectoryBots, type MatchedDirectoryBot } from "./bot-directory.ts"; import { scoutProject, suggestTeam } from "./project-scout.ts"; @@ -163,6 +174,7 @@ ensureDirs(); const cfg = loadConfig(); const registry = new ProviderRegistry(BUILT_IN_DRIVERS); await registry.load(instanceConfigs(cfg)); +let providerGeneration = 0; const bundledSkills = loadBundledSkills(); const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills"))); @@ -185,6 +197,48 @@ utilityParentPort?.on("message", (event) => { const bus = new EventBus(); bus.attach(registry.instances()); +const activeTurnIds = new Map(); +const staleTurnIds = new Set(); +const pendingTurnInterrupts = new Set(); + +function rememberStaleTurn(turnId: string): void { + staleTurnIds.add(turnId); + if (staleTurnIds.size > 1024) { + const oldest = staleTurnIds.values().next().value; + if (oldest) staleTurnIds.delete(oldest); + } +} + +function beginTurnThread(threadId: string): void { + const previous = activeTurnIds.get(threadId); + if (previous) rememberStaleTurn(previous); + activeTurnIds.delete(threadId); + pendingTurnInterrupts.delete(threadId); +} + +async function interruptProviderTurn( + instance: ProviderInstance | null | undefined, + threadId: string, + queueIfNotActive = false, +): Promise { + if (!instance) return; + if (queueIfNotActive) pendingTurnInterrupts.add(threadId); + await instance.adapter.interruptTurn(threadId).catch(() => {}); +} + +function observeTurnStarted(event: RuntimeEvent): void { + if (event.type !== "turn.started" || !event.turnId || staleTurnIds.has(event.turnId)) return; + const previous = activeTurnIds.get(event.threadId); + if (previous && previous !== event.turnId) rememberStaleTurn(previous); + activeTurnIds.set(event.threadId, event.turnId); +} + +function isStaleTurnEvent(event: RuntimeEvent): boolean { + if (!event.turnId) return false; + return staleTurnIds.has(event.turnId) || ( + activeTurnIds.has(event.threadId) && activeTurnIds.get(event.threadId) !== event.turnId + ); +} // ── peer-agent comms wiring ──────────────────────────────────────────── // A shared secret guards the localhost-only /api/internal endpoints the @@ -279,6 +333,7 @@ function askBotAndWait(targetBotId: string, message: string, depth: number, from }; const unsub = bus.subscribe((e: RuntimeEvent) => { if (e.threadId !== threadId) return; + if (isStaleTurnEvent(e)) return; if (e.type === "item.completed" && e.itemType === "assistant_text") { text += (text ? "\n" : "") + e.text; } else if (e.type === "turn.completed") { @@ -620,6 +675,13 @@ const repeats = new RepeatDetector({ thresholds: [5, 10, 20], maxKeysPerThread: // touched, and turns parked on a human approval are exempt. const TURN_STALL_MS = Math.max(60_000, Number(process.env.OMB_TURN_STALL_MS) || 20 * 60_000); const roomStallCompletions = new RoomTurnStallRegistry(); +const stalledTurnReleases = new Map>(); +function clearStalledTurnRelease(threadId: string): void { + const timer = stalledTurnReleases.get(threadId); + if (!timer) return; + clearTimeout(timer); + stalledTurnReleases.delete(threadId); +} const watchdog = new TurnWatchdog({ stallMs: TURN_STALL_MS, checkMs: 60_000, @@ -627,7 +689,7 @@ const watchdog = new TurnWatchdog({ repeats.settle(turn.threadId); const bot = store.bot(turn.botId); const instance = bot ? registry.get(bot.modelSelection.instanceId) : null; - void instance?.adapter.interruptTurn(turn.threadId).catch(() => {}); + void interruptProviderTurn(instance, turn.threadId, true); const minutes = Math.round(TURN_STALL_MS / 60_000); store.appendMessage(turn.threadId, { role: "bot", @@ -637,11 +699,15 @@ const watchdog = new TurnWatchdog({ finalizeDelegationWatch(turn.threadId, false, "", "Delegated turn stalled and was stopped"); turnUsage.delete(turn.threadId); roomStallCompletions.stall(turn.threadId); + stopHumanWaitLeaseRenewal(turn.threadId); + beginTurnThread(turn.threadId); // ACP interruption settles within five seconds; other adapters settle // sooner. Keep ownership during that grace period so another turn cannot // overlap the process we are stopping. The normal turn.completed fold // clears it first when the adapter responds. + clearStalledTurnRelease(turn.threadId); const release = setTimeout(() => { + stalledTurnReleases.delete(turn.threadId); const group = store.groupByThread(turn.threadId); const speaker = groupSpeakers.get(turn.threadId); if (group && group.busyBotId === turn.botId && speaker?.botId === turn.botId) { @@ -652,6 +718,8 @@ const watchdog = new TurnWatchdog({ if (currentBot?.busy) { stopScreenPoller(currentBot.id); if (activeVpsThreads.get(currentBot.id) === turn.threadId) activeVpsThreads.delete(currentBot.id); + releaseLocalVmThread(turn.threadId); + releaseExistingVmThread(turn.threadId); store.setActivity(currentBot.id, "idle"); // The grace fallback replaces a missing turn.completed event. Release // every kind of work that may have queued behind this bot, including @@ -661,15 +729,27 @@ const watchdog = new TurnWatchdog({ drainSecretResumes(); } }, 6_000); + stalledTurnReleases.set(turn.threadId, release); release.unref?.(); }, }); watchdog.start(); bus.subscribe((event: RuntimeEvent) => { - if (event.type === "request.opened") watchdog.setWaitingOnHuman(event.threadId, true); - else if (event.type === "request.resolved") watchdog.setWaitingOnHuman(event.threadId, false); - else if (event.type === "turn.completed") watchdog.settle(event.threadId); + observeTurnStarted(event); + if (isStaleTurnEvent(event)) return; + if (event.type === "request.opened") { + watchdog.setWaitingOnHuman(event.threadId, true); + renewHumanWaitLease(event.threadId); + } else if (event.type === "request.resolved") { + watchdog.setWaitingOnHuman(event.threadId, false); + stopHumanWaitLeaseRenewal(event.threadId); + } + else if (event.type === "turn.completed") { + pendingTurnInterrupts.delete(event.threadId); + clearStalledTurnRelease(event.threadId); + watchdog.settle(event.threadId); + } else watchdog.touch(event.threadId); }); @@ -713,15 +793,48 @@ let routines: RoutineManager | null = null; const localVmOwnerBusy = (botId: string) => store.bot(botId)?.busy === true; const localVmLeases = new LocalVmLeasePool(30 * 60_000); const localVmLifecycleBusy = new Set(); +const botDeletionBusy = new Set(); const localVmThreadTargets = new Map(); const localVmActiveThreads = new Map(); 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 humanWaitLeaseRenewals = new Map>(); +const LEASE_RENEWAL_MS = 5 * 60_000; const LOCAL_VM_IDLE_MS = 8 * 60 * 60_000; const localVmIdles = new Map(); +function stopHumanWaitLeaseRenewal(threadId: string): void { + const timer = humanWaitLeaseRenewals.get(threadId); + if (!timer) return; + clearInterval(timer); + humanWaitLeaseRenewals.delete(threadId); +} + +function renewHumanWaitLease(threadId: string): void { + const touch = () => { + const target = localVmThreadTargets.get(threadId); + if (target) { + localVmLeaseFor(target).touch(threadId); + return; + } + if (existingVmThreadIds.has(threadId)) { + existingVmLease.touch(threadId); + return; + } + stopHumanWaitLeaseRenewal(threadId); + }; + touch(); + if (humanWaitLeaseRenewals.has(threadId)) return; + const timer = setInterval(touch, LEASE_RENEWAL_MS); + timer.unref?.(); + humanWaitLeaseRenewals.set(threadId, timer); +} + function localVmTargetForBot(botId: string): LocalVmTarget { return localVmMode(cfg) === "per-bot" ? perBotLocalVmTarget(botId) : SHARED_LOCAL_VM_TARGET; } @@ -756,6 +869,7 @@ function localVmIdleFor(target: LocalVmTarget): LocalVmIdleTimer { } function releaseLocalVmThread(threadId: string): void { + stopHumanWaitLeaseRenewal(threadId); const target = localVmThreadTargets.get(threadId); if (!target) return; localVmLeaseFor(target).release(threadId); @@ -763,9 +877,20 @@ function releaseLocalVmThread(threadId: string): void { localVmThreadTargets.delete(threadId); } +function releaseExistingVmThread(threadId: string, closeSessions = true): void { + stopHumanWaitLeaseRenewal(threadId); + const botId = existingVmThreadIds.get(threadId); + if (!botId) return; + existingVmLease.release(threadId); + if (existingVmActiveThreads.get(botId) === threadId) existingVmActiveThreads.delete(botId); + existingVmThreadIds.delete(threadId); + if (closeSessions) closeExistingVmScreenshotSessions(); +} + // 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]; @@ -776,13 +901,20 @@ void (async () => { })(); bus.subscribe((event: RuntimeEvent) => { + if (isStaleTurnEvent(event)) return; const localVmTarget = localVmThreadTargets.get(event.threadId); if (localVmTarget) { 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); + const completionBot = store.botByThread(event.threadId); + const deferCompletionRelease = Boolean(completionBot && screenPollers.has(completionBot.id)); + if (!deferCompletionRelease) { + releaseLocalVmThread(event.threadId); + releaseExistingVmThread(event.threadId); + } } broadcast({ kind: "runtime", event }); const routineRun = routines?.handleRuntimeEvent(event) ?? null; @@ -1050,25 +1182,38 @@ bus.subscribe((event: RuntimeEvent) => { output: tokens?.output, costUsd: event.cost ?? null, }); + const hasFinalFrame = screenPollers.has(bot.id); // settled → idle; a setup failure already marked it dead, keep that - if (store.bot(bot.id)?.activity !== "dead") store.setActivity(bot.id, "idle"); + if (!hasFinalFrame && store.bot(bot.id)?.activity !== "dead") store.setActivity(bot.id, "idle"); store.patchBot(bot.id, { unread: true }); if (routineRun?.status !== "failed") { // the frame carries the bot's avatar so every desktop client can // show the notification under that bot's own face notify(buildNotification("done", bot, event.threadId, reply, { avatarUrl: bot.avatarUrl })); } - if (screenPollers.has(bot.id)) { + if (hasFinalFrame) { // the last live frame becomes a settled inline screen message — // the screenshot-in-chat moment. One fresh capture first, so the // frame shows the turn's END state (the final tool's poke may // still be in flight). - void finalScreenFrame(bot.id).then((frame) => { + const finalization = finalScreenFrame(bot.id).then((frame) => { // the bot may have been deleted while the capture ran if (frame && store.bot(bot.id)) { pushMessage({ role: "bot", kind: "screen", png: frame.png, mime: frame.mime }); } - }).finally(clearVpsTurn); + }, () => undefined).finally(() => { + releaseLocalVmThread(event.threadId); + releaseExistingVmThread(event.threadId); + if (store.bot(bot.id)?.activity !== "dead") store.setActivity(bot.id, "idle"); + clearVpsTurn(); + drainConnectorResumes(); + drainQueuedSends(); + }); + pendingFinalFrames.set(event.threadId, finalization); + void finalization.then( + () => { if (pendingFinalFrames.get(event.threadId) === finalization) pendingFinalFrames.delete(event.threadId); }, + () => { if (pendingFinalFrames.get(event.threadId) === finalization) pendingFinalFrames.delete(event.threadId); }, + ); } else if (vpsTurn) { clearVpsTurn(); } @@ -1131,6 +1276,7 @@ function finalizeDelegationWatch( // may be five different commands. Arguments come from ACP item titles and // from every permission ask's summary (the command being approved). bus.subscribe((event: RuntimeEvent) => { + if (isStaleTurnEvent(event)) return; if (event.type === "turn.completed" || event.type === "session.exited") return void repeats.settle(event.threadId); let key: string | null = null; if (event.type === "item.started" && event.itemType === "tool") { @@ -1200,6 +1346,7 @@ const runDelegatedTurn: Parameters[3] = (toBotId, text, }; bus.subscribe((event: RuntimeEvent) => { + if (isStaleTurnEvent(event)) return; if (event.type !== "turn.completed") return; // A turn that failed or was interrupted drops its queue rather than // firing it later: the user who hit Stop does not expect the delegations @@ -1220,11 +1367,13 @@ bus.subscribe((event: RuntimeEvent) => { // user's own words — stop-then-steer is the point, so an interrupted turn // drains too. bus.subscribe((event: RuntimeEvent) => { + if (isStaleTurnEvent(event)) return; if (event.type !== "turn.completed") return; drainQueuedSends(); }); function drainQueuedSends() { + if (providerConfigBusy) return; drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds) => // A plain attended turn — no automationSource, no unattended, no comms // depth: exactly what typing the same words into an idle bot would run. @@ -1262,6 +1411,7 @@ const screenPollers = new Map< touched: boolean; } >(); +const pendingFinalFrames = new Map>(); /** The preview shares the box's single command endpoint with the agent's * own actions, so every frame we take is latency stolen from the work the @@ -1379,8 +1529,15 @@ async function startTurn( ) { const bot = store.bot(botId); if (!bot) throw Object.assign(new Error("no such bot"), { status: 404 }); + if (botDeletionBusy.has(bot.id)) { + throw Object.assign(new Error("this bot is being deleted — wait for it to settle"), { status: 409 }); + } + if (providerConfigBusy) { + throw Object.assign(new Error("provider settings are being updated — retry this turn"), { status: 409 }); + } if (bot.busy) throw Object.assign(new Error("the bot is already working — interrupt it first"), { status: 409 }); const threadId = opts?.threadId ?? bot.threadId; + beginTurnThread(threadId); // a webhook turn, or one inherited from a bot already running unattended if (opts?.automationSource === "webhook" || opts?.unattended) markUnattended(bot.id); // a person typing into this bot ends the unattended window immediately @@ -1405,6 +1562,7 @@ async function startTurn( ); } const instanceId = instance.instanceId; + const providerGenerationAtStart = providerGeneration; const model = opts?.runOn === "cloud" ? instance.models.default : bot.modelSelection.model; // a cloud routine borrows the instance default model, so it borrows no // per-bot effort either @@ -1473,6 +1631,41 @@ async function startTurn( .filter(Boolean) .join(" "); + const wants = opts?.runOn === "cloud" ? "cloud" : bot.computer; + const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true; + let reservedLocalVmTarget: LocalVmTarget | null = null; + let reservedExistingVm = false; + // Reserve VM ownership before background setup can await integrations or + // readiness. Config and lifecycle routes must see this turn immediately. + if (wants === "vm") { + if (localVmModeChangeBusy) { + throw new Error("the Local VM source or isolation policy is changing — wait for setup to finish"); + } + 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"); + } + if (localVmSource(cfg) === "existing") { + 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); + reservedExistingVm = true; + } 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"); + } + 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(); + reservedLocalVmTarget = localVmTarget; + } + } + // busy flips immediately so the composer locks; the dispatch itself runs // in the background — box provisioning can take ~90s and must never // hang the HTTP request @@ -1526,46 +1719,44 @@ async function startTurn( // tools that would fail on every call or spawn an unnecessary proxy. 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 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"; 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; let autoVpsProblem: string | 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. if (wants === "vm") { - 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 (reservedExistingVm) { + // 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. + 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 = reservedLocalVmTarget; + if (!localVmTarget) throw new Error("the Local VM destination was not reserved"); + 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", @@ -1723,6 +1914,9 @@ async function startTurn( // (activeVpsThreads was already claimed above, before the provision or // reuse await, so the backend guards saw this turn the whole time.) + if (providerConfigBusy || providerGeneration !== providerGenerationAtStart) { + throw new Error("the provider settings changed while this turn was starting — retry it"); + } watchdog.watch(threadId, bot.id); await instance.adapter.sendTurn({ threadId, @@ -1744,6 +1938,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." : "") + @@ -1772,6 +1968,12 @@ async function startTurn( integrations, cwd, }); + // An interrupt can arrive after the bot becomes busy but before a + // provider registers its active turn. Replay that request now that the + // adapter owns the thread instead of leaving a hung process running. + if (pendingTurnInterrupts.delete(threadId)) { + await instance.adapter.interruptTurn(threadId).catch(() => {}); + } // dispatched: the rewind is spent, and the old cursors are dead if (rewound) store.patchBot(bot.id, { rewound: false, resumeCursors: {} }); // and this engine now owns the thread's most recent turn @@ -1784,7 +1986,9 @@ async function startTurn( startScreenPoller(bot.id, previewCapture, { screenIsTheWork: instance.driverKind === "boxAgent" }); } } catch (e) { + pendingTurnInterrupts.delete(threadId); releaseLocalVmThread(threadId); + releaseExistingVmThread(threadId); if (activeVpsThreads.get(bot.id) === threadId) activeVpsThreads.delete(bot.id); watchdog.settle(threadId); turnUsage.delete(threadId); @@ -1829,7 +2033,7 @@ routines = new RoutineManager({ : bot ? registry.get(bot.modelSelection.instanceId) : null; - await instance?.adapter.interruptTurn(threadId); + await interruptProviderTurn(instance, threadId, true); }, onRunFailed: (run) => { const bot = store.bot(run.botId); @@ -1937,6 +2141,7 @@ async function runGroupMemberTurn( spoken.add(botId); const instance = registry.get(bot.modelSelection.instanceId); const userName = cfg.profile?.name?.trim() || "User"; + const providerGenerationAtStart = providerGeneration; if (!instance) { const message = `${bot.name}'s model is unavailable`; store.appendMessage(group.threadId, { @@ -1946,7 +2151,7 @@ async function runGroupMemberTurn( tool: { name: `error: ${message}`, ok: false }, }); onDispatchError?.(message); - return true; + return false; } // One turn per bot at a time, across BOTH engines. Without this a bot // could run its 1:1 turn and a room turn concurrently — two provider @@ -1961,7 +2166,25 @@ async function runGroupMemberTurn( tool: { name: message, ok: false }, }); onDispatchError?.(message); - return true; + return false; + } + if (botDeletionBusy.has(bot.id)) { + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + from: { botId: bot.id, name: bot.name, color: bot.color }, + tool: { name: `${bot.name} is being deleted — skipped this round`, ok: false }, + }); + return false; + } + if (providerConfigBusy) { + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + from: { botId: bot.id, name: bot.name, color: bot.color }, + tool: { name: "error: provider settings are being updated — retry this room turn", ok: false }, + }); + return false; } const integrations: NonNullable[0]["integrations"]> = {}; if (hop < MAX_COMMS_DEPTH && instance.adapter.capabilities.agentsMcp === true) { @@ -1982,6 +2205,7 @@ async function runGroupMemberTurn( } } catch (error) { const message = `connected apps are unavailable — ${error instanceof Error ? error.message : String(error)}`; + if (!store.group(group.id)) return false; store.appendMessage(group.threadId, { role: "bot", kind: "activity", @@ -1989,8 +2213,42 @@ async function runGroupMemberTurn( tool: { name: `error: ${message}`, ok: false }, }); onDispatchError?.(message); - return true; + return false; } + // Connected-app setup yields before the turn owns the room. A deletion can + // therefore win during that await; never dispatch into a removed room. + if (!store.group(group.id)) return false; + const currentBot = store.bot(bot.id); + if (!currentBot) return false; + if (currentBot.busy) { + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + from: { botId: bot.id, name: bot.name, color: bot.color }, + tool: { name: `${bot.name} became busy in another conversation — skipped this round`, ok: false }, + }); + return false; + } + if (botDeletionBusy.has(bot.id)) return false; + if (providerGeneration !== providerGenerationAtStart) { + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + from: { botId: bot.id, name: bot.name, color: bot.color }, + tool: { name: "error: provider settings changed while this room turn was starting — retry it", ok: false }, + }); + return false; + } + if (providerConfigBusy) { + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + from: { botId: bot.id, name: bot.name, color: bot.color }, + tool: { name: "error: provider settings are being updated — retry this room turn", ok: false }, + }); + return false; + } + beginTurnThread(group.threadId); store.setActivity(bot.id, "working"); store.patchGroup(group.id, { busyBotId: bot.id }); // the store's change stream carries the frame @@ -2064,6 +2322,7 @@ async function runGroupMemberTurn( }; unsub = bus.subscribe((e: RuntimeEvent) => { if (e.threadId !== group.threadId) return; + if (isStaleTurnEvent(e)) return; if (e.type === "item.completed" && e.itemType === "assistant_text") replyText += `\n${e.text}`; else if (e.type === "turn.completed") finish("settled"); // Waiting on a person is not turn work: hold the ceiling while an @@ -2075,14 +2334,22 @@ async function runGroupMemberTurn( deadline.start(); unregisterStall = roomStallCompletions.register(group.threadId, () => finish("stalled")); watchdog.watch(group.threadId, bot.id); - instance.adapter - .sendTurn({ + instance.adapter + .sendTurn({ threadId: group.threadId, text, system: roomSystem, cwd, integrations, - ...memberTurnSelection(bot.modelSelection), + ...memberTurnSelection(bot.modelSelection), + }) + .then(async () => { + // A room interrupt can arrive after the room is marked busy but before + // the provider registers its turn. Replay it once dispatch owns the + // thread, matching the single-turn dispatch path above. + if (pendingTurnInterrupts.delete(group.threadId)) { + await instance.adapter.interruptTurn(group.threadId).catch(() => {}); + } }) .catch((err) => { const message = err instanceof Error ? err.message : "turn failed"; @@ -2124,7 +2391,9 @@ async function runGroupMemberTurn( .filter((b): b is NonNullable => Boolean(b) && b!.id !== bot.id); for (const next of roomResponders(replyText, members, { kind: "mentions" })) { if (spoken.has(next.id)) continue; - if (!(await runGroupMemberTurn(groupId, next.id, hop + 1, spoken))) return false; + const settled = await runGroupMemberTurn(groupId, next.id, hop + 1, spoken); + const currentAfterTurn = store.group(groupId); + if (!settled && (!currentAfterTurn || currentAfterTurn.busyBotId || providerConfigBusy)) return false; } } return true; @@ -2197,7 +2466,11 @@ function startGroupTurn(groupId: string, text: string, replyTo?: Message) { const spoken = new Set(); for (const responder of responders) { if (spoken.has(responder.id)) continue; - if (!(await runGroupMemberTurn(groupId, responder.id, 0, spoken))) break; + const settled = await runGroupMemberTurn(groupId, responder.id, 0, spoken); + const currentAfterTurn = store.group(groupId); + // A skipped member does not own the room, so other independent + // responders may still speak. A stalled turn, reload, or deletion does. + if (!settled && (!currentAfterTurn || currentAfterTurn.busyBotId || providerConfigBusy)) break; } }); groupQueues.set(groupId, next.catch(() => {})); @@ -2280,7 +2553,10 @@ function dispatchConnectorResume(entry: { botId: string; threadId: string; resum pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry); return; } - await runGroupMemberTurn(current.group.id, entry.botId, 0, new Set(), prompt); + const settled = await runGroupMemberTurn(current.group.id, entry.botId, 0, new Set(), prompt); + if (!settled && store.bot(entry.botId)) { + pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry); + } }); groupQueues.set(owner.group.id, next.catch((error) => { markConnectorResumeFailed(entry.threadId, entry.resumeKey, error instanceof Error ? error.message : String(error)); @@ -2311,6 +2587,7 @@ function maybeResumeConnectors(botId: string, threadId: string, resumeKey: strin } function drainConnectorResumes() { + if (providerConfigBusy) return; for (const [key, entry] of pendingConnectorResumes) { if (store.bot(entry.botId)?.busy) continue; pendingConnectorResumes.delete(key); @@ -2422,6 +2699,7 @@ function drainSecretResumes() { } bus.subscribe((event: RuntimeEvent) => { + if (isStaleTurnEvent(event)) return; if (event.type === "turn.completed") { drainConnectorResumes(); drainSecretResumes(); @@ -2501,9 +2779,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, @@ -2521,20 +2803,33 @@ async function existingPerBotLocalVmCount(runtime: Runtime) { return existing.filter(Boolean).length; } +function perBotLocalVmWorkspaceNames(): string[] | null { + if (!existsSync(PER_BOT_VM_HOMES_DIR)) return []; + try { + const entries = readdirSync(PER_BOT_VM_HOMES_DIR, { withFileTypes: true }); + if (entries.some((entry) => !entry.isDirectory() || !/^[0-9a-f]{16}$/.test(entry.name))) return null; + return entries.map((entry) => entry.name); + } catch { + return null; + } +} + async function perBotLocalVmCountForModeChange(): Promise { - const targets = [...new Map(store.bots.map((bot) => { - const target = perBotLocalVmTarget(bot.id); - return [target.key, target] as const; - })).values()]; - if (targets.length === 0) return 0; + const workspaceNames = perBotLocalVmWorkspaceNames(); + if (workspaceNames === null) return null; const runtime = await containerRuntimeStatus(); if (!runtime.runtime || !runtime.daemonUp) { - return targets.some((target) => existsSync(target.workspaceDir)) ? null : 0; + return workspaceNames.length > 0 ? null : 0; } - return existingPerBotLocalVmCount(runtime.runtime); + const containerNames = await containerComputerManagedPerBotNames(runtime.runtime); + if (containerNames === null) return null; + const occupied = new Set(workspaceNames); + for (const name of containerNames) occupied.add(name.slice(-16)); + return occupied.size; } function configStatus() { + const source = localVmSource(cfg); return { xai: { configured: Boolean(cfg.xai?.key) }, composio: { @@ -2551,10 +2846,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) ?? "" }, features: { skillRecorder: skillRecorderEnabled(cfg) }, }; } @@ -2562,6 +2856,47 @@ function configStatus() { /** Rebuild the provider fleet after a config change so new keys take * effect without a server restart (kills any in-flight turns). */ async function reloadProviders() { + while (pendingFinalFrames.size > 0) { + await Promise.allSettled(pendingFinalFrames.values()); + } + const interruptedGroups = store.groups.filter((group) => Boolean(group.busyBotId)); + const interruptedGroupBotIds = new Set( + interruptedGroups.flatMap((group) => group.busyBotId ? [group.busyBotId] : []), + ); + const interruptedThreads = new Set([ + ...activeTurnIds.keys(), + ...store.bots.filter((bot) => bot.busy).map((bot) => bot.threadId), + ...interruptedGroups.map((group) => group.threadId), + ]); + providerGeneration += 1; + watchdog.stop(); + watchdog.start(); + for (const threadId of interruptedThreads) { + closeOpenApprovals(threadId); + lastReply.delete(threadId); + turnUsage.delete(threadId); + repeats.settle(threadId); + beginTurnThread(threadId); + } + for (const group of interruptedGroups) { + const speaker = groupSpeakers.get(group.threadId); + const speakerBot = group.busyBotId ? store.bot(group.busyBotId) : undefined; + store.appendMessage(group.threadId, { + role: "bot", + kind: "activity", + from: speaker ?? (speakerBot + ? { botId: speakerBot.id, name: speakerBot.name, color: speakerBot.color } + : undefined), + tool: { name: "error: turn interrupted — provider settings changed", ok: false }, + }); + roomStallCompletions.stall(group.threadId); + groupSpeakers.delete(group.threadId); + groupQueues.delete(group.id); + if (store.group(group.id)?.busyBotId) store.patchGroup(group.id, { busyBotId: null, unread: true }); + } + for (const threadId of humanWaitLeaseRenewals.keys()) stopHumanWaitLeaseRenewal(threadId); + for (const timer of stalledTurnReleases.values()) clearTimeout(timer); + stalledTurnReleases.clear(); bus.detachAll(); await registry.disposeAll(); await registry.load(instanceConfigs(cfg)); @@ -2570,10 +2905,13 @@ async function reloadProviders() { // async under the hood), stranding the bot busy — and its screen poller — // forever. Settle anything still marked busy. for (const b of store.bots.filter((b) => b.busy)) { + clearStalledTurnRelease(b.threadId); const vmThread = [...localVmThreadTargets.entries()].find(([, target]) => 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( @@ -2582,11 +2920,13 @@ async function reloadProviders() { "", "Delegated turn did not finish — provider settings changed", ); - store.appendMessage(b.threadId, { - role: "bot", - kind: "activity", - tool: { name: "error: turn interrupted — provider settings changed", ok: false }, - }); + if (!interruptedGroupBotIds.has(b.id)) { + store.appendMessage(b.threadId, { + role: "bot", + kind: "activity", + tool: { name: "error: turn interrupted — provider settings changed", ok: false }, + }); + } store.setActivity(b.id, "idle"); } // killed turns settle here without a turn.completed event, so anything @@ -3842,6 +4182,7 @@ const server = createServer(async (req, res) => { if (m && method === "DELETE") { const group = store.group(m[1]); if (!group) return json(res, 404, { error: "no such room" }); + if (group.busyBotId) return json(res, 409, { error: "stop the active room turn before deleting the room" }); lastReply.delete(group.threadId); store.deleteGroup(group.id); for (const dir of [EVENTS_DIR, NATIVE_DIR]) { @@ -3853,6 +4194,7 @@ const server = createServer(async (req, res) => { } m = path.match(/^\/api\/groups\/([\w-]+)\/messages$/); if (m && method === "POST") { + if (providerConfigBusy) return json(res, 409, { error: "provider settings are being updated — retry this room turn" }); const body = await readBody(req); const text = String(body.text ?? "").trim(); if (!text) return json(res, 400, { error: "text required" }); @@ -3868,7 +4210,7 @@ const server = createServer(async (req, res) => { if (!group) return json(res, 404, { error: "no such room" }); const busy = group.busyBotId ? store.bot(group.busyBotId) : undefined; const instance = busy ? registry.get(busy.modelSelection.instanceId) : undefined; - await instance?.adapter.interruptTurn(group.threadId).catch(() => {}); + await interruptProviderTurn(instance, group.threadId, Boolean(busy)); closeOpenApprovals(group.threadId); return json(res, 200, { ok: true }); } @@ -3991,6 +4333,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` }); @@ -4107,10 +4457,11 @@ const server = createServer(async (req, res) => { patch.alwaysAllow = [...new Set(body.alwaysAllow as string[])].slice(0, 200); } if (existingBot?.computer === "local" && body.computer !== undefined && body.computer !== "local") { - await registry - .get(existingBot.modelSelection.instanceId) - ?.adapter.interruptTurn(existingBot.threadId) - .catch(() => {}); + await interruptProviderTurn( + registry.get(existingBot.modelSelection.instanceId), + existingBot.threadId, + existingBot.busy, + ); } const chiefMovedSections = Boolean(existingBot?.chiefOfStaff) && @@ -4135,7 +4486,7 @@ const server = createServer(async (req, res) => { store.bots .filter((bot) => bot.computer === "local") .map((bot) => - registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(bot.threadId), + interruptProviderTurn(registry.get(bot.modelSelection.instanceId), bot.threadId, bot.busy), ) .filter((turn): turn is Promise => Boolean(turn)), ); @@ -4145,43 +4496,63 @@ 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") { - 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" }); + if (bot.busy || existingVmActiveThreads.has(bot.id)) { + return json(res, 409, { error: "stop this bot's turn before deleting the bot" }); + } + if (botDeletionBusy.has(bot.id)) { + return json(res, 409, { error: "stop this bot's turn before deleting the bot" }); + } + botDeletionBusy.add(bot.id); + const localVmDeletionTarget = localVmMode(cfg) === "per-bot" ? perBotLocalVmTarget(bot.id) : null; + try { + if (localVmDeletionTarget) { + if (localVmActiveThreads.has(localVmDeletionTarget.key) || localVmLifecycleBusy.has(localVmDeletionTarget.key)) { + return json(res, 409, { error: "stop this bot's Local VM turn or setup action before deleting the bot" }); + } + // Claim the target before status inspection and hold it through the + // delete. A concurrent create must not resurrect a bot's VM after + // this request has passed its initial checks. + localVmLifecycleBusy.add(localVmDeletionTarget.key); + const vm = await containerComputerStatus(undefined, undefined, localVmDeletionTarget); + if (existsSync(localVmDeletionTarget.workspaceDir)) { + return json(res, 409, { + error: "delete this bot's Local VM container and durable workspace before deleting the bot", + }); + } + if (vm.container !== "missing") { + return json(res, 409, { error: "delete this bot's Local VM from its Computer panel before deleting the bot" }); + } } - const vm = await containerComputerStatus(undefined, undefined, target); - if (!vm.daemonUp && existsSync(target.workspaceDir)) { - return json(res, 409, { - error: "start the container runtime and delete this bot's Local VM before deleting the bot", - }); + // a running turn dies with its bot + await interruptProviderTurn(registry.get(bot.modelSelection.instanceId), bot.threadId, true); + stopScreenPoller(bot.id); + activeVpsThreads.delete(bot.id); + for (const [threadId, ownerBotId] of existingVmThreadIds) { + if (ownerBotId === bot.id) releaseExistingVmThread(threadId); } - if (vm.container !== "missing") { - return json(res, 409, { error: "delete this bot's Local VM from its Computer panel before deleting the bot" }); + routines!.disableForBot(bot.id); + webhooks.disableForBot(bot.id); + lastReply.delete(bot.threadId); + // a peer approval naming this bot can never be meaningfully answered + // now, and its caller would otherwise wait out the 15-minute timeout + cancelPeerApprovalsFor(bot.id); + discardDelegations(commsBus, bot.threadId); + computerControl.forget(bot.id); + const target = perBotLocalVmTarget(bot.id); + localVmIdles.get(target.key)?.cancel(); + localVmIdles.delete(target.key); + store.deleteBot(bot.id); + for (const dir of [EVENTS_DIR, NATIVE_DIR]) { + try { + unlinkSync(join(dir, `${bot.threadId}.ndjson`)); + } catch {} } + return json(res, 200, { ok: true }); + } finally { + if (localVmDeletionTarget) localVmLifecycleBusy.delete(localVmDeletionTarget.key); + botDeletionBusy.delete(bot.id); + drainConnectorResumes(); } - // 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); - // a peer approval naming this bot can never be meaningfully answered - // now, and its caller would otherwise wait out the 15-minute timeout - cancelPeerApprovalsFor(bot.id); - discardDelegations(commsBus, bot.threadId); - computerControl.forget(bot.id); - const target = perBotLocalVmTarget(bot.id); - localVmIdles.get(target.key)?.cancel(); - localVmIdles.delete(target.key); - store.deleteBot(bot.id); - for (const dir of [EVENTS_DIR, NATIVE_DIR]) { - try { - unlinkSync(join(dir, `${bot.threadId}.ndjson`)); - } catch {} - } - return json(res, 200, { ok: true }); } // ── bot skills: imported Agent Skills (SKILL.md) ──────────────────── @@ -4472,10 +4843,10 @@ const server = createServer(async (req, res) => { // from its own chat must reach that turn, not just the 1:1 thread const busyGroup = store.groups.find((g) => g.busyBotId === bot.id); if (busyGroup) { - await instance?.adapter.interruptTurn(busyGroup.threadId).catch(() => {}); + await interruptProviderTurn(instance, busyGroup.threadId, true); closeOpenApprovals(busyGroup.threadId); } - await instance?.adapter.interruptTurn(bot.threadId).catch(() => {}); + await interruptProviderTurn(instance, bot.threadId, bot.busy); closeOpenApprovals(bot.threadId); return json(res, 200, { ok: true }); } @@ -4534,7 +4905,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") { @@ -4546,6 +4917,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" }); } @@ -4563,6 +4939,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, @@ -4575,6 +4952,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), @@ -4585,7 +4966,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") { @@ -4595,6 +4976,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" }); @@ -4628,6 +5014,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, @@ -4643,6 +5030,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, { @@ -4762,6 +5153,8 @@ const server = createServer(async (req, res) => { return json(res, 200, { instances: await registry.describe() }); } finally { providerConfigBusy = false; + drainConnectorResumes(); + drainQueuedSends(); } } @@ -4774,16 +5167,56 @@ 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" }); + let localVmSourceChanged = false; + let localVmConnectionChanged = false; 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 }); } + if (patch.localVm !== undefined) { + const nextLocalVmConfig = { + ...cfg, + localVm: { ...cfg.localVm, ...patch.localVm }, + }; + const sourceChanged = localVmSource(nextLocalVmConfig) !== localVmSource(cfg); + const aliasChanged = localVmSshAlias(nextLocalVmConfig) !== localVmSshAlias(cfg); + localVmSourceChanged = sourceChanged; + localVmConnectionChanged = sourceChanged || aliasChanged; + const vmTurnBusy = store.bots.some((bot) => bot.busy === true && bot.computer === "vm"); + const existingVmBusy = existingVmActiveThreads.size > 0 || + (localVmSource(cfg) === "existing" && vmTurnBusy); + const managedVmBusy = localVmActiveThreads.size > 0 || + (localVmSource(cfg) === "managed" && vmTurnBusy); + if ((sourceChanged || aliasChanged) && existingVmBusy) { + return json(res, 409, { error: "stop the active Existing VM turn before changing its source or SSH config alias" }); + } + if (sourceChanged && managedVmBusy) { + return json(res, 409, { error: "stop the active Local VM turn before changing its source" }); + } + if (sourceChanged && (localVmLifecycleBusy.size > 0 || localVmImageBusy || localVmProvisionBusy || localVmModeChangeBusy)) { + return json(res, 409, { error: "stop Local VM setup actions before changing the Local VM source" }); + } + } providerConfigBusy = true; const changingLocalVmMode = patch.localVm?.mode !== undefined && patch.localVm.mode !== localVmMode(cfg); - if (changingLocalVmMode) localVmModeChangeBusy = true; + const changingLocalVmPolicy = changingLocalVmMode || localVmSourceChanged; + if (changingLocalVmPolicy) localVmModeChangeBusy = true; try { + if (localVmSourceChanged && localVmMode(cfg) === "per-bot") { + const existing = await perBotLocalVmCountForModeChange(); + if (existing === null) { + return json(res, 409, { + error: "start the container runtime and delete every per-bot Local VM before switching the Local VM source", + }); + } + if (existing > 0) { + return json(res, 409, { + error: `delete the ${existing} per-bot Local VM${existing === 1 ? "" : "s"} before switching the Local VM source`, + }); + } + } if (changingLocalVmMode) { if (localVmActiveThreads.size > 0 || localVmLifecycleBusy.size > 0 || localVmImageBusy) { return json(res, 409, { error: "stop Local VM turns and setup actions before changing the Local VM isolation mode" }); @@ -4792,7 +5225,7 @@ const server = createServer(async (req, res) => { const existing = await perBotLocalVmCountForModeChange(); if (existing === null) { return json(res, 409, { - error: "start the container runtime and delete every per-bot VM before switching to shared mode", + error: "start the container runtime and delete every per-bot Local VM before switching to shared mode", }); } if (existing > 0) { @@ -4859,6 +5292,7 @@ const server = createServer(async (req, res) => { syncCredentialEnv(patch); Object.assign(cfg, loadConfig()); } + if (localVmConnectionChanged) closeExistingVmScreenshotSessions(); // Provider keys change the fleet. Profile, voice, VPS, and room timeout // changes do not rebuild it: no driver reads them, and they should not // interrupt in-flight turns. @@ -4877,8 +5311,10 @@ const server = createServer(async (req, res) => { broadcast({ kind: "config", ...status }); return json(res, 200, status); } finally { - if (changingLocalVmMode) localVmModeChangeBusy = false; + if (changingLocalVmPolicy) localVmModeChangeBusy = false; providerConfigBusy = false; + drainConnectorResumes(); + drainQueuedSends(); } } @@ -5060,6 +5496,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)); @@ -5089,6 +5531,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] === "exec") { return json(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" }); diff --git a/server/mcp-bridge.test.ts b/server/mcp-bridge.test.ts index b3f66fbb3..b208ccc5a 100644 --- a/server/mcp-bridge.test.ts +++ b/server/mcp-bridge.test.ts @@ -149,6 +149,17 @@ describe("createLineSplitter", () => { splitter.flush(); expect(lines).toEqual(['{"text":"mouse 🐭"}']); }); + + it("stops buffering an oversized unterminated frame", () => { + const lines: string[] = []; + const onOverflow = vi.fn(); + const splitter = createLineSplitter((line) => lines.push(line), { maxLineChars: 4, onOverflow }); + splitter.push("12345"); + splitter.push("\nignored\n"); + splitter.flush(); + expect(onOverflow).toHaveBeenCalledOnce(); + expect(lines).toEqual([]); + }); }); describe("createGateInterceptor", () => { diff --git a/server/mcp-bridge.ts b/server/mcp-bridge.ts index 68ef573fd..42ae6cb80 100644 --- a/server/mcp-bridge.ts +++ b/server/mcp-bridge.ts @@ -39,6 +39,24 @@ export interface BridgeLiveness { args: string[]; } +export const MAX_MCP_LINE_CHARS = 16 * 1024 * 1024; + +export type ControlGate = { url: string; token: string }; + +export class IncompleteControlConfigError extends Error { + constructor(label: string) { + super(`incomplete ${label} control configuration`); + this.name = "IncompleteControlConfigError"; + } +} + +export function controlGateFromEnv(label: string): ControlGate | undefined { + const url = process.env.OMB_CONTROL_URL ?? ""; + const token = process.env.OMB_CONTROL_TOKEN ?? ""; + if (Boolean(url) !== Boolean(token)) throw new IncompleteControlConfigError(label); + return url && token ? { url, token } : undefined; +} + /** Run the liveness command; alive means "exited 0 within the timeout". The * probe is its own short-lived process, so it cannot inherit the wedged * connection it is diagnosing. */ @@ -71,6 +89,16 @@ export interface WatchdogHandle { stop: () => void; } +export interface LineSplitter { + push: (chunk: Buffer | string) => void; + flush: () => void; +} + +export interface LineSplitterOptions { + maxLineChars?: number; + onOverflow?: () => void; +} + /** Inactivity → probe → (only then) declare dead. Traffic arriving while a * probe is in flight vetoes even a failed probe: bytes are better evidence * of life than a health command racing a congested link. */ @@ -132,34 +160,53 @@ export interface BridgeOptions { liveness?: BridgeLiveness; /** Enables the who-is-driving gate: the harness's loopback control * endpoint plus its per-boot token. Absent → fully transparent bridge. */ - gate?: { url: string; token: string }; + gate?: ControlGate; } /** Collect a byte stream into complete newline-terminated lines. MCP's * stdio transport is one JSON-RPC frame per line, so line boundaries are - * the only safe place to inspect — or inject — anything. */ -export function createLineSplitter(onLine: (line: string) => void): { - push: (chunk: Buffer | string) => void; - flush: () => void; -} { + * the only safe place to inspect — or inject — anything. An unterminated + * frame is still bounded so a dead or hostile peer cannot grow this buffer + * without limit. */ +export function createLineSplitter(onLine: (line: string) => void, options: LineSplitterOptions = {}) { + const maxLineChars = options.maxLineChars ?? MAX_MCP_LINE_CHARS; let pending = ""; + let overflowed = false; const decoder = new StringDecoder("utf8"); - return { + const overflow = () => { + if (overflowed) return; + overflowed = true; + pending = ""; + options.onOverflow?.(); + }; + const emit = (line: string) => { + if (line.length > maxLineChars) { + overflow(); + return; + } + onLine(line); + }; + const splitter: LineSplitter = { push(chunk) { - pending += typeof chunk === "string" ? chunk : decoder.write(chunk); + if (overflowed) return; + pending += Buffer.isBuffer(chunk) ? decoder.write(chunk) : chunk; let newline: number; while ((newline = pending.indexOf("\n")) !== -1) { const line = pending.slice(0, newline); pending = pending.slice(newline + 1); - onLine(line); + emit(line); + if (overflowed) return; } + if (pending.length > maxLineChars) overflow(); }, flush() { + if (overflowed) return; pending += decoder.end(); - if (pending) onLine(pending); + if (pending) emit(pending); pending = ""; }, }; + return splitter; } /** The gate itself, factored free of process wiring so a test can drive it @@ -216,7 +263,14 @@ export function runMcpBridge(options: BridgeOptions): void { child.stdin.on("error", () => {}); child.stderr.pipe(process.stderr); - let detach: () => void; + let detach: () => void = () => {}; + const failFrameLimit = () => { + process.stderr.write(`${options.label} MCP frame exceeded its output limit; ending the bridge\n`); + process.exitCode = 1; + detach(); + child.stdin.destroy(); + child.kill("SIGKILL"); + }; if (options.gate) { const client = createControlClient({ url: options.gate.url, token: options.gate.token }); const inbound = createLineSplitter( @@ -225,6 +279,7 @@ export function runMcpBridge(options: BridgeOptions): void { forward: (line) => child.stdin.write(line + "\n"), refuse: (line) => process.stdout.write(line + "\n"), }), + { onOverflow: failFrameLimit }, ); const onStdin = (chunk: Buffer) => inbound.push(chunk); process.stdin.on("data", onStdin); @@ -235,7 +290,7 @@ export function runMcpBridge(options: BridgeOptions): void { // Injected refusals must never land inside one of the child's // half-written frames, so the child's stdout is re-emitted at line // granularity as well. - const outbound = createLineSplitter((line) => process.stdout.write(line + "\n")); + const outbound = createLineSplitter((line) => process.stdout.write(line + "\n"), { onOverflow: failFrameLimit }); child.stdout.on("data", (chunk) => outbound.push(chunk)); child.stdout.on("end", () => outbound.flush()); detach = () => { diff --git a/server/proxy-paths.ts b/server/proxy-paths.ts index 5a67582af..1a157e5bf 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/server/testing/png-fixture.ts b/server/testing/png-fixture.ts new file mode 100644 index 000000000..456994e96 --- /dev/null +++ b/server/testing/png-fixture.ts @@ -0,0 +1,49 @@ +import { deflateSync } from "node:zlib"; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const typeBytes = Buffer.from(type, "ascii"); + const body = Buffer.concat([typeBytes, data]); + const result = Buffer.alloc(12 + data.length); + result.writeUInt32BE(data.length, 0); + body.copy(result, 4); + result.writeUInt32BE(crc32(body), 8 + data.length); + return result; +} + +export function validPngFixture(): Buffer { + const width = 32; + const height = 32; + const scanlines = Buffer.alloc(height * (width * 4 + 1)); + for (let y = 0; y < height; y++) { + const row = y * (width * 4 + 1); + for (let x = 0; x < width; x++) { + const pixel = row + 1 + x * 4; + scanlines[pixel] = (x * 17 + y * 31) & 0xff; + scanlines[pixel + 1] = (x * 43 + y * 11) & 0xff; + scanlines[pixel + 2] = (x * 7 + y * 53) & 0xff; + scanlines[pixel + 3] = 0xff; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + PNG_SIGNATURE, + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(scanlines)), + chunk("IEND", Buffer.alloc(0)), + ]); +} diff --git a/server/vps-computer.test.ts b/server/vps-computer.test.ts index e5579160f..e10b7c119 100644 --- a/server/vps-computer.test.ts +++ b/server/vps-computer.test.ts @@ -30,16 +30,13 @@ import { reuseVps, type VpsCommandRunner, } from "./vps-computer.ts"; +import { validPngFixture } from "./testing/png-fixture.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"), -]); +const screenshot = validPngFixture(); function fixture({ image = true, diff --git a/server/vps-container-mcp.ts b/server/vps-container-mcp.ts index 7da24fe62..524d9359c 100644 --- a/server/vps-container-mcp.ts +++ b/server/vps-container-mcp.ts @@ -3,7 +3,7 @@ // user's normal SSH config and agent; this process stores no credentials. // The piping, drain-safe exit, and dead-transport watchdog live in // mcp-bridge.ts, shared with the Local VM entry point. -import { runMcpBridge } from "./mcp-bridge.ts"; +import { controlGateFromEnv, runMcpBridge } from "./mcp-bridge.ts"; import { vpsContainerMcpArgs, vpsDockerArgs } from "./vps-computer.ts"; const [alias, containerName] = process.argv.slice(2); @@ -16,10 +16,15 @@ try { process.exit(2); } -// The who-is-driving pair rides in env, not argv — argv is world-readable -// through `ps`, and the token guards a loopback endpoint. -const controlUrl = process.env.OMB_CONTROL_URL ?? ""; -const controlToken = process.env.OMB_CONTROL_TOKEN ?? ""; +let gate: ReturnType; +try { + // The who-is-driving pair rides in env, not argv — argv is world-readable + // through `ps`, and the token guards a loopback endpoint. + gate = controlGateFromEnv("VPS"); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : "incomplete VPS control configuration"}\n`); + process.exit(2); +} runMcpBridge({ command: "docker", @@ -29,5 +34,5 @@ runMcpBridge({ // driver: a busy desktop mid-tool-call must never look dead, while an // unreachable VPS must, and `docker version` distinguishes exactly that. liveness: { command: "docker", args: vpsDockerArgs(sshAlias, ["version", "--format", "{{.Server.Version}}"]) }, - ...(controlUrl && controlToken ? { gate: { url: controlUrl, token: controlToken } } : {}), + gate, }); diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 2087d8920..c9a5b26ea 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -5,6 +5,7 @@ // separate preview remains explicitly user-initiated. Auto never selects a // Linux user's desktop. import { useEffect, useRef, useState } from "react"; +import { z } from "zod"; import { CalendarDays, CalendarClock, @@ -48,6 +49,11 @@ async function api(path: string, init?: RequestInit): Promise { return body; } +function stringOrNull(value: string | null | undefined): string | null { + const parsed = z.string().safeParse(value); + return parsed.success ? parsed.data : null; +} + type Phase = | "checking" | "unconfigured" @@ -63,7 +69,8 @@ type Phase = | "off" | "error"; -interface LocalVmStatus { +interface ManagedLocalVmStatus { + source: "managed"; mode: "shared" | "per-bot"; max_instances: number; image: boolean; @@ -80,6 +87,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([], { @@ -144,6 +171,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"; // Pause the screenshot poll while this bot's viewer is open; seed from the // live viewer so a remount/switch mid-session doesn't wrongly resume it. @@ -198,7 +228,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" @@ -240,7 +270,14 @@ export function ComputerPanel({ bot }: { bot: Bot }) { api(`/api/bots/${bot.id}/local-computer`) .then((rawStatus) => { if (!alive) return; - const status: LocalVmStatus = rawStatus; + let status: LocalVmStatus; + if (rawStatus.source === "existing") { + // SAFETY: this endpoint's discriminant and existing status shape are owned by the server contract. + status = rawStatus as ExistingLocalVmStatus; + } else { + // SAFETY: managed status fields are returned by the same endpoint and retain the managed shape. + status = { 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 ?? ""); @@ -248,6 +285,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 && @@ -407,6 +447,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. @@ -450,7 +491,8 @@ export function ComputerPanel({ bot }: { bot: Bot }) { vmInFlight.current = true; try { const { image } = await api(`/api/bots/${bot.id}/local-computer/screenshot`, { method: "POST" }); - if (alive && typeof image === "string") setVmFrame(image); + const frame = stringOrNull(image); + if (alive && frame) setVmFrame(frame); } catch (e) { if (alive) setError(e instanceof Error ? e.message : String(e)); } finally { @@ -523,7 +565,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { type: "computerControl", botId: bot.id, held: snap.held === true, - helpReason: typeof snap.helpReason === "string" ? snap.helpReason : null, + helpReason: stringOrNull(snap.helpReason), }); }) .catch(() => {}); @@ -541,7 +583,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { type: "computerControl", botId: bot.id, held: snap.held === true, - helpReason: typeof snap.helpReason === "string" ? snap.helpReason : null, + helpReason: stringOrNull(snap.helpReason), }); return snap; }; @@ -554,6 +596,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); @@ -648,14 +694,22 @@ 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: "{}", }); + let status: LocalVmStatus; + if (rawStatus.source === "existing") { + // SAFETY: this endpoint's discriminant and existing status shape are owned by the server contract. + status = rawStatus as ExistingLocalVmStatus; + } else { + // SAFETY: managed status fields are returned by the same endpoint and retain the managed shape. + status = { 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) { @@ -768,7 +822,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}
@@ -796,7 +850,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" : 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} /> ) : (
@@ -811,7 +865,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." @@ -829,16 +883,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 @@ -1069,7 +1135,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 8dfd1fa9e..f3ed681a7 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,52 @@ 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); + let nextStatus: Status; + if (body.source === "existing") { + // SAFETY: the local-computer endpoint returns the discriminated ExistingStatus contract. + nextStatus = body as ExistingStatus; + } else { + // SAFETY: the local-computer endpoint returns the managed status fields under this branch. + nextStatus = { source: "managed", ...body } as ManagedStatus; + } + setStatus(nextStatus); setError(null); }, []); + useEffect(() => { + const configuredSource = state.config?.localVm.source ?? status?.source; + if (configuredSource) setSource(configuredSource); + }, [state.config?.localVm.source, status?.source]); + + const existingStatusAlias = status?.source === "existing" ? status.sshAlias ?? "" : null; + + useEffect(() => { + if (state.config?.localVm.sshAlias !== undefined) setAlias(state.config.localVm.sshAlias); + else if (existingStatusAlias !== null) setAlias(existingStatusAlias); + }, [state.config?.localVm.sshAlias, existingStatusAlias]); + 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); @@ -132,17 +177,17 @@ export function LocalComputerSection() { } finally { if (active) { setLoading(false); - timer = window.setTimeout(() => void poll(), 5000); + timer = window.setTimeout(() => void poll(), source === "existing" ? 30_000 : 5000); } } }; - void poll(); + void poll(refreshKey > 0); return () => { active = false; controller?.abort(); if (timer !== undefined) window.clearTimeout(timer); }; - }, [refresh, refreshKey]); + }, [refresh, refreshKey, source]); const post = async (action: Exclude) => { const response = await fetch(`/api/local-computer/${action}`, { @@ -152,6 +197,7 @@ export function LocalComputerSection() { }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error ?? `${action} failed`); + // SAFETY: local-computer lifecycle endpoints return the same discriminated status contract as refresh(). setStatus(body as Status); }; @@ -183,7 +229,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 +249,189 @@ 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?.errorCode === "ssh-missing" && ( +
Install OpenSSH so the ssh command is available in OpenMausBot's PATH, then re-check.
+ )} + {existingStatus?.ssh === "unreachable" && existingStatus.errorCode !== "ssh-missing" && ( +
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 +441,24 @@ 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) => ( + + ))} +
@@ -314,7 +545,7 @@ export function LocalComputerSection() {
- +
Podman and Colima are free. Docker Desktop may require a paid licence for larger companies and government use.
@@ -329,18 +560,18 @@ export function LocalComputerSection() { - {!status?.runtime ? null : c?.runtimeStart ? ( + {!managedStatus?.runtime ? null : c?.runtimeStart ? ( ) : (
Open the installed runtime and start its engine, then re-check.
)}
- - {status?.daemonUp && ( + + {managedStatus?.daemonUp && ( void act("pull")}>Prepare Cua desktop )} {c?.pull &&
Show base-image download
} @@ -363,9 +594,9 @@ export function LocalComputerSection() { <>
- {status?.problem} + {managedStatus?.problem}
- {status?.image ? ( + {managedStatus?.image ? ( void act("recreate")} danger> Delete and recreate @@ -373,11 +604,11 @@ export function LocalComputerSection() {
Prepare the pinned Cua desktop above before replacing this VM.
)} - ) : status?.container === "stopped" ? ( + ) : managedStatus?.container === "stopped" ? ( void act("start")}>Start Local VM - ) : status?.container === "running" ? ( + ) : managedStatus?.container === "running" ? (
Waiting for the desktop…
- ) : status?.image ? ( + ) : managedStatus?.image ? ( void act("run")}>Create Local VM ) : null} {c?.run &&
Show command
} @@ -397,12 +628,12 @@ export function LocalComputerSection() { {existing && (
- {status?.container === "running" && ( + {managedStatus?.container === "running" && ( void act("stop")}> Stop @@ -413,9 +644,9 @@ export function LocalComputerSection() {
)}
- Durable workspace: {status?.workspace_path ?? "not created"} ·{" "} - Cua Driver: {status?.driver_version ?? "0.20.0"} · Local image: {status?.image_ref ?? "not prepared"} - {status?.base_image_ref ? <> · Base: {status.base_image_ref} : null} + Durable workspace: {managedStatus?.workspace_path ?? "not created"} ·{" "} + Cua Driver: {managedStatus?.driver_version ?? "0.20.0"} · Local image: {managedStatus?.image_ref ?? "not prepared"} + {managedStatus?.base_image_ref ? <> · Base: {managedStatus.base_image_ref} : null}
diff --git a/src/state/store.test.ts b/src/state/store.test.ts index 4c94035be..1cfa0419e 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -52,7 +52,7 @@ describe("config status frames", () => { box: { configured: false }, vps: { configured: true, sshAlias: "homelab" }, rooms: { turnTimeoutMinutes: 20 }, - localVm: { mode: "per-bot", maxInstances: 3 }, + localVm: { source: "managed", mode: "per-bot", maxInstances: 3, sshAlias: "" }, opencodeGo: { configured: true }, tts: { configured: true, ready: true, voice: "Ada" }, profile: { name: "Ian", email: "ian@example.test" }, @@ -64,13 +64,27 @@ describe("config status frames", () => { box: { configured: false }, vps: { configured: true, sshAlias: "homelab" }, rooms: { turnTimeoutMinutes: 20 }, - localVm: { mode: "per-bot", maxInstances: 3 }, + localVm: { source: "managed", mode: "per-bot", maxInstances: 3, sshAlias: "" }, opencodeGo: { configured: true }, tts: { configured: true, ready: true, voice: "Ada" }, profile: { name: "Ian", email: "ian@example.test" }, features: { skillRecorder: true }, }); }); + + it("does not expose managed isolation fields for an Existing VM", () => { + const status = configStatusFromFrame({ + xai: { configured: false }, + composio: { configured: false, mode: "unavailable" }, + box: { configured: false }, + vps: { configured: false, sshAlias: "" }, + rooms: { turnTimeoutMinutes: 5 }, + localVm: { source: "existing", sshAlias: "linux-vm" }, + }); + expect(status.localVm).toEqual({ source: "existing", sshAlias: "linux-vm" }); + expect(status.localVm).not.toHaveProperty("mode"); + expect(status.localVm).not.toHaveProperty("maxInstances"); + }); }); describe("Teach a skill feature flag", () => { @@ -79,7 +93,7 @@ describe("Teach a skill feature flag", () => { box: { configured: false }, vps: { configured: false, sshAlias: "" }, rooms: { turnTimeoutMinutes: 5 }, - localVm: { mode: "shared", maxInstances: 2 }, + localVm: { source: "managed", mode: "shared", maxInstances: 2, sshAlias: "" }, features: { skillRecorder: true }, }); diff --git a/src/state/store.tsx b/src/state/store.tsx index 52233f36c..9d3d1f147 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -256,7 +256,9 @@ export interface ConfigStatus { box: { configured: boolean }; vps: { configured: boolean; sshAlias: string }; rooms: { turnTimeoutMinutes: number }; - localVm: { mode: "shared" | "per-bot"; maxInstances: number }; + localVm: + | { source: "managed"; mode: "shared" | "per-bot"; maxInstances: number; sshAlias: string } + | { source: "existing"; 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