Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/docs/content/docs/computers/local-computer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions companion/src/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
9 changes: 7 additions & 2 deletions companion/test/proxy-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
28 changes: 27 additions & 1 deletion companion/test/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")], {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down
20 changes: 13 additions & 7 deletions companion/test/wire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions scripts/bundle-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions server/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
loadConfig,
localVmMaxInstances,
localVmMode,
localVmSource,
localVmSshAlias,
parseConfigPatch,
parseStoredConfig,
roomTurnTimeoutMinutes,
Expand Down Expand Up @@ -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 },
});
Expand All @@ -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");
});
Expand Down
23 changes: 20 additions & 3 deletions server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,17 @@ 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()
.int()
.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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
48 changes: 43 additions & 5 deletions server/container-computer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
computerProxyEnv,
containerComputerAction,
containerComputerMcp,
containerComputerManagedPerBotNames,
containerComputerScreenshot,
containerComputerStatus,
containerRuntimeStatus,
Expand All @@ -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<string, string | Error>) {
const calls: string[] = [];
Expand All @@ -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([
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading