Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
70555b8
feat(companion): let a phone with computer access preview a bot's Loc…
ruigomeseu Oct 1, 2026
67ac60a
feat(ios): show a bot's Local VM on demand, even while it is idle
ruigomeseu Oct 1, 2026
320e699
docs: describe Local VM stills on the phone behind computer access
ruigomeseu Oct 1, 2026
4ae1449
test: isolated fixture for the iOS Local VM view
ruigomeseu Oct 1, 2026
600bebc
fix(ios): picture the opened thread's Local VM and follow access chan…
ruigomeseu Oct 1, 2026
f71b0a3
fix(ios): caption a streamed frame when computer access is off; ignor…
ruigomeseu Oct 1, 2026
3f556a3
test: abort the iOS Local VM fixture cleanly during server startup
ruigomeseu Oct 1, 2026
4bbe2db
feat(server,companion): relay a Local VM's live desktop to a phone ho…
ruigomeseu Oct 1, 2026
1c46d58
feat(ios): an RFB client and the calls to take a Local VM and join it…
ruigomeseu Oct 1, 2026
ea66c48
feat(ios): take control of a bot's Local VM from the phone
ruigomeseu Oct 1, 2026
a8045c5
fix: bind a phone's Local VM desktop to its control lease, and harden…
ruigomeseu Oct 1, 2026
b1d7b9c
fix(ios): ignore a stale 401 from the previous computer in Local VM c…
ruigomeseu Oct 1, 2026
4266651
fix(ios): hand back a take that lands after the person left; leave an…
ruigomeseu Oct 2, 2026
c8dbeaa
fix(ios): hand a Local VM back through the computer that granted its …
ruigomeseu Oct 2, 2026
afc8793
fix(ios): keep Take control disabled until the hand-back has finished
ruigomeseu Oct 2, 2026
bef2689
feat(server,ios): drive a Local VM from a phone paired with the serve…
ruigomeseu Oct 2, 2026
da2829a
Refuse phone control of unreserved Local VM pool seats
ruigomeseu Oct 2, 2026
032406e
fix(ios): shrink the trackpad while the keyboard is up, so the deskto…
ruigomeseu Oct 2, 2026
63991db
fix(ios): collapse the trackpad inside the keyboard's own animation
ruigomeseu Oct 2, 2026
e6fc9f4
fix(ios): make room for the keyboard in the trackpad's own transaction
ruigomeseu Oct 2, 2026
3e27dbd
fix(ios): fade the trackpad hint back in place once the keyboard is gone
ruigomeseu Oct 2, 2026
b3dcf37
chore(verify): show a captured Local VM desktop in the iOS fixture in…
ruigomeseu Oct 2, 2026
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
9 changes: 5 additions & 4 deletions companion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ upstream hardened its loopback gate.
| | |
|---|---|
| **Pairing** | A high-entropy QR credential plus a six-digit manual fallback, valid two minutes and single-use. Redeeming either returns a device token stored only as a SHA-256 digest. |
| **Authorisation** | Every request needs that token. Full cloud-desktop access is a separate per-device capability, off by default. A rebinding page cannot obtain either. |
| **Authorisation** | Every request needs that token. Computer access — the cloud desktop, and seeing or taking control of the Local VM — is a separate per-device capability, off by default. A rebinding page cannot obtain either. |
| **The allowlist** | Default deny, per method and path (`src/routes.ts`) — the list is every request the app makes, and nothing else. General bot/room PATCH routes stay closed; read state and approval grants use narrow verbs. A route that appears in the harness later is closed to devices until someone adds it here on purpose. |
| **Scrubbing** | `resumeCursors` — the harness's own provider session ids — never reach a device, whether or not the harness still sends them. |
| **Discovery** | Bonjour, so a phone finds the computer by name instead of by typed address. |
Expand Down Expand Up @@ -73,11 +73,12 @@ trusted-network-only rather than described as something it is not.
request that carries one is a browser that has found this port. Refused
before the token is even looked at — stricter than the harness's own rule,
which allows loopback origins.
- **Hold credentials, settings, or Local VM control.** Credential plaintext
- **Hold credentials, settings, or the Local VM's lifecycle.** Credential plaintext
remains transient on the phone and desktop only: the sidecar can carry one
QR-keyed HPKE envelope for an exact pending card, but cannot open or retain
it. General credential/configuration routes, settings, and Local VM control
remain unavailable. See `src/routes.ts` for the exact boundary.
it. General credential/configuration routes, settings, and the Local VM's
lifecycle remain unavailable; driving the Local VM goes through the same
per-device viewer relay as a VPS desktop. See `src/routes.ts` for the exact boundary.

## Running it

Expand Down
59 changes: 55 additions & 4 deletions companion/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,54 @@ const forwardHeaders = (req: IncomingMessage, authenticatedDeviceId?: string, mu
/** The device-facing handler: refuse a browser, check the allowlist, check
* the token, then replay the request to the harness over loopback and scrub
* what comes back. Pairing is the one route that stops here. */
/** Ask the harness, as the paired device, whether a control lease still
* holds a bot's computer. Read-only (`action: "check"`); every failure —
* no token yet, a refusal, a timeout, an unreadable answer — is "no". */
function harnessControlCheck(options: ProxyOptions) {
return (deviceId: string, botId: string, controlLeaseId: string): Promise<boolean> => new Promise((resolve) => {
const mutationToken = options.mutationToken?.();
if (options.mutationToken && !mutationToken) return resolve(false);
const body = JSON.stringify({ action: "check", controlLeaseId });
const headers: Record<string, string> = {
accept: "application/json",
"content-type": "application/json",
"content-length": String(Buffer.byteLength(body)),
"x-openmausbot-companion": "1",
"x-openmausbot-companion-device": deviceId,
};
if (mutationToken) headers["x-openmausbot-companion-auth"] = mutationToken;
const request = httpRequest({
hostname: "127.0.0.1",
port: options.harnessPort,
path: `/api/bots/${encodeURIComponent(botId)}/computer/control`,
method: "POST",
headers,
}, (response) => {
const chunks: Buffer[] = [];
let size = 0;
response.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > 16_384) return request.destroy();
chunks.push(chunk);
});
response.once("end", () => {
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { owned?: unknown };
resolve(response.statusCode === 200 && parsed.owned === true);
} catch {
resolve(false);
}
});
response.once("error", () => resolve(false));
});
request.setTimeout(5_000, () => request.destroy());
request.once("error", () => resolve(false));
request.end(body);
});
}

export function createProxyHandler(options: ProxyOptions) {
const viewers = new CompanionViewerRelay();
const viewers = new CompanionViewerRelay({ checkControl: harnessControlCheck(options) });
const handle = function handle(req: IncomingMessage, res: ServerResponse): void {
const path = (req.url ?? "/").split("?")[0];
const method = req.method ?? "GET";
Expand Down Expand Up @@ -293,11 +339,11 @@ export function createProxyHandler(options: ProxyOptions) {
if (denial) return sendJson(res, denial.status, { error: denial.error });

// Pairing a phone grants the ordinary companion surface, not a browser
// session with every credential that may exist inside the cloud desktop.
// session with every credential that may exist inside a bot's computer.
// The computer owner enables this capability per device, off by default.
if (isCloudDesktopAccess(method, path) && !device?.cloudDesktopAccess) {
return sendJson(res, 403, {
error: "cloud desktop access is off for this device — enable it in OpenMausBot → Settings → Remote access",
error: "computer access is off for this device — enable it in OpenMausBot → Settings → Remote access",
});
}

Expand Down Expand Up @@ -570,7 +616,12 @@ export function createProxyHandler(options: ProxyOptions) {
// JSON.parse handles it fine.
let text: string;
try {
parsed = viewers.rewriteJoinResponse(path, parsed, device?.id);
parsed = viewers.rewriteJoinResponse(
path,
parsed,
device?.id,
new URL(req.url ?? "/", "http://companion.invalid").searchParams.get("controlLeaseId"),
);
text = JSON.stringify(scrub(parsed));
} catch {
sendJson(res, 502, { error: "the response could not be prepared for this device" });
Expand Down
27 changes: 26 additions & 1 deletion companion/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ export const CLOUD_DESKTOP_CONTROL_ROUTE = {
path: /^\/api\/bots\/[\w-]+\/computer\/(?:control|screenshot|viewer-close)$/,
} as const;

/** The Local VM's live desktop, relayed by the sidecar like a VPS viewer.
* The harness grants it only while a person holds that bot's computer. */
export const LOCAL_VM_JOIN_ROUTE = {
method: "POST",
path: /^\/api\/bots\/[\w-]+\/local-computer\/join$/,
} as const;

/** A still of a bot's Local VM, on demand. The VM's lifecycle stays on the
* host; this only reads a picture of it, behind the same per-device
* computer-access capability as the cloud desktop. */
export const LOCAL_VM_SCREENSHOT_ROUTE = {
method: "POST",
path: /^\/api\/bots\/[\w-]+\/local-computer\/screenshot$/,
} as const;

export function isCloudDesktopJoin(method: string, path: string): boolean {
return method === CLOUD_DESKTOP_JOIN_ROUTE.method && CLOUD_DESKTOP_JOIN_ROUTE.path.test(path);
}
Expand All @@ -61,9 +76,13 @@ export function isMessageFileDownload(method: string, path: string): boolean {
return method === MESSAGE_FILE_ROUTE.method && MESSAGE_FILE_ROUTE.path.test(path);
}

/** Every route that shows or drives a bot's computer — cloud or Local VM —
* and so needs the device's computer-access capability, not just a token. */
export function isCloudDesktopAccess(method: string, path: string): boolean {
return isCloudDesktopJoin(method, path)
|| (method === CLOUD_DESKTOP_CONTROL_ROUTE.method && CLOUD_DESKTOP_CONTROL_ROUTE.path.test(path));
|| (method === CLOUD_DESKTOP_CONTROL_ROUTE.method && CLOUD_DESKTOP_CONTROL_ROUTE.path.test(path))
|| (method === LOCAL_VM_SCREENSHOT_ROUTE.method && LOCAL_VM_SCREENSHOT_ROUTE.path.test(path))
|| (method === LOCAL_VM_JOIN_ROUTE.method && LOCAL_VM_JOIN_ROUTE.path.test(path));
}

/** Every request the iOS app makes, and nothing else.
Expand Down Expand Up @@ -129,6 +148,12 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [
CLOUD_DESKTOP_JOIN_ROUTE,

CLOUD_DESKTOP_CONTROL_ROUTE,
// A picture of the Local VM — not its lifecycle, which stays on the host.
// Gated per device by the proxy like the cloud desktop above.
LOCAL_VM_SCREENSHOT_ROUTE,
// Its live desktop while a person holds the computer, relayed like the
// VPS viewer and behind the same per-device capability.
LOCAL_VM_JOIN_ROUTE,
// rooms — making one, and talking in one
{ method: "POST", path: /^\/api\/groups$/ },
{ method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ },
Expand Down
53 changes: 48 additions & 5 deletions companion/src/viewer-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,20 @@ interface ViewerSession {
origin: string;
expiresAt: number;
sockets: Set<{ destroy(): void }>;
/** A Local VM viewer lives only as long as its control lease holds. */
watch?: ReturnType<typeof setInterval>;
}

/** Whether `controlLeaseId` still holds `botId`'s computer, asked on behalf of
* the paired device. Any failure must answer false. */
export type ControlCheck = (deviceId: string, botId: string, controlLeaseId: string) => Promise<boolean>;

const VIEWER_PATH = /^\/vps-viewer\/([A-Za-z0-9_-]{32})(\/.*)?$/;
const BOT_JOIN_PATH = /^\/api\/bots\/([\w-]+)\/computer\/join$/;
/** Both joins hand back a loopback noVNC address: a VPS through its SSH
* tunnel, and the Local VM's own published port. */
const BOT_JOIN_PATH = /^\/api\/bots\/([\w-]+)\/(computer|local-computer)\/join$/;
const CONTROL_LEASE = /^[A-Za-z0-9_-]{16,120}$/;
const CONTROL_CHECK_MS = 3_000;
const SESSION_TTL_MS = 8 * 60 * 60_000;
const MAX_SESSIONS = 64;

Expand Down Expand Up @@ -102,6 +112,13 @@ function acceptUpgrade(socket: Duplex, response: IncomingMessage): void {

export class CompanionViewerRelay {
readonly #sessions = new Map<string, ViewerSession>();
readonly #checkControl?: ControlCheck;
readonly #checkMs: number;

constructor(options: { checkControl?: ControlCheck; checkIntervalMs?: number } = {}) {
this.#checkControl = options.checkControl;
this.#checkMs = options.checkIntervalMs ?? CONTROL_CHECK_MS;
}

#prune(): void {
const now = Date.now();
Expand All @@ -116,6 +133,7 @@ export class CompanionViewerRelay {
}

#remove(session: ViewerSession): void {
if (session.watch) clearInterval(session.watch);
this.#sessions.delete(session.id);
for (const socket of session.sockets) socket.destroy();
session.sockets.clear();
Expand All @@ -137,25 +155,50 @@ export class CompanionViewerRelay {
}
}

rewriteJoinResponse(path: string, value: unknown, deviceId?: string): unknown {
const botId = BOT_JOIN_PATH.exec(path)?.[1];
rewriteJoinResponse(path: string, value: unknown, deviceId?: string, controlLeaseId?: string | null): unknown {
const join = BOT_JOIN_PATH.exec(path);
const botId = join?.[1];
if (!botId || !value || typeof value !== "object" || Array.isArray(value)) return value;
const body = value as Record<string, unknown>;
const viewer = safeLoopbackViewer(body.joinUrl);
if (!viewer) return value;
if (!deviceId) throw new Error("the paired device has no viewer identity");
// A Local VM viewer is bound to the control lease that asked for it, and
// is closed the moment that lease stops holding the computer. Without a
// lease, or nothing to check it with, it is never handed out at all.
const localVm = join[2] === "local-computer";
if (localVm && (!controlLeaseId || !CONTROL_LEASE.test(controlLeaseId) || !this.#checkControl)) {
throw new Error("a Local VM viewer needs a control lease");
}

this.#prune();
this.close(deviceId, botId);
const id = randomBytes(24).toString("base64url");
this.#sessions.set(id, {
const session: ViewerSession = {
id,
botId,
deviceId,
origin: viewer.origin,
expiresAt: Date.now() + SESSION_TTL_MS,
sockets: new Set(),
});
};
this.#sessions.set(id, session);
if (localVm) {
const check = this.#checkControl!;
const lease = controlLeaseId!;
let checking = false;
session.watch = setInterval(() => {
if (checking) return;
checking = true;
check(deviceId, botId, lease)
.catch(() => false)
.then((owned) => {
checking = false;
if (!owned && this.#isActive(session)) this.#remove(session);
});
}, this.#checkMs);
session.watch.unref?.();
}

const settings = new URLSearchParams(viewer.hash.slice(1));
settings.set("path", `vps-viewer/${id}/websockify`);
Expand Down
26 changes: 23 additions & 3 deletions companion/test/proxy-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,11 @@ describe("preparing a harness response for a device", () => {
}
});

it("requires the host to enable cloud desktop for viewer and preview requests", async () => {
it("requires the host to enable computer access for viewer and preview requests", async () => {
cloudDesktopAccess = false;
try {
for (const action of ["join", "screenshot"]) {
const { status, text } = await device(`/api/bots/b1/computer/${action}`, "POST");
for (const path of ["computer/join", "computer/screenshot", "local-computer/screenshot", "local-computer/join"]) {
const { status, text } = await device(`/api/bots/b1/${path}`, "POST");
expect(status).toBe(403);
expect(text).toContain("enable it in OpenMausBot");
expect(text).toContain("Settings → Remote access");
Expand Down Expand Up @@ -159,6 +159,26 @@ describe("preparing a harness response for a device", () => {
expect(joinUrl).not.toContain("127.0.0.1:45678");
});

it("turns the Local VM's loopback viewer into the same device-scoped path", async () => {
respond = (res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({
joinUrl: "http://127.0.0.1:45679/vnc.html#autoconnect=true&resize=scale&password=vm-secret",
}));
};
const { status, text } = await device("/api/bots/b1/local-computer/join?controlLeaseId=phone-lease-0123456789", "POST");
expect(status).toBe(200);
const joinUrl = String(JSON.parse(text).joinUrl);
expect(joinUrl).toMatch(/^\/vps-viewer\/[A-Za-z0-9_-]{32}\/vnc\.html#/);
expect(joinUrl).toContain("password=vm-secret");
expect(joinUrl).not.toContain("127.0.0.1:45679");

// Without a control lease the address never reaches the device.
const refused = await device("/api/bots/b1/local-computer/join", "POST");
expect(refused.status).toBe(502);
expect(refused.text).not.toContain("vm-secret");
});

it("never forwards a body it could not scrub", async () => {
// `scrub` recurses once per level, so a deeply nested body throws
// RangeError while JSON.parse handles it without complaint. That gap is
Expand Down
22 changes: 21 additions & 1 deletion companion/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// and the one that quietly stopped being true once before.
import { describe, expect, it } from "vitest";

import { denyReason } from "../src/routes.ts";
import { denyReason, isCloudDesktopAccess } from "../src/routes.ts";

const ask = (method: string, path: string, authenticated = true) =>
denyReason({ method, path, authenticated });
Expand Down Expand Up @@ -70,6 +70,8 @@ describe("what the app may do", () => {
["POST", "/api/bots/bot_123/computer/control"],
["POST", "/api/bots/bot_123/computer/screenshot"],
["POST", "/api/bots/bot_123/computer/viewer-close"],
["POST", "/api/bots/bot_123/local-computer/screenshot"],
["POST", "/api/bots/bot_123/local-computer/join"],
["POST", "/api/groups/room-1/messages"],
["POST", "/api/groups/room-1/interrupt"],
["DELETE", "/api/groups/room-1/queue/queue_1"],
Expand Down Expand Up @@ -190,6 +192,24 @@ describe("what it may not", () => {
expect(allowed("POST", "/api/bots/bot_123/computer/exec")).toBe(false);
});

it("previews a Local VM without reaching its lifecycle", () => {
expect(allowed("POST", "/api/bots/bot_123/local-computer/screenshot")).toBe(true);
expect(isCloudDesktopAccess("POST", "/api/bots/bot_123/local-computer/screenshot")).toBe(true);
expect(allowed("GET", "/api/bots/bot_123/local-computer/screenshot")).toBe(false);
expect(allowed("GET", "/api/bots/bot_123/local-computer")).toBe(false);
for (const action of ["run", "stop", "remove"]) {
expect(allowed("POST", `/api/bots/bot_123/local-computer/${action}`)).toBe(false);
}
expect(allowed("POST", "/api/local-computer/screenshot")).toBe(false);
});

it("joins a Local VM's live desktop only behind computer access", () => {
expect(allowed("POST", "/api/bots/bot_123/local-computer/join")).toBe(true);
expect(isCloudDesktopAccess("POST", "/api/bots/bot_123/local-computer/join")).toBe(true);
expect(allowed("GET", "/api/bots/bot_123/local-computer/join")).toBe(false);
expect(allowed("POST", "/api/local-computer/join")).toBe(false);
});

it("allows only the exact encrypted credential submission verb", () => {
expect(allowed("POST", "/api/bots/bot_123/secret-cards/message_1/provide")).toBe(true);
expect(allowed("GET", "/api/bots/bot_123/secret-cards/message_1/provide")).toBe(false);
Expand Down
Loading
Loading