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 ? "" : "

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() {
{device.name}
Last seen {relative(device.lastSeenAt)}
+
+ Cloud desktop + +