diff --git a/companion/README.md b/companion/README.md
index b188cee57..60193e20d 100644
--- a/companion/README.md
+++ b/companion/README.md
@@ -29,8 +29,8 @@ upstream hardened its loopback gate.
| | |
|---|---|
-| **Pairing** | A six-digit code shown on the computer, valid two minutes, five attempts. Redeeming it returns a device token stored only as a SHA-256 digest. |
-| **Authorisation** | Every request needs that token. A rebinding page cannot obtain one. |
+| **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. |
| **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. |
diff --git a/companion/src/control.ts b/companion/src/control.ts
index d31faeced..cfdec2e43 100644
--- a/companion/src/control.ts
+++ b/companion/src/control.ts
@@ -176,6 +176,17 @@ export function createControlServer(options: ControlOptions): Server {
options.devices.closePairing();
return json(res, 200, companionState(options));
}
+ const cloudDesktop = path.match(/^\/devices\/([\w-]+)\/cloud-desktop$/);
+ if (cloudDesktop && (method === "POST" || method === "DELETE")) {
+ try {
+ if (!options.devices.setCloudDesktopAccess(cloudDesktop[1], method === "POST")) {
+ return json(res, 404, { error: "no such device" });
+ }
+ } catch {
+ return json(res, 500, { error: "could not save cloud desktop access" });
+ }
+ return json(res, 200, companionState(options));
+ }
const revoke = path.match(/^\/devices\/([\w-]+)$/);
if (revoke && method === "DELETE") {
if (!options.devices.revoke(revoke[1])) return json(res, 404, { error: "no such device" });
@@ -279,7 +290,9 @@ function render(s) {
(s.devices.length
? "
" + s.devices.map((d) =>
"
" + esc(d.name) + "
" +
- "
Last seen " + ago(d.lastSeenAt) + "
" +
+ "
Last seen " + ago(d.lastSeenAt) + "
" +
+ "" +
"
").join("") + "
"
: "
No phones are paired yet.
");
@@ -288,6 +301,12 @@ function render(s) {
for (const b of document.querySelectorAll("[data-revoke]")) {
b.addEventListener("click", async () => render(await api("/devices/" + b.dataset.revoke, "DELETE")));
}
+ for (const b of document.querySelectorAll("[data-cloud]")) {
+ b.addEventListener("click", async () => render(await api(
+ "/devices/" + b.dataset.cloud + "/cloud-desktop",
+ b.dataset.allowed === "1" ? "DELETE" : "POST"
+ )));
+ }
if (s.pairing) {
const tick = () => {
const left = Math.max(0, Math.round((s.pairing.expiresAt - Date.now()) / 1000));
diff --git a/companion/src/devices.ts b/companion/src/devices.ts
index c07804735..82b9b9756 100644
--- a/companion/src/devices.ts
+++ b/companion/src/devices.ts
@@ -23,6 +23,9 @@ export interface DeviceRecord {
tokenHash: string;
createdAt: number;
lastSeenAt: number;
+ /** Full interactive access to a bot's cloud desktop. Deliberately off on
+ * every new and migrated device until the computer owner enables it. */
+ cloudDesktopAccess: boolean;
}
/** What the UI is allowed to see: a device without its secret. */
@@ -100,6 +103,7 @@ function normalizeDevice(record: Partial & { id: string; tokenHash
name: cleanDeviceName(record.name),
createdAt,
lastSeenAt: timestamp(record.lastSeenAt, createdAt),
+ cloudDesktopAccess: record.cloudDesktopAccess === true,
};
}
@@ -209,6 +213,7 @@ export class DeviceRegistry {
tokenHash: sha256(token),
createdAt: Date.now(),
lastSeenAt: Date.now(),
+ cloudDesktopAccess: false,
};
this.devices.push(device);
// Unlike the lastSeenAt write below, this one must not be swallowed. A
@@ -260,6 +265,23 @@ export class DeviceRegistry {
this.persist();
return true;
}
+
+ /** Grant or remove the one capability that crosses from companion actions
+ * into full desktop control. This is per device so a watch-only phone does
+ * not inherit a different phone's permission. */
+ setCloudDesktopAccess(id: string, allowed: boolean): boolean {
+ const device = this.devices.find((candidate) => candidate.id === id);
+ if (!device) return false;
+ const previous = device.cloudDesktopAccess;
+ device.cloudDesktopAccess = allowed;
+ try {
+ this.persist();
+ } catch (error) {
+ device.cloudDesktopAccess = previous;
+ throw error;
+ }
+ return true;
+ }
}
/**
diff --git a/companion/src/index.ts b/companion/src/index.ts
index 06f513287..640175b21 100755
--- a/companion/src/index.ts
+++ b/companion/src/index.ts
@@ -118,7 +118,7 @@ const companion = createServer(
harnessPort: HARNESS_PORT,
// `authenticate` also stamps lastSeenAt, which is what makes the control
// page able to say when a phone was last heard from.
- authenticate: (token) => Boolean(devices.authenticate(token)),
+ authenticate: (token) => devices.authenticate(token),
redeem: (code, deviceName) => devices.redeem(code, deviceName),
serverName: machineName,
}),
diff --git a/companion/src/proxy.ts b/companion/src/proxy.ts
index 1d0a0af15..21718b1eb 100644
--- a/companion/src/proxy.ts
+++ b/companion/src/proxy.ts
@@ -14,7 +14,7 @@
import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
import { bearerToken } from "./devices.ts";
-import { denyReason } from "./routes.ts";
+import { denyReason, isCloudDesktopJoin } from "./routes.ts";
import { createSseScrubber, isJson, scrub } from "./wire.ts";
/** What the forwarding handler needs from the process around it. */
@@ -22,7 +22,7 @@ export interface ProxyOptions {
/** Where the harness is listening on loopback. */
harnessPort: number;
/** Does this bearer token belong to a paired device? */
- authenticate: (token: string | undefined) => boolean;
+ authenticate: (token: string | undefined) => { cloudDesktopAccess: boolean } | null;
/** Redeem a pairing code. Handled here and never forwarded: the harness
* has no such route and no idea devices exist — pairing is the sidecar's
* own concern, and the one thing a device does before it has a token. */
@@ -131,6 +131,8 @@ export function createProxyHandler(options: ProxyOptions) {
return sendJson(res, 403, { error: "forbidden: cross-origin request" });
}
+ const token = bearerToken(req.headers.authorization);
+ const device = options.authenticate(token);
const denial = denyReason({
path,
method,
@@ -138,10 +140,19 @@ export function createProxyHandler(options: ProxyOptions) {
// reimplemented: this file used to have a second one, and two parsers
// that disagree about what a credential looks like means the header a
// phone sends authenticates on one code path and not the other.
- authenticated: options.authenticate(bearerToken(req.headers.authorization)),
+ authenticated: Boolean(device),
});
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.
+ // The computer owner enables this capability per device, off by default.
+ if (isCloudDesktopJoin(method, path) && !device?.cloudDesktopAccess) {
+ return sendJson(res, 403, {
+ error: "cloud desktop access is off for this phone — enable it in OpenMausBot → Settings → Companion",
+ });
+ }
+
// Pairing terminates here. Forwarding it would hand the harness a route
// it does not have, and the 404 would read to a phone as "wrong address".
if (method === "POST" && path === "/api/pair") {
diff --git a/companion/src/routes.ts b/companion/src/routes.ts
index a5f386d36..3a739ce1a 100644
--- a/companion/src/routes.ts
+++ b/companion/src/routes.ts
@@ -32,6 +32,18 @@ export interface RouteRequest {
authenticated: boolean;
}
+/** The one companion route that crosses into full interactive desktop
+ * control. Both the allowlist and capability gate consume this classifier so
+ * their security decisions cannot drift apart. */
+export const CLOUD_DESKTOP_JOIN_ROUTE = {
+ method: "POST",
+ path: /^\/api\/bots\/[\w-]+\/computer\/join$/,
+} as const;
+
+export function isCloudDesktopJoin(method: string, path: string): boolean {
+ return method === CLOUD_DESKTOP_JOIN_ROUTE.method && CLOUD_DESKTOP_JOIN_ROUTE.path.test(path);
+}
+
/** Every request the iOS app makes, and nothing else.
*
* Ids are `[\w-]+`, matching the harness's own route patterns. The paths
@@ -61,6 +73,9 @@ const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [
{ method: "POST", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
{ method: "PATCH", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
{ method: "DELETE", path: /^\/api\/bots\/[\w-]+\/tasks\/[\w-]+$/ },
+ // Full cloud desktop access. The route is narrow and the proxy applies a
+ // second, per-device capability check before it reaches the harness.
+ CLOUD_DESKTOP_JOIN_ROUTE,
// rooms
{ method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ },
diff --git a/companion/test/control.test.ts b/companion/test/control.test.ts
index 986c11d20..1975a80f0 100644
--- a/companion/test/control.test.ts
+++ b/companion/test/control.test.ts
@@ -12,6 +12,7 @@ import { DeviceRegistry } from "../src/devices.ts";
let control: Server;
let port = 0;
+let devices: DeviceRegistry;
const ask = async (
method: string,
@@ -28,8 +29,9 @@ const ask = async (
};
beforeAll(async () => {
+ devices = new DeviceRegistry();
control = createControlServer({
- devices: new DeviceRegistry(),
+ devices,
companionPort: 8810,
discovery: () => ({ advertising: false, name: "Test computer" }),
});
@@ -43,6 +45,39 @@ afterAll(async () => {
});
describe("origins the control server will change state for", () => {
+ it("controls cloud desktop access per paired device", async () => {
+ const { code } = devices.openPairing();
+ const paired = devices.redeem(code, "iPhone");
+ if ("error" in paired) throw new Error(paired.error);
+
+ expect(paired.device.cloudDesktopAccess).toBe(false);
+ expect((await ask("POST", `/devices/${paired.device.id}/cloud-desktop`)).status).toBe(200);
+ expect(devices.authenticate(paired.token)?.cloudDesktopAccess).toBe(true);
+ expect((await ask("DELETE", `/devices/${paired.device.id}/cloud-desktop`)).status).toBe(200);
+ expect(devices.authenticate(paired.token)?.cloudDesktopAccess).toBe(false);
+ expect((await ask("POST", "/devices/missing/cloud-desktop")).status).toBe(404);
+ });
+
+ it("reports a permission write failure without dropping the control server", async () => {
+ const [device] = devices.list();
+ const writable = devices as unknown as { persist: () => void };
+ const persist = writable.persist;
+ writable.persist = () => {
+ throw new Error("ENOSPC: no space left on device");
+ };
+ try {
+ const failed = await ask("POST", `/devices/${device.id}/cloud-desktop`);
+ expect(failed).toEqual({
+ status: 500,
+ body: { error: "could not save cloud desktop access" },
+ });
+ expect(devices.list().find((candidate) => candidate.id === device.id)?.cloudDesktopAccess).toBe(false);
+ expect((await ask("GET", "/state")).status).toBe(200);
+ } finally {
+ writable.persist = persist;
+ }
+ });
+
it("refuses a state change from a foreign page", async () => {
// The attack this exists for: a form POST needs no preflight, and the
// Host header on it is the loopback one this server already approves.
diff --git a/companion/test/devices.test.ts b/companion/test/devices.test.ts
index 77ac5eac0..9b2d86582 100644
--- a/companion/test/devices.test.ts
+++ b/companion/test/devices.test.ts
@@ -60,6 +60,7 @@ describe("DeviceRegistry", () => {
delete stored.devices[0].name;
delete stored.devices[0].lastSeenAt;
delete stored.devices[0].createdAt;
+ delete stored.devices[0].cloudDesktopAccess;
writeFileSync(file, JSON.stringify(stored));
const reloaded = new DeviceRegistry();
@@ -68,6 +69,7 @@ describe("DeviceRegistry", () => {
expect(listed.name).toBe("Companion");
expect(Number.isFinite(listed.lastSeenAt)).toBe(true);
expect(Number.isFinite(listed.createdAt)).toBe(true);
+ expect(listed.cloudDesktopAccess).toBe(false);
// and the token it was paired with still works
expect(reloaded.authenticate(token)?.id).toBe(device.id);
});
@@ -126,6 +128,31 @@ describe("DeviceRegistry", () => {
expect(registry.count()).toBe(1);
});
+ it("keeps cloud desktop access off until enabled for that device", () => {
+ const registry = new DeviceRegistry();
+ const { token, device } = pair(registry);
+
+ expect(device.cloudDesktopAccess).toBe(false);
+ expect(registry.authenticate(token)?.cloudDesktopAccess).toBe(false);
+ expect(registry.setCloudDesktopAccess(device.id, true)).toBe(true);
+ expect(registry.authenticate(token)?.cloudDesktopAccess).toBe(true);
+ expect(new DeviceRegistry().authenticate(token)?.cloudDesktopAccess).toBe(true);
+ expect(registry.setCloudDesktopAccess(device.id, false)).toBe(true);
+ expect(registry.authenticate(token)?.cloudDesktopAccess).toBe(false);
+ expect(registry.setCloudDesktopAccess("missing", true)).toBe(false);
+ });
+
+ it("rolls cloud desktop access back when it cannot be saved", () => {
+ const registry = new DeviceRegistry();
+ const { token, device } = pair(registry);
+ (registry as unknown as { persist: () => void }).persist = () => {
+ throw new Error("ENOSPC: no space left on device");
+ };
+
+ expect(() => registry.setCloudDesktopAccess(device.id, true)).toThrow("ENOSPC");
+ expect(registry.authenticate(token)?.cloudDesktopAccess).toBe(false);
+ });
+
it("uses a high-entropy QR credential and burns the manual fallback with it", () => {
const registry = new DeviceRegistry();
const { code, token } = registry.openPairing();
diff --git a/companion/test/proxy-response.test.ts b/companion/test/proxy-response.test.ts
index 712880fc7..8711894ff 100644
--- a/companion/test/proxy-response.test.ts
+++ b/companion/test/proxy-response.test.ts
@@ -23,6 +23,7 @@ const deeplyNested = (() => {
let harness: Server;
let sidecar: Server;
let sidecarPort = 0;
+let cloudDesktopAccess = true;
/** What the stub harness answers with next. Set per test. */
let respond: (res: ServerResponse) => void = (res) => res.end();
@@ -33,8 +34,9 @@ const close = (server: Server | undefined): Promise =>
new Promise((resolve) => (server ? server.close(() => resolve()) : resolve()));
/** A request as a paired device makes it. */
-const device = async (path = "/api/bots"): Promise<{ status: number; text: string }> => {
+const device = async (path = "/api/bots", method = "GET"): Promise<{ status: number; text: string }> => {
const res = await fetch(`http://127.0.0.1:${sidecarPort}${path}`, {
+ method,
headers: { authorization: `Bearer ${TOKEN}` },
});
return { status: res.status, text: await res.text() };
@@ -47,7 +49,7 @@ beforeAll(async () => {
sidecar = createServer(
createProxyHandler({
harnessPort,
- authenticate: (t) => t === TOKEN,
+ authenticate: (t) => (t === TOKEN ? { cloudDesktopAccess } : null),
redeem: () => ({ error: "not used here" }),
serverName: () => "Test computer",
}),
@@ -61,6 +63,27 @@ afterAll(async () => {
});
describe("preparing a harness response for a device", () => {
+ it("requires the Mac to enable cloud desktop for this phone", async () => {
+ cloudDesktopAccess = false;
+ try {
+ const { status, text } = await device("/api/bots/b1/computer/join", "POST");
+ expect(status).toBe(403);
+ expect(text).toContain("enable it in OpenMausBot");
+ } finally {
+ cloudDesktopAccess = true;
+ }
+ });
+
+ it("forwards only the enabled device's request for a fresh viewer", async () => {
+ respond = (res) => {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ joinUrl: "https://desktop.example/session/fresh", state: "ready" }));
+ };
+ const { status, text } = await device("/api/bots/b1/computer/join", "POST");
+ expect(status).toBe(200);
+ expect(JSON.parse(text).joinUrl).toBe("https://desktop.example/session/fresh");
+ });
+
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
diff --git a/companion/test/proxy.test.ts b/companion/test/proxy.test.ts
index 853aa8d83..d1f8f5064 100644
--- a/companion/test/proxy.test.ts
+++ b/companion/test/proxy.test.ts
@@ -167,7 +167,7 @@ beforeAll(async () => {
sidecar = createServer(
createProxyHandler({
harnessPort: HARNESS_PORT,
- authenticate: (t) => t === TOKEN,
+ authenticate: (t) => (t === TOKEN ? { cloudDesktopAccess: true } : null),
redeem: (code, deviceName) =>
code === "424242"
? { token: TOKEN, device: { id: "d1", name: String(deviceName) } }
@@ -368,7 +368,7 @@ describe("the sidecar in front of an unmodified harness", () => {
const orphan = createServer(
createProxyHandler({
harnessPort: 1,
- authenticate: () => true,
+ authenticate: () => ({ cloudDesktopAccess: true }),
redeem: () => ({ error: "no" }),
serverName: () => "Test computer",
}),
@@ -400,7 +400,7 @@ describe("the sidecar in front of an unmodified harness", () => {
const stalled = createServer(
createProxyHandler({
harnessPort: mutePort,
- authenticate: () => true,
+ authenticate: () => ({ cloudDesktopAccess: true }),
redeem: () => ({ error: "no" }),
serverName: () => "Test computer",
// the shipped value is 30s; the behaviour under test is the same one
@@ -451,7 +451,7 @@ describe("the sidecar in front of an unmodified harness", () => {
const relay = createServer(
createProxyHandler({
harnessPort: slowPort,
- authenticate: () => true,
+ authenticate: () => ({ cloudDesktopAccess: true }),
redeem: () => ({ error: "no" }),
serverName: () => "Test computer",
}),
@@ -510,7 +510,7 @@ describe("the sidecar in front of an unmodified harness", () => {
const relay = createServer(
createProxyHandler({
harnessPort: floodPort,
- authenticate: () => true,
+ authenticate: () => ({ cloudDesktopAccess: true }),
redeem: () => ({ error: "no" }),
serverName: () => "Test computer",
}),
@@ -548,7 +548,7 @@ describe("pairing, end to end", () => {
const paired = createServer(
createProxyHandler({
harnessPort: HARNESS_PORT,
- authenticate: (t) => Boolean(registry.authenticate(t ?? undefined)),
+ authenticate: (t) => registry.authenticate(t ?? undefined),
redeem: (code, deviceName) => registry.redeem(code, deviceName),
serverName: () => "Ada's computer",
}),
diff --git a/companion/test/routes.test.ts b/companion/test/routes.test.ts
index 54c16426f..d87da0a4c 100644
--- a/companion/test/routes.test.ts
+++ b/companion/test/routes.test.ts
@@ -42,6 +42,7 @@ describe("what the app may do", () => {
["POST", "/api/bots/bot_123/tasks/th_1"],
["PATCH", "/api/bots/bot_123/tasks/th_1"],
["DELETE", "/api/bots/bot_123/tasks/th_1"],
+ ["POST", "/api/bots/bot_123/computer/join"],
["POST", "/api/groups/room-1/messages"],
["POST", "/api/groups/room-1/read"],
["GET", "/api/threads/th_1/messages"],
@@ -88,6 +89,15 @@ describe("what it may not", () => {
expect(ask("GET", "/index.html")?.status).toBe(404);
});
+ it("opens only a fresh cloud viewer, not the cloud computer control API", () => {
+ expect(allowed("POST", "/api/bots/bot_123/computer/join")).toBe(true);
+ expect(allowed("GET", "/api/bots/bot_123/computer")).toBe(false);
+ expect(allowed("POST", "/api/bots/bot_123/computer/provision")).toBe(false);
+ expect(allowed("POST", "/api/bots/bot_123/computer/sleep")).toBe(false);
+ expect(allowed("POST", "/api/bots/bot_123/computer/exec")).toBe(false);
+ expect(allowed("POST", "/api/bots/bot_123/computer/screenshot")).toBe(false);
+ });
+
// The method is part of the allowance, not decoration: reading the fleet
// and deleting a bot are the same path.
it("allows a path only for the methods it was allowed for", () => {
diff --git a/companion/test/upstream-failure.test.ts b/companion/test/upstream-failure.test.ts
index bafa035ee..655c9576d 100644
--- a/companion/test/upstream-failure.test.ts
+++ b/companion/test/upstream-failure.test.ts
@@ -39,7 +39,7 @@ const stand = async (harness: Server): Promise => {
const sidecar = createServer(
createProxyHandler({
harnessPort,
- authenticate: () => true,
+ authenticate: () => ({ cloudDesktopAccess: true }),
redeem: () => ({ error: "not in this test" }),
serverName: () => "Ada's computer",
}),
@@ -142,7 +142,7 @@ describe("an upstream that fails mid-stream", () => {
const sidecar = createServer(
createProxyHandler({
harnessPort,
- authenticate: () => true,
+ authenticate: () => ({ cloudDesktopAccess: true }),
redeem: () => ({ error: "not in this test" }),
serverName: () => "Ada's computer",
}),
diff --git a/docs/ios-companion.md b/docs/ios-companion.md
index 65ea38f6c..f3f6f4d2f 100644
--- a/docs/ios-companion.md
+++ b/docs/ios-companion.md
@@ -140,6 +140,8 @@ Allowed in the first release:
- Read the fleet, rooms, instances, configuration status, and transcripts.
- Fetch settled screen images and opt into live screen frames.
+- Request a fresh interactive cloud-desktop viewer only when the computer
+ owner has enabled that capability for this specific paired phone.
- Send messages, interrupt bots, answer approvals/questions, and mark chats
read.
- Create a basic bot.
@@ -156,6 +158,8 @@ Intentionally refused:
- Pairing, device revocation, or companion lifecycle control.
- Local VM lifecycle, webhooks, connectors, routines, team import/export, and
internal peer-agent routes.
+- Cloud computer provisioning, sleep, shell execution, and screenshot APIs.
+ The phone receives only the fresh `join` viewer URL, never the provider key.
- New harness routes that have not been reviewed for phone access.
## Stream and state model
@@ -238,5 +242,5 @@ distribution scope:
4. **Distribution:** signing, bundle ownership, privacy declarations,
TestFlight, and App Store review material. Swift tests and an unsigned
simulator build already run in the repository CI.
-5. **Optional expansion:** voice/call mode, richer computer interaction, or a
- hosted relay. Each requires its own threat-model review.
+5. **Optional expansion:** voice/call mode, Local VM or host-computer
+ interaction, or a hosted relay. Each requires its own threat-model review.
diff --git a/electron/companion.mjs b/electron/companion.mjs
index 650c7e7bd..cb52a454f 100644
--- a/electron/companion.mjs
+++ b/electron/companion.mjs
@@ -222,3 +222,11 @@ export async function companionRevoke(deviceId) {
await control("DELETE", `/devices/${deviceId}`).catch(() => {});
return companionState();
}
+
+/** Enable or remove interactive cloud-desktop access for one paired phone. */
+export async function companionCloudDesktopAccess(deviceId, allowed) {
+ if (!proc) return companionState();
+ if (!/^[\w-]{1,64}$/.test(String(deviceId ?? ""))) return companionState();
+ await control(allowed ? "POST" : "DELETE", `/devices/${deviceId}/cloud-desktop`).catch(() => {});
+ return companionState();
+}
diff --git a/electron/main.mjs b/electron/main.mjs
index 604fb3f3f..aec15675e 100644
--- a/electron/main.mjs
+++ b/electron/main.mjs
@@ -150,6 +150,7 @@ const LOG_DIR = app.getPath("logs");
let logStream = null;
import {
companionPairing,
+ companionCloudDesktopAccess,
companionRevoke,
companionState,
startCompanion,
@@ -445,6 +446,9 @@ ipcMain.handle("companion:start", () =>
);
ipcMain.handle("companion:stop", () => stopCompanion());
ipcMain.handle("companion:pairing", (_event, open) => companionPairing(Boolean(open)));
+ipcMain.handle("companion:cloud-desktop", (_event, deviceId, allowed) =>
+ companionCloudDesktopAccess(deviceId, Boolean(allowed)),
+);
ipcMain.handle("companion:revoke", (_event, deviceId) => companionRevoke(deviceId));
ipcMain.handle("desktop:capabilities", async () =>
diff --git a/electron/preload.cjs b/electron/preload.cjs
index f47cded1d..45dbe4b43 100644
--- a/electron/preload.cjs
+++ b/electron/preload.cjs
@@ -15,6 +15,7 @@ contextBridge.exposeInMainWorld("ogb", {
start: () => ipcRenderer.invoke("companion:start"),
stop: () => ipcRenderer.invoke("companion:stop"),
pairing: (open) => ipcRenderer.invoke("companion:pairing", open),
+ cloudDesktop: (deviceId, allowed) => ipcRenderer.invoke("companion:cloud-desktop", deviceId, allowed),
revoke: (deviceId) => ipcRenderer.invoke("companion:revoke", deviceId),
},
/** One frame of this computer's screen as a data: URL when supported. */
diff --git a/ios/App/CloudDesktopBrowser.swift b/ios/App/CloudDesktopBrowser.swift
new file mode 100644
index 000000000..824f9ba7a
--- /dev/null
+++ b/ios/App/CloudDesktopBrowser.swift
@@ -0,0 +1,23 @@
+// The cloud provider's noVNC viewer, kept inside the app without teaching
+// OpenMausMobile how to speak VNC or retain the provider's session token.
+// SFSafariViewController supplies a hardened browser, WebSocket support and
+// its own visible origin; dismissing it discards our only reference to the
+// freshly minted URL.
+import SafariServices
+import SwiftUI
+
+struct CloudDesktopBrowser: UIViewControllerRepresentable {
+ let url: URL
+
+ func makeUIViewController(context: Context) -> SFSafariViewController {
+ let configuration = SFSafariViewController.Configuration()
+ configuration.entersReaderIfAvailable = false
+ configuration.barCollapsingEnabled = true
+ let browser = SFSafariViewController(url: url, configuration: configuration)
+ browser.dismissButtonStyle = .close
+ browser.preferredControlTintColor = .systemBlue
+ return browser
+ }
+
+ func updateUIViewController(_ browser: SFSafariViewController, context: Context) {}
+}
diff --git a/ios/App/ComputerView.swift b/ios/App/ComputerView.swift
index a997096a3..f012c7557 100644
--- a/ios/App/ComputerView.swift
+++ b/ios/App/ComputerView.swift
@@ -21,6 +21,10 @@ struct ComputerView: View {
let bot: Bot
@EnvironmentObject private var session: Session
@Environment(\.dismiss) private var dismiss
+ @State private var confirmingDesktop = false
+ @State private var openingDesktop = false
+ @State private var desktopURL: URL?
+ @State private var desktopError: String?
private var frame: ScreenFrame? { session.state.screens[bot.id] }
@@ -51,11 +55,61 @@ struct ComputerView: View {
// Busy is the difference between "the picture is a moment old"
// and "the picture is however it was left" — worth saying,
// because a still frame looks identical either way.
- Text(current.busy == true ? "Live" : "Idle")
+ Text(current.busy == true ? "Preview" : "Idle")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(current.busy == true ? Color.green : Color.secondary)
}
}
+ .safeAreaInset(edge: .bottom) {
+ if current.computer == "cloud" {
+ VStack(spacing: 8) {
+ if let desktopError {
+ Text(desktopError)
+ .font(.footnote)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ }
+ Button {
+ confirmingDesktop = true
+ } label: {
+ if openingDesktop {
+ ProgressView()
+ .tint(.white)
+ .frame(maxWidth: .infinity)
+ } else {
+ Label("Open live cloud desktop", systemImage: "display")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(openingDesktop)
+ Text("Interactive VNC session. Access must be enabled for this phone in the Mac's Companion settings.")
+ .font(.caption)
+ .foregroundStyle(Color.white.opacity(0.6))
+ .multilineTextAlignment(.center)
+ }
+ .padding(.horizontal, 18)
+ .padding(.vertical, 12)
+ .background(.ultraThinMaterial)
+ }
+ }
+ .alert("Open live cloud desktop?", isPresented: $confirmingDesktop) {
+ Button("Cancel", role: .cancel) {}
+ Button("Open desktop") { Task { await openDesktop() } }
+ } message: {
+ Text("This gives this phone full control of the cloud computer, including anything signed in inside it.")
+ }
+ .sheet(
+ isPresented: Binding(
+ get: { desktopURL != nil },
+ set: { if !$0 { desktopURL = nil } }
+ )
+ ) {
+ if let desktopURL {
+ CloudDesktopBrowser(url: desktopURL)
+ .ignoresSafeArea()
+ }
+ }
.onAppear {
session.watchScreen(of: bot.id)
}
@@ -81,4 +135,16 @@ struct ComputerView: View {
}
}
}
+
+ @MainActor
+ private func openDesktop() async {
+ openingDesktop = true
+ desktopError = nil
+ defer { openingDesktop = false }
+ do {
+ desktopURL = try await session.cloudDesktop(for: current)
+ } catch {
+ desktopError = error.localizedDescription
+ }
+ }
}
diff --git a/ios/App/Session.swift b/ios/App/Session.swift
index fbe415f52..2efa90190 100644
--- a/ios/App/Session.swift
+++ b/ios/App/Session.swift
@@ -381,6 +381,18 @@ final class Session: ObservableObject {
await perform { try await $0.interrupt(botId: bot.id) }
}
+ /// Ask for one fresh cloud viewer URL. Unlike ordinary actions this
+ /// returns the value to a browser sheet and never writes it to app state.
+ func cloudDesktop(for bot: Bot) async throws -> URL {
+ guard let client else { throw APIError.transport("This computer is offline.") }
+ do {
+ return try await client.cloudDesktop(botId: bot.id).url
+ } catch let error as APIError where error.isUnauthorized {
+ status = .unauthorized
+ throw error
+ }
+ }
+
func markRead(_ chat: Chat) async {
await perform(quietly: true) {
switch chat {
diff --git a/ios/AppStore/review-notes.md b/ios/AppStore/review-notes.md
index 667e7599c..0dea5091d 100644
--- a/ios/AppStore/review-notes.md
+++ b/ios/AppStore/review-notes.md
@@ -12,6 +12,12 @@ To review the app:
address and six-digit code shown by the desktop panel.
5. Create a bot on the desktop or with the `+` button in the iPhone roster, then send a message.
+Optional cloud-desktop review requires an ascii.dev Box configured on the Mac.
+For the paired phone, enable **Cloud desktop** under **Settings → Companion**,
+open a bot configured for **Cloud box**, choose its computer preview on iPhone,
+and confirm **Open live cloud desktop**. The app requests a fresh HTTPS viewer
+session and does not use or store the provider API key.
+
The phone and computer must be on the same trusted network. Alternatively, both may be signed into the same Tailscale network and the reviewer may enter the computer's `.ts.net` MagicDNS name.
No purchase or subscription is required. The computer is the source of bot data and credentials; the developer cannot provide a universal demo account without routing reviewers into someone else's private computer.
diff --git a/ios/README.md b/ios/README.md
index 033fca2c6..ac35902ed 100644
--- a/ios/README.md
+++ b/ios/README.md
@@ -116,9 +116,10 @@ here by simply not having the methods:
|---|---|
| Read bots, rooms and transcripts | Write API keys (`PUT /api/config`) |
| Send messages | Manage pairing or revoke devices |
-| **Answer approvals and questions** | Drive the Local VM |
+| **Answer approvals and questions** | Drive the Local VM or this computer |
| Interrupt a bot, mark chats read | Reach `/api/internal/*` |
| Fetch screen images on demand | Load the packaged desktop UI |
+| Open an explicitly enabled cloud desktop | Provision, sleep or run shell commands on cloud computers |
Marking a chat read and remembering an approval use purpose-built server
verbs. The sidecar does not expose the general bot or room `PATCH` routes,
@@ -128,6 +129,12 @@ working directories.
Companion settings stay on the computer on purpose: losing the phone must not
mean losing the ability to lock it out.
+Interactive cloud desktop access is additionally enabled per paired device and
+starts off. The phone asks the Mac to mint a fresh provider URL after an
+explicit warning, validates that it is HTTPS, opens it in an in-app Safari
+sheet, and never persists it. The Local VM's loopback-only noVNC listener and
+the host computer remain unreachable through the companion.
+
## Design notes
- **Zero third-party dependencies.** The raw-byte SSE reader, Keychain,
diff --git a/ios/Sources/CompanionCore/Client.swift b/ios/Sources/CompanionCore/Client.swift
index ba3109510..8a77aaa3e 100644
--- a/ios/Sources/CompanionCore/Client.swift
+++ b/ios/Sources/CompanionCore/Client.swift
@@ -454,6 +454,17 @@ public struct CompanionClient: Sendable {
try await send(try makeRequest("POST", "/api/bots/\(botId)/interrupt"))
}
+ /// Mint a fresh interactive viewer for an existing cloud computer. The
+ /// response URL is a bearer credential: the caller presents it directly
+ /// and never stores it. The sidecar additionally requires this paired
+ /// device's cloud-desktop capability to be enabled on the Mac.
+ public func cloudDesktop(botId: String) async throws -> CloudDesktopSession {
+ try await send(
+ try makeRequest("POST", "/api/bots/\(botId)/computer/join"),
+ as: CloudDesktopSession.self
+ )
+ }
+
public func markRead(botId: String) async throws {
try await send(try makeRequest("POST", "/api/bots/\(botId)/read"))
}
diff --git a/ios/Sources/CompanionCore/Models.swift b/ios/Sources/CompanionCore/Models.swift
index 0868c845e..63ab3f6c2 100644
--- a/ios/Sources/CompanionCore/Models.swift
+++ b/ios/Sources/CompanionCore/Models.swift
@@ -261,6 +261,31 @@ public struct PairResponse: Codable, Sendable {
public var serverName: String
}
+/// A freshly minted provider viewer. It is deliberately not Codable for
+/// persistence: the URL is a short-lived bearer credential and belongs only
+/// in memory for the browser session that requested it.
+public struct CloudDesktopSession: Decodable, Sendable {
+ public let url: URL
+
+ private enum CodingKeys: String, CodingKey { case joinUrl }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let raw = try container.decode(String.self, forKey: .joinUrl)
+ guard let parsed = URL(string: raw),
+ parsed.scheme?.lowercased() == "https",
+ parsed.host != nil
+ else {
+ throw DecodingError.dataCorruptedError(
+ forKey: .joinUrl,
+ in: container,
+ debugDescription: "Cloud desktop URL must be HTTPS"
+ )
+ }
+ url = parsed
+ }
+}
+
public struct ProviderSnapshot: Codable, Hashable, Sendable {
public var state: String
public var reason: String?
diff --git a/ios/Tests/CompanionCoreTests/ConnectionTests.swift b/ios/Tests/CompanionCoreTests/ConnectionTests.swift
index 629559df5..5fc622495 100644
--- a/ios/Tests/CompanionCoreTests/ConnectionTests.swift
+++ b/ios/Tests/CompanionCoreTests/ConnectionTests.swift
@@ -67,4 +67,19 @@ final class ConnectionTests: XCTestCase {
XCTAssertNil(PairingInvite.parse(try XCTUnwrap(URL(string: "openmausbot://pair?address=host%2Fpath&code=123456"))))
XCTAssertNil(PairingInvite.parse(try XCTUnwrap(URL(string: "openmausbot://pair?address=one.local&address=two.local&code=123456"))))
}
+
+ func testAcceptsOnlyAnHTTPSCloudDesktopSession() throws {
+ let valid = Data(#"{"joinUrl":"https://desktop.example/session/fresh","state":"ready"}"#.utf8)
+ let session = try JSONDecoder().decode(CloudDesktopSession.self, from: valid)
+ XCTAssertEqual(session.url.absoluteString, "https://desktop.example/session/fresh")
+
+ for value in [
+ "http://desktop.example/session",
+ "javascript:alert(1)",
+ "not a URL"
+ ] {
+ let data = try JSONSerialization.data(withJSONObject: ["joinUrl": value])
+ XCTAssertThrowsError(try JSONDecoder().decode(CloudDesktopSession.self, from: data))
+ }
+ }
}
diff --git a/src/components/CompanionSection.tsx b/src/components/CompanionSection.tsx
index b9915af37..895c9fc07 100644
--- a/src/components/CompanionSection.tsx
+++ b/src/components/CompanionSection.tsx
@@ -23,6 +23,7 @@ interface Device {
name: string;
createdAt: number;
lastSeenAt: number;
+ cloudDesktopAccess: boolean;
}
interface CompanionState {
@@ -53,6 +54,7 @@ type Bridge = {
start: () => Promise;
stop: () => Promise;
pairing: (open: boolean) => Promise;
+ cloudDesktop: (deviceId: string, allowed: boolean) => Promise;
revoke: (deviceId: string) => Promise;
};
@@ -289,7 +291,7 @@ export function CompanionSection() {
title="Paired devices"
subtitle={
state.devices.length
- ? "Removing a device signs it out immediately."
+ ? "Cloud desktop is full interactive access. Enable it only for a phone you trust; removing a device signs it out immediately."
: "No phones are paired yet."
}
>
@@ -302,6 +304,19 @@ export function CompanionSection() {