From 61b864e3777fb305373a60b860c6e238055e8018 Mon Sep 17 00:00:00 2001 From: mnthr7 Date: Sun, 16 Aug 2026 23:44:36 +0000 Subject: [PATCH 01/32] =?UTF-8?q?Add=20Settings=20=E2=86=92=20Companion,?= =?UTF-8?q?=20a=20toggle=20that=20starts=20the=20sidecar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidecar is a separate process, and the first version of that meant a terminal command and a browser tab to pair in. That was a bad trade for something the rest of the app does in a panel, and it did not have to be one: the app already forks the harness as a child process, so forking one more is a thing it knows how to do. Settings → Companion turns it on, shows the address to type into the phone, opens a pairing window, lists paired devices and revokes them. Turning it off stops the process, which is still the honest off switch — there is no flag left behind claiming a listener that is not there. - `electron/companion.mjs` owns the lifecycle: `utilityProcess.fork`, and it waits for the control port to answer before reporting success rather than assuming the fork worked. A missing `dist-companion/index.js` reports what to run instead of failing as a timeout. - The renderer never talks to the control port. Everything goes through `ipcMain.handle`, which keeps the UI on one origin, avoids CORS, and puts the narrow list of things the renderer may ask for in one file rather than implying it from whatever the control server happens to serve. - Packaging stages the compiled sidecar beside the harness, and `package:prepare` builds it, so a packaged app has it and a dev checkout gets told to run `pnpm build:companion`. The panel reports rather than guesses. When there is no MagicDNS name it says which Tailscale CLI paths were tried and what each said, because telling someone to turn on MagicDNS when they already have it on is worse than saying nothing. Depends on the previous change, which adds `companion/`. Co-Authored-By: Claude Opus 5 --- electron-builder.yml | 4 + electron/companion.mjs | 157 +++++++++++++++ electron/main.mjs | 23 +++ electron/preload.cjs | 11 ++ package.json | 4 +- src/components/CompanionSection.tsx | 284 ++++++++++++++++++++++++++++ src/components/SettingsModal.tsx | 6 +- src/state/store.tsx | 2 +- 8 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 electron/companion.mjs create mode 100644 src/components/CompanionSection.tsx diff --git a/electron-builder.yml b/electron-builder.yml index 15e00e424e..af7f848d0c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -40,6 +40,10 @@ extraResources: to: ui - from: dist-server to: server + # the companion sidecar — a separate process because it is the only part + # of the app that listens off this machine, and it stays off until asked + - from: dist-companion + to: companion mac: target: diff --git a/electron/companion.mjs b/electron/companion.mjs new file mode 100644 index 0000000000..b665c94814 --- /dev/null +++ b/electron/companion.mjs @@ -0,0 +1,157 @@ +// The companion sidecar's lifecycle, as seen by the desktop app. +// +// The sidecar is a separate process on purpose — it is the only thing here +// that listens off-machine, and keeping it out of the harness is what lets +// the harness stay loopback-only and unpatched. But "separate process" does +// not have to mean "open a terminal": the app already forks the harness the +// same way, and a toggle in Settings is what anyone actually wants. +// +// The renderer never talks to the sidecar's control port directly. It calls +// through here, which keeps the UI on one origin, avoids CORS, and means the +// narrow list of things the renderer may ask for is written down in one +// place rather than implied by whatever the control server happens to serve. +import { app, utilityProcess } from "electron"; +import fs from "node:fs"; +import path from "node:path"; + +// Passed to the fork rather than left to the sidecar's own defaults, so the +// port this file fetches the control API on cannot drift from the port the +// sidecar opened. They must stay clear of the harness, which takes 8799 for +// itself and 8800 for its webhook receiver — the sidecar refuses to start on +// either and says which, rather than racing it for the socket. +const CONTROL_PORT = 8811; +const COMPANION_PORT = 8810; + +let proc = null; +let lastError = null; + +/** Where the sidecar's compiled entry lives. + * + * Packaged, it is staged into resources alongside the harness. In dev there + * is no such directory, so it comes from dist-companion — which means + * `pnpm build:companion` has to have been run at least once. Returning null + * rather than a path that does not exist is what lets the toggle say so + * instead of failing with a spawn error nobody can read. */ +const entryPoint = (resourcesPath) => { + const packaged = path.join(resourcesPath, "companion", "index.js"); + if (app.isPackaged) return fs.existsSync(packaged) ? packaged : null; + const built = path.join(app.getAppPath(), "dist-companion", "index.js"); + return fs.existsSync(built) ? built : null; +}; + +/** Ask the sidecar's own control server, which is the same API the standalone + * page uses. Short timeout: this is loopback, and a spinner in Settings that + * never resolves is worse than an error. */ +async function control(method, urlPath) { + const res = await fetch(`http://127.0.0.1:${CONTROL_PORT}${urlPath}`, { + method, + signal: AbortSignal.timeout(4000), + }); + if (!res.ok && res.status !== 404) throw new Error(`companion control ${res.status}`); + return res.json(); +} + +export function companionRunning() { + return proc !== null; +} + +export async function startCompanion({ resourcesPath, harnessPort, log }) { + if (proc) return companionState(); + lastError = null; + const entry = entryPoint(resourcesPath); + if (!entry) { + lastError = app.isPackaged + ? "the companion is missing from this build" + : "run `pnpm build:companion` once, then try again"; + return companionState(); + } + log?.(`companion fork ${entry}`); + + const child = utilityProcess.fork(entry, [], { + env: { + ...process.env, + OMB_PORT: String(harnessPort), + OMB_COMPANION_PORT: String(COMPANION_PORT), + OMB_CONTROL_PORT: String(CONTROL_PORT), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (d) => log?.(`[companion] ${String(d).trimEnd()}`)); + child.stderr?.on("data", (d) => log?.(`[companion err] ${String(d).trimEnd()}`)); + + let exited = false; + child.once("exit", (code) => { + exited = true; + // A non-zero exit before we saw it answer is the interesting case: the + // usual cause is the port already being taken, and the sidecar's own + // message says which one and why. + if (proc === child) proc = null; + log?.(`companion exited code=${code}`); + }); + + // Wait for the control port rather than assuming the fork worked. Without + // this the toggle would flip to "on" and the panel would then fail every + // request, which reads as a broken app rather than a failed start. + for (let i = 0; i < 40; i++) { + if (exited) { + lastError = "the companion could not start — check the log"; + return companionState(); + } + try { + await control("GET", "/state"); + proc = child; + return companionState(); + } catch { + await new Promise((r) => setTimeout(r, 150)); + } + } + try { + child.kill(); + } catch { + /* already gone */ + } + lastError = "the companion did not come up in time"; + return companionState(); +} + +export async function stopCompanion() { + const child = proc; + proc = null; + lastError = null; + if (!child) return companionState(); + try { + child.kill(); + } catch { + /* already gone */ + } + return companionState(); +} + +/** Everything the panel renders. Shaped so "off" is a complete answer rather + * than an absence — the panel should never have to guess. */ +export async function companionState() { + if (!proc) { + return { enabled: false, port: COMPANION_PORT, devices: [], pairing: null, ...(lastError ? { error: lastError } : {}) }; + } + try { + const state = await control("GET", "/state"); + return { enabled: true, ...state }; + } catch { + // running but unreachable: report it rather than claiming health + return { enabled: true, port: COMPANION_PORT, devices: [], pairing: null, error: "the companion is not responding" }; + } +} + +export async function companionPairing(open) { + if (!proc) return companionState(); + await control(open ? "POST" : "DELETE", "/pairing").catch(() => {}); + return companionState(); +} + +export async function companionRevoke(deviceId) { + if (!proc) return companionState(); + // the id came from the renderer, so it does not get to shape a path + if (!/^[\w-]{1,64}$/.test(String(deviceId ?? ""))) return companionState(); + await control("DELETE", `/devices/${deviceId}`).catch(() => {}); + return companionState(); +} diff --git a/electron/main.mjs b/electron/main.mjs index 083ef6f763..b7aee280cf 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -37,6 +37,14 @@ let serverReady = true; // parent's stdio leads nowhere and a failed boot is otherwise undiagnosable. const LOG_DIR = app.getPath("logs"); let logStream = null; +import { + companionPairing, + companionRevoke, + companionState, + startCompanion, + stopCompanion, +} from "./companion.mjs"; + function slog(line) { try { if (!logStream) { @@ -276,6 +284,18 @@ ipcMain.handle("speech:finish", () => { if (process.platform === "darwin") finishSpeech(); }); +// ── companion sidecar ────────────────────────────────────────────────── +// The renderer gets these five and nothing else: it can turn the companion +// on and off, look at it, open or cancel a pairing window, and remove a +// device. It cannot reach the sidecar's control port itself. +ipcMain.handle("companion:state", () => companionState()); +ipcMain.handle("companion:start", () => + startCompanion({ resourcesPath: process.resourcesPath, harnessPort: SERVER_PORT, log: slog }), +); +ipcMain.handle("companion:stop", () => stopCompanion()); +ipcMain.handle("companion:pairing", (_event, open) => companionPairing(Boolean(open))); +ipcMain.handle("companion:revoke", (_event, deviceId) => companionRevoke(deviceId)); + ipcMain.handle("desktop:capabilities", async () => desktopCapabilities({ platform: process.platform, @@ -339,6 +359,9 @@ app.on("before-quit", (e) => { try { serverProc?.kill(); } catch {} + // the sidecar holds a socket that is reachable from off this machine — + // it should not outlive the window by even a moment + void stopCompanion(); // a live dictation session runs its own helper child that holds the mic — // stop it here so quitting never orphans a recording process stopSpeech(); diff --git a/electron/preload.cjs b/electron/preload.cjs index d649d62325..871a70d47d 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -6,6 +6,17 @@ contextBridge.exposeInMainWorld("ogb", { /** Host platform ("darwin" | "win32" | "linux") — for platform-aware UI. */ platform: process.platform, getCapabilities: () => ipcRenderer.invoke("desktop:capabilities"), + /** The companion sidecar: the one part of this app that listens off the + * machine, so it runs as its own process and is off until switched on. + * Every call answers with the whole state, so the panel never has to + * stitch two round-trips together. */ + companion: { + state: () => ipcRenderer.invoke("companion:state"), + start: () => ipcRenderer.invoke("companion:start"), + stop: () => ipcRenderer.invoke("companion:stop"), + pairing: (open) => ipcRenderer.invoke("companion:pairing", open), + revoke: (deviceId) => ipcRenderer.invoke("companion:revoke", deviceId), + }, /** One frame of this computer's screen as a data: URL when supported. */ screenFrame: () => ipcRenderer.invoke("screen:frame"), speechStart: (options) => ipcRenderer.invoke("speech:start", options), diff --git a/package.json b/package.json index 9e865631a7..0b41fffed7 100644 --- a/package.json +++ b/package.json @@ -33,14 +33,14 @@ "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", "test:cua-container": "node scripts/smoke-cua-container.mjs", - "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/speech.mjs", + "check:electron": "node --check electron/main.mjs && node --check electron/companion.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/speech.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json", "build:companion": "tsc -p tsconfig.companion.build.json", "build:speech": "node electron/build-speech-helper.mjs", "build:cua": "node scripts/prepare-cua.mjs", "build:updater": "node scripts/bundle-updater.mjs", - "package:prepare": "pnpm build && pnpm build:server && pnpm build:updater", + "package:prepare": "pnpm build && pnpm build:server && pnpm build:companion && pnpm build:updater", "package:mac": "pnpm package:prepare && pnpm build:speech && pnpm build:cua && electron-builder --mac --publish never", "package:win": "pnpm package:prepare && electron-builder --win --publish never", "package:linux": "pnpm package:prepare && electron-builder --linux --x64 --publish never", diff --git a/src/components/CompanionSection.tsx b/src/components/CompanionSection.tsx new file mode 100644 index 0000000000..c350510002 --- /dev/null +++ b/src/components/CompanionSection.tsx @@ -0,0 +1,284 @@ +// App Settings → Companion. Turning this on starts the companion sidecar: a +// separate process that opens an authenticated port a paired phone can +// reach. Everything else in the app keeps talking to 127.0.0.1 exactly as +// before, and the harness itself is untouched. +// +// The sidecar is separate on purpose — it is the only thing here that +// listens off this machine, and keeping it out of the harness is what lets +// the harness stay loopback-only. That is an implementation detail from this +// panel's point of view, which is the point: a toggle, a code, a list of +// devices. Nobody should need a terminal to use their phone. +// +// The panel is deliberately blunt about what it does. "Your bots can run +// shell commands" is the honest reason a network switch here deserves a +// sentence of explanation rather than a bare toggle. +import { useCallback, useEffect, useState } from "react"; +import { Loader2, Smartphone, Trash2 } from "lucide-react"; +import { Card } from "./SettingsPrimitives"; + +interface Device { + id: string; + name: string; + createdAt: number; + lastSeenAt: number; +} + +interface CompanionState { + enabled: boolean; + port: number; + devices: Device[]; + pairing: { code: string; expiresAt: number } | null; + /** Every address a phone could dial, and which of them is which. */ + addresses?: string[]; + /** The tailnet address, when this machine is on one. */ + tailscale?: string; + /** Its MagicDNS name. Preferred over the address for anything typed into + * a phone: iOS refuses plain HTTP to 100.64/10, which is CGNAT space and + * not one of the ranges its local-networking exemption covers, and ATS + * exceptions match by name rather than by subnet. */ + tailnetName?: string; + lan?: string | null; + /** Bonjour: when advertising, the phone finds this computer by name. */ + discovery?: { advertising: boolean; name: string }; + /** Why it is not running, or not answering. */ + error?: string; +} + +/** The bridge the preload exposes. Absent in a browser tab — the companion + * needs a process only the desktop app can start. */ +type Bridge = { + state: () => Promise; + start: () => Promise; + stop: () => Promise; + pairing: (open: boolean) => Promise; + revoke: (deviceId: string) => Promise; +}; + +const bridge = (): Bridge | null => + (globalThis as { ogb?: { companion?: Bridge } }).ogb?.companion ?? null; + +const relative = (at: number) => { + const seconds = Math.round((Date.now() - at) / 1000); + if (seconds < 90) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} h ago`; + return `${Math.round(hours / 24)} d ago`; +}; + +export function CompanionSection() { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [now, setNow] = useState(() => Date.now()); + + const load = useCallback(async () => { + const companion = bridge(); + if (!companion) return; + try { + setState(await companion.state()); + } catch { + /* the main process is gone; the rest of the app already says so */ + } + }, []); + + const act = async (call: (companion: Bridge) => Promise) => { + const companion = bridge(); + if (!companion) return; + setBusy(true); + setError(null); + try { + setState(await call(companion)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + void load(); + }, [load]); + + // While a code is on screen it has to count down — and the same tick is + // what notices the phone on the other end finishing the handshake. + useEffect(() => { + if (!state?.pairing) return; + const timer = window.setInterval(() => { + setNow(Date.now()); + void load(); + }, 1000); + return () => window.clearInterval(timer); + }, [state?.pairing, load]); + + if (!bridge()) { + return ( + +
+ + ); + } + + if (!state) { + return ( + + + + ); + } + + const secondsLeft = state.pairing ? Math.max(0, Math.round((state.pairing.expiresAt - now) / 1000)) : 0; + // Prefer the tailnet when there is one: it survives changing wifi, works + // away from home, and gets through a guest network that isolates its + // clients — the case where a LAN address looks perfectly correct and + // reaches nothing. The name beats the address because a phone can only + // use the name. + const tailnet = state.tailnetName ?? state.tailscale; + const address = tailnet ?? state.addresses?.[0]; + + return ( +
+ +
+
+
{state.enabled ? "On" : "Off"}
+
+ {!state.enabled + ? "Nothing on this computer is reachable from the network." + : !address + ? `Listening on port ${state.port} — no network address yet.` + : tailnet + ? `Enter ${tailnet}:${state.port} on your phone — that works from anywhere on your tailnet, on any network.` + : state.discovery?.advertising + ? // The address is shown even when Bonjour is working. + // Discovery can be advertising happily and still not + // reach the phone — a guest network that isolates its + // clients blocks multicast — and when that happens the + // typed address is the way out. Hiding it behind a + // failure the panel cannot detect is no help at all. + `Your phone will find this computer as "${state.discovery.name}", or you can enter ${address}:${state.port}.` + : `Listening on ${address}:${state.port} — enter that on your phone.`} +
+
+ +
+ {state.enabled && tailnet && state.lan && ( +
+ On this network only: {state.lan}:{state.port} +
+ )} + {/* A tailnet address with no name is workable on a laptop and not on + a phone, so say so rather than letting pairing fail with an + unexplained policy error. */} + {state.enabled && state.tailscale && !state.tailnetName && ( +
+ You're on a tailnet, but this computer's MagicDNS name couldn't be read from the + Tailscale app — either MagicDNS is off, or the Tailscale command line tool isn't + where we looked. iPhones can't connect to a bare tailnet address, so check the + OpenMausBot log for which paths were tried. +
+ )} + {state.enabled && !state.tailscale && ( +
+ Only reachable on this network. Install Tailscale on both this computer and your phone + to reach it from anywhere — including networks that stop devices from seeing each other. +
+ )} + {(error || state.error) && ( +
{error ?? state.error}
+ )} +
+ + {state.enabled && ( + + {state.pairing ? ( +
+
+
{state.pairing.code}
+
+ Expires in {secondsLeft}s{address ? ` · ${address}:${state.port}` : ""} +
+
+ +
+ ) : ( + + )} +
+ )} + + + {state.devices.length > 0 && ( +
    + {state.devices.map((device) => ( +
  • + +
    +
    {device.name}
    +
    Last seen {relative(device.lastSeenAt)}
    +
    + +
  • + ))} +
+ )} +
+
+ ); +} + +const cnSwitch = (on: boolean) => + `relative h-6 w-11 shrink-0 rounded-full transition-colors disabled:opacity-40 ${on ? "bg-accent" : "bg-raised"}`; +const cnKnob = (on: boolean) => + `absolute top-[3px] h-[18px] w-[18px] rounded-full bg-white transition-all ${on ? "left-[21px]" : "left-[3px]"}`; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 040236f682..5bd50c1911 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -3,11 +3,12 @@ // is the stuff shared by every bot: who you are, your keys, and the // machine your bots can borrow. import { useEffect, useRef, useState } from "react"; -import { KeyRound, Monitor, User, Volume2, X } from "lucide-react"; +import { KeyRound, Monitor, Smartphone, User, Volume2, X } from "lucide-react"; import { useStore, type AppSettingsSection } from "@/state/store"; import { ApiKeyRow } from "./ApiKeys"; import { useUpdaterState } from "@/lib/updater"; import { LocalComputerSection } from "./LocalComputerSection"; +import { CompanionSection } from "./CompanionSection"; import { Card } from "./SettingsPrimitives"; import { VoiceSettings } from "./VoiceSettings"; import { cn } from "@/lib/cn"; @@ -15,6 +16,7 @@ import { cn } from "@/lib/cn"; const SECTIONS: Array<{ id: AppSettingsSection; label: string; icon: typeof User }> = [ { id: "general", label: "General", icon: User }, { id: "connections", label: "Connections", icon: KeyRound }, + { id: "companion", label: "Companion", icon: Smartphone }, { id: "computer", label: "Local VM", icon: Monitor }, { id: "voice", label: "Voice", icon: Volume2 }, ]; @@ -214,6 +216,8 @@ export function SettingsModal() { )} + {section === "companion" && } + {section === "voice" && } {section === "computer" && } diff --git a/src/state/store.tsx b/src/state/store.tsx index 3aa4d9547b..5d0168158b 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -202,7 +202,7 @@ export interface InstanceInfo { install?: EngineInstall; } -export type AppSettingsSection = "general" | "connections" | "voice" | "computer"; +export type AppSettingsSection = "general" | "connections" | "companion" | "voice" | "computer"; interface AppState { bots: Bot[]; From 1ada75024d51d8c8c388afb12fed885a5c8a02da Mon Sep 17 00:00:00 2001 From: mnthr7 Date: Sun, 16 Aug 2026 23:43:03 +0000 Subject: [PATCH 02/32] Add companion/: a sidecar that lets a paired phone reach the harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phone cannot reach the harness, and should not be able to. The loopback gate refuses any request whose Host is not local, which is exactly right for a process holding provider keys and an approval switch — the guarantee is structural, and weakening it to let a phone in would weaken it for everyone. So this does not weaken it. `companion/` is a separate process that speaks to the harness as this machine, over loopback, exactly as the desktop window does. The harness needs no changes and does not know the sidecar exists. phone ──LAN/tailnet──▶ companion :8810 ──loopback──▶ harness :8799 Three sockets, and the split between them is the security model: :8810 0.0.0.0 devices — token required, allowlisted, scrubbed :8811 127.0.0.1 you — pairing and revocation, never off-machine :8799 127.0.0.1 the harness, unmodified - **Pairing** is a six-digit code shown on the computer and typed into the phone, redeemed once, inside a window, for a token. A token is per-device and revocable, and revocation is loopback-only: losing the phone must not mean losing the ability to lock it out. - **The allowlist is default deny**, per method and path — the list is every request the app makes, and nothing else. A route the harness gains later is closed to devices until someone adds it here on purpose. Anything else gets "no route", which keeps a stolen token from enumerating the API. - **A browser is refused before the token is read.** A native app sends no Origin; anything that does has found this port and has no business on it. - **Responses are scrubbed** of the harness's own bookkeeping, on JSON and on the SSE stream alike. The SSE transform emits an event the moment it is complete and never touches the blank-line terminator or the `id:` line — both of which have silently broken this project before. - **Ports stay clear of the harness**, which owns two: itself, and the webhook receiver one above it. Overlap is refused by name before anything binds, rather than raced for and lost by whoever started second. - **Discovery** is a zero-dependency mDNS responder, so the phone finds the computer by name on a LAN. Failing is not an error anyone has to fix — pairing by typed address still works, and the page says so. Tests boot a real harness and drive it through a real proxy, because every bug this design can have lives in the seam between them and none are visible to a unit test: SSE arriving but never terminating an event, a cursor dropped in transit, the loopback gate rejecting a proxied request. The allowlist is tested directly, including that a route it has never heard of is denied. Nothing here is wired into the app — `pnpm companion` runs it, and running it is the opt-in. The Settings toggle is a separate change. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + companion/README.md | 104 +++++++ companion/package.json | 16 ++ companion/src/control.ts | 201 +++++++++++++ companion/src/devices.ts | 198 +++++++++++++ companion/src/index.ts | 201 +++++++++++++ companion/src/listener.ts | 227 +++++++++++++++ companion/src/mdns.ts | 505 +++++++++++++++++++++++++++++++++ companion/src/proxy.ts | 208 ++++++++++++++ companion/src/routes.ts | 113 ++++++++ companion/src/state.ts | 55 ++++ companion/src/wire.ts | 85 ++++++ companion/test/devices.test.ts | 136 +++++++++ companion/test/mdns.test.ts | 360 +++++++++++++++++++++++ companion/test/ports.test.ts | 90 ++++++ companion/test/proxy.test.ts | 394 +++++++++++++++++++++++++ companion/test/routes.test.ts | 114 ++++++++ companion/test/wire.test.ts | 93 ++++++ package.json | 2 + tsconfig.companion.build.json | 14 + tsconfig.server.build.json | 8 +- tsconfig.server.json | 13 +- vite.config.ts | 7 +- 23 files changed, 3142 insertions(+), 5 deletions(-) create mode 100644 companion/README.md create mode 100644 companion/package.json create mode 100644 companion/src/control.ts create mode 100644 companion/src/devices.ts create mode 100644 companion/src/index.ts create mode 100644 companion/src/listener.ts create mode 100644 companion/src/mdns.ts create mode 100644 companion/src/proxy.ts create mode 100644 companion/src/routes.ts create mode 100644 companion/src/state.ts create mode 100644 companion/src/wire.ts create mode 100644 companion/test/devices.test.ts create mode 100644 companion/test/mdns.test.ts create mode 100644 companion/test/ports.test.ts create mode 100644 companion/test/proxy.test.ts create mode 100644 companion/test/routes.test.ts create mode 100644 companion/test/wire.test.ts create mode 100644 tsconfig.companion.build.json diff --git a/.gitignore b/.gitignore index 9c5ebde022..84b25d0633 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules dist dist-electron dist-native +# the sidecar's compiled entry — regenerated by `pnpm build:companion`, +# which package:prepare runs before electron-builder stages it +dist-companion *.local .DS_Store *.tsbuildinfo diff --git a/companion/README.md b/companion/README.md new file mode 100644 index 0000000000..3607af8eb8 --- /dev/null +++ b/companion/README.md @@ -0,0 +1,104 @@ +# companion + +The sidecar a phone talks to. + +OpenMausBot's harness listens on `127.0.0.1` and nothing else, which is the +right default and one it has recently gone out of its way to enforce: it now +rejects any request whose `Host` is not loopback, defeating DNS rebinding. + +This is a separate process that sits in front of it. A paired device reaches +*this*, over the LAN or a tailnet; this reaches the harness over loopback, as +a request from the machine the harness already trusts. **The harness needs no +changes and does not know this exists.** + +That is the entire point of the design. The alternative — teaching the harness +to bind a second socket — means a patch to somebody else's request handler, +carried across every release, and it is the patch that broke the first time +upstream hardened its loopback gate. + +``` + phone ──LAN/tailnet──▶ companion :8810 ──loopback──▶ harness :8799 + ▲ ▲ + │ token, allowlist, │ unmodified, + │ Origin refused │ loopback-only +``` + +## What it is responsible for + +| | | +|---|---| +| **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. | +| **The allowlist** | Default deny, per method and path (`src/routes.ts`) — the list is every request the app makes, and nothing else. 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. | + +## What it deliberately does not do + +- **Serve the desktop UI.** A phone asking for `/` gets a 404. Serving HTML + here would make this a web server, which it is not. +- **Accept anything with an `Origin` header.** A native app sends none, so a + 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.** Those stay on the + machine. See `src/routes.ts` for the exact refusals and why. + +## Running it + +With the harness already up (`pnpm dev:server`), from the repo root: + +```sh +pnpm companion +``` + +It prints where to point the phone, and where you pair: + +``` +companion http://0.0.0.0:8810 → harness 127.0.0.1:8799 +pair here http://127.0.0.1:8811 +on your phone, enter macbook.tail1234.ts.net:8810 +``` + +Open the pairing page, click **Start pairing**, and type the six digits into +the phone. Stopping the process is the off switch — running it *is* the +opt-in, so there is no toggle to forget. + +| Environment | Default | | +|---|---|---| +| `OMB_PORT` | `8799` | where the harness is | +| `OMB_COMPANION_PORT` | `8810` | where devices connect | +| `OMB_CONTROL_PORT` | `8811` | the pairing page, loopback only | +| `OMB_COMPANION_DIR` | `~/.openmausbot-companion` | paired devices live here | +| `OMB_COMPANION_NAME` | `OpenMausBot` | what the phone calls this computer | + +The harness owns two ports, not one: itself, and a webhook receiver one above +it (`OMB_WEBHOOK_PORT`). The companion refuses to start on either and says +which — the alternative is a race for the socket, where starting second means +the companion will not come up and starting first means webhooks quietly stop +working with the explanation logged somewhere else entirely. + +## Layout + +``` +src/index.ts the entrypoint — three sockets, and the split between them +src/proxy.ts the forwarding handler; also owns /api/pair +src/routes.ts the allowlist — what a device may ask for +src/wire.ts scrubbing, including the SSE stream transform +src/control.ts the loopback pairing page +src/devices.ts pairing codes and device tokens +src/listener.ts LAN and tailnet addresses +src/mdns.ts the zero-dependency Bonjour responder +src/state.ts where paired devices are written, atomically +test/ run against a real harness, booted per file +``` + +## Tests + +`pnpm test` from the repo root covers this alongside everything else. + +`test/proxy.test.ts` boots the real harness and drives the real proxy, because +every bug this design can have lives in the seam between them: an SSE event +that never terminates, a resume cursor dropped in transit, a `content-length` +set beside a `transfer-encoding`. None of those are visible to a unit test — +the last one was found by this suite within a minute of it existing. diff --git a/companion/package.json b/companion/package.json new file mode 100644 index 0000000000..4ef45f65c5 --- /dev/null +++ b/companion/package.json @@ -0,0 +1,16 @@ +{ + "name": "@openmausbot/companion", + "version": "0.1.0", + "private": true, + "description": "A sidecar that lets a paired phone reach an unmodified OpenMausBot harness", + "type": "module", + "bin": { + "openmausbot-companion": "./src/index.ts" + }, + "engines": { + "node": ">=24" + }, + "scripts": { + "start": "node src/index.ts" + } +} diff --git a/companion/src/control.ts b/companion/src/control.ts new file mode 100644 index 0000000000..6cbd2d002a --- /dev/null +++ b/companion/src/control.ts @@ -0,0 +1,201 @@ +// The bit a person looks at: a small page on loopback for pairing a device +// and revoking one. +// +// This replaces the Settings → Companion panel that used to live inside the +// desktop app. Losing that panel is the real cost of moving out of the +// harness, and this is the honest replacement rather than a pretence that +// the cost is zero: it is a separate page at a separate address, and you +// have to know it exists. +// +// Loopback only, deliberately and non-negotiably. This surface can open a +// pairing window and revoke devices — it is the thing the companion listener +// refuses to expose to phones for exactly that reason. Serving it anywhere +// else would hand away the control plane the design just took care to +// withhold. +import { createServer, type Server, type ServerResponse } from "node:http"; + +import type { DeviceRegistry } from "./devices.ts"; +import { lanAddresses, tailnetName, tailscaleAddress } from "./listener.ts"; + +export interface ControlOptions { + devices: DeviceRegistry; + /** Where a phone connects — for display, and for the pairing instructions. */ + companionPort: number; + /** Whether Bonjour came up, and under what name. */ + discovery: () => { advertising: boolean; name: string }; +} + +const json = (res: ServerResponse, status: number, body: unknown) => { + const text = JSON.stringify(body); + res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }); + res.end(text); +}; + +export function companionState(options: ControlOptions) { + const addresses = lanAddresses(); + const tailscale = tailscaleAddress(addresses); + const name = tailnetName(); + const pairing = options.devices.pairing(); + return { + port: options.companionPort, + addresses, + ...(tailscale ? { tailscale } : {}), + ...(tailscale && name ? { tailnetName: name } : {}), + lan: addresses.find((a) => a !== tailscale) ?? null, + pairing: pairing ? { code: pairing.code, expiresAt: pairing.expiresAt } : null, + devices: options.devices.list(), + discovery: options.discovery(), + }; +} + +export function createControlServer(options: ControlOptions): Server { + return createServer((req, res) => { + const path = (req.url ?? "/").split("?")[0]; + const method = req.method ?? "GET"; + + // Belt and braces: this server binds 127.0.0.1, so a non-loopback Host + // should be impossible. It is still worth refusing, because "impossible" + // here rests on a bind argument three files away, and the cost of being + // wrong is the control plane. + const host = String(req.headers.host ?? "").split(":")[0].toLowerCase(); + if (host && host !== "127.0.0.1" && host !== "localhost" && host !== "[::1]" && host !== "::1") { + return json(res, 403, { error: "forbidden: loopback only" }); + } + + if (method === "GET" && (path === "/" || path === "/index.html")) { + const html = page(); + res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html) }); + return res.end(html); + } + if (method === "GET" && path === "/state") return json(res, 200, companionState(options)); + if (method === "POST" && path === "/pairing") { + const window = options.devices.openPairing(); + return json(res, 201, { ...companionState(options), code: window.code }); + } + if (method === "DELETE" && path === "/pairing") { + options.devices.closePairing(); + 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" }); + return json(res, 200, companionState(options)); + } + return json(res, 404, { error: `no route: ${method} ${path}` }); + }); +} + +/** One self-contained page. No build step and no assets on purpose — a + * sidecar that needed bundling would be a much bigger thing to run. */ +function page(): string { + return ` + + +OpenMausBot Companion + +
+

OpenMausBot Companion

+

Your phone reaches this computer through here. Only pair a device you trust.

+
+
+
+
+ +`; +} diff --git a/companion/src/devices.ts b/companion/src/devices.ts new file mode 100644 index 0000000000..34501611ec --- /dev/null +++ b/companion/src/devices.ts @@ -0,0 +1,198 @@ +// Companion devices — the phones allowed to reach this harness over the +// network. Everything here exists because of one fact: the harness has no +// authentication at all, and it is right not to have any on 127.0.0.1. The +// loopback socket IS the credential — the same reason the app can PUT an API +// key without proving anything. The moment a second socket leaves loopback +// that assumption is gone, so a device token becomes the credential instead. +// +// Tokens follow the same write-only rule as the keys in config.json: the +// token is generated once, handed to the phone at pairing, and never stored +// — devices.json keeps only its SHA-256. A stolen devices.json is not a +// stolen fleet. +import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { DATA_DIR, ensureDataDir, writeFileAtomic } from "./state.ts"; + +export interface DeviceRecord { + id: string; + name: string; + /** sha256 of the bearer token — never the token itself */ + tokenHash: string; + createdAt: number; + lastSeenAt: number; +} + +/** What the UI is allowed to see: a device without its secret. */ +export type PublicDevice = Omit; + +/** A pairing window: one short-lived code, deliberately single-use. + * + * Six digits is only 1e6 possibilities, which is brute-forceable in seconds + * against a LAN service — so the code is never the whole defence. It lives + * for two minutes, dies after a handful of wrong guesses, and only exists at + * all while the user is looking at the pairing screen. */ +export interface PairingWindow { + code: string; + expiresAt: number; + attemptsLeft: number; +} + +const DEVICES_FILE = join(DATA_DIR, "devices.json"); +export const PAIRING_TTL_MS = 120_000; +export const MAX_PAIRING_ATTEMPTS = 5; +/** Bounds the file, and a fleet of 20 phones is already an odd story. */ +export const MAX_DEVICES = 20; +/** lastSeen is a UI nicety, not an audit log — don't write on every request. */ +const LAST_SEEN_WRITE_MS = 60_000; + +const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); + +/** Constant-time compare of two hex digests of the same length. A plain === + * on a token hash leaks its prefix through timing; cheap to avoid. */ +function sameDigest(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex")); + } catch { + return false; + } +} + +/** Same treatment for the pairing code, which is compared far more often + * than it is correct. */ +function sameCode(a: string, b: string): boolean { + const left = Buffer.from(a, "utf8"); + const right = Buffer.from(b, "utf8"); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +/** Device names come from the phone, so they are untrusted display text: + * clamp the length and drop control characters before they reach a UI. */ +export function cleanDeviceName(raw: unknown): string { + const name = String(raw ?? "") + .replace(/[\u0000-\u001f\u007f]/g, " ") + .trim() + .slice(0, 60); + return name || "Companion"; +} + +export class DeviceRegistry { + private devices: DeviceRecord[] = []; + private window: PairingWindow | null = null; + private lastSeenWrites = new Map(); + + constructor() { + try { + const parsed = JSON.parse(readFileSync(DEVICES_FILE, "utf8")); + if (Array.isArray(parsed?.devices)) { + this.devices = parsed.devices.filter( + (d: unknown): d is DeviceRecord => + typeof (d as DeviceRecord)?.id === "string" && typeof (d as DeviceRecord)?.tokenHash === "string", + ); + } + } catch { + /* first run, or a file we can't read — start with no paired devices */ + } + } + + private persist() { + ensureDataDir(); + writeFileAtomic(DEVICES_FILE, JSON.stringify({ devices: this.devices }, null, 2)); + } + + list(): PublicDevice[] { + return this.devices.map(({ tokenHash, ...rest }) => rest); + } + + count(): number { + return this.devices.length; + } + + /** The live pairing window, or null. Expiry is evaluated on read so a + * stale window can never be redeemed by a caller that skipped a tick. */ + pairing(): PairingWindow | null { + if (this.window && this.window.expiresAt <= Date.now()) this.window = null; + return this.window; + } + + openPairing(): PairingWindow { + this.window = { + code: String(randomInt(0, 1_000_000)).padStart(6, "0"), + expiresAt: Date.now() + PAIRING_TTL_MS, + attemptsLeft: MAX_PAIRING_ATTEMPTS, + }; + return this.window; + } + + closePairing() { + this.window = null; + } + + /** Redeem a pairing code for a device token. + * + * The token is returned exactly once, here. There is no endpoint that can + * read it back — a phone that loses it pairs again. */ + redeem(code: string, name: unknown): { device: PublicDevice; token: string } | { error: string } { + const window = this.pairing(); + if (!window) return { error: "no pairing is in progress — open Companion settings on your computer" }; + if (this.devices.length >= MAX_DEVICES) return { error: "too many paired devices — remove one first" }; + if (!sameCode(window.code, String(code ?? ""))) { + window.attemptsLeft -= 1; + // A burned window is the whole point: without this, six digits is a + // few seconds of guessing. + if (window.attemptsLeft <= 0) { + this.closePairing(); + return { error: "too many incorrect codes — start pairing again" }; + } + return { error: "that code is not right" }; + } + this.closePairing(); + + const token = `omb_${randomBytes(32).toString("base64url")}`; + const device: DeviceRecord = { + id: randomUUID(), + name: cleanDeviceName(name), + tokenHash: sha256(token), + createdAt: Date.now(), + lastSeenAt: Date.now(), + }; + this.devices.push(device); + this.persist(); + const { tokenHash, ...pub } = device; + return { device: pub, token }; + } + + /** Resolve a bearer token to its device, or null. */ + authenticate(token: string | undefined): DeviceRecord | null { + if (!token) return null; + const hash = sha256(token); + const device = this.devices.find((d) => sameDigest(d.tokenHash, hash)); + if (!device) return null; + const now = Date.now(); + if (now - (this.lastSeenWrites.get(device.id) ?? 0) > LAST_SEEN_WRITE_MS) { + device.lastSeenAt = now; + this.lastSeenWrites.set(device.id, now); + this.persist(); + } + return device; + } + + revoke(id: string): boolean { + const before = this.devices.length; + this.devices = this.devices.filter((d) => d.id !== id); + if (this.devices.length === before) return false; + this.lastSeenWrites.delete(id); + this.persist(); + return true; + } +} + +/** Pull the bearer token out of an Authorization header. */ +export function bearerToken(header: string | undefined): string | undefined { + if (!header) return undefined; + const match = /^Bearer (.+)$/.exec(header.trim()); + return match ? match[1].trim() : undefined; +} diff --git a/companion/src/index.ts b/companion/src/index.ts new file mode 100644 index 0000000000..7ef0960d35 --- /dev/null +++ b/companion/src/index.ts @@ -0,0 +1,201 @@ +// The sidecar, as one command. +// +// node companion/src/index.ts +// +// Three sockets, and the split between them is the whole security model: +// +// :8810 0.0.0.0 devices token required, allowlisted, scrubbed +// :8811 127.0.0.1 you pairing and revocation — never off-machine +// :8799 127.0.0.1 the harness spoken to as this machine, unmodified +// +// 8810 rather than 8800, which is where these started: the harness opens a +// webhook receiver one port above its own, so 8800 is already taken by the +// app this is a sidecar to. Ten clear of the harness leaves it room to add +// another adjacent listener without taking this one out again. +// +// Running this process *is* the opt-in. There is no toggle, because a toggle +// inside a process you chose to start would be ceremony: stopping it is the +// off switch, and it is a more honest one than a flag in a file. +import { createServer } from "node:http"; + +import { createControlServer } from "./control.ts"; +import { DeviceRegistry } from "./devices.ts"; +import { lanAddresses, refreshTailnetName, tailnetName, tailscaleAddress } from "./listener.ts"; +import { advertisableAddresses, defaultHostName, dnsLabel, MdnsResponder, type ServiceInfo } from "./mdns.ts"; +import { createProxyHandler } from "./proxy.ts"; + +const num = (value: string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : fallback; +}; + +const HARNESS_PORT = num(process.env.OMB_PORT, 8799); +const WEBHOOK_PORT = num(process.env.OMB_WEBHOOK_PORT, HARNESS_PORT + 1); +const COMPANION_PORT = num(process.env.OMB_COMPANION_PORT, 8810); +const CONTROL_PORT = num(process.env.OMB_CONTROL_PORT, 8811); +const SERVICE_TYPE = "_openmausbot._tcp"; + +/** Ports the harness takes for itself, and what it uses each for. + * + * Checked up front rather than left to EADDRINUSE, because the collision is + * a race and the loser is whoever started second: bind first and the harness + * reports its webhook receiver unavailable instead, which surfaces nowhere + * near here. "Port 8800 is the webhook receiver" is a sentence someone can + * act on; "address already in use" sends them to `lsof`. */ +const HARNESS_PORTS = new Map([ + [HARNESS_PORT, "the harness itself"], + [WEBHOOK_PORT, "the harness's webhook receiver"], +]); + +const conflict = (name: string, port: number): string | null => { + const owner = HARNESS_PORTS.get(port); + return owner ? `${name} is set to port ${port}, which is ${owner}` : null; +}; + +/** What the phone sees this computer called. + * + * Asked of the harness rather than invented here: it already knows whose + * computer this is, from the profile collected during onboarding, and the + * built-in companion used exactly this. A phone that paired before the move + * should not suddenly find a differently-named computer in its list. + * + * Read once at startup and cached. An override wins, and a harness that is + * not up or has no profile falls back rather than blocking — the name is a + * label, and no part of pairing depends on it. */ +let cachedName = process.env.OMB_COMPANION_NAME?.trim() || ""; + +const machineName = (): string => cachedName || "OpenMausBot"; + +async function refreshMachineName(): Promise { + if (cachedName) return; // an explicit override is not ours to second-guess + try { + const res = await fetch(`http://127.0.0.1:${HARNESS_PORT}/api/config`, { + signal: AbortSignal.timeout(3000), + }); + if (!res.ok) return; + const config = (await res.json()) as { profile?: { name?: string } }; + const owner = config.profile?.name?.trim(); + if (owner) cachedName = `${owner}'s computer`; + } catch { + /* not up, or no profile — "OpenMausBot" is a fine thing to be called */ + } +} + +const devices = new DeviceRegistry(); +const mdns = new MdnsResponder(); + +const service = (): ServiceInfo => ({ + // one DNS label: no dots, and inside the 63-byte limit + name: dnsLabel(machineName()), + type: SERVICE_TYPE, + port: COMPANION_PORT, + host: defaultHostName(), + addresses: advertisableAddresses(), + // TXT entries cap at 255 bytes, and this one is user-supplied + txt: ["v=1", `name=${machineName().slice(0, 200)}`], +}); + +const companion = createServer( + createProxyHandler({ + 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 ?? undefined)), + redeem: (code, deviceName) => devices.redeem(code, deviceName), + serverName: machineName, + }), +); + +const control = createControlServer({ + devices, + companionPort: COMPANION_PORT, + discovery: () => ({ advertising: mdns.advertising, name: service().name }), +}); + +const listen = (server: ReturnType, port: number, host: string): Promise => + new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException) => { + server.removeListener("listening", onListening); + // A second copy of the sidecar is the usual cause once the harness's + // own ports are ruled out above, and "close whatever is using it" + // sends someone hunting through `lsof` for a process they started. + const hint = ` — another copy of the companion may already be running; ${ + port === COMPANION_PORT ? "OMB_COMPANION_PORT" : "OMB_CONTROL_PORT" + } chooses a different one`; + reject( + error.code === "EADDRINUSE" + ? new Error(`port ${port} is already in use${hint}`) + : error, + ); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); + +async function main(): Promise { + const clash = + conflict("OMB_COMPANION_PORT", COMPANION_PORT) ?? conflict("OMB_CONTROL_PORT", CONTROL_PORT); + if (clash) throw new Error(`${clash}. Pick another port.`); + + await listen(control, CONTROL_PORT, "127.0.0.1"); + await listen(companion, COMPANION_PORT, "0.0.0.0"); + + // Before advertising: the service name goes into the Bonjour record, and + // re-advertising under a new name later would show the phone two computers. + await refreshMachineName(); + + // Asking Tailscale costs a subprocess, so it happens once, here, rather + // than per request. Silent on every failure: not installed, not logged in, + // not running all just mean "no name", and the address still works. + const tailscaleTried: string[] = []; + await refreshTailnetName((cli, outcome) => tailscaleTried.push(` ${cli} — ${outcome}`)).catch(() => {}); + + // Discovery failing is not an error anyone has to fix — port 5353 taken by + // another responder, multicast off, a guest network that isolates its + // clients. Pairing by typed address still works, and the control page says + // so rather than pretending the list will fill in. + await mdns.advertise(service()).catch((error: unknown) => { + console.warn(`bonjour unavailable: ${error instanceof Error ? error.message : String(error)}`); + }); + + const addresses = lanAddresses(); + const tailscale = tailscaleAddress(addresses); + const reach = tailnetName() ?? tailscale ?? addresses[0]; + console.log(`companion http://0.0.0.0:${COMPANION_PORT} → harness 127.0.0.1:${HARNESS_PORT}`); + console.log(`pair here http://127.0.0.1:${CONTROL_PORT}`); + if (reach) console.log(`on your phone, enter ${reach}:${COMPANION_PORT}`); + if (tailscale && !tailnetName()) { + // Do not tell someone to turn on MagicDNS when they may well have it on + // already — say what was actually tried, so the difference between "off" + // and "we could not find the CLI" is visible instead of guessed at. + console.log("no MagicDNS name found. Tailscale CLI attempts:"); + for (const line of tailscaleTried) console.log(line); + } +} + +const shutdown = async (signal: string): Promise => { + console.log(`\n${signal} — stopping`); + await mdns.stop().catch(() => {}); + // close() waits for open connections, and an SSE stream never ends on its + // own — drop the sockets so "stop" means stopped, now. + companion.closeAllConnections?.(); + control.closeAllConnections?.(); + await Promise.all([ + new Promise((r) => companion.close(() => r())), + new Promise((r) => control.close(() => r())), + ]); + process.exit(0); +}; + +process.on("SIGINT", () => void shutdown("SIGINT")); +process.on("SIGTERM", () => void shutdown("SIGTERM")); + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/companion/src/listener.ts b/companion/src/listener.ts new file mode 100644 index 0000000000..b70a900044 --- /dev/null +++ b/companion/src/listener.ts @@ -0,0 +1,227 @@ +// The companion listener — a second HTTP socket, off by default, that a +// paired phone can reach. It runs the same request handler as the loopback +// server; the only difference is the `remote` flag the handler gets, which +// is what turns on authentication and turns off the routes a phone has no +// business calling. +// +// Two listeners rather than one bind on 0.0.0.0, deliberately. A single +// socket cannot tell "the desktop app on this machine" from "something else +// on the coffee-shop wifi" — `req.socket.localAddress` is 127.0.0.1 for both +// when a 0.0.0.0 listener is reached over loopback. Separate sockets make +// the distinction structural instead of a guess, so the trusted path stays +// exactly as trusted as it was before this file existed. +import { execFile } from "node:child_process"; +import { createServer, type RequestListener, type Server } from "node:http"; +import { homedir, networkInterfaces } from "node:os"; +import { join } from "node:path"; + +/** Every IPv4 address a phone on the same network could dial. Link-local + * (169.254/16) is dropped: it means DHCP failed and nothing will reach us. */ +export function lanAddresses(): string[] { + const out: string[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family !== "IPv4" || entry.internal) continue; + if (entry.address.startsWith("169.254.")) continue; + out.push(entry.address); + } + } + return out; +} + +/** Tailscale hands its nodes an address in 100.64.0.0/10 — the CGNAT range + * RFC 6598 set aside, which is why it never collides with a home network. + * + * Worth telling apart from a LAN address because it behaves completely + * differently: it does not change when you join another wifi, it works from + * anywhere the tailnet reaches, and it survives the guest network that + * isolates its clients. For a companion it is the *better* address, and the + * only one that keeps working when you leave the house. */ +export function tailscaleAddress(addresses: string[] = lanAddresses()): string | null { + for (const address of addresses) { + const [first, second] = address.split(".").map(Number); + if (first === 100 && second >= 64 && second <= 127) return address; + } + return null; +} + +/** The machine's MagicDNS name, e.g. `macbook.tail1234.ts.net`. + * + * Worth having as well as the address, because a phone reaching a tailnet + * over plain HTTP is on the wrong side of App Transport Security: iOS + * exempts local networking, and 100.64/10 is CGNAT shared space rather than + * one of the private ranges that exemption covers. A `ts.net` hostname can + * be exempted by name, which an address cannot. + * + * Read once when the listener comes up and cached — asking Tailscale is a + * subprocess, and nothing here is worth spawning one per request. */ +let cachedTailnetName: string | null = null; + +export function tailnetName(): string | null { + return cachedTailnetName; +} + +/** Every place the Tailscale CLI is plausibly installed, best first. */ +export function tailscaleCandidates(home = homedir()): string[] { + // Absolute paths first, PATH last: a process the desktop app forks inherits + // whatever PATH the app was launched with, and an app opened from Finder + // gets /usr/bin:/bin:/usr/sbin:/sbin — no Homebrew, no /usr/local. Relying + // on the lookup alone works in a terminal and fails in the real app, which + // is exactly the way round that is hardest to notice. + return [ + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + join(home, "Applications", "Tailscale.app", "Contents", "MacOS", "Tailscale"), + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", + "/usr/bin/tailscale", + "/run/current-system/sw/bin/tailscale", + "tailscale", + ]; +} + +/** PATH with the usual package-manager locations added back, for the bare + * `tailscale` attempt. Costs nothing when PATH was already complete. */ +const searchPath = (): string => + [process.env.PATH ?? "", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] + .filter(Boolean) + .join(":"); + +/** Ask the Tailscale CLI where it thinks we are. + * + * Every failure is survivable — not installed, not logged in, not running all + * just mean "no name", and the address still works. But *silently* survivable + * was the wrong call: a panel that says "turn on MagicDNS" to somebody who + * has MagicDNS on is worse than no message, and there was no way to tell + * which of these paths had been tried. `onAttempt` is how the caller can say. + */ +export async function refreshTailnetName( + onAttempt?: (cli: string, outcome: string) => void, +): Promise { + for (const cli of tailscaleCandidates()) { + const name = await new Promise((resolve) => { + execFile( + cli, + ["status", "--json"], + { timeout: 5000, env: { ...process.env, PATH: searchPath() } }, + (error, stdout) => { + if (error) { + onAttempt?.(cli, error.message.split("\n")[0]); + return resolve(null); + } + try { + const dns = JSON.parse(stdout)?.Self?.DNSName; + // MagicDNS names are fully qualified, trailing dot and all + const trimmed = typeof dns === "string" && dns ? dns.replace(/\.$/, "") : null; + onAttempt?.(cli, trimmed ? `ok: ${trimmed}` : "ran, but no MagicDNS name in status"); + resolve(trimmed); + } catch { + onAttempt?.(cli, "ran, but its output was not JSON"); + resolve(null); + } + }, + ); + }); + if (name) { + cachedTailnetName = name; + return; + } + } + cachedTailnetName = null; +} + +export interface RemoteState { + enabled: boolean; + port: number; + addresses: string[]; + /** The tailnet address, when this machine is on one. */ + tailscale?: string; + /** Its MagicDNS name, when Tailscale will tell us. */ + tailnetName?: string; + /** Why the listener is not up despite being enabled (e.g. port in use). */ + error?: string; +} + +export class RemoteListener { + private server: Server | null = null; + private lastError: string | undefined; + private readonly handler: RequestListener; + readonly port: number; + + // Plain assignments, not constructor parameter properties: the harness + // runs straight off .ts through Node's strip-only type stripping, which + // rejects any TypeScript syntax that emits code. + constructor(handler: RequestListener, port: number) { + this.handler = handler; + this.port = port; + } + + get running(): boolean { + return this.server !== null; + } + + state(): RemoteState { + const addresses = this.running ? lanAddresses() : []; + const tailscale = tailscaleAddress(addresses); + const name = tailnetName(); + return { + enabled: this.running, + port: this.port, + addresses, + ...(tailscale ? { tailscale } : {}), + ...(tailscale && name ? { tailnetName: name } : {}), + ...(this.lastError ? { error: this.lastError } : {}), + }; + } + + /** Bind 0.0.0.0:port. Resolves with the new state either way — a port + * conflict is a message the user can act on, never a crashed harness. */ + async enable(): Promise { + if (this.server) return this.state(); + const server = createServer(this.handler); + this.lastError = undefined; + try { + await new Promise((resolve, reject) => { + const onError = (err: NodeJS.ErrnoException) => { + server.removeListener("listening", onListening); + reject(err); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(this.port, "0.0.0.0"); + }); + } catch (e) { + const err = e as NodeJS.ErrnoException; + this.lastError = + err.code === "EADDRINUSE" + ? `port ${this.port} is already in use — close whatever is using it and try again` + : err.message; + try { + server.close(); + } catch { + /* never bound */ + } + return this.state(); + } + // A listener whose sockets keep the process alive would stop the harness + // from exiting on SIGTERM while a phone holds an SSE stream open. + server.unref(); + this.server = server; + return this.state(); + } + + async disable(): Promise { + const server = this.server; + this.server = null; + this.lastError = undefined; + if (!server) return this.state(); + // close() waits for open connections, and an SSE stream never ends on + // its own — drop the sockets so "turn it off" means off, now. + server.closeAllConnections?.(); + await new Promise((resolve) => server.close(() => resolve())); + return this.state(); + } +} diff --git a/companion/src/mdns.ts b/companion/src/mdns.ts new file mode 100644 index 0000000000..030130c577 --- /dev/null +++ b/companion/src/mdns.ts @@ -0,0 +1,505 @@ +// mDNS / DNS-SD advertisement, so a phone finds this computer without +// anyone typing an IP address. +// +// Written out rather than pulled in: the responder half of mDNS is a DNS +// message encoder, a multicast socket, and a table of which questions we +// answer — and the alternative is a dependency tree under the process that +// holds the user's API keys and runs their agents. The house rule (don't +// take a dependency where code will do) points the same way. +// +// Scope is deliberately the *responder* half of RFC 6762/6763 and no more: +// we answer questions about our own service and announce ourselves. We do +// not browse, and we do not probe for name conflicts before claiming a name +// (§8.1) — the host record we claim is derived from a hash of the machine's +// hostname, so a collision needs two machines with the same hostname on one +// network, and the cost of that is a duplicate row in a picker rather than +// anything broken. +import { createHash } from "node:crypto"; +import { createSocket, type Socket } from "node:dgram"; +import { hostname, networkInterfaces } from "node:os"; + +const MDNS_ADDRESS = "224.0.0.251"; +const MDNS_PORT = 5353; +/** RFC 6762 §10: host records are short-lived, service records are not. */ +const TTL_HOST = 120; +const TTL_SERVICE = 4500; +/** RFC 6762 §11 — multicast DNS must not be routed off the link. */ +const MULTICAST_TTL = 255; + +export const TYPE = { A: 1, PTR: 12, TXT: 16, SRV: 33, ANY: 255 } as const; +const CLASS_IN = 1; +/** Top bit of a record's class: "this replaces whatever you cached." */ +const FLUSH = 0x8000; +/** Top bit of a question's class: "answer me directly, not to the group." */ +const QU = 0x8000; +/** The meta-query browsers use to enumerate service types on a network. */ +export const SERVICE_ENUMERATION = "_services._dns-sd._udp.local"; + +// ── wire format ──────────────────────────────────────────────────────── + +export interface Question { + name: string; + type: number; + /** the QU bit — the asker wants a unicast reply */ + unicast: boolean; +} + +export interface DnsMessage { + id: number; + /** true for a response; we ignore those, we are not a browser */ + response: boolean; + questions: Question[]; +} + +/** A record we can answer with. `ResourceRecord`, not `Record` — the latter + * is TypeScript's own utility type and shadowing it reads terribly. */ +export type ResourceRecord = + | { name: string; type: 1; data: string } + | { name: string; type: 12; data: string } + | { name: string; type: 16; data: string[] } + | { name: string; type: 33; data: { port: number; target: string } }; + +export function encodeName(name: string): Buffer { + const chunks: Buffer[] = []; + for (const label of name.split(".").filter(Boolean)) { + const bytes = Buffer.from(label, "utf8"); + if (bytes.length > 63) throw new Error(`label longer than 63 bytes: ${label}`); + chunks.push(Buffer.from([bytes.length]), bytes); + } + chunks.push(Buffer.from([0])); + return Buffer.concat(chunks); +} + +/** Read a name, following compression pointers. Returns the offset just + * past the name *as written here*, which is not where a followed pointer + * ended up. */ +function decodeName(buf: Buffer, start: number): { name: string; offset: number } { + const labels: string[] = []; + let pos = start; + let end = start; + let jumped = false; + let hops = 0; + + for (;;) { + if (pos >= buf.length) throw new Error("truncated name"); + const length = buf[pos]; + if (length === 0) { + if (!jumped) end = pos + 1; + break; + } + if ((length & 0xc0) === 0xc0) { + if (pos + 1 >= buf.length) throw new Error("truncated pointer"); + const target = ((length & 0x3f) << 8) | buf[pos + 1]; + if (!jumped) end = pos + 2; + jumped = true; + // a packet is free to point at itself; we are not free to follow it + if (++hops > 16) throw new Error("compression pointer loop"); + pos = target; + continue; + } + const from = pos + 1; + if (from + length > buf.length) throw new Error("truncated label"); + labels.push(buf.toString("utf8", from, from + length)); + pos = from + length; + if (!jumped) end = pos; + } + return { name: labels.join("."), offset: end }; +} + +/** Parse an inbound packet, or null if it is not one we can read. Every + * byte here arrived from the local network unauthenticated, so a malformed + * packet must be a `null`, never a throw into the socket handler. */ +export function decodeMessage(buf: Buffer): DnsMessage | null { + try { + if (buf.length < 12) return null; + const id = buf.readUInt16BE(0); + const flags = buf.readUInt16BE(2); + const questionCount = buf.readUInt16BE(4); + const questions: Question[] = []; + let offset = 12; + for (let i = 0; i < questionCount; i++) { + const decoded = decodeName(buf, offset); + offset = decoded.offset; + if (offset + 4 > buf.length) return null; + const type = buf.readUInt16BE(offset); + const klass = buf.readUInt16BE(offset + 2); + offset += 4; + questions.push({ name: decoded.name, type, unicast: (klass & QU) !== 0 }); + } + return { id, response: (flags & 0x8000) !== 0, questions }; + } catch { + return null; + } +} + +function encodeRecord(record: ResourceRecord, ttlOverride?: number): Buffer { + let rdata: Buffer; + let ttl: number; + switch (record.type) { + case TYPE.A: { + const octets = record.data.split(".").map(Number); + if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) { + throw new Error(`not an IPv4 address: ${record.data}`); + } + rdata = Buffer.from(octets); + ttl = TTL_HOST; + break; + } + case TYPE.PTR: + rdata = encodeName(record.data); + ttl = TTL_SERVICE; + break; + case TYPE.TXT: { + const entries = record.data.map((entry) => { + const bytes = Buffer.from(entry, "utf8"); + if (bytes.length > 255) throw new Error(`TXT entry longer than 255 bytes: ${entry}`); + return Buffer.concat([Buffer.from([bytes.length]), bytes]); + }); + // an empty TXT record is one zero-length string, never zero bytes + rdata = entries.length ? Buffer.concat(entries) : Buffer.from([0]); + ttl = TTL_SERVICE; + break; + } + case TYPE.SRV: { + const header = Buffer.alloc(6); + header.writeUInt16BE(0, 0); // priority + header.writeUInt16BE(0, 2); // weight + header.writeUInt16BE(record.data.port, 4); + rdata = Buffer.concat([header, encodeName(record.data.target)]); + ttl = TTL_HOST; + break; + } + } + + const name = encodeName(record.name); + const fixed = Buffer.alloc(10); + fixed.writeUInt16BE(record.type, 0); + fixed.writeUInt16BE(CLASS_IN | FLUSH, 2); + fixed.writeUInt32BE(ttlOverride ?? ttl, 4); + fixed.writeUInt16BE(rdata.length, 8); + return Buffer.concat([name, fixed, rdata]); +} + +export function encodeResponse( + answers: ResourceRecord[], + additionals: ResourceRecord[] = [], + opts: { id?: number; ttl?: number; questions?: Question[] } = {}, +): Buffer { + const questions = opts.questions ?? []; + const header = Buffer.alloc(12); + header.writeUInt16BE(opts.id ?? 0, 0); + header.writeUInt16BE(0x8400, 2); // QR=1 (response), AA=1 (authoritative) + header.writeUInt16BE(questions.length, 4); + header.writeUInt16BE(answers.length, 6); + header.writeUInt16BE(0, 8); + header.writeUInt16BE(additionals.length, 10); + + const questionBytes = questions.map((question) => { + const suffix = Buffer.alloc(4); + suffix.writeUInt16BE(question.type, 0); + suffix.writeUInt16BE(CLASS_IN, 2); + return Buffer.concat([encodeName(question.name), suffix]); + }); + + return Buffer.concat([ + header, + ...questionBytes, + ...answers.map((record) => encodeRecord(record, opts.ttl)), + ...additionals.map((record) => encodeRecord(record, opts.ttl)), + ]); +} + +// ── the service we advertise ─────────────────────────────────────────── + +export interface ServiceInfo { + /** human-readable instance name — what a picker on the phone shows */ + name: string; + /** e.g. "_openmausbot._tcp" */ + type: string; + port: number; + /** the name our A records claim, e.g. "openmausbot-1a2b3c4d.local" */ + host: string; + addresses: string[]; + /** DNS-SD key=value pairs */ + txt: string[]; +} + +const recordKey = (record: ResourceRecord) => + `${record.name.toLowerCase()}|${record.type}|${JSON.stringify(record.data)}`; + +function dedupe(records: ResourceRecord[], exclude: ResourceRecord[] = []): ResourceRecord[] { + const seen = new Set(exclude.map(recordKey)); + const out: ResourceRecord[] = []; + for (const record of records) { + const key = recordKey(record); + if (seen.has(key)) continue; + seen.add(key); + out.push(record); + } + return out; +} + +/** The four records that describe the service, as a browser expects them. */ +export function serviceRecords(service: ServiceInfo) { + const serviceName = `${service.type}.local`; + const instance = `${service.name}.${serviceName}`; + return { + serviceName, + instance, + ptr: { name: serviceName, type: TYPE.PTR, data: instance } as ResourceRecord, + srv: { + name: instance, + type: TYPE.SRV, + data: { port: service.port, target: service.host }, + } as ResourceRecord, + txt: { name: instance, type: TYPE.TXT, data: service.txt } as ResourceRecord, + addresses: service.addresses.map( + (address) => ({ name: service.host, type: TYPE.A, data: address }) as ResourceRecord, + ), + }; +} + +/** Everything we shout when we arrive (and, with ttl 0, when we leave). */ +export function announcement(service: ServiceInfo): ResourceRecord[] { + const { ptr, srv, txt, addresses } = serviceRecords(service); + return [ptr, srv, txt, ...addresses]; +} + +/** What to answer a query with — the whole protocol decision, kept pure so + * it can be read and tested without a socket. + * + * A PTR question is answered with SRV, TXT and A in the additional section + * (RFC 6763 §12): browsing then resolves in one round trip instead of three, + * which is the difference between a picker that fills in instantly and one + * that looks broken for a second. */ +export function answersFor( + message: DnsMessage, + service: ServiceInfo, +): { answers: ResourceRecord[]; additionals: ResourceRecord[] } { + if (message.response) return { answers: [], additionals: [] }; + const { serviceName, instance, ptr, srv, txt, addresses } = serviceRecords(service); + const answers: ResourceRecord[] = []; + const additionals: ResourceRecord[] = []; + + for (const question of message.questions) { + const name = question.name.toLowerCase(); + const asks = (type: number) => question.type === type || question.type === TYPE.ANY; + + if (name === SERVICE_ENUMERATION && asks(TYPE.PTR)) { + answers.push({ name: SERVICE_ENUMERATION, type: TYPE.PTR, data: serviceName }); + } else if (name === serviceName.toLowerCase() && asks(TYPE.PTR)) { + answers.push(ptr); + additionals.push(srv, txt, ...addresses); + } else if (name === instance.toLowerCase()) { + if (asks(TYPE.SRV)) { + answers.push(srv); + additionals.push(...addresses); + } + if (asks(TYPE.TXT)) answers.push(txt); + } else if (name === service.host.toLowerCase() && asks(TYPE.A)) { + answers.push(...addresses); + } + } + + const deduped = dedupe(answers); + return { answers: deduped, additionals: dedupe(additionals, deduped) }; +} + +// ── naming ───────────────────────────────────────────────────────────── + +/** One DNS label: no dots (they would split it into two labels), no control + * characters, and inside the 63-byte limit even in UTF-8. */ +export function dnsLabel(text: string, fallback = "OpenMausBot"): string { + let label = text + .replace(/[\u0000-\u001f\u007f]/g, "") + .replace(/\./g, " ") + .trim(); + while (Buffer.byteLength(label, "utf8") > 63) label = label.slice(0, -1).trimEnd(); + return label || fallback; +} + +/** A host name nothing else on the network claims. + * + * Deliberately not `.local`: on macOS the system responder owns + * that name and defends it, and picking a fight with mDNSResponder over the + * user's own machine name is a bad trade for a companion feature. */ +export function defaultHostName(machine = hostname()): string { + const digest = createHash("sha256").update(machine).digest("hex").slice(0, 8); + return `openmausbot-${digest}.local`; +} + +/** Every IPv4 address worth publishing (same rule as the listener's). */ +export function advertisableAddresses(): string[] { + const out: string[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const entry of entries ?? []) { + if (entry.family !== "IPv4" || entry.internal) continue; + if (entry.address.startsWith("169.254.")) continue; + out.push(entry.address); + } + } + return out; +} + +// ── the responder ────────────────────────────────────────────────────── + +export interface ResponderOptions { + /** Test rigs bind an ephemeral port and skip the group join; the packet + * handling below is the same code either way. */ + port?: number; + multicast?: boolean; +} + +export class MdnsResponder { + private socket: Socket | null = null; + private service: ServiceInfo | null = null; + private timers: ReturnType[] = []; + private readonly port: number; + private readonly multicast: boolean; + + constructor(options: ResponderOptions = {}) { + this.port = options.port ?? MDNS_PORT; + this.multicast = options.multicast ?? true; + } + + get advertising(): boolean { + return this.socket !== null; + } + + /** The bound port — ephemeral ports make this the only way to find it. */ + address(): number | null { + try { + return this.socket?.address().port ?? null; + } catch { + return null; + } + } + + /** Start advertising. Resolves false when mDNS could not start, which is + * never fatal: pairing by typed address still works, and a companion + * feature must not take the harness down because port 5353 was busy. */ + async advertise(service: ServiceInfo): Promise { + await this.stop(); + if (!service.addresses.length) return false; + + const socket = createSocket({ type: "udp4", reuseAddr: true }); + // Bind errors arrive as events, and an unhandled 'error' on a socket + // is an uncaught exception that would take the harness with it. + socket.on("error", () => void this.stop()); + socket.on("message", (buf, remote) => this.handle(buf, remote.address, remote.port)); + + try { + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(this.port, () => resolve()); + }); + } catch { + try { + socket.close(); + } catch { + /* never bound */ + } + return false; + } + + if (this.multicast) { + try { + socket.setMulticastTTL(MULTICAST_TTL); + } catch { + /* not fatal — the default TTL still reaches the local link */ + } + // Join on each interface explicitly: the default route is not + // necessarily the network the phone is on. + let joined = false; + for (const address of service.addresses) { + try { + socket.addMembership(MDNS_ADDRESS, address); + joined = true; + } catch { + /* interface went away, or already joined */ + } + } + if (!joined) { + try { + socket.addMembership(MDNS_ADDRESS); + } catch { + /* no multicast here; announcements below will simply go nowhere */ + } + } + } + + this.socket = socket; + this.service = service; + // RFC 6762 §8.3: announce more than once, spaced out, because the first + // packet is the one most likely to be lost. + for (const delay of [0, 1000, 3000]) { + const timer = setTimeout(() => this.announce(), delay); + timer.unref?.(); + this.timers.push(timer); + } + return true; + } + + /** Stop advertising, telling the network to forget us rather than letting + * the records rot in caches for 75 minutes (RFC 6762 §10.1). */ + async stop(): Promise { + for (const timer of this.timers.splice(0)) clearTimeout(timer); + const socket = this.socket; + const service = this.service; + this.socket = null; + this.service = null; + if (!socket) return; + if (service) { + try { + this.send(socket, encodeResponse(announcement(service), [], { ttl: 0 })); + } catch { + /* going away anyway */ + } + } + await new Promise((resolve) => { + try { + socket.close(() => resolve()); + } catch { + resolve(); + } + }); + } + + private announce() { + if (!this.socket || !this.service) return; + try { + this.send(this.socket, encodeResponse(announcement(this.service))); + } catch { + /* the interface may have gone away between timer and send */ + } + } + + private handle(buf: Buffer, from: string, fromPort: number) { + if (!this.socket || !this.service) return; + const message = decodeMessage(buf); + if (!message || message.response || !message.questions.length) return; + const { answers, additionals } = answersFor(message, this.service); + if (!answers.length) return; + + // RFC 6762 §6.7: a query from a port other than 5353 is a legacy + // resolver, and its reply goes back to that port, echoing the id and + // the question it asked. The QU bit asks for the same directness. + const legacy = fromPort !== MDNS_PORT; + const unicast = legacy || message.questions.some((question) => question.unicast); + const packet = encodeResponse( + answers, + additionals, + legacy ? { id: message.id, questions: message.questions } : {}, + ); + try { + if (unicast) this.socket.send(packet, fromPort, from); + else this.send(this.socket, packet); + } catch { + /* a send failure is one lost answer; the asker retries */ + } + } + + private send(socket: Socket, packet: Buffer) { + socket.send(packet, this.port, this.multicast ? MDNS_ADDRESS : "127.0.0.1"); + } +} diff --git a/companion/src/proxy.ts b/companion/src/proxy.ts new file mode 100644 index 0000000000..46c09f40ae --- /dev/null +++ b/companion/src/proxy.ts @@ -0,0 +1,208 @@ +// The forwarding half of the sidecar. +// +// A device's request arrives here, is checked against the allowlist, and is +// replayed to the harness on 127.0.0.1 as a request from this machine. The +// response comes back scrubbed. +// +// The reason this works with an unmodified harness is worth stating plainly, +// because it is the whole basis of the design: the harness rejects any +// request whose Host is not loopback — a DNS-rebinding defence — and a +// request this process makes to 127.0.0.1 satisfies that by construction. So +// the sidecar does NOT forward the device's Host or Origin. It speaks to the +// harness as itself, from the machine the harness is already willing to +// serve. Nothing upstream has to change, or even know this exists. +import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http"; + +import { denyReason } from "./routes.ts"; +import { createSseScrubber, isJson, scrub } from "./wire.ts"; + +export interface ProxyOptions { + /** Where the harness is listening on loopback. */ + harnessPort: number; + /** Does this bearer token belong to a paired device? */ + authenticate: (token: string | null) => boolean; + /** 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. */ + redeem: ( + code: string, + deviceName: unknown, + ) => { token: string; device: unknown } | { error: string }; + /** What the phone should call this computer in its connection list. */ + serverName: () => string; +} + +/** Read a JSON body, bounded. An unbounded read on an unauthenticated route + * is a way to be memory-exhausted by anyone who can reach the port. */ +const readJson = (req: IncomingMessage, limit = 64 * 1024): Promise> => + new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > limit) { + reject(new Error("body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("error", reject); + req.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8").trim(); + if (!text) return resolve({}); + try { + const parsed: unknown = JSON.parse(text); + resolve(parsed && typeof parsed === "object" ? (parsed as Record) : {}); + } catch { + reject(new Error("invalid JSON body")); + } + }); + }); + +const bearer = (header: string | undefined): string | null => { + const value = (header ?? "").trim(); + return value.toLowerCase().startsWith("bearer ") ? value.slice(7).trim() || null : null; +}; + +const sendJson = (res: ServerResponse, status: number, body: unknown): void => { + const text = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(text), + }); + res.end(text); +}; + +/** Headers worth carrying to the harness. An allowlist rather than a + * blocklist: `host` and `origin` must not travel (see above), `authorization` + * is the sidecar's credential and means nothing to the harness, and hop-by-hop + * headers are by definition not ours to relay. */ +const forwardHeaders = (req: IncomingMessage): Record => { + const out: Record = { accept: String(req.headers.accept ?? "*/*") }; + const contentType = req.headers["content-type"]; + if (contentType) out["content-type"] = String(contentType); + // Last-Event-ID is how a reconnecting client asks for the gap. Dropping it + // would turn every resume into a full re-hydration, silently. + const lastEventId = req.headers["last-event-id"]; + if (lastEventId) out["last-event-id"] = String(lastEventId); + return out; +}; + +export function createProxyHandler(options: ProxyOptions) { + return function handle(req: IncomingMessage, res: ServerResponse): void { + const path = (req.url ?? "/").split("?")[0]; + const method = req.method ?? "GET"; + + // A native app sends no Origin. Anything that does is a browser that has + // found this port, and a browser has no business on it — refused before + // the token is even looked at, and regardless of what the origin says. + if (req.headers.origin) { + return sendJson(res, 403, { error: "forbidden: cross-origin request" }); + } + + const denial = denyReason({ + path, + method, + authenticated: options.authenticate(bearer(req.headers.authorization)), + }); + if (denial) return sendJson(res, denial.status, { error: denial.error }); + + // 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") { + readJson(req).then( + (body) => { + const result = options.redeem(String(body.code ?? ""), body.deviceName); + if ("error" in result) return sendJson(res, 401, { error: result.error }); + return sendJson(res, 201, { ...result, serverName: options.serverName() }); + }, + (error: Error) => sendJson(res, 400, { error: error.message }), + ); + return; + } + + const upstream = httpRequest( + { + hostname: "127.0.0.1", + port: options.harnessPort, + path: req.url, + method, + headers: forwardHeaders(req), + }, + (harness) => { + const contentType = harness.headers["content-type"]; + const isStream = String(contentType ?? "").includes("text/event-stream"); + + if (isStream) { + // Headers first and flushed, or nothing downstream believes the + // connection is live. content-length is meaningless here and + // content-encoding would be a lie once we rewrite the bytes. + res.writeHead(harness.statusCode ?? 200, { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + // Nagle would hold a small frame back waiting for company. On a + // stream whose frames are small and whose whole value is being + // timely, that is exactly wrong. + "x-accel-buffering": "no", + }); + res.flushHeaders?.(); + res.socket?.setNoDelay(true); + + const scrubStream = createSseScrubber(); + harness.setEncoding("utf8"); + harness.on("data", (chunk: string) => { + const rewritten = scrubStream(chunk); + if (rewritten) res.write(rewritten); + }); + harness.on("end", () => res.end()); + harness.on("error", () => res.destroy()); + // A device that hangs up must take the upstream connection with + // it, or the harness accumulates readers nobody is listening to. + res.on("close", () => harness.destroy()); + return; + } + + if (!isJson(String(contentType ?? ""))) { + // images and anything else: byte-for-byte, no parsing + res.writeHead(harness.statusCode ?? 200, harness.headers); + harness.pipe(res); + return; + } + + const chunks: Buffer[] = []; + harness.on("data", (chunk: Buffer) => chunks.push(chunk)); + harness.on("error", () => res.destroy()); + harness.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + let text = body; + try { + text = JSON.stringify(scrub(JSON.parse(body))); + } catch { + /* not JSON after all — send what we were given */ + } + const headers = { ...harness.headers }; + // The body was re-serialised, so nothing the harness said about + // its framing survives. `transfer-encoding` matters most: leaving + // it alongside the content-length set below is a protocol + // violation, and Node's own parser rejects the response outright + // rather than tolerating it. + delete headers["content-length"]; + delete headers["content-encoding"]; + delete headers["transfer-encoding"]; + res.writeHead(harness.statusCode ?? 200, { + ...headers, + "content-length": Buffer.byteLength(text), + }); + res.end(text); + }); + }, + ); + + upstream.on("error", () => + sendJson(res, 502, { error: "OpenMausBot is not running on this computer" }), + ); + req.pipe(upstream); + }; +} diff --git a/companion/src/routes.ts b/companion/src/routes.ts new file mode 100644 index 0000000000..351d19c17e --- /dev/null +++ b/companion/src/routes.ts @@ -0,0 +1,113 @@ +// What a paired device is allowed to ask for. +// +// The default is deny, and that direction is the whole point: the sidecar +// sits in front of an API it does not own and cannot see the future of. A +// route that appears in the harness later is closed to phones until someone +// decides otherwise, because the alternative is that every upstream release +// silently widens what a lost phone can reach. +// +// This file used to claim that and not do it — it listed refusals and let +// everything else under `/api/` through. In the time between writing it and +// noticing, upstream added webhook triggers, connected-app authorisation and +// routines, all of which a paired phone could drive: minting an +// internet-reachable trigger, rotating a signing secret out from under +// whatever was sending to it, disconnecting a Google account. None of that +// was a decision anyone made. It was the default. +// +// So the list below is the surface, derived from what the app actually +// calls. Adding a feature to the phone means adding its route here, on +// purpose, in a diff someone can read. That cost is the feature. + +/** A refusal to send back, or null to let the request through. */ +export interface Denial { + status: number; + error: string; +} + +export interface RouteRequest { + path: string; + method: string; + /** Whether the bearer token on the request matched a paired device. */ + authenticated: boolean; +} + +/** Every request the iOS app makes, and nothing else. + * + * Ids are `[\w-]+`, matching the harness's own route patterns. The paths + * arrive undecoded and are anchored at both ends, so an encoded traversal + * fails to match and is denied rather than forwarded — the failure mode of + * a strict pattern is a closed door, which is the one to have. */ +const ALLOWED: ReadonlyArray<{ method: string; path: RegExp }> = [ + // liveness — not used by the app, but it is the first thing anyone curls + // when pairing will not work, and it discloses nothing + { method: "GET", path: /^\/api\/health$/ }, + // configured-or-not booleans. The write side is refused below: reading + // which providers are set up is not reading their keys. + { method: "GET", path: /^\/api\/config$/ }, + { method: "GET", path: /^\/api\/events$/ }, + { method: "GET", path: /^\/api\/instances$/ }, + + // the fleet, and making a bot + { method: "GET", path: /^\/api\/bots$/ }, + { method: "POST", path: /^\/api\/bots$/ }, + { method: "PATCH", path: /^\/api\/bots\/[\w-]+$/ }, + { method: "POST", path: /^\/api\/bots\/[\w-]+\/messages$/ }, + { method: "POST", path: /^\/api\/bots\/[\w-]+\/interrupt$/ }, + + // rooms + { method: "PATCH", path: /^\/api\/groups\/[\w-]+$/ }, + { method: "POST", path: /^\/api\/groups\/[\w-]+\/messages$/ }, + + // a transcript, its images, and answering an approval + { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages$/ }, + { method: "GET", path: /^\/api\/threads\/[\w-]+\/messages\/[\w-]+\/image$/ }, + { method: "POST", path: /^\/api\/threads\/[\w-]+\/respond$/ }, +]; + +/** Route families worth naming in the refusal. + * + * Everything not allowed is denied either way; this only decides whether the + * person gets a sentence or a 404. These are the ones someone might + * reasonably expect to work from the phone, where "no route" would read as a + * bug in the companion rather than a decision about where host configuration + * happens. Order matters only in that the first match wins. */ +const EXPLAINED: ReadonlyArray<{ path: RegExp; error: string }> = [ + { + path: /^\/api\/(companion|devices)(\/|$)/, + // Losing the phone must not mean losing the ability to lock it out. + error: "companion settings are managed on your computer", + }, + { path: /^\/api\/config$/, error: "API keys can only be changed on your computer" }, + { path: /^\/api\/local-computer(\/|$)/, error: "the Local VM is set up on your computer" }, + { + // Creating one exposes an endpoint to the internet, and rotating a + // secret breaks whatever was sending to it. Neither belongs on a device + // that lives in a pocket. + path: /^\/api\/webhooks(\/|$)/, + error: "webhooks are set up on your computer", + }, + { path: /^\/api\/connectors(\/|$)/, error: "connected apps are set up on your computer" }, + { path: /^\/api\/routines(\/|$)/, error: "routines are set up on your computer" }, + { path: /^\/api\/teams(\/|$)/, error: "teams are imported and exported on your computer" }, +]; + +export function denyReason({ path, method, authenticated }: RouteRequest): Denial | null { + // Pairing is the one thing a device does before it has a credential. + if (method === "POST" && path === "/api/pair") return null; + + if (!authenticated) { + return { status: 401, error: "pair this device from the OpenMausBot companion on your computer" }; + } + + if (ALLOWED.some((route) => route.method === method && route.path.test(path))) return null; + + const explained = EXPLAINED.find((family) => family.path.test(path)); + if (explained) return { status: 403, error: explained.error }; + + // Everything else, including routes the harness really does have. Saying + // "no route" rather than "not allowed" keeps the sidecar from enumerating + // the API to anyone holding a stolen token — and it is what the peer-agent + // endpoints under /api/internal/ always got, since off this machine they + // genuinely do not exist. + return { status: 404, error: `no route: ${method} ${path}` }; +} diff --git a/companion/src/state.ts b/companion/src/state.ts new file mode 100644 index 0000000000..0c90af29ad --- /dev/null +++ b/companion/src/state.ts @@ -0,0 +1,55 @@ +// Where the sidecar keeps its own state, and how it writes it. +// +// Its own directory, not the harness's. The two processes have separate +// lifecycles and separate concerns, and a sidecar that writes into +// ~/.openmausbot would be reaching into somebody else's data layout — the +// exact coupling this design exists to avoid. If the harness reorganises its +// files tomorrow, nothing here notices. +import { randomUUID } from "node:crypto"; +import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** OMB_COMPANION_DIR isolates a test rig from a real paired fleet. */ +export const DATA_DIR = process.env.OMB_COMPANION_DIR ?? join(homedir(), ".openmausbot-companion"); + +export function ensureDataDir(): void { + mkdirSync(DATA_DIR, { recursive: true }); +} + +/** + * Durable, atomic file replace: write a sibling temp file, fsync it, then + * rename over the target. `rename(2)` is atomic on the same filesystem, so a + * crash mid-write can never leave a truncated file behind — a reader sees + * either the complete old contents or the complete new ones. + * + * It matters more here than it looks. This file holds the paired devices; a + * half-written one fails to parse on next boot and is silently treated as + * empty, which would sign every phone out with no way to tell why. + */ +export function writeFileAtomic(path: string, data: string): void { + const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`; + let fd: number | null = null; + try { + fd = openSync(tmp, "w"); + writeFileSync(fd, data); + fsyncSync(fd); + closeSync(fd); + fd = null; + renameSync(tmp, path); + } catch (e) { + if (fd !== null) { + try { + closeSync(fd); + } catch { + /* best-effort cleanup */ + } + } + try { + unlinkSync(tmp); + } catch { + /* best-effort cleanup */ + } + throw e; + } +} diff --git a/companion/src/wire.ts b/companion/src/wire.ts new file mode 100644 index 0000000000..54bad3608f --- /dev/null +++ b/companion/src/wire.ts @@ -0,0 +1,85 @@ +// What the harness says, minus what a device has no business holding. +// +// `resumeCursors` is the harness's own bookkeeping: the native session id to +// resume, per instance, per task. It goes out on every bot payload and every +// `bot` SSE frame. That is harmless noise while every client is the machine +// itself, and stops being harmless the moment a client is a phone on someone +// else's wifi. +// +// Upstream may fix this at source — there is a PR open for it — at which +// point this becomes a no-op rather than a lie, which is the right way for a +// sidecar to depend on someone else's API: assume nothing, and be correct +// either way. + +/** Recursively drop `resumeCursors`, wherever it appears. */ +export function scrub(value: T): T { + if (Array.isArray(value)) return value.map(scrub) as unknown as T; + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, inner] of Object.entries(value as Record)) { + if (key === "resumeCursors") continue; + out[key] = scrub(inner); + } + return out as T; + } + return value; +} + +/** True when a body is worth parsing at all. Anything else passes through + * untouched — an image endpoint must never be JSON.parsed. */ +export const isJson = (contentType: string | undefined): boolean => + Boolean(contentType && contentType.split(";")[0].trim().toLowerCase() === "application/json"); + +/** + * Rewrites an SSE byte stream, scrubbing each event's `data:` payload while + * leaving everything else exactly as it arrived. + * + * Two properties this has to hold, both learned the hard way on the client + * side of this same stream: + * + * - **It must not swallow blank lines.** The blank line *is* the event + * terminator; a transform that normalises whitespace produces a stream + * that parses to nothing and looks perfectly healthy at both ends. + * - **It must not wait for more than one event.** Buffering to a frame + * boundary is bounded and fine. Buffering for a fixed size or a timer + * turns a live stream into a batch one, and the symptom is a phone that + * sits on "Connecting…" while the server logs a healthy connection. + * + * `id:` lines are preserved verbatim: they are the resume cursor, and + * rewriting them would silently break `?since=`. + */ +export function createSseScrubber(): (chunk: string) => string { + let pending = ""; + return (chunk: string): string => { + pending += chunk; + let out = ""; + for (;;) { + const boundary = pending.indexOf("\n\n"); + if (boundary < 0) break; + const event = pending.slice(0, boundary); + pending = pending.slice(boundary + 2); + out += scrubEvent(event) + "\n\n"; + } + return out; + }; +} + +/** One complete SSE event, `data:` payload scrubbed, everything else kept. */ +function scrubEvent(event: string): string { + return event + .split("\n") + .map((line) => { + // `: keepalive` comments, `id:`, `event:`, `retry:` — not ours to touch + if (!line.startsWith("data:")) return line; + const raw = line.slice(5).trimStart(); + if (!raw) return line; + try { + return `data: ${JSON.stringify(scrub(JSON.parse(raw)))}`; + } catch { + // not JSON: pass it through rather than dropping it. A frame this + // code does not understand is still the harness's to send. + return line; + } + }) + .join("\n"); +} diff --git a/companion/test/devices.test.ts b/companion/test/devices.test.ts new file mode 100644 index 0000000000..c86f4d2081 --- /dev/null +++ b/companion/test/devices.test.ts @@ -0,0 +1,136 @@ +// Companion device registry contract. The three properties that matter: +// a token is never recoverable from disk, a pairing code cannot be ground +// down by guessing, and revoking a device actually revokes it. +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { DATA_DIR } from "../src/state.ts"; +import { bearerToken, cleanDeviceName, DeviceRegistry, MAX_PAIRING_ATTEMPTS } from "../src/devices.ts"; + +const pair = (registry: DeviceRegistry, name = "iPhone") => { + const { code } = registry.openPairing(); + const result = registry.redeem(code, name); + if ("error" in result) throw new Error(`pairing failed: ${result.error}`); + return result; +}; + +describe("DeviceRegistry", () => { + beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + }); + + it("issues a token that authenticates, and never stores it", () => { + const registry = new DeviceRegistry(); + const { token, device } = pair(registry); + + expect(token.startsWith("omb_")).toBe(true); + expect(registry.authenticate(token)?.id).toBe(device.id); + + // the file on disk holds a digest, not the credential + const raw = readFileSync(join(DATA_DIR, "devices.json"), "utf8"); + expect(raw).not.toContain(token); + expect(JSON.parse(raw).devices[0].tokenHash).toHaveLength(64); + + // and nothing the UI can read exposes it either + expect(JSON.stringify(registry.list())).not.toContain(token); + expect(registry.list()[0]).not.toHaveProperty("tokenHash"); + }); + + it("survives a restart", () => { + const { token } = pair(new DeviceRegistry()); + expect(new DeviceRegistry().authenticate(token)).not.toBeNull(); + }); + + it("refuses unknown, empty, and near-miss tokens", () => { + const registry = new DeviceRegistry(); + const { token } = pair(registry); + + expect(registry.authenticate(undefined)).toBeNull(); + expect(registry.authenticate("")).toBeNull(); + expect(registry.authenticate("omb_nope")).toBeNull(); + expect(registry.authenticate(token.slice(0, -1))).toBeNull(); + expect(registry.authenticate(`${token}x`)).toBeNull(); + }); + + it("burns the pairing window after too many wrong codes", () => { + const registry = new DeviceRegistry(); + const { code } = registry.openPairing(); + const wrong = code === "000000" ? "111111" : "000000"; + + for (let i = 1; i < MAX_PAIRING_ATTEMPTS; i++) { + expect(registry.redeem(wrong, "iPhone")).toEqual({ error: "that code is not right" }); + expect(registry.pairing()).not.toBeNull(); + } + // the last one closes the window rather than counting down forever + expect(registry.redeem(wrong, "iPhone")).toMatchObject({ error: expect.stringContaining("start pairing again") }); + expect(registry.pairing()).toBeNull(); + + // and the real code is worthless now + expect(registry.redeem(code, "iPhone")).toMatchObject({ error: expect.stringContaining("no pairing") }); + expect(registry.count()).toBe(0); + }); + + it("spends a code exactly once", () => { + const registry = new DeviceRegistry(); + const { code } = registry.openPairing(); + expect(registry.redeem(code, "iPhone")).toHaveProperty("token"); + expect(registry.redeem(code, "iPad")).toMatchObject({ error: expect.stringContaining("no pairing") }); + expect(registry.count()).toBe(1); + }); + + it("refuses an expired window without a timer", () => { + const registry = new DeviceRegistry(); + const window = registry.openPairing(); + // reach in and age it, rather than sleeping for two minutes + window.expiresAt = Date.now() - 1; + expect(registry.pairing()).toBeNull(); + expect(registry.redeem(window.code, "iPhone")).toMatchObject({ error: expect.stringContaining("no pairing") }); + }); + + it("revokes one device without touching the others", () => { + const registry = new DeviceRegistry(); + const phone = pair(registry, "iPhone"); + const tablet = pair(registry, "iPad"); + expect(registry.count()).toBe(2); + + expect(registry.revoke(phone.device.id)).toBe(true); + expect(registry.revoke(phone.device.id)).toBe(false); + expect(registry.authenticate(phone.token)).toBeNull(); + expect(registry.authenticate(tablet.token)?.name).toBe("iPad"); + + // revocation is durable, not just in-memory + expect(new DeviceRegistry().authenticate(phone.token)).toBeNull(); + }); + + it("treats a corrupt devices.json as no paired devices", () => { + pair(new DeviceRegistry()); + writeFileSync(join(DATA_DIR, "devices.json"), "{ not json"); + expect(new DeviceRegistry().count()).toBe(0); + }); +}); + +describe("cleanDeviceName", () => { + it("clamps, trims, and strips control characters", () => { + expect(cleanDeviceName(" Milind's iPhone ")).toBe("Milind's iPhone"); + // an untrusted label must not carry NULs or ANSI escapes into a UI + expect(cleanDeviceName("bad\u0000name\u001b[31m")).toBe("bad name [31m"); + expect(cleanDeviceName("x".repeat(200))).toHaveLength(60); + }); + + it("falls back rather than allowing an empty label", () => { + expect(cleanDeviceName("")).toBe("Companion"); + expect(cleanDeviceName(undefined)).toBe("Companion"); + expect(cleanDeviceName(" ")).toBe("Companion"); + }); +}); + +describe("bearerToken", () => { + it("reads only a well-formed Bearer header", () => { + expect(bearerToken("Bearer omb_abc")).toBe("omb_abc"); + expect(bearerToken(" Bearer omb_abc ")).toBe("omb_abc"); + expect(bearerToken("omb_abc")).toBeUndefined(); + expect(bearerToken("Basic omb_abc")).toBeUndefined(); + expect(bearerToken(undefined)).toBeUndefined(); + }); +}); diff --git a/companion/test/mdns.test.ts b/companion/test/mdns.test.ts new file mode 100644 index 0000000000..d181d8b735 --- /dev/null +++ b/companion/test/mdns.test.ts @@ -0,0 +1,360 @@ +// mDNS responder contract. The wire format is the part with no room for +// "close enough" — a browser either parses the packet or the service is +// invisible — so it is tested byte by byte, against packets built by hand +// rather than by the encoder under test. +import { createSocket } from "node:dgram"; +import { describe, expect, it } from "vitest"; + +import { tailscaleAddress } from "../src/listener.ts"; +import { + advertisableAddresses, + announcement, + answersFor, + decodeMessage, + defaultHostName, + dnsLabel, + encodeName, + encodeResponse, + MdnsResponder, + SERVICE_ENUMERATION, + serviceRecords, + TYPE, + type ServiceInfo, +} from "../src/mdns.ts"; + +const service: ServiceInfo = { + name: "Milind's computer", + type: "_openmausbot._tcp", + port: 8800, + host: "openmausbot-1a2b3c4d.local", + addresses: ["192.168.1.42"], + txt: ["v=1", "name=Milind's computer"], +}; + +const INSTANCE = "Milind's computer._openmausbot._tcp.local"; +const SERVICE_NAME = "_openmausbot._tcp.local"; + +/** A query packet, built by hand so the decoder is tested against the + * format rather than against our own encoder. */ +function query(name: string, type: number, opts: { id?: number; unicast?: boolean } = {}): Buffer { + const header = Buffer.alloc(12); + header.writeUInt16BE(opts.id ?? 0, 0); + header.writeUInt16BE(0, 2); // QR=0, a question + header.writeUInt16BE(1, 4); + const suffix = Buffer.alloc(4); + suffix.writeUInt16BE(type, 0); + suffix.writeUInt16BE(opts.unicast ? 0x8001 : 1, 2); + return Buffer.concat([header, encodeName(name), suffix]); +} + +/** Read the records out of a response packet — again by hand. */ +function parseResponse(buf: Buffer) { + const questionCount = buf.readUInt16BE(4); + const answerCount = buf.readUInt16BE(6); + const additionalCount = buf.readUInt16BE(10); + let offset = 12; + + const readName = (): string => { + const labels: string[] = []; + for (;;) { + const length = buf[offset]; + if (length === 0) { + offset += 1; + break; + } + labels.push(buf.toString("utf8", offset + 1, offset + 1 + length)); + offset += 1 + length; + } + return labels.join("."); + }; + + for (let i = 0; i < questionCount; i++) { + readName(); + offset += 4; + } + + const records: Array<{ name: string; type: number; klass: number; ttl: number; rdata: Buffer }> = []; + for (let i = 0; i < answerCount + additionalCount; i++) { + const name = readName(); + const type = buf.readUInt16BE(offset); + const klass = buf.readUInt16BE(offset + 2); + const ttl = buf.readUInt32BE(offset + 4); + const length = buf.readUInt16BE(offset + 8); + const rdata = buf.subarray(offset + 10, offset + 10 + length); + offset += 10 + length; + records.push({ name, type, klass, ttl, rdata }); + } + return { id: buf.readUInt16BE(0), flags: buf.readUInt16BE(2), questionCount, answerCount, records }; +} + +describe("wire format", () => { + it("encodes names as length-prefixed labels", () => { + expect(encodeName("a.bc")).toEqual(Buffer.from([1, 0x61, 2, 0x62, 0x63, 0])); + expect(encodeName("")).toEqual(Buffer.from([0])); + expect(() => encodeName("x".repeat(64))).toThrow(/63 bytes/); + }); + + it("decodes a question, QU bit included", () => { + const decoded = decodeMessage(query(SERVICE_NAME, TYPE.PTR, { id: 7 })); + expect(decoded).toMatchObject({ id: 7, response: false }); + expect(decoded!.questions).toEqual([{ name: SERVICE_NAME, type: TYPE.PTR, unicast: false }]); + + const direct = decodeMessage(query(SERVICE_NAME, TYPE.PTR, { unicast: true })); + expect(direct!.questions[0].unicast).toBe(true); + }); + + it("follows a compression pointer without following it forever", () => { + // question name is a pointer to offset 12, where the real name sits + const name = encodeName(SERVICE_NAME); + const header = Buffer.alloc(12); + header.writeUInt16BE(1, 4); + const suffix = Buffer.alloc(4); + suffix.writeUInt16BE(TYPE.PTR, 0); + suffix.writeUInt16BE(1, 2); + // layout: header(12) | pointer(2) | type+class(4) | the name it points at + const pointer = Buffer.from([0xc0, 12 + 2 + 4]); + const packet = Buffer.concat([header, pointer, suffix, name]); + expect(decodeMessage(packet)!.questions[0].name).toBe(SERVICE_NAME); + + // a pointer to itself must not hang the socket handler + const loop = Buffer.concat([header, Buffer.from([0xc0, 12]), suffix]); + expect(decodeMessage(loop)).toBeNull(); + }); + + it("returns null for anything it cannot read, rather than throwing", () => { + // these arrive unauthenticated from the local network + expect(decodeMessage(Buffer.alloc(0))).toBeNull(); + expect(decodeMessage(Buffer.alloc(5))).toBeNull(); + expect(decodeMessage(Buffer.from([0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 0, 0]))).toBeNull(); + // a label claiming more bytes than the packet holds + const oneQuestion = Buffer.alloc(12); + oneQuestion.writeUInt16BE(1, 4); + expect(decodeMessage(Buffer.concat([oneQuestion, Buffer.from([40, 0x61])]))).toBeNull(); + // a header promising questions that were never written + const liar = Buffer.alloc(12); + liar.writeUInt16BE(9, 4); + expect(decodeMessage(liar)).toBeNull(); + }); + + it("encodes each record type the way a browser expects to read it", () => { + const { ptr, srv, txt, addresses } = serviceRecords(service); + const parsed = parseResponse(encodeResponse([ptr, srv, txt, ...addresses])); + + expect(parsed.flags).toBe(0x8400); // response, authoritative + expect(parsed.answerCount).toBe(4); + + const [ptrRec, srvRec, txtRec, aRec] = parsed.records; + // every record sets the cache-flush bit and class IN + for (const record of parsed.records) expect(record.klass).toBe(0x8001); + + expect(ptrRec).toMatchObject({ name: SERVICE_NAME, type: TYPE.PTR, ttl: 4500 }); + + expect(srvRec).toMatchObject({ name: INSTANCE, type: TYPE.SRV, ttl: 120 }); + expect(srvRec.rdata.readUInt16BE(0)).toBe(0); // priority + expect(srvRec.rdata.readUInt16BE(2)).toBe(0); // weight + expect(srvRec.rdata.readUInt16BE(4)).toBe(8800); + + expect(txtRec.type).toBe(TYPE.TXT); + expect(txtRec.rdata[0]).toBe(3); // "v=1" is length-prefixed + expect(txtRec.rdata.toString("utf8", 1, 4)).toBe("v=1"); + + expect(aRec).toMatchObject({ name: service.host, type: TYPE.A, ttl: 120 }); + expect([...aRec.rdata]).toEqual([192, 168, 1, 42]); + }); + + it("writes a goodbye as the same records with a zero TTL", () => { + const parsed = parseResponse(encodeResponse(announcement(service), [], { ttl: 0 })); + expect(parsed.records).toHaveLength(4); + for (const record of parsed.records) expect(record.ttl).toBe(0); + }); + + it("encodes an empty TXT as one empty string, not zero bytes", () => { + const [record] = parseResponse(encodeResponse([{ name: INSTANCE, type: TYPE.TXT, data: [] }])).records; + expect([...record.rdata]).toEqual([0]); + }); +}); + +describe("answersFor", () => { + const ask = (name: string, type: number) => answersFor(decodeMessage(query(name, type))!, service); + + it("answers a browse with the resolution attached", () => { + const { answers, additionals } = ask(SERVICE_NAME, TYPE.PTR); + expect(answers).toHaveLength(1); + expect(answers[0]).toMatchObject({ type: TYPE.PTR, data: INSTANCE }); + // SRV + TXT + A ride along so browsing resolves in one round trip + expect(additionals.map((r) => r.type).sort()).toEqual([TYPE.A, TYPE.TXT, TYPE.SRV].sort()); + }); + + it("answers a resolve, and an address lookup", () => { + const srv = ask(INSTANCE, TYPE.SRV); + expect(srv.answers[0]).toMatchObject({ type: TYPE.SRV, data: { port: 8800, target: service.host } }); + expect(srv.additionals[0]).toMatchObject({ type: TYPE.A }); + + expect(ask(INSTANCE, TYPE.TXT).answers[0]).toMatchObject({ type: TYPE.TXT }); + expect(ask(service.host, TYPE.A).answers[0]).toMatchObject({ type: TYPE.A, data: "192.168.1.42" }); + }); + + it("answers ANY with everything about that name, and never twice", () => { + const { answers, additionals } = ask(INSTANCE, TYPE.ANY); + expect(answers.map((r) => r.type).sort()).toEqual([TYPE.TXT, TYPE.SRV].sort()); + // the A record is an additional, so it must not also be an answer + expect(additionals.map((r) => r.type)).toEqual([TYPE.A]); + }); + + it("takes part in service-type enumeration", () => { + const { answers } = ask(SERVICE_ENUMERATION, TYPE.PTR); + expect(answers[0]).toMatchObject({ name: SERVICE_ENUMERATION, data: SERVICE_NAME }); + }); + + it("matches names case-insensitively, as DNS does", () => { + expect(ask(SERVICE_NAME.toUpperCase(), TYPE.PTR).answers).toHaveLength(1); + }); + + it("stays silent about anything that is not ours", () => { + expect(ask("_ssh._tcp.local", TYPE.PTR).answers).toEqual([]); + expect(ask("someone-else.local", TYPE.A).answers).toEqual([]); + // right name, wrong type + expect(ask(service.host, TYPE.SRV).answers).toEqual([]); + // and we answer questions, not other people's answers + const response = decodeMessage(encodeResponse(announcement(service)))!; + expect(answersFor({ ...response, response: true, questions: [] }, service).answers).toEqual([]); + }); +}); + +describe("naming", () => { + it("keeps an instance name to a single valid label", () => { + // a dot would silently split the name into two labels + expect(dnsLabel("Dr. Smith's computer")).toBe("Dr Smith's computer"); + expect(dnsLabel("x".repeat(200)).length).toBeLessThanOrEqual(63); + expect(Buffer.byteLength(dnsLabel("é".repeat(60)), "utf8")).toBeLessThanOrEqual(63); + expect(dnsLabel("")).toBe("OpenMausBot"); + expect(dnsLabel(" ")).toBe("OpenMausBot"); + }); + + it("claims a host name the system responder will not fight us for", () => { + const name = defaultHostName("Milinds-MacBook-Pro"); + expect(name).toMatch(/^openmausbot-[0-9a-f]{8}\.local$/); + // stable across restarts, distinct per machine + expect(defaultHostName("Milinds-MacBook-Pro")).toBe(name); + expect(defaultHostName("another-machine")).not.toBe(name); + expect(name).not.toContain("Milinds-MacBook-Pro"); + }); + + it("publishes only routable IPv4 addresses", () => { + for (const address of advertisableAddresses()) { + expect(address).toMatch(/^\d+\.\d+\.\d+\.\d+$/); + expect(address.startsWith("127.")).toBe(false); + expect(address.startsWith("169.254.")).toBe(false); + } + }); +}); + +// The socket loop, over unicast on an ephemeral port: no multicast group is +// needed to prove that a query in becomes a correct answer out, and CI +// containers rarely route multicast at all. +describe("MdnsResponder", () => { + const askResponder = (port: number, packet: Buffer) => + new Promise((resolve, reject) => { + const socket = createSocket("udp4"); + const timer = setTimeout(() => { + socket.close(); + reject(new Error("no response from the responder")); + }, 5_000); + timer.unref?.(); + socket.on("message", (buf) => { + clearTimeout(timer); + socket.close(); + resolve(buf); + }); + socket.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + socket.bind(0, "127.0.0.1", () => socket.send(packet, port, "127.0.0.1")); + }); + + it("answers a real query on a real socket", async () => { + const responder = new MdnsResponder({ port: 0, multicast: false }); + expect(await responder.advertise(service)).toBe(true); + try { + const port = responder.address()!; + const parsed = parseResponse(await askResponder(port, query(SERVICE_NAME, TYPE.PTR, { id: 42 }))); + + // a query from an ephemeral port is a legacy resolver: it gets its + // own id and question echoed back (RFC 6762 §6.7) + expect(parsed.id).toBe(42); + expect(parsed.questionCount).toBe(1); + expect(parsed.answerCount).toBe(1); + expect(parsed.records[0]).toMatchObject({ name: SERVICE_NAME, type: TYPE.PTR }); + expect(parsed.records.some((r) => r.type === TYPE.SRV)).toBe(true); + } finally { + await responder.stop(); + } + }); + + it("says nothing at all about a service that is not ours", async () => { + const responder = new MdnsResponder({ port: 0, multicast: false }); + await responder.advertise(service); + try { + await expect(askResponder(responder.address()!, query("_ssh._tcp.local", TYPE.PTR))).rejects.toThrow( + /no response/, + ); + } finally { + await responder.stop(); + } + }, 15_000); + + it("survives garbage on the socket", async () => { + const responder = new MdnsResponder({ port: 0, multicast: false }); + await responder.advertise(service); + const port = responder.address()!; + try { + const noise = createSocket("udp4"); + await new Promise((resolve) => noise.bind(0, "127.0.0.1", resolve)); + for (const junk of [Buffer.alloc(0), Buffer.from("hello"), Buffer.alloc(600, 0xff)]) { + noise.send(junk, port, "127.0.0.1"); + } + noise.close(); + + // still answering afterwards is the assertion that matters + const parsed = parseResponse(await askResponder(port, query(SERVICE_NAME, TYPE.PTR))); + expect(parsed.answerCount).toBe(1); + } finally { + await responder.stop(); + } + }); + + it("refuses to advertise with no address to publish, and stops cleanly", async () => { + const responder = new MdnsResponder({ port: 0, multicast: false }); + expect(await responder.advertise({ ...service, addresses: [] })).toBe(false); + expect(responder.advertising).toBe(false); + // stopping something that never started is a no-op, not a crash + await responder.stop(); + + expect(await responder.advertise(service)).toBe(true); + expect(responder.advertising).toBe(true); + await responder.stop(); + expect(responder.advertising).toBe(false); + expect(responder.address()).toBeNull(); + }); +}); + +// Tailscale hands out 100.64.0.0/10 (RFC 6598 shared address space), which +// is what makes a tailnet address distinguishable from a LAN one — and +// worth distinguishing, because it is the address that still works when the +// phone is on a different network entirely. +describe("tailscaleAddress", () => { + it("picks the CGNAT address out of a mixed list", () => { + expect(tailscaleAddress(["192.168.1.42", "100.102.178.88"])).toBe("100.102.178.88"); + expect(tailscaleAddress(["100.64.0.1"])).toBe("100.64.0.1"); + expect(tailscaleAddress(["100.127.255.254"])).toBe("100.127.255.254"); + }); + + it("does not mistake a neighbouring 100.x for a tailnet", () => { + // 100.0.0.0/10 and 100.128.0.0/9 are ordinary public space + expect(tailscaleAddress(["100.63.255.255"])).toBeNull(); + expect(tailscaleAddress(["100.128.0.1"])).toBeNull(); + expect(tailscaleAddress(["10.0.0.5", "192.168.1.1", "172.16.0.1"])).toBeNull(); + expect(tailscaleAddress([])).toBeNull(); + }); +}); diff --git a/companion/test/ports.test.ts b/companion/test/ports.test.ts new file mode 100644 index 0000000000..4bad309576 --- /dev/null +++ b/companion/test/ports.test.ts @@ -0,0 +1,90 @@ +// The sidecar refuses ports the harness owns. +// +// This exists because of a bug that shipped: the sidecar's device port was +// 8800, and a later harness release started opening a webhook receiver one +// port above itself — 8800, by default. Two processes, one socket, and the +// loser was whichever started second. Started by the desktop app the harness +// wins and the companion never comes up; started by hand first the companion +// wins and the harness logs its webhook receiver unavailable, a hundred lines +// away from anything the person was doing. +// +// So the defaults moved clear, and the overlap is now refused by name rather +// than discovered as EADDRINUSE. This checks the refusal, because the value +// of the whole thing is the sentence it prints. +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ENTRY = join(HERE, "..", "src", "index.ts"); + +/** Start the sidecar and collect how it died. Never reaches `listen` in any + * case here — the check runs first, so nothing binds and nothing to clean. */ +const start = (env: Record): Promise<{ code: number | null; err: string }> => + new Promise((resolve) => { + const child = spawn(process.execPath, [ENTRY], { + env: { ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...env }, + stdio: ["ignore", "ignore", "pipe"], + }); + let err = ""; + child.stderr.on("data", (c) => (err += c)); + child.on("close", (code) => resolve({ code, err })); + setTimeout(() => child.kill("SIGKILL"), 15_000).unref?.(); + }); + +describe("port conflicts with the harness", () => { + it("refuses the harness's own port, and says so", async () => { + const { code, err } = await start({ OMB_PORT: "9100", OMB_COMPANION_PORT: "9100" }); + expect(code).toBe(1); + expect(err).toContain("OMB_COMPANION_PORT"); + expect(err).toContain("the harness itself"); + }, 20_000); + + // The one that actually bit. The webhook port is implicit — one above the + // harness — so someone reading only their own config sees no overlap. + it("refuses the webhook receiver's port, and names it", async () => { + const { code, err } = await start({ OMB_PORT: "9100", OMB_COMPANION_PORT: "9101" }); + expect(code).toBe(1); + expect(err).toContain("9101"); + expect(err).toContain("webhook receiver"); + }, 20_000); + + it("refuses it for the control port too", async () => { + const { code, err } = await start({ + OMB_PORT: "9100", + OMB_COMPANION_PORT: "9200", + OMB_CONTROL_PORT: "9101", + }); + expect(code).toBe(1); + expect(err).toContain("OMB_CONTROL_PORT"); + expect(err).toContain("webhook receiver"); + }, 20_000); + + // An explicit OMB_WEBHOOK_PORT moves the receiver, which frees the port + // above the harness. Refusing it anyway would be refusing a port nothing + // is on. + it("follows OMB_WEBHOOK_PORT rather than assuming the port above", async () => { + const { code, err } = await start({ + OMB_PORT: "9100", + OMB_WEBHOOK_PORT: "9500", + OMB_COMPANION_PORT: "9500", + }); + expect(code).toBe(1); + expect(err).toContain("webhook receiver"); + + // And the port it vacated is free. Asserted without booting the thing: + // the companion port is checked before the control port, so putting the + // vacated 9101 on the companion and the conflict on the control means a + // message naming the *control* port proves 9101 got through. + const moved = await start({ + OMB_PORT: "9100", + OMB_WEBHOOK_PORT: "9500", + OMB_COMPANION_PORT: "9101", + OMB_CONTROL_PORT: "9500", + }); + expect(moved.code).toBe(1); + expect(moved.err).toContain("OMB_CONTROL_PORT"); + expect(moved.err).not.toContain("OMB_COMPANION_PORT"); + }, 40_000); +}); diff --git a/companion/test/proxy.test.ts b/companion/test/proxy.test.ts new file mode 100644 index 0000000000..b74ff55a54 --- /dev/null +++ b/companion/test/proxy.test.ts @@ -0,0 +1,394 @@ +// The sidecar in front of a real harness. +// +// These boot the actual server and talk to it through the actual proxy, +// because every bug this design can have lives in the seam between them and +// none of them are visible to a unit test. In particular: SSE arriving but +// never terminating an event, a resume cursor being dropped on the way +// through, and the harness's loopback gate rejecting a proxied request. +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer, request, type Server } from "node:http"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createProxyHandler } from "../src/proxy.ts"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, "..", ".."); +const HARNESS_PORT = 19600 + Math.floor(Math.random() * 3000); +// +10, not +1: the harness opens its webhook receiver one port above itself, +// and this file needs three consecutive free ports of its own. +const SIDECAR_PORT = HARNESS_PORT + 10; +const HARNESS = `http://127.0.0.1:${HARNESS_PORT}`; +const SIDECAR = `http://127.0.0.1:${SIDECAR_PORT}`; + +const TOKEN = "omb_test_token"; +let harness: ChildProcess; +let sidecar: Server; +let home: string; +let stderr = ""; + +/** a request as a device makes it: a token, and a Host that is not loopback */ +const device = async ( + method: string, + path: string, + opts: { token?: string | null; body?: unknown; headers?: Record } = {}, +): Promise<{ status: number; body: any }> => { + const token = opts.token === undefined ? TOKEN : opts.token; + const res = await fetch(`${SIDECAR}${path}`, { + method, + headers: { + ...(opts.body ? { "content-type": "application/json" } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...opts.headers, + }, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }); + const text = await res.text(); + let body: any = text; + try { + body = JSON.parse(text); + } catch { + /* not JSON */ + } + return { status: res.status, body }; +}; + +/** raw request with a chosen Host header — fetch will not let us set one */ +const withHost = (port: number, host: string, path = "/api/health", headers: Record = {}) => + new Promise((resolve, reject) => { + const r = request({ hostname: "127.0.0.1", port, path, headers: { host, ...headers } }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + r.on("error", reject); + r.end(); + }); + +beforeAll(async () => { + home = mkdtempSync(join(tmpdir(), "companion-test-")); + mkdirSync(join(home, ".openmausbot"), { recursive: true }); + writeFileSync( + join(home, ".openmausbot", "config.json"), + JSON.stringify({ instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" } } }), + ); + + harness = spawn(process.execPath, [join(ROOT, "server", "index.ts")], { + cwd: ROOT, + env: { + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + HOME: home, + USERPROFILE: home, + OMB_PORT: String(HARNESS_PORT), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + harness.stderr!.on("data", (c) => (stderr += c)); + + const deadline = Date.now() + 25_000; + for (;;) { + try { + if ((await fetch(`${HARNESS}/api/health`)).ok) break; + } catch { + /* not up yet */ + } + if (Date.now() > deadline) throw new Error(`harness never came up:\n${stderr}`); + if (harness.exitCode !== null) throw new Error(`harness exited ${harness.exitCode}:\n${stderr}`); + await new Promise((r) => setTimeout(r, 150)); + } + + sidecar = createServer( + createProxyHandler({ + harnessPort: HARNESS_PORT, + authenticate: (t) => t === TOKEN, + redeem: (code, deviceName) => + code === "424242" + ? { token: TOKEN, device: { id: "d1", name: String(deviceName) } } + : { error: "that code is not right" }, + serverName: () => "Test computer", + }), + ); + await new Promise((r) => sidecar.listen(SIDECAR_PORT, "127.0.0.1", r)); +}, 40_000); + +afterAll(async () => { + await new Promise((r) => (sidecar ? sidecar.close(() => r()) : r())); + harness?.kill("SIGTERM"); + await new Promise((resolve) => { + if (!harness || harness.exitCode !== null) return resolve(); + harness.on("close", () => resolve()); + // SIGKILL, then keep waiting for `close`. Resolving in the same tick as + // the signal — which is what this did — starts deleting the home + // directory out from under a process that has not died yet, and the + // delete is what fails. A loaded CI runner loses that race; a laptop + // wins it every time, which is why it reads as a phantom. + setTimeout(() => harness.kill("SIGKILL"), 5_000).unref?.(); + // and a floor, so a process that somehow survives SIGKILL cannot hang + // the suite instead + setTimeout(resolve, 10_000).unref?.(); + }); + rmSync(home, { recursive: true, force: true }); +}); + +describe("the sidecar in front of an unmodified harness", () => { + it("reaches the harness with a Host the harness itself would refuse", async () => { + // The premise of the whole design. Current upstream rejects a + // non-loopback Host outright (DNS rebinding); a phone's Host is a LAN + // address or a tailnet name and can never satisfy that. The sidecar + // speaks to loopback as itself, so the device's Host never travels. + // + // Written so it passes against an older harness too: what is being + // asserted is that the *sidecar* answers, whatever the harness's own + // policy happens to be. + expect(await withHost(SIDECAR_PORT, "macbook.tail1234.ts.net:8800", "/api/bots", { + authorization: `Bearer ${TOKEN}`, + })).toBe(200); + expect(await withHost(SIDECAR_PORT, "192.168.1.42:8800", "/api/bots", { + authorization: `Bearer ${TOKEN}`, + })).toBe(200); + }); + + it("refuses anything carrying an Origin, before looking at the token", async () => { + const { status } = await device("GET", "/api/bots", { headers: { origin: "https://evil.example" } }); + expect(status).toBe(403); + // even a loopback origin, which the harness itself would allow + const local = await device("GET", "/api/bots", { headers: { origin: `http://127.0.0.1:${HARNESS_PORT}` } }); + expect(local.status).toBe(403); + }); + + it("requires a paired token", async () => { + expect((await device("GET", "/api/bots", { token: null })).status).toBe(401); + expect((await device("GET", "/api/bots", { token: "omb_wrong" })).status).toBe(401); + expect((await device("GET", "/api/bots")).status).toBe(200); + }); + + it("refuses what a device has no business doing, by default", async () => { + // settings and credentials stay on the machine + expect((await device("PUT", "/api/config", { body: { xai: { apiKey: "x" } } })).status).toBe(403); + expect((await device("GET", "/api/devices")).status).toBe(403); + expect((await device("GET", "/api/companion")).status).toBe(403); + expect((await device("POST", "/api/local-computer/start")).status).toBe(403); + // the internal peer-comms API does not exist off-machine + expect((await device("GET", "/api/internal/peers")).status).toBe(404); + // nor does the desktop UI + expect((await device("GET", "/")).status).toBe(404); + // but reading config is fine — it is booleans, not keys + expect((await device("GET", "/api/config")).status).toBe(200); + }); + + 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 + // that the allowlist carries these routes to the harness at all — the + // harness's own "no such pending request" is proof it arrived, and is a + // far better signal than a 403 from the sidecar would be. + // + // Worth pinning separately from sending a message, because these are the + // routes a default-deny allowlist is most likely to omit by accident. + const { body } = await device("GET", "/api/bots"); + const bot = body.bots[0]; + + for (const [method, path, payload] of [ + ["POST", `/api/threads/${bot.threadId}/respond`, { requestId: "nope", behavior: "allow" }], + ["POST", `/api/bots/${bot.id}/messages`, { text: "hello from a test" }], + ["POST", `/api/bots/${bot.id}/interrupt`, undefined], + ["PATCH", `/api/bots/${bot.id}`, { unread: false }], + ] as const) { + const res = await device(method, path, payload ? { body: payload } : {}); + // whatever the harness decides, the sidecar must not be the one saying no + expect(res.status, `${method} ${path} was blocked by the sidecar`).not.toBe(403); + expect(res.status, `${method} ${path} never reached the harness`).not.toBe(404); + } + }); + + it("never passes the provider session cursors through", async () => { + const listed = await device("GET", "/api/bots"); + expect(listed.status).toBe(200); + expect(JSON.stringify(listed.body)).not.toContain("resumeCursors"); + for (const bot of listed.body.bots) { + expect(bot).not.toHaveProperty("resumeCursors"); + for (const task of bot.tasks ?? []) expect(task).not.toHaveProperty("resumeCursors"); + } + + // Whether the harness sent any is deliberately NOT asserted: some + // versions leak them, some do not, and the sidecar scrubs either way. + // Asserting on the harness's behaviour here would make this test pass + // or fail on which harness happens to be checked out. That the scrubber + // is not a no-op is pinned in wire.test.ts, against a payload built to + // contain them. + }); + + it("streams events through, terminated and scrubbed, keeping the resume cursor", async () => { + const controller = new AbortController(); + const res = await fetch(`${SIDECAR}/api/events`, { + headers: { accept: "text/event-stream", authorization: `Bearer ${TOKEN}` }, + signal: controller.signal, + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/event-stream"); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + /** read until `want` finds a complete event, or give up */ + const nextEvent = async (want: (event: string) => boolean): Promise => { + const deadline = Date.now() + 10_000; + for (;;) { + let boundary = buffered.indexOf("\n\n"); + while (boundary >= 0) { + const event = buffered.slice(0, boundary); + buffered = buffered.slice(boundary + 2); + if (want(event)) return event; + boundary = buffered.indexOf("\n\n"); + } + if (Date.now() > deadline) return null; + const { value, done } = await reader.read(); + if (done) return null; + buffered += decoder.decode(value, { stream: true }); + } + }; + + try { + // The blank-line terminator is the thing worth asserting: an SSE + // transform that normalises whitespace produces a stream that parses + // to nothing while looking perfectly healthy at both ends. + const hello = await nextEvent((e) => e.includes('"kind":"hello"')); + expect(hello).not.toBeNull(); + expect(JSON.parse(hello!.replace(/^data:\s*/, ""))).toMatchObject({ kind: "hello" }); + + // A broadcast frame, which is the one that carries `id:` — the resume + // cursor. Rewriting the payload must not disturb it. + const { body } = await device("GET", "/api/bots"); + const botId = body.bots[0].id; + await device("PATCH", `/api/bots/${botId}`, { body: { unread: true } }); + + const frame = await nextEvent((e) => e.startsWith("id: ")); + expect(frame).not.toBeNull(); + expect(frame).toMatch(/^id: [0-9a-f]+:\d+\n/); + expect(frame).not.toContain("resumeCursors"); + } finally { + controller.abort(); + } + }); + + it("says the harness is down rather than hanging, when it is", async () => { + const orphan = createServer( + createProxyHandler({ + harnessPort: 1, + authenticate: () => true, + redeem: () => ({ error: "no" }), + serverName: () => "Test computer", + }), + ); + await new Promise((r) => orphan.listen(0, "127.0.0.1", r)); + const port = (orphan.address() as { port: number }).port; + try { + const res = await fetch(`http://127.0.0.1:${port}/api/bots`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + expect(res.status).toBe(502); + expect(((await res.json()) as { error: string }).error).toContain("not running"); + } finally { + await new Promise((r) => orphan.close(() => r())); + } + }); +}); + +// The whole loop, with the real registry rather than a stub: open a pairing +// window on the control surface, redeem the code the way the phone does, and +// use the token that comes back. This is the path that has no unit-test +// equivalent — every piece is real except the phone. +describe("pairing, end to end", () => { + it("turns a code shown on the computer into a working device token", async () => { + const { DeviceRegistry } = await import("../src/devices.ts"); + const { createControlServer } = await import("../src/control.ts"); + + const registry = new DeviceRegistry(); + const port = SIDECAR_PORT + 1; + const controlPort = SIDECAR_PORT + 2; + const paired = createServer( + createProxyHandler({ + harnessPort: HARNESS_PORT, + authenticate: (t) => Boolean(registry.authenticate(t ?? undefined)), + redeem: (code, deviceName) => registry.redeem(code, deviceName), + serverName: () => "Ada's computer", + }), + ); + const control = createControlServer({ + devices: registry, + companionPort: port, + discovery: () => ({ advertising: false, name: "OpenMausBot" }), + }); + await new Promise((r) => paired.listen(port, "127.0.0.1", r)); + await new Promise((r) => control.listen(controlPort, "127.0.0.1", r)); + const base = `http://127.0.0.1:${port}`; + const ctl = `http://127.0.0.1:${controlPort}`; + + try { + // before pairing, the phone gets nowhere + expect((await fetch(`${base}/api/bots`)).status).toBe(401); + + // the person clicks "Start pairing" on the computer + const opened = (await (await fetch(`${ctl}/pairing`, { method: "POST" })).json()) as { code: string }; + expect(opened.code).toMatch(/^\d{6}$/); + + // a wrong code is refused, and does not burn the window + const wrong = await fetch(`${base}/api/pair`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: "000000", deviceName: "Impostor" }), + }); + expect(wrong.status).toBe(401); + + // the right one is redeemed exactly once, and never forwarded upstream + const res = await fetch(`${base}/api/pair`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: opened.code, deviceName: "Ada's iPhone" }), + }); + expect(res.status).toBe(201); + const body = (await res.json()) as { token: string; serverName: string }; + expect(body.serverName).toBe("Ada's computer"); + expect(body.token).toMatch(/^omb_/); + + // and the token works on the real API, through the real proxy + const bots = await fetch(`${base}/api/bots`, { headers: { authorization: `Bearer ${body.token}` } }); + expect(bots.status).toBe(200); + expect(await bots.text()).not.toContain("resumeCursors"); + + // the computer can see the phone, and take it away again + const state = (await (await fetch(`${ctl}/state`)).json()) as { + devices: Array<{ id: string; name: string }>; + }; + expect(state.devices.map((d) => d.name)).toContain("Ada's iPhone"); + const revoked = await fetch(`${ctl}/devices/${state.devices[0].id}`, { method: "DELETE" }); + expect(revoked.status).toBe(200); + expect((await fetch(`${base}/api/bots`, { headers: { authorization: `Bearer ${body.token}` } })).status).toBe(401); + } finally { + await new Promise((r) => paired.close(() => r())); + await new Promise((r) => control.close(() => r())); + } + }); + + it("keeps the control surface off the network", async () => { + const { DeviceRegistry } = await import("../src/devices.ts"); + const { createControlServer } = await import("../src/control.ts"); + const control = createControlServer({ + devices: new DeviceRegistry(), + companionPort: 8800, + discovery: () => ({ advertising: false, name: "OpenMausBot" }), + }); + await new Promise((r) => control.listen(0, "127.0.0.1", r)); + const port = (control.address() as { port: number }).port; + try { + // pairing and revocation are exactly what a phone must never reach + expect(await withHost(port, "macbook.tail1234.ts.net:8801", "/state")).toBe(403); + expect(await withHost(port, `127.0.0.1:${port}`, "/state")).toBe(200); + } finally { + await new Promise((r) => control.close(() => r())); + } + }); +}); diff --git a/companion/test/routes.test.ts b/companion/test/routes.test.ts new file mode 100644 index 0000000000..85f9af30e3 --- /dev/null +++ b/companion/test/routes.test.ts @@ -0,0 +1,114 @@ +// The allowlist. +// +// The proxy tests prove the app's own calls reach a real harness. These prove +// the other half, which no end-to-end test can: that everything else does +// not. The case worth caring about is the last one — a route nobody here has +// heard of is denied, because that is the property the whole file exists for +// and the one that quietly stopped being true once before. +import { describe, expect, it } from "vitest"; + +import { denyReason } from "../src/routes.ts"; + +const ask = (method: string, path: string, authenticated = true) => + denyReason({ method, path, authenticated }); + +const allowed = (method: string, path: string) => ask(method, path) === null; + +describe("credentials", () => { + it("lets an unpaired device pair, and do nothing else", () => { + expect(ask("POST", "/api/pair", false)).toBeNull(); + expect(ask("GET", "/api/bots", false)?.status).toBe(401); + expect(ask("GET", "/api/health", false)?.status).toBe(401); + }); +}); + +describe("what the app may do", () => { + // Every request in ios/Sources/CompanionCore/Client.swift. If one of these + // fails, a screen on the phone is broken. + const calls: Array<[string, string]> = [ + ["GET", "/api/health"], + ["GET", "/api/config"], + ["GET", "/api/events"], + ["GET", "/api/instances"], + ["GET", "/api/bots"], + ["POST", "/api/bots"], + ["PATCH", "/api/bots/bot_123"], + ["POST", "/api/bots/bot_123/messages"], + ["POST", "/api/bots/bot_123/interrupt"], + ["PATCH", "/api/groups/room-1"], + ["POST", "/api/groups/room-1/messages"], + ["GET", "/api/threads/th_1/messages"], + ["GET", "/api/threads/th_1/messages/msg_2/image"], + ["POST", "/api/threads/th_1/respond"], + ]; + + for (const [method, path] of calls) { + it(`allows ${method} ${path}`, () => expect(ask(method, path)).toBeNull()); + } +}); + +describe("what it may not", () => { + it("refuses host configuration, and says where it happens", () => { + for (const [method, path] of [ + ["PUT", "/api/config"], + ["PATCH", "/api/config"], + ["GET", "/api/devices"], + ["GET", "/api/companion"], + ["POST", "/api/local-computer/start"], + ["POST", "/api/webhooks"], + ["POST", "/api/webhooks/wh_1/rotate"], + ["GET", "/api/connectors"], + ["DELETE", "/api/connectors/gmail"], + ["GET", "/api/routines"], + ["POST", "/api/teams/import"], + ] as Array<[string, string]>) { + const denial = ask(method, path); + expect(denial?.status, `${method} ${path}`).toBe(403); + expect(denial?.error, `${method} ${path}`).toMatch(/on your computer/); + } + }); + + it("denies the peer-agent endpoints exist at all", () => { + expect(ask("GET", "/api/internal/peers")?.status).toBe(404); + expect(ask("POST", "/api/internal/ask-bot")?.status).toBe(404); + }); + + it("does not serve the desktop UI", () => { + expect(ask("GET", "/")?.status).toBe(404); + expect(ask("GET", "/index.html")?.status).toBe(404); + }); + + // 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", () => { + expect(allowed("GET", "/api/bots")).toBe(true); + expect(allowed("DELETE", "/api/bots/bot_123")).toBe(false); + expect(allowed("POST", "/api/threads/th_1/messages")).toBe(false); + expect(allowed("GET", "/api/groups/room-1")).toBe(false); + }); + + // Patterns are anchored, so a path that merely starts right is still a + // path nobody allowed. + it("is not fooled by a prefix", () => { + expect(allowed("GET", "/api/bots/bot_123/computer")).toBe(false); + expect(allowed("GET", "/api/botsandthensome")).toBe(false); + expect(allowed("GET", "/api/events/all")).toBe(false); + expect(allowed("GET", "/api/threads/th_1/messages/msg_2/image/../../../config")).toBe(false); + expect(allowed("GET", "/api/bots%2f..%2fwebhooks")).toBe(false); + }); + + // The one that matters. Upstream adds routes on its own schedule, and the + // sidecar must not carry them to a phone because nobody wrote a rule + // against a thing that did not exist yet. + it("denies a route it has never heard of", () => { + for (const path of [ + "/api/whatever-ships-next", + "/api/bots/bot_123/some-new-verb", + "/api/secrets", + ]) { + expect(allowed("GET", path), path).toBe(false); + expect(allowed("POST", path), path).toBe(false); + expect(allowed("DELETE", path), path).toBe(false); + } + }); +}); diff --git a/companion/test/wire.test.ts b/companion/test/wire.test.ts new file mode 100644 index 0000000000..fab9d83916 --- /dev/null +++ b/companion/test/wire.test.ts @@ -0,0 +1,93 @@ +// The scrubber, against payloads built to contain what it removes. +// +// The proxy test proves nothing leaks through end to end. It cannot prove +// the scrubber does any work, because whether the harness leaks depends on +// which version is checked out. That is what these pin. +import { describe, expect, it } from "vitest"; + +import { createSseScrubber, isJson, scrub } from "../src/wire.ts"; + +describe("scrub", () => { + it("removes resumeCursors wherever it is nested", () => { + const bot = { + id: "b1", + name: "Rio", + resumeCursors: { ghost: "session-abc" }, + tasks: [ + { threadId: "t1", title: "One", resumeCursors: { ghost: "session-def" } }, + { threadId: "t2", title: "Two" }, + ], + }; + const cleaned = scrub({ bots: [bot], groups: [] }); + + expect(JSON.stringify(cleaned)).not.toContain("resumeCursors"); + expect(JSON.stringify(cleaned)).not.toContain("session-abc"); + expect(JSON.stringify(cleaned)).not.toContain("session-def"); + // and nothing else was disturbed + expect(cleaned).toEqual({ + bots: [{ id: "b1", name: "Rio", tasks: [{ threadId: "t1", title: "One" }, { threadId: "t2", title: "Two" }] }], + groups: [], + }); + }); + + it("leaves values it does not own alone", () => { + expect(scrub(null)).toBe(null); + expect(scrub(42)).toBe(42); + expect(scrub("resumeCursors")).toBe("resumeCursors"); // a string, not a key + expect(scrub([1, [2, [3]]])).toEqual([1, [2, [3]]]); + }); +}); + +describe("isJson", () => { + it("matches only real JSON content types", () => { + expect(isJson("application/json")).toBe(true); + expect(isJson("application/json; charset=utf-8")).toBe(true); + expect(isJson("APPLICATION/JSON")).toBe(true); + expect(isJson("image/png")).toBe(false); + expect(isJson("text/event-stream")).toBe(false); + expect(isJson(undefined)).toBe(false); + }); +}); + +describe("createSseScrubber", () => { + it("keeps the blank-line terminator that ends an event", () => { + // The failure this guards against is silent: a stream that never + // terminates an event parses to nothing while both ends look healthy. + const out = createSseScrubber()('data: {"kind":"hello"}\n\n'); + expect(out).toBe('data: {"kind":"hello"}\n\n'); + expect(out.endsWith("\n\n")).toBe(true); + }); + + 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 out = createSseScrubber()(frame); + + expect(out).toContain("id: abc123:7\n"); + expect(out).not.toContain("resumeCursors"); + const data = JSON.parse(out.split("\n").find((l) => l.startsWith("data:"))!.slice(5)); + expect(data).toEqual({ kind: "bot", bot: { id: "b1" } }); + }); + + it("emits an event as soon as it is complete, not when the chunk ends", () => { + const scrubStream = createSseScrubber(); + // one event split across three arbitrary chunk boundaries + expect(scrubStream('id: a:1\nda')).toBe(""); + expect(scrubStream('ta: {"kind":"bot"}')).toBe(""); + expect(scrubStream("\n\nid: a:2\n")).toBe('id: a:1\ndata: {"kind":"bot"}\n\n'); + // the second event is still incomplete and correctly withheld + expect(scrubStream('data: {"kind":"message"}\n\n')).toBe('id: a:2\ndata: {"kind":"message"}\n\n'); + }); + + it("passes keepalive comments and unparseable data through untouched", () => { + const scrubStream = createSseScrubber(); + expect(scrubStream(": keepalive\n\n")).toBe(": keepalive\n\n"); + expect(scrubStream("data: not json at all\n\n")).toBe("data: not json at all\n\n"); + }); + + it("handles several events arriving in one chunk", () => { + const out = createSseScrubber()( + 'id: a:1\ndata: {"a":1,"resumeCursors":{}}\n\nid: a:2\ndata: {"b":2}\n\n', + ); + expect(out).toBe('id: a:1\ndata: {"a":1}\n\nid: a:2\ndata: {"b":2}\n\n'); + }); +}); diff --git a/package.json b/package.json index d940138280..9e865631a7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "packageManager": "pnpm@10.33.0", "scripts": { "dev": "vite", + "companion": "node --experimental-strip-types companion/src/index.ts", "dev:server": "node --experimental-strip-types server/index.ts", "dev:desktop": "electron .", "build": "tsc -b && tsc -p tsconfig.server.json && vite build", @@ -35,6 +36,7 @@ "check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/speech.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json", + "build:companion": "tsc -p tsconfig.companion.build.json", "build:speech": "node electron/build-speech-helper.mjs", "build:cua": "node scripts/prepare-cua.mjs", "build:updater": "node scripts/bundle-updater.mjs", diff --git a/tsconfig.companion.build.json b/tsconfig.companion.build.json new file mode 100644 index 0000000000..605ea236c9 --- /dev/null +++ b/tsconfig.companion.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.server.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist-companion", + "rootDir": "companion/src", + "rewriteRelativeImportExtensions": true, + "declaration": false, + "sourceMap": false + }, + "include": [ + "companion/src" + ] +} diff --git a/tsconfig.server.build.json b/tsconfig.server.build.json index e5a0372b8e..0003a53f3b 100644 --- a/tsconfig.server.build.json +++ b/tsconfig.server.build.json @@ -7,5 +7,11 @@ "declaration": false, "sourceMap": false }, - "exclude": ["server/**/*.test.ts", "server/testing"] + "exclude": [ + "server/**/*.test.ts", + "server/testing" + ], + "include": [ + "server" + ] } diff --git a/tsconfig.server.json b/tsconfig.server.json index 187dbea3e6..5bc50a266d 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -1,7 +1,9 @@ { "compilerOptions": { "target": "ES2023", - "lib": ["ES2023"], + "lib": [ + "ES2023" + ], "module": "NodeNext", "moduleResolution": "NodeNext", "allowImportingTsExtensions": true, @@ -12,7 +14,12 @@ "skipLibCheck": true, "isolatedModules": true, "noEmit": true, - "types": ["node"] + "types": [ + "node" + ] }, - "include": ["server"] + "include": [ + "server", + "companion" + ] } diff --git a/vite.config.ts b/vite.config.ts index 263a6e81b2..8044254075 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,12 @@ export default defineConfig({ plugins: [react(), tailwindcss()], test: { environment: "node", - include: ["server/**/*.test.ts", "electron/**/*.test.mjs", "src/**/*.test.ts"], + include: [ + "server/**/*.test.ts", + "electron/**/*.test.mjs", + "src/**/*.test.ts", + "companion/**/*.test.ts", + ], setupFiles: ["server/testing/setup.ts"], // the suite spawns fake provider CLIs and a real harness server; // parallel files introduce load-sensitive flakes for no win From 13b71fa61f34e38b3a6413040b9cb2fe9d00db2c Mon Sep 17 00:00:00 2001 From: mnthr7 Date: Sun, 16 Aug 2026 23:49:09 +0000 Subject: [PATCH 03/32] Add ios/: the SwiftUI companion app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone half. A native iOS app that pairs with the sidecar, finds the computer by Bonjour or by typed address, and gives a bot the same conversation the desktop does: the fleet, a transcript, approvals, the bot's screen, and a reply that arrives as it is typed. - `Sources/CompanionCore` is everything that is not a view — the wire types, the SSE parser, the client, and the fold that maintains state. It is a Swift package rather than app-target source so `swift test` runs it with no Xcode, no simulator and no signing, which is also what lets the decoding tests run against fixtures captured from a real harness. - **The tests are the interesting part.** Decoding runs against bytes the server actually sent, captured by `scripts/capture-companion-fixtures.mjs` — hand-written test JSON tests our idea of the API, and the risk in a two-language client is that our idea drifts without anything failing. The stream tests run against a real URLSession, because two bugs shipped past every other test in the few lines between "URLSession has bytes" and "the app has frames". - **It assumes the harness is newer than it is.** An unrecognised stream frame falls through rather than throwing, and so does an unrecognised message kind — `kind` is not optional, so without that a single new kind fails the decode of the whole thread page. A computer newer than the phone is the ordinary state of a companion app, not an edge case. - Replies render markdown, matching the desktop's split: bots get it, what you typed is shown as you typed it. The streaming bubble uses the same renderer so the handover to the settled message is invisible. - The mascot is the desktop's own silhouette, parsed from the same path data, and the app icon is generated from `build/icon.svg` by `scripts/make-app-icon.mjs` so it cannot drift from the thing it depicts. `ios/TESTING.md` is the manual pass — the parts no automated test covers, including what each failure actually looks like on the phone. Depends on the two previous changes, which add `companion/` and the toggle. Co-Authored-By: Claude Opus 5 --- docs/ios-companion.md | 491 +++++++++++++++++ ios/.gitignore | 6 + .../AppIcon.appiconset/Contents.json | 14 + .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 56582 bytes ios/App/ChatListView.swift | 296 +++++++++++ ios/App/ChatView.swift | 496 ++++++++++++++++++ ios/App/CompanionApp.swift | 74 +++ ios/App/ComputerView.swift | 83 +++ ios/App/Discovery.swift | 133 +++++ ios/App/Keychain.swift | 67 +++ ios/App/MarkdownText.swift | 131 +++++ ios/App/MausAvatar.swift | 278 ++++++++++ ios/App/PairingView.swift | 205 ++++++++ ios/App/Session.swift | 395 ++++++++++++++ ios/App/SettingsView.swift | 58 ++ ios/Package.swift | 27 + ios/README.md | 159 ++++++ ios/Sources/CompanionCore/Client.swift | 273 ++++++++++ ios/Sources/CompanionCore/Frames.swift | 164 ++++++ ios/Sources/CompanionCore/Markdown.swift | 138 +++++ ios/Sources/CompanionCore/Models.swift | 288 ++++++++++ ios/Sources/CompanionCore/SSE.swift | 146 ++++++ ios/Sources/CompanionCore/Store.swift | 270 ++++++++++ ios/TESTING.md | 280 ++++++++++ .../CompanionCoreTests/DecodingTests.swift | 274 ++++++++++ .../CompanionCoreTests/EventStreamTests.swift | 193 +++++++ .../Fixtures/bots-full.json | 55 ++ .../Fixtures/bots-paged.json | 99 ++++ .../CompanionCoreTests/Fixtures/config.json | 21 + .../Fixtures/forbidden.json | 3 + .../Fixtures/instances.json | 21 + .../Fixtures/options-card.json | 17 + .../Fixtures/pair-rejected.json | 3 + .../Fixtures/pair-response.json | 10 + .../Fixtures/sse-frames.json | 111 ++++ .../Fixtures/sse-hello.json | 5 + .../Fixtures/thread-page.json | 21 + .../Fixtures/unauthorized.json | 3 + .../CompanionCoreTests/MarkdownTests.swift | 197 +++++++ ios/Tests/CompanionCoreTests/SSETests.swift | 85 +++ ios/Tests/CompanionCoreTests/StoreTests.swift | 323 ++++++++++++ ios/project.yml | 86 +++ scripts/capture-companion-fixtures.mjs | 220 ++++++++ scripts/make-app-icon.mjs | 312 +++++++++++ 44 files changed, 6531 insertions(+) create mode 100644 docs/ios-companion.md create mode 100644 ios/.gitignore create mode 100644 ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/App/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 ios/App/ChatListView.swift create mode 100644 ios/App/ChatView.swift create mode 100644 ios/App/CompanionApp.swift create mode 100644 ios/App/ComputerView.swift create mode 100644 ios/App/Discovery.swift create mode 100644 ios/App/Keychain.swift create mode 100644 ios/App/MarkdownText.swift create mode 100644 ios/App/MausAvatar.swift create mode 100644 ios/App/PairingView.swift create mode 100644 ios/App/Session.swift create mode 100644 ios/App/SettingsView.swift create mode 100644 ios/Package.swift create mode 100644 ios/README.md create mode 100644 ios/Sources/CompanionCore/Client.swift create mode 100644 ios/Sources/CompanionCore/Frames.swift create mode 100644 ios/Sources/CompanionCore/Markdown.swift create mode 100644 ios/Sources/CompanionCore/Models.swift create mode 100644 ios/Sources/CompanionCore/SSE.swift create mode 100644 ios/Sources/CompanionCore/Store.swift create mode 100644 ios/TESTING.md create mode 100644 ios/Tests/CompanionCoreTests/DecodingTests.swift create mode 100644 ios/Tests/CompanionCoreTests/EventStreamTests.swift create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/bots-full.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/bots-paged.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/config.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/forbidden.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/instances.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/options-card.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/pair-rejected.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/pair-response.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/sse-frames.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/sse-hello.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/thread-page.json create mode 100644 ios/Tests/CompanionCoreTests/Fixtures/unauthorized.json create mode 100644 ios/Tests/CompanionCoreTests/MarkdownTests.swift create mode 100644 ios/Tests/CompanionCoreTests/SSETests.swift create mode 100644 ios/Tests/CompanionCoreTests/StoreTests.swift create mode 100644 ios/project.yml create mode 100644 scripts/capture-companion-fixtures.mjs create mode 100644 scripts/make-app-icon.mjs diff --git a/docs/ios-companion.md b/docs/ios-companion.md new file mode 100644 index 0000000000..7a0fda418d --- /dev/null +++ b/docs/ios-companion.md @@ -0,0 +1,491 @@ +# iOS companion — architecture and plan + +**Status:** built and working on an iPhone — pairing, roster, sending, approvals, +and reach from any network over Tailscale. Push (APNs) is still design. + +**The shape changed late, and the rest of this document predates it.** The +companion no longer lives inside the harness. It is a separate process, +`companion/`, that a paired phone reaches and that speaks to the harness over +loopback as an ordinary client. Read [The sidecar](#the-sidecar-and-why) before +taking any of the Layer 1 design below as current — the *decisions* there still +hold, but they are implemented one process to the left of where they are +described. + +The goal: your bots keep running on the laptop, and the phone becomes the place you +watch them, answer their approvals, and send them the next thing. The laptop stays +the only machine that owns agent processes, credentials, transcripts and computers. +The phone owns nothing — it is a second client on the same harness the desktop app +already talks to. + +## What the repo gives us for free + +The harness was already built for exactly this shape. From the README's own rule — +*"clients hold no transports"* — the desktop app is already a thin client: + +| Piece | Where | Why it matters for iOS | +|---|---|---| +| Whole API is HTTP + JSON | `server/index.ts` | No Electron/IPC coupling. A native client is a first-class peer, not a hack. | +| One SSE stream for all state | `GET /api/events`, `server/index.ts:1075` | The phone folds the same frames the React store folds (`src/state/store.tsx:1058`). | +| Server-side event folding | `bus.subscribe(...)`, `server/index.ts:174` | The server already turns provider events into settled `Message` records. The phone can render `message` / `message.patch` frames and skip most protocol work. | +| Full hydration in one call | `GET /api/bots` | Cold start / reconnect is a single request. | +| Approvals are thread-addressed | `POST /api/threads/:threadId/respond`, `server/index.ts:1358` | Answering an approval from the phone needs **zero** new server concepts. | +| Voice already server-side | `POST /api/tts/speak` | The phone can play a reply aloud without an ElevenLabs key on-device. | +| Canonical per-thread log | `~/.openmausbot/events/.ndjson`, `server/harness/bus.ts:34` | A durable backing store for catch-up replay after the phone was asleep. | + +So the companion is **not** a rewrite. It is: make the harness reachable and +authenticated, make its stream resumable and cheap, and write a native client. + +## What blocked it + +The gaps this plan set out to close, in the order they bite. Layers 1 and 2 have since +closed 1–6; the line references below point at the code as it stood before that. + +1. **The server is loopback-only.** `server.listen(PORT, "127.0.0.1")` (`server/index.ts:1638`). + A phone cannot reach it at all. +2. **There is no authentication on `/api/*`.** Only `/api/internal/*` is guarded by a + boot-generated bearer token (`server/index.ts:945`). That is a correct and + deliberate design *for a loopback socket*. The moment the socket leaves loopback, + anything on the network can start turns, approve shell commands, read every + transcript, and PUT new API keys via `/api/config`. **Auth is a hard prerequisite + for binding anywhere else — not a follow-up.** +3. **The SSE stream is not resumable.** Broadcast frames carry no sequence number and + the endpoint honours no `Last-Event-ID` (`server/index.ts:1075`). A desktop client + papers over this by re-fetching everything on reconnect. A phone reconnects + constantly (backgrounding, cell↔wifi, tunnels), so "refetch the world" is the wrong + default. +4. **Hydration is heavy.** `GET /api/bots` returns *every* bot with its *entire* + transcript, plus every group with its entire transcript (`server/index.ts:1096`). + Fine over loopback; not fine on cellular. +5. **Screen frames are fat and unconditional.** The box preview broadcasts a base64 + frame every ~6s to *all* SSE clients while a bot works (`server/index.ts:389`), and + the turn-end frame is persisted inline in the transcript as `message.png` + (`server/index.ts:341`). A phone that never opened the computer panel still pays for + it, twice. +6. **There are no notifications of any kind.** `BotRecord.notifications` exists + (`server/store.ts:137`) and the settings toggle writes it + (`src/components/SettingsPanel.tsx:359`) but **nothing reads it.** It is a dead + switch. For a companion, "your bot is waiting on you" is the entire product. +7. **No pairing/device concept.** No device list, no revoke, no "this phone is + connected" surface in the desktop UI. + +## Architecture + +```mermaid +flowchart LR + subgraph phone ["iPhone — SwiftUI companion"] + UI[Chats · approvals · composer] + ST[(Actor store)] + TR[HTTP + SSE client] + KC[Keychain: device token] + end + subgraph laptop ["Laptop — OpenMausBot (unchanged core)"] + AUTH[Auth + pairing
server/remote.ts] + API[Existing HTTP API
server/index.ts] + BUS[Event bus → SSE + seq ring] + NOTIF[Notifier] + AGENTS[claude · codex · grok
computers · approvals] + end + UI --> ST --> TR + TR -- "Bearer device token" --> AUTH --> API --> AGENTS + BUS -- "SSE ?since=seq" --> TR + NOTIF -- "APNs (phase 3)" --> phone + KC -.-> TR +``` + +Four layers, each shippable on its own. + +### Layer 1 — reachability and identity (server, this repo) + +New file `server/remote.ts`, plus a small amount of wiring in `server/index.ts`. + +- **Opt-in second listener.** Keep `127.0.0.1:8799` exactly as it is — the desktop app, + the agents-proxy, and every test keep working untouched. When the user turns + *Settings → Companion* on, bind a **second** listener on `0.0.0.0` (same request + handler). Off by default. Persisted in `~/.openmausbot/config.json`. +- **Auth by socket, not by route.** One check at the top of the handler: + a request arriving on the loopback listener is trusted (today's behaviour, unchanged); + a request on the remote listener must present `Authorization: Bearer `. + This is the smallest change that cannot regress the desktop. +- **Pairing.** Desktop shows a QR containing `{host, port, code}` where `code` is a + short-lived (2 min) one-time code. Phone scans it, `POST /api/pair {code, deviceName}` + → a long-lived per-device token. Tokens live in `~/.openmausbot/devices.json` with + name / created / last-seen, and the Companion settings panel lists and revokes them. +- **Discovery.** Advertise `_openmausbot._tcp` over Bonjour so the phone finds the + laptop on the same Wi-Fi with no typing — written out rather than taken as a + dependency (see below). +- **Scope the token.** A paired phone should not be able to rewrite the user's API keys. + Simplest useful split: device tokens are denied `PUT/PATCH /api/config` and the + `/api/local-computer/*` lifecycle routes. Everything else (chat, approvals, tasks, + routines, computer view) is allowed. + +#### What shipped + +Built as described above, in `server/devices.ts`, `server/remote.ts`, and the auth gate +in `server/index.ts`: + +- **Two listeners, not one bind.** `127.0.0.1:8799` is byte-for-byte the socket it always + was — the desktop window, the agents-proxy and the tests are unaffected. The companion + is a *second* `http.Server` running the same handler with `remote: true`, on + `0.0.0.0:8800` (`OMB_REMOTE_PORT`). This is what makes "did this come from this + machine?" a structural fact rather than an address guess: a single `0.0.0.0` bind + reports `localAddress` `127.0.0.1` for loopback traffic, so one socket could not tell + the desktop app from the coffee-shop wifi. +- **Default deny on the companion socket.** `remoteDenial()` allows a route family + explicitly, so anything added later is closed to phones until someone opens it. A + paired phone may chat, answer approvals, manage tasks and rooms — and may **not** + write API keys (`PUT /api/config`), manage the companion (`/api/remote/*`, + `/api/devices/*` — losing the phone must not mean losing the ability to lock it out), + drive Local VM lifecycle, reach `/api/internal/*`, or load the packaged UI. +- **Tokens are write-only, like the API keys.** Pairing mints `omb_<32 random bytes>`, + returns it once, and stores only its SHA-256 in `~/.openmausbot/devices.json`. Nothing + can read it back; a phone that loses it pairs again. Comparisons are constant-time. +- **The six-digit code is never the whole defence.** It lives two minutes, dies after + five wrong guesses, and only exists while the user is on the pairing screen. +- **Off by default**, remembered across restarts only if it actually bound — a failed + bind (port in use) reports rather than persisting a broken "enabled". + +**Discovery is Bonjour, with no dependency.** `server/mdns.ts` is a responder for the +half of RFC 6762/6763 we actually need: answer questions about our own service, announce +on arrival, and send a zero-TTL goodbye on the way out. It is ~380 lines of DNS message +encoding and a multicast socket, against a dependency tree running inside the process +that holds the user's API keys — the house rule about not taking a dependency where code +will do points the same way. Details worth knowing: + +- **A PTR answer carries SRV, TXT and A as additionals** (RFC 6763 §12), so browsing + resolves in one round trip rather than three. That is the difference between a picker + that fills in instantly and one that looks broken for a second. +- **The host record is `openmausbot-.local`, not `.local`.** + On macOS the system responder owns the latter and defends it; picking a fight with + mDNSResponder over the user's own machine name is a bad trade for a companion feature. +- **We do not probe for name conflicts** (§8.1). The claimed host name is derived from + the machine's hostname, so a collision takes two machines with the same hostname on one + network, and costs a duplicate row in a picker rather than anything broken. +- **Discovery failing is not an error.** Port 5353 taken, no multicast on this network — + the panel falls back to showing the address to type. The listener never depends on it. +- The advertised name follows the profile name, and re-announces when it changes rather + than leaving a stale name on the network. + +`server/devices.test.ts` covers the registry contract, `server/mdns.test.ts` the wire +format byte by byte (against packets built by hand, not by the encoder under test) plus +the socket loop over an ephemeral unicast port; `server/index.test.ts` boots the real +server and exercises the handshake over the network socket, including every refusal +above. + +**Out of the house:** deliberately *not* solving NAT traversal in v1. Same-Wi-Fi is the +honest first release. For remote access, document Tailscale — the laptop and phone both +run it, the phone hits the tailnet IP, and the auth layer above is what makes that safe. +A project-run relay (laptop dials out, phone connects, both meet at a WebSocket broker) +is a phase-4 decision with real hosting, cost and privacy consequences; it should not +gate v1. + +### Layer 2 — a stream a phone can actually hold (server) + +- **Sequence the broadcast.** Give every frame emitted by `broadcast()` a monotonic + `seq`. Keep the last ~500 frames in a ring buffer. `GET /api/events?since=` + replays what the client missed and then goes live; if `since` is older than the ring, + answer `{kind:"resync"}` and let the client do a full hydrate. This is ~30 lines and + it fixes reconnect for the desktop too. +- **Paginate hydration.** `GET /api/bots?messages=` (default full, so nothing + breaks) and `GET /api/threads/:threadId/messages?before=&limit=50` for + scrollback. +- **Get images out of the transcript body.** Serve `screen` messages' pixels from + `GET /api/threads/:threadId/messages/:id/image` and omit `png` from list payloads + when the client asks for the slim shape. Same for live frames: only push `screen` + SSE frames to a client that has said it is watching (`POST /api/devices/:id/watch` + or a query param on `/api/events`). +- **Wake the dead notifications flag.** A `Notifier` subscribed to the bus that fires on + the three things worth a buzz: `request.opened` (approval or question — the important + one), `turn.completed` for a bot with `notifications: true`, and routine-run failures + (`server/routines.ts`). Deliver over SSE as a `{kind:"notify"}` frame first — that + alone gives correct behaviour while the app is open — with APNs behind the same + interface later. + +#### What shipped + +- **The stream is resumable.** Every broadcast frame is numbered and the last 500 are + kept. A client reconnects with its cursor and gets exactly what it missed, or an + explicit `resumed: false` telling it to hydrate — never a partial replay, which would + leave a permanent hole in its state. The cursor is `:` and rides in the + SSE `id:` field, so a **browser EventSource resumes through its own `Last-Event-ID` + with no client code at all**; the stream id is what keeps a cursor from a previous + boot (where the sequence restarted at 1) from replaying the wrong run's frames. + The desktop app now hydrates only when the server says it must — previously it + re-downloaded every transcript on every reconnect. +- **Hydration can be paged.** `GET /api/bots?messages=n` returns the newest n per thread + with `hasMore`; `GET /api/threads/:threadId/messages?before=&limit=` walks backwards + for scrollback. Omitting `?messages` returns exactly the shape it always did, so the + desktop is untouched. An unknown `before` cursor is a 404 rather than a silent newest + page — otherwise a client paginates in a circle and never reaches the top. +- **Images are fetched, not pushed.** In the paged shape a `screen` message carries + `hasImage: true` instead of a base64 PNG; the pixels come from + `GET /api/threads/:threadId/messages/:id/image`, cached immutably. +- **Live screen frames are opt-out.** `GET /api/events?screens=off` drops the ~6-second + desktop captures for clients that aren't showing the computer panel. The filter + applies to replay too, so an opted-out client's cursor stays meaningful. +- **`BotRecord.notifications` finally does something.** `server/notify.ts` holds the + policy — a bot *blocked on you* is worth an interruption, a bot that *finished* is + worth one if you asked, and nothing else is — and the harness emits + `{kind:"notify", notification}`. The hook sits at the point in the event fold where a + card actually reaches a human, so anything auto mode answered by itself never buzzes. + The desktop consumes it as a native notification when the window isn't focused, which + makes the existing settings toggle real on desktop as well as on the phone. + +### Layer 3 — the iOS app + +Native **SwiftUI, iOS 17+, zero third-party dependencies.** Reasons: SSE over +`URLSession.bytes` is ~60 lines; the data model is small and already JSON; Keychain, +Bonjour (`NWBrowser`), QR (`VisionKit`/`AVFoundation`), and notifications are all +first-party; and a React Native shell would drag in a build system this repo doesn't have. + +``` +ios/OpenMausCompanion/ + Networking/ Endpoint.swift SSEClient.swift Pairing.swift Discovery.swift + Model/ Bot.swift Group.swift Message.swift RuntimeEvent.swift ← mirrors server/store.ts + server/contracts.ts + State/ Store.swift (actor) Reducer.swift ← mirrors src/state/store.tsx + Features/ ChatList/ Chat/ ApprovalCard/ ComputerPanel/ Settings/ + App/ CompanionApp.swift Keychain.swift Notifications.swift +``` + +Design notes that matter: + +- **Thin client, on purpose.** The server already folds runtime events into `Message` + records, so v1 can subscribe to `message`, `message.patch`, `bot`, `group`, `thread`, + `notify` and *ignore* `runtime` entirely — using `bot.busy` for the typing indicator. + Token-by-token streaming (`content.delta`) is a nice-to-have layered on after, not a + prerequisite. This is the single biggest scope saving available. +- **Lifecycle is the hard part, not the UI.** On foreground: `GET /api/bots?messages=50`, + then open SSE with `?since=`. On background: tear the stream down immediately (iOS will + kill it anyway) and record the last `seq`. +- **Approval cards are the headline screen.** An `options` message with + `card.requestId` renders Allow / Deny / Always-allow and posts to + `/api/threads/:threadId/respond` — plus "always allow" patching `alwaysAllow` on the + bot, exactly as `src/state/store.tsx:859` does today. +- **Voice is genuinely better on iOS than on desktop.** `/api/tts/speak` returns audio + the phone can play directly, and call mode's blocker on desktop is that dictation is + macOS-only (`docs/voice-mode.md`) — `SFSpeechRecognizer` is on every iPhone. Call + mode is a strong phase-4 feature, not a port of a limitation. + +#### What shipped + +`ios/` holds a SwiftUI app in two halves. `CompanionCore` is a SwiftPM library +with no UI and nothing beyond Foundation — wire types, the SSE line parser, the +API client, and the fold from frames to state — so it builds and tests with +`swift test` alone. `App/` is the SwiftUI layer plus everything that needs a +device: `NWBrowser` discovery, the keychain, and the lifecycle that decides when +the stream lives. + +The thing worth copying from this layer is how the contract is pinned. +`scripts/capture-companion-fixtures.mjs` boots a real harness, drives the real +pairing handshake over the real network socket, and writes the responses to +`ios/Tests/CompanionCoreTests/Fixtures/`. The Swift models were written against +those bytes, and the decoding tests read them — so the app is checked against +what the server *sends*, not against anyone's memory of what it sends. That +caught a live defect on the first run: the `bot` frame was shipping +`resumeCursors`, the harness's own provider session ids, to every client. + +Re-run the capture whenever the companion API changes and commit the diff; a +change there is a change to the contract, and reviewing it is the point. + +**What the first real run found.** The Swift was written with no toolchain +available, so its first compile was on a Mac. The fixtures did their job — the +core compiled and all 33 tests passed on the first attempt, with no decoding +errors at all. But three bugs still surfaced, every one of them in the few lines +between "URLSession has bytes" and "the app has frames", and every one invisible +to the parser tests because the parser was never wrong: a `timeoutInterval` of +`.greatestFiniteMagnitude` (which URLSession adds to the current time to get a +deadline), letting `URLSession.AsyncBytes` go out of scope (it cancels its task +when released), and reading with `bytes.lines` (which folds consecutive newlines +together and so never reports the blank line that ends an SSE event). + +The lesson worth carrying into Layer 4: fixtures pin the *contract* very well +and say nothing about the *plumbing*. `ios/Tests/CompanionCoreTests/EventStreamTests.swift` +is the answer to that — the one test that drives a real `URLSession`. + +Two smaller fixes came out of the same session: the `bot` SSE frame was shipping +`resumeCursors` to every client, and `claudeSignedIn` only looked for the +Linux/WSL credentials file, so every signed-in Mac reported as signed out. + +### Layer 4 — push, and being away from home + +- **APNs** needs something the project does not have: a signing key and a process that + can reach Apple. The privacy-preserving shape is a stateless relay that receives + `{deviceToken, kind, botName}` — never message content — and forwards it; the phone + fetches details itself once opened. Until that exists, notifications while the app is + backgrounded simply do not arrive, and the app is honest about it. +- **Remote access** is Tailscale-documented first; a relay only if people actually ask. + +#### What shipped: remote access + +Tailscale needs no protocol work at all — the listener already binds `0.0.0.0`, so a +tailnet address was being served from the day Layer 1 landed. What was missing was +that nobody could *see* it: the panel printed `addresses[0]`, and the tailnet address +is usually not first. + +So the work is telling the two addresses apart and saying which is which. +`tailscaleAddress()` in `server/remote.ts` recognises the CGNAT range RFC 6598 set +aside — `100.64.0.0/10`, which is exactly why it never collides with a home network — +and `refreshTailnetName()` shells out to the Tailscale CLI once at startup for +`Self.DNSName`, the MagicDNS name. Both land in `RemoteState`, and the panel prefers +the tailnet, listing the LAN address separately as the secondary thing it now is. + +The name matters more than it looks. A phone reaching a tailnet over plain HTTP is on +the wrong side of App Transport Security: `NSAllowsLocalNetworking` exempts the +private ranges, and `100.64/10` is shared CGNAT space rather than one of them, so iOS +refuses the request before it reaches the network. ATS exceptions are by *name*, not +by subnet — so an address cannot be exempted and a hostname can. Every tailnet name +ends in `ts.net`, which makes one `NSExceptionDomains` entry in `ios/project.yml` cover +every machine anyone will ever own. Pair by name, not by number. + +This turned out to matter far more than "nice for travelling". The LAN path failed +repeatedly during Layer 3 testing on a network where both devices were demonstrably on +the same SSID: a guest network that isolates its clients drops Bonjour multicast *and* +direct connections, so discovery finds nothing, the typed address times out, and both +ends look healthy. There is nothing to fix on either machine. Tailscale routes around +the whole category rather than diagnosing it, which is why the runbook now sends +people there as soon as a typed address fails (`ios/TESTING.md`, stage 5). + +## Plan + +| Phase | Deliverable | Where | +|---|---|---| +| **0** | ✅ This document, agreed | `docs/` | +| **1** | ✅ Auth + pairing + opt-in remote bind + Bonjour discovery + Settings → Companion (toggle, code, device list, revoke) — **since moved into `companion/`** | `companion/`, `electron/companion.mjs`, `src/components/CompanionSection.tsx` | +| **2** | ✅ `seq` + cursor replay, paged hydration, image endpoint, opt-out screen frames, `notify` frames | `server/index.ts`, `server/notify.ts`, `src/lib/notify.ts` | +| **3** | ✅ iOS app: pair → chat list → chat → **approvals** → send. Foreground-only. | `ios/` | +| **4** | ✅ Remote access over Tailscale — tailnet address and MagicDNS name surfaced, ATS exempted, runbook. Still open: computer panel, streaming deltas, TTS playback, APNs + relay, call mode | `companion/src/listener.ts`, `ios/project.yml` | +| **5** | ✅ The sidecar: the whole companion moved out of the harness, which is now unmodified | `companion/`, `electron/companion.mjs` | + +Phases 1 and 2 were worth doing regardless of the phone, and both already paid off on +the desktop: reconnects no longer re-download every transcript, and the per-bot +notifications toggle finally does something. + +## The sidecar, and why + +Layers 1–4 were built inside the harness: a second listener, an auth gate and a +default-deny allowlist wired into `handle()` in `server/index.ts`. It worked, and +it was the wrong place for it. + +**What went wrong.** Upstream merged `fix: enforce loopback-only + Origin checks` +— a DNS-rebinding defence that rejects any request whose `Host` is not loopback, +before any route runs. That is correct for a socket any web page can reach with +no credential of its own. It is also fatal to a companion: a phone's `Host` is a +LAN address or a tailnet name and can never satisfy it, so every paired device +would have got a 403 before its token was looked at. The companion would have +shipped dead, and nothing in its own tests would have noticed. + +That was not bad luck. It is what carrying a patch to somebody else's request +handler costs, and it will keep costing: the rebase across 57 upstream commits +that surfaced it took an afternoon, and there will be more of them. + +**What replaced it.** `companion/` is a separate process. A device reaches it; +it reaches the harness over loopback, as a request from the machine the harness +already trusts. The device's `Host` and `Origin` never travel, so the loopback +gate is satisfied by construction. **The harness needs no changes and does not +know the sidecar exists.** + +``` + phone ──LAN/tailnet──▶ companion :8810 ──loopback──▶ harness :8799 + ▲ ▲ + │ token, allowlist, │ unmodified, + │ Origin refused │ loopback-only +``` + +Everything the harness version decided still holds — pairing codes, digest-only +token storage, default-deny per route family, `resumeCursors` stripped on the +way out. They are just implemented one process to the left. + +**Three things worth knowing:** + +- **It is stricter about `Origin` than the harness is.** A native app sends none, + so a request carrying one is a browser that has found a port with no business + serving browsers — refused even for a loopback origin, which the harness allows. +- **The proxy has to scrub the SSE stream, not just pipe it.** `resumeCursors` + appears in `bot` frames too, and upstream still sends them. The transform emits + an event the moment it is complete and never touches the blank-line terminator + or the `id:` line — both of which have silently broken this project before. +- **The control surface is loopback-only and separate.** Pairing and revocation + are exactly what a phone must never reach, so they live on `127.0.0.1:8811`, + not on the socket devices talk to. + +**The cost, stated plainly.** Moving out of the harness meant losing Settings → +Companion, and the first version replaced a toggle with a terminal command and a +browser tab. That was a bad trade and it did not have to be one: the desktop app +already forks the harness as a utility process, so it now forks the sidecar the +same way and the toggle is back. The renderer talks to it over IPC rather than +fetching the control port, which keeps the UI on one origin. What remains in +upstream's tree is roughly 40 lines in `electron/main.mjs` plus a settings panel +— in files that change far less often than `handle()` does. + +**When this stops being necessary.** If a maintainer ever wants a companion +in-tree, the sidecar is not wasted: `devices.ts`, the allowlist and the scrubber +move back unchanged. The sidecar is the same feature with a different owner, not +a different design. + +## Decisions + +1. **Remote reach for v1: the LAN, or Tailscale.** No relay until someone asks — and + after Layer 4, Tailscale is the path the docs lead with rather than the footnote, + because a network that isolates its clients defeats the LAN path entirely. +2. **The phone may not change app settings or API keys.** Enforced by `remoteDenial()`. +3. **Distribution: the App Store.** See the caveats below — this is the one decision with + consequences that reach back into the architecture. +4. **Code layout: `ios/` in this repo**, for the upstreaming reason below. + +## Upstreaming + +This repo is a fork of `milind-soni/OpenMausBot` and shares its history, so contributing +back is an ordinary pull request. `CONTRIBUTING.md` shapes *how*: + +> Small, focused PRs. One concern per PR. […] Big changes: open an issue first and agree +> on the approach. + +Which means the layered plan is not just an implementation order — it is the PR series: + +The branches are built and pushed to this fork as `upstreaming/1…5`. Each one +typechecks and passes the full suite on its own, and together they reproduce the +server and web tree that was verified on a phone — exactly, byte for byte, which is +worth re-checking after any further split: + +```sh +git diff upstreaming/5-tailscale claude/open-mouse-ios-companion-mhzsdu -- server/ src/ +``` + +| PR | Branch | Content | Upstream appeal | +|---|---|---|---| +| 1 | ✅ **merged** as #123 | `claudeSignedIn` consults the Keychain on macOS (upstream has since rewritten it to ask the CLI, which is better) | **Nothing to do with the phone.** A live bug: every signed-in Mac reports as signed out | +| 2 | ✅ **merged** as #124 | `seq` + `?since=` replay, paged hydration + image endpoint, the `notify` builder | **Wanted by the desktop app too** — today reconnect refetches the world, and the per-bot notifications toggle is a dead switch | +| 3–5 | ~~`upstreaming/3-companion-listener`, `4-bonjour`, `5-tailscale`~~ | **Withdrawn.** Superseded by the sidecar — there is nothing left to upstream, because the harness is no longer patched. Built, green and pushed if ever wanted | +| 6 | `upstreaming/6-resume-cursors` | The `resumeCursors` leak fix, standing alone — a real bug for every client, nothing to do with phones | +| — | — | Layer 3: `ios/` — no longer needs upstream's agreement, since the sidecar needs nothing from them | + +**The ordering is deliberate and differs from the obvious one.** PR 1 is a two-file +bug fix with no connection to any of this; it goes first because it is the cheapest +possible first contact with a maintainer who has never seen this work. PR 2 is a pure +harness improvement that stands on its own merits whether or not a phone app is ever +wanted in the tree. Only PR 3 asks for anything. + +3, 4 and 5 are stacked — each is based on the one before, so if 3 is rejected the rest +go with it, which is the honest dependency anyway. 1 and 2 are independent of +everything, including each other. + +**Open an issue upstream before proposing `ios/`**: adding a Swift target to a +TypeScript repo is precisely the "big change" `CONTRIBUTING.md` asks to agree on in +advance. `docs/ios-companion.md` is that issue's content, which is why it is deliberately +absent from PRs 1–5 — a design doc for a phone app has no business landing in a repo +whose maintainer has not yet said yes to one. + +Per the checklist: `pnpm typecheck` and `pnpm test` green on each branch, new server +behaviour tested, no `dist-server/` churn, and before/after screenshots for the +Companion panel. + +**The App Store decision needs raising early**, because it is not only a build concern: + +- An app store listing has an *owner*. If `ios/` lives upstream, whose Apple Developer + account publishes it, and whose name is on the privacy declarations? That is a + maintainer question, not a code question — ask it in the same issue. +- Review will look hard at an app whose purpose is to approve shell commands on a + laptop. It needs `NSLocalNetworkUsageDescription`, and a reviewer with no OpenMausBot + laptop must still be able to open the app and see something — so a demo/offline mode + is a real requirement, not polish. +- Nothing about phases 1–4 depends on this, so it does not block any work. It only + blocks shipping. diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000000..704f13dcff --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,6 @@ +# SwiftPM / Xcode build output +.build/ +.swiftpm/ +DerivedData/ +*.xcodeproj +*.xcworkspace diff --git a/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d90c80b618 --- /dev/null +++ b/ios/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "icon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/ios/App/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/ios/App/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..edd66c343f85c29f9b236065976ad8a38314a6d6 GIT binary patch literal 56582 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�AE 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 ay~7yx$4Ju&yADwJH+AC730KF5#Qh&wdbVEx literal 0 HcmV?d00001 diff --git a/ios/App/ChatListView.swift b/ios/App/ChatListView.swift new file mode 100644 index 0000000000..7af4f8634d --- /dev/null +++ b/ios/App/ChatListView.swift @@ -0,0 +1,296 @@ +// The roster. +// +// Styled after the desktop's messaging-app feel rather than a settings +// list: big mascot faces, the bot's role as a chip beside its name, a +// preview line, and no dividers. Anything waiting on you is pulled to the +// top, because that is the one thing a phone is better at than the laptop. +import SwiftUI +import CompanionCore + +struct ChatListView: View { + @EnvironmentObject private var session: Session + @State private var query = "" + /// Driven so that making a bot can open it. Value-based navigation alone + /// cannot push without a tap, and a new bot appearing silently at the + /// bottom of the roster is a poor answer to pressing +. + @State private var path = NavigationPath() + + var body: some View { + NavigationStack(path: $path) { + // A hand-built header rather than the navigation bar. Two + // reasons: `.searchable` anchors its field to the *bottom* of the + // screen on iOS 26, which is not where a roster's search belongs, + // and an empty-titled nav bar reserves a surprising amount of + // room above the first row. Drawing it here makes the top of the + // list the top of the screen on every iOS. + VStack(spacing: 0) { + header + StatusBanner() + + ScrollView { + LazyVStack(spacing: 0) { + if query.isEmpty { + ForEach(session.state.pendingApprovals, id: \.message.id) { pending in + if let chat = chat(forThread: pending.threadId) { + NavigationLink(value: chat) { + WaitingRow(chat: chat, card: pending.message.card) + } + .buttonStyle(.plain) + } + } + } + + ForEach(chats) { chat in + NavigationLink(value: chat) { + ChatRow( + chat: chat, + preview: session.state.preview(chat), + at: session.state.lastActivity(chat) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + .refreshable { session.connect() } + .overlay { + if chats.isEmpty { + ContentUnavailableView( + query.isEmpty ? "No bots yet" : "Nothing matches", + systemImage: query.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass", + description: Text( + query.isEmpty + ? "Bots you create on your computer show up here." + : "No chat matches \u{201C}\(query)\u{201D}." + ) + ) + } + } + } + // top-aligned: the roster fills downward from the header + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: Chat.self) { ChatView(chat: $0) } + } + } + + /// Who you are, and how to find a chat — both at the top, always. + private var header: some View { + HStack(spacing: 12) { + NavigationLink { SettingsView() } label: { + ProfileAvatar(name: session.connection?.name ?? "You") + } + .buttonStyle(.plain) + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.secondary) + + TextField("Search chats", text: $query) + .font(.system(size: 16)) + .submitLabel(.search) + .autocorrectionDisabled() + + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Capsule().fill(Color.secondary.opacity(0.16))) + + // Same place the desktop puts it, top-right of the roster. + Button { + Task { + if let bot = await session.createBot() { path.append(Chat.bot(bot)) } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.primary) + .frame(width: 34, height: 34) + } + .buttonStyle(.plain) + .accessibilityLabel("New bot") + } + .padding(.horizontal, 16) + .padding(.top, 6) + .padding(.bottom, 10) + } + + private var chats: [Chat] { + let all = session.state.chats + guard !query.isEmpty else { return all } + return all.filter { + $0.name.localizedCaseInsensitiveContains(query) + || $0.subtitle.localizedCaseInsensitiveContains(query) + || session.state.preview($0).localizedCaseInsensitiveContains(query) + } + } + + private func chat(forThread threadId: String) -> Chat? { + if let bot = session.state.bot(forThread: threadId) { return .bot(bot) } + if let room = session.state.room(forThread: threadId) { return .room(room) } + return nil + } +} + +struct ChatRow: View { + let chat: Chat + let preview: String + let at: Double + + var body: some View { + HStack(alignment: .top, spacing: 14) { + MausAvatar(color: chat.color, size: 52) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 8) { + Text(chat.name) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.primary) + .lineLimit(1) + .layoutPriority(1) + + // the bot's job, the way the desktop shows it + if !chat.subtitle.isEmpty { + Text(chat.subtitle) + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(Color.secondary.opacity(0.15))) + } + + Spacer(minLength: 4) + + Text(RelativeStamp.list(at)) + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) + .fixedSize() + } + + HStack(alignment: .top, spacing: 8) { + Text(preview.isEmpty ? " " : preview) + .font(.system(size: 15)) + .foregroundStyle(Color.secondary) + .lineLimit(1) + + Spacer(minLength: 0) + + if chat.busy { + ProgressView().controlSize(.mini) + } else if chat.unread { + Circle() + .fill(MausPalette.color(chat.color)) + .frame(width: 9, height: 9) + .padding(.top, 5) + } + } + } + } + .padding(.vertical, 14) + .contentShape(Rectangle()) + } +} + +/// A bot that stopped and needs a person. The whole reason for the app, so +/// it gets to sit above the roster and look unlike everything else. +struct WaitingRow: View { + let chat: Chat + let card: OptionCard? + + var body: some View { + HStack(spacing: 12) { + MausAvatar(color: chat.color, size: 38) + + VStack(alignment: .leading, spacing: 3) { + Label("\(chat.name) is waiting on you", systemImage: "hand.raised.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.primary) + Text(card?.subtitle ?? "") + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + Spacer(minLength: 0) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.accentColor.opacity(0.14)) + ) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } +} + +/// Connection state, shown only when it is not "fine". +struct StatusBanner: View { + @EnvironmentObject private var session: Session + + var body: some View { + Group { + switch session.status { + case .live, .unpaired: + EmptyView() + case .connecting: + banner("Connecting…", systemImage: "arrow.triangle.2.circlepath", tint: .secondary) + case let .offline(reason): + banner(reason, systemImage: "wifi.slash", tint: .orange) + case .unauthorized: + banner("This phone was unpaired on the computer.", systemImage: "lock.slash", tint: .red) + } + } + .animation(.default, value: session.status) + } + + private func banner(_ text: String, systemImage: String, tint: Color) -> some View { + Label(text, systemImage: systemImage) + .font(.footnote) + .foregroundStyle(tint) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.regularMaterial, in: Capsule()) + .padding(.top, 4) + } +} + +/// Timestamps the way a messaging app writes them. +enum RelativeStamp { + /// Roster: time today, weekday this week, date beyond that. + static func list(_ at: Double) -> String { + guard at > 0 else { return "" } + let date = Date(timeIntervalSince1970: at / 1000) + let calendar = Calendar.current + if calendar.isDateInToday(date) { + return date.formatted(date: .omitted, time: .shortened) + } + if calendar.isDateInYesterday(date) { return "Yesterday" } + if let week = calendar.date(byAdding: .day, value: -6, to: Date()), date > week { + return date.formatted(.dateTime.weekday(.wide)) + } + return date.formatted(.dateTime.day().month(.abbreviated)) + } + + /// In a transcript: enough to place a gap in the conversation. + static func separator(_ date: Date) -> String { + let calendar = Calendar.current + let time = date.formatted(date: .omitted, time: .shortened) + if calendar.isDateInToday(date) { return "Today \(time)" } + if calendar.isDateInYesterday(date) { return "Yesterday \(time)" } + return "\(date.formatted(.dateTime.day().month(.abbreviated))) \(time)" + } +} diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift new file mode 100644 index 0000000000..fa9a7b5417 --- /dev/null +++ b/ios/App/ChatView.swift @@ -0,0 +1,496 @@ +// One conversation: the transcript, the approval cards, and the composer. +// +// The transcript is whatever the harness folded — settled text, tool chips, +// option cards, screenshots. This renders those and nothing else; it does +// not re-derive anything from provider events, because the server already +// did that and having two folds is how two clients start disagreeing. +import SwiftUI +import CompanionCore +#if canImport(UIKit) +import UIKit +#endif + +struct ChatView: View { + let chat: Chat + @EnvironmentObject private var session: Session + @Environment(\.dismiss) private var dismiss + @State private var draft = "" + @FocusState private var composerFocused: Bool + + /// The live bubble's scroll target. A constant because there is at most + /// one per chat and it has no message id to borrow. + static let liveBubbleId = "companion.live" + + private var messages: [Message] { + session.state.transcript(forThread: chat.threadId) + } + + /// The live chat record, so busy/unread stay current as frames land. + private var current: Chat { + switch chat { + case let .bot(bot): return session.state.bot(bot.id).map(Chat.bot) ?? chat + case let .room(room): + return session.state.rooms.first { $0.id == room.id }.map(Chat.room) ?? chat + } + } + + var body: some View { + // A VStack with the composer as a sibling, rather than a scroll view + // with `.safeAreaInset`. The inset version sized itself to its + // content, so a short transcript left the composer floating in the + // middle of the screen with black beneath it. Here the scroll area is + // explicitly told to take everything the composer does not. + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + // VStack, not LazyVStack. A lazy stack does not know how + // tall it is until its rows have been built, so + // `.defaultScrollAnchor(.bottom)` anchors against an + // estimate and the chat opens somewhere in the middle of + // the conversation. Building all of it up front makes the + // height exact and the anchor land on the newest message. + // A thread holds 50 messages until you ask for more, so + // there is nothing here worth being lazy about. + VStack(alignment: .leading, spacing: 12) { + if session.state.hasMore[chat.threadId] == true { + Button("Load earlier messages") { + // keep the reader where they were: after older + // messages are prepended, sit back on the one + // that used to be at the top + let anchor = messages.first?.id + Task { + await session.loadOlder(threadId: chat.threadId) + if let anchor { proxy.scrollTo(anchor, anchor: .top) } + } + } + .font(.footnote) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in + VStack(alignment: .leading, spacing: 12) { + // a gap in time is worth marking; a timestamp + // on every message is just noise + if startsANewStretch(at: index) { + Text(RelativeStamp.separator(message.date)) + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .frame(maxWidth: .infinity) + .padding(.top, 6) + } + MessageRow(chat: current, message: message) + } + .id(message.id) + } + + // The reply as it is typed. It sits after the last + // settled message and disappears the moment the real + // one arrives — the store clears it on the same frame + // that appends the message, so there is never a beat + // where both are on screen. + if let live = session.state.streaming[chat.threadId], !live.isEmpty { + StreamingBubble(text: live, reasoning: nil) + .id(Self.liveBubbleId) + } else if let thinking = session.state.reasoning[chat.threadId], !thinking.isEmpty { + // Only while there is no answer yet. Once tokens + // of the reply exist, the reasoning is behind us + // and showing both is just noise. + StreamingBubble(text: nil, reasoning: thinking) + .id(Self.liveBubbleId) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + // A conversation grows from the bottom: a transcript shorter + // than the screen rests at the bottom, and opening a chat + // starts on the newest message rather than the oldest. + .defaultScrollAnchor(.bottom) + .onChange(of: messages.count) { _, _ in + guard let last = messages.last else { return } + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + // Follow the text as it arrives. Keyed on length rather than + // the string so this fires once per delta batch, and without + // animation — animating every token turns a smooth stream + // into a stutter, because each scroll interrupts the last. + .onChange(of: session.state.streaming[chat.threadId]?.count ?? 0) { _, length in + guard length > 0 else { return } + proxy.scrollTo(Self.liveBubbleId, anchor: .bottom) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + composer + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.primary) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.secondary.opacity(0.16))) + } + } + ToolbarItem(placement: .principal) { + HStack(spacing: 8) { + MausAvatar(color: current.color, size: 26) + Text(current.name) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.primary) + } + .padding(.leading, 6) + .padding(.trailing, 14) + .padding(.vertical, 5) + .background(Capsule().fill(Color.secondary.opacity(0.16))) + } + if case let .bot(bot) = current { + // Rooms have no computer of their own — whichever member is + // speaking owns one, and picking for the reader would be a + // guess. Bots only. + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + ComputerView(bot: bot) + } label: { + Image(systemName: "display") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Color.primary) + } + .accessibilityLabel("Watch \(bot.name)'s computer") + } + } + if current.busy, case let .bot(bot) = current { + ToolbarItem(placement: .topBarTrailing) { + Button("Stop") { Task { await session.interrupt(bot: bot) } } + } + } + } + .task { + // opening a chat is what marks it read, exactly as on the desktop + if current.unread { await session.markRead(current) } + } + } + + /// True when this message opens a fresh stretch of conversation — the + /// first one, or one that follows a gap of half an hour or more. + private func startsANewStretch(at index: Int) -> Bool { + guard index > 0 else { return true } + return messages[index].at - messages[index - 1].at > 30 * 60 * 1000 + } + + private var canSend: Bool { + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func submit() { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + draft = "" + Task { await session.send(text, to: current) } + } + + private var composer: some View { + HStack(spacing: 10) { + TextField("Ask \(current.name)", text: $draft, axis: .vertical) + .lineLimit(1...5) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Capsule().fill(Color.secondary.opacity(0.16))) + .focused($composerFocused) + .submitLabel(.send) + // Return sends, Shift+Return breaks the line — the shape + // every chat app has. `.ignored` hands the keypress back to + // the text field, which is what inserts the newline; there is + // no way to type one otherwise once Return is claimed. + .onKeyPress(.return, phases: .down) { press in + guard !press.modifiers.contains(.shift) else { return .ignored } + submit() + return .handled + } + // software keyboards have no Shift+Return, so their Return + // key is a send — which is what `.submitLabel(.send)` promises + .onSubmit(submit) + + Button { + submit() + } label: { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(uiColor: .systemBackground)) + .frame(width: 36, height: 36) + .background( + Circle().fill(canSend ? Color.primary : Color.secondary.opacity(0.35)) + ) + } + .disabled(!canSend) + .animation(.easeOut(duration: 0.15), value: canSend) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.bar) + } +} + +struct MessageRow: View { + let chat: Chat + let message: Message + + var body: some View { + switch message.kind { + case .text: + TextBubble(message: message) + case .options: + CardView(chat: chat, message: message) + case .activity: + ActivityChip(tool: message.tool) + case .screen: + ScreenShot(threadId: chat.threadId, message: message) + case .unknown: + // A message kind from a newer computer. Almost everything the + // harness sends carries `text`, so showing it is usually the + // whole message and always better than a gap in the transcript. + // When there is nothing to show, show nothing — a placeholder + // saying "unsupported" is a worse gap than the gap. + if let text = message.text, !text.isEmpty { + TextBubble(message: message) + } + } + } +} + +struct TextBubble: View { + let message: Message + + var body: some View { + let mine = message.role == .user + HStack { + if mine { Spacer(minLength: 44) } + VStack(alignment: .leading, spacing: 4) { + // rooms attribute each line to the member who said it + if let from = message.from { + Text(from.name) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(MausPalette.color(from.color)) + } + // Bots get markdown, you do not — the same split the desktop + // makes. Markdown you did not intend is worse than markdown + // you did: a message about `**` should show the asterisks. + if mine { + Text(message.text ?? "") + .font(.system(size: 17)) + .foregroundStyle(Color.primary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } else { + MarkdownText(source: message.text ?? "") + .foregroundStyle(Color.primary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.secondary.opacity(mine ? 0.24 : 0.13)) + ) + if !mine { Spacer(minLength: 44) } + } + } +} + +/// A tool the bot ran. Deliberately quiet — these are the bulk of a busy +/// transcript and they are context, not content. +struct ActivityChip: View { + let tool: ToolActivity? + + var body: some View { + if let tool { + Label { + Text(tool.name).lineLimit(1) + } icon: { + Image(systemName: tool.ok == false ? "exclamationmark.triangle" : "wrench.and.screwdriver") + } + .font(.system(size: 13)) + .foregroundStyle(tool.ok == false ? Color.red : Color.secondary) + .padding(.leading, 4) + } + } +} + +/// An option card. When it still has a request behind it, this is the +/// screen the companion exists for — a bot stopped, and only a person can +/// let it continue. +struct CardView: View { + let chat: Chat + let message: Message + @EnvironmentObject private var session: Session + @State private var answering = false + + var body: some View { + if let card = message.card { + VStack(alignment: .leading, spacing: 12) { + Text(card.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.primary) + Text(card.subtitle) + .font(.system(size: 15)) + .foregroundStyle(Color.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + + if let held = card.held { + Label(held, systemImage: "exclamationmark.shield") + .font(.system(size: 13)) + .foregroundStyle(.orange) + } + + if card.isPending { + HStack(spacing: 10) { + ForEach(card.options, id: \.self) { option in + Button(option) { + answering = true + Task { + await session.answer(threadId: chat.threadId, card: card, choice: option) + answering = false + } + } + .buttonStyle(.borderedProminent) + .tint(option.lowercased() == "deny" ? Color.secondary : Color.accentColor) + .disabled(answering) + } + } + + // The grant key comes from the card. The phone never + // derives its own, so it cannot permit something subtly + // wider than the computer would have. + if card.allowKey != nil, case let .bot(bot) = chat { + Button("Always allow this tool") { + answering = true + Task { + await session.alwaysAllow(bot: bot, card: card) + await session.answer(threadId: chat.threadId, card: card, choice: "Allow") + answering = false + } + } + .font(.system(size: 14)) + .disabled(answering) + } + } else if let answered = card.answered { + Label(answered, systemImage: "checkmark.circle") + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.secondary.opacity(0.13)) + ) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(card.isPending ? Color.accentColor : .clear, lineWidth: 1.5) + } + } + } +} + +/// A frame of the bot's computer. In the paged shape the pixels are not in +/// the transcript — they are fetched here, once, when the row appears. +struct ScreenShot: View { + let threadId: String + let message: Message + @EnvironmentObject private var session: Session + @State private var data: Data? + + var body: some View { + Group { + if let data, let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.secondary.opacity(0.13)) + .frame(height: 160) + .overlay { ProgressView() } + } + } + .task { + guard data == nil else { return } + if let inline = message.png, let decoded = Data(base64Encoded: inline) { + data = decoded + } else if message.hasImage == true { + data = await session.image(threadId: threadId, messageId: message.id) + } + } + } +} + +/// The reply as it is being typed, styled to match the settled bubble it is +/// about to become — the handover should be invisible, and any difference in +/// padding or corner radius reads as the message jumping on arrival. +/// +/// A caret rather than a spinner: a spinner says "something is happening +/// somewhere", which the reader already knows. A caret at the end of real +/// text says how far along it is. +/// +/// The caret does not blink, deliberately. The obvious way to blink it — +/// `withAnimation(.repeatForever) { flag.toggle() }` in `onAppear` — animates +/// the change once and then sits still, and a caret that blinks twice and +/// stops looks more broken than one that never blinks. A correct version +/// animates opacity on a separate view, which needs a device to get right; +/// static is honest until then. +struct StreamingBubble: View { + let text: String? + let reasoning: String? + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + if let reasoning, !reasoning.isEmpty, text?.isEmpty != false { + // Quieter and smaller than an answer, because it is not + // one. Tail-limited: reasoning runs to thousands of words + // and the part worth seeing is always the end. + // + // Plain text, unlike the answer: the tail cut lands + // wherever it lands, and rendering markdown that starts + // mid-syntax invents structure the model did not write. + Text(String(reasoning.suffix(400))) + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let text, !text.isEmpty { + // Same renderer as the settled bubble, for the same + // reason as the padding: a live reply showing `**bold**` + // that snaps to bold on arrival is the message jumping, + // just in a different dimension. The parser tolerates the + // half-finished markdown this is always holding — an + // unclosed fence renders as code, an unclosed link as the + // characters typed so far. + MarkdownText(source: text, caret: true) + .foregroundStyle(Color.primary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.secondary.opacity(0.13)) + ) + Spacer(minLength: 44) + } + // No `.textSelection` on purpose: selecting text that is still growing + // fights the reader, and the settled bubble a frame later is + // selectable anyway. + } +} diff --git a/ios/App/CompanionApp.swift b/ios/App/CompanionApp.swift new file mode 100644 index 0000000000..c96f0e71a3 --- /dev/null +++ b/ios/App/CompanionApp.swift @@ -0,0 +1,74 @@ +// App entry, and the one place that decides when the event stream lives. +// +// A phone is not a desktop: the stream is torn down the moment the app +// leaves the screen, because iOS is going to kill it anyway and doing it +// deliberately means the cursor is written down at a known point. Coming +// back asks the harness what was missed rather than asking for everything. +import SwiftUI + +@main +struct CompanionApp: App { + @StateObject private var session = Session() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(session) + .onChange(of: scenePhase) { _, phase in + switch phase { + case .active: session.connect() + case .background, .inactive: session.disconnect() + @unknown default: break + } + } + } + } +} + +struct RootView: View { + @EnvironmentObject private var session: Session + + var body: some View { + Group { + switch session.status { + case .unpaired: + PairingView() + case .unauthorized: + UnpairedView() + default: + ChatListView() + } + } + .alert( + "Something went wrong", + isPresented: Binding( + get: { session.actionError != nil }, + set: { if !$0 { session.actionError = nil } } + ), + presenting: session.actionError + ) { _ in + Button("OK", role: .cancel) { session.actionError = nil } + } message: { message in + Text(message) + } + } +} + +/// The token stopped working. Almost always because someone revoked this +/// phone on the computer — which is exactly what that button is for, so the +/// honest thing is to say so and offer to pair again. +struct UnpairedView: View { + @EnvironmentObject private var session: Session + + var body: some View { + ContentUnavailableView { + Label("This phone was unpaired", systemImage: "lock.slash") + } description: { + Text("It was removed from the computer's companion settings, or the pairing was reset.") + } actions: { + Button("Pair again") { session.signOut() } + .buttonStyle(.borderedProminent) + } + } +} diff --git a/ios/App/ComputerView.swift b/ios/App/ComputerView.swift new file mode 100644 index 0000000000..b3abe4a1ab --- /dev/null +++ b/ios/App/ComputerView.swift @@ -0,0 +1,83 @@ +// A bot's computer, live. +// +// The harness already screenshots a working bot every few seconds and pushes +// the frame to any client that asked for it. This is that, and nothing more: +// no clicking, no typing, no control. Watching is the useful half on a phone +// — you want to know what it is doing, not to do it yourself on a screen the +// size of a playing card. +// +// Frames are expensive (hundreds of kilobytes of base64 each), so they are +// off unless this view is on screen. `watchScreen` reopens the stream asking +// for them and `stopWatchingScreen` reopens it asking not to; both resume +// from the cursor, so the reconnect costs nothing but a round trip. +import SwiftUI +import CompanionCore +#if canImport(UIKit) +import UIKit +#endif + +struct ComputerView: View { + let bot: Bot + @EnvironmentObject private var session: Session + @Environment(\.dismiss) private var dismiss + + private var frame: ScreenFrame? { session.state.screens[bot.id] } + + /// The bot as the stream last described it — `busy` is what tells us + /// whether more frames are coming or this is the last one. + private var current: Bot { session.state.bot(bot.id) ?? bot } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + if let image = frame.flatMap(\.data).flatMap(UIImage.init(data:)) { + Image(uiImage: image) + .resizable() + .scaledToFit() + // The desktop is wider than the phone, so it lands as a + // letterbox. Pinch-to-zoom would be the obvious next + // thing; scaledToFit is the honest starting point. + .accessibilityLabel("\(current.name)'s computer") + } else { + waiting + } + } + .navigationTitle(current.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + // 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") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(current.busy == true ? Color.green : Color.secondary) + } + } + .task { + session.watchScreen(of: bot.id) + } + .onDisappear { + session.stopWatchingScreen(of: bot.id) + } + } + + private var waiting: some View { + VStack(spacing: 12) { + ProgressView().tint(.white) + Text(current.busy == true ? "Waiting for a frame…" : "Nothing to show yet") + .font(.system(size: 15)) + .foregroundStyle(Color.white.opacity(0.7)) + // An idle bot is not being screenshotted at all, so this would + // otherwise be an indefinite spinner with no explanation. + if current.busy != true { + Text("This bot's computer is only captured while it is working.") + .font(.system(size: 13)) + .foregroundStyle(Color.white.opacity(0.45)) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + } +} diff --git a/ios/App/Discovery.swift b/ios/App/Discovery.swift new file mode 100644 index 0000000000..8d4cc56644 --- /dev/null +++ b/ios/App/Discovery.swift @@ -0,0 +1,133 @@ +// Finding computers on the network, so nobody types an IP address. +// +// The other half of `server/mdns.ts`: the harness advertises +// `_openmausbot._tcp` while its companion listener is up, and this browses +// for it. NWBrowser is first-party and does the mDNS work; all that is left +// is resolving each result to a host and port. +// +// Requires NSLocalNetworkUsageDescription and NSBonjourServices in the app's +// Info.plist — without them iOS returns no results at all, silently, which +// is a confusing hour if you don't know to look. +import Foundation +import Network +import CompanionCore + +@MainActor +final class Discovery: ObservableObject { + struct Found: Identifiable, Hashable { + /// The Bonjour instance name — the human label the harness derived + /// from the profile name, e.g. "Ada Lovelace's computer". + let name: String + let endpoint: NWEndpoint + + var id: String { name } + } + + @Published private(set) var found: [Found] = [] + @Published private(set) var browsing = false + /// Set when the browser could not start at all — almost always the + /// missing Info.plist keys, so it is worth surfacing rather than + /// showing an empty list forever. + @Published private(set) var failure: String? + + private var browser: NWBrowser? + + func start() { + guard browser == nil else { return } + let parameters = NWParameters() + parameters.includePeerToPeer = false + let browser = NWBrowser( + for: .bonjour(type: "_openmausbot._tcp", domain: nil), + using: parameters + ) + + browser.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + switch state { + case .ready: + self?.browsing = true + self?.failure = nil + case let .failed(error): + self?.browsing = false + self?.failure = error.localizedDescription + self?.stop() + case .cancelled: + self?.browsing = false + default: + break + } + } + } + + browser.browseResultsChangedHandler = { [weak self] results, _ in + let found = results.compactMap { result -> Found? in + guard case let .service(name, _, _, _) = result.endpoint else { return nil } + return Found(name: name, endpoint: result.endpoint) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + Task { @MainActor in self?.found = found } + } + + self.browser = browser + browser.start(queue: .main) + } + + func stop() { + browser?.cancel() + browser = nil + browsing = false + } + + /// Resolve a browse result to something `Connection` can hold. + /// + /// NWBrowser hands back a service endpoint, not an address: resolving + /// means actually opening a connection and asking what it connected to. + /// A ten-second ceiling keeps a half-present service from hanging the + /// pairing screen forever. + func resolve(_ service: Found) async throws -> Connection { + let connection = NWConnection(to: service.endpoint, using: .tcp) + defer { connection.cancel() } + + let resolved: NWEndpoint = try await withCheckedThrowingContinuation { continuation in + var settled = false + let finish: (Result) -> Void = { result in + guard !settled else { return } + settled = true + continuation.resume(with: result) + } + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + if let endpoint = connection.currentPath?.remoteEndpoint { + finish(.success(endpoint)) + } else { + finish(.failure(APIError.transport("Couldn't work out that computer's address."))) + } + case let .failed(error): + finish(.failure(APIError.transport(error.localizedDescription))) + case .cancelled: + finish(.failure(APIError.transport("Connection cancelled."))) + default: + break + } + } + connection.start(queue: .main) + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + finish(.failure(APIError.transport("That computer didn't answer."))) + } + } + + guard case let .hostPort(host, port) = resolved else { + throw APIError.transport("Couldn't work out that computer's address.") + } + return Connection(name: service.name, host: Self.plainHost(host), port: Int(port.rawValue)) + } + + /// `NWEndpoint.Host` prints IPv6 with its scope zone attached + /// ("fe80::1%en0"), which is not something URLComponents will take. + static func plainHost(_ host: NWEndpoint.Host) -> String { + let text = "\(host)" + return text.split(separator: "%").first.map(String.init) ?? text + } +} diff --git a/ios/App/Keychain.swift b/ios/App/Keychain.swift new file mode 100644 index 0000000000..452eb3dda9 --- /dev/null +++ b/ios/App/Keychain.swift @@ -0,0 +1,67 @@ +// Device tokens, in the keychain. +// +// The token is the whole credential: anyone holding it can talk to the +// user's harness, which runs shell commands on their laptop. UserDefaults +// would be wrong for it, and so would anything that lands in an iCloud or +// iTunes backup — hence `ThisDeviceOnly`, which also matches the server's +// model, where a token belongs to one paired device and is revoked per +// device. +import Foundation +import Security + +enum Keychain { + private static let service = "com.openmausbot.companion.token" + + static func save(_ token: String, for connectionId: String) throws { + let data = Data(token.utf8) + // delete-then-add rather than SecItemUpdate: re-pairing replaces the + // token, and an update against a missing item is an error path with + // no upside here + remove(connectionId) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connectionId, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError(status: status) + } + } + + static func token(for connectionId: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connectionId, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + @discardableResult + static func remove(_ connectionId: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connectionId, + ] + return SecItemDelete(query as CFDictionary) == errSecSuccess + } +} + +struct KeychainError: LocalizedError { + let status: OSStatus + + var errorDescription: String? { + let detail = SecCopyErrorMessageString(status, nil) as String? ?? "status \(status)" + return "Couldn't save the pairing securely: \(detail)" + } +} diff --git a/ios/App/MarkdownText.swift b/ios/App/MarkdownText.swift new file mode 100644 index 0000000000..2c6341acbc --- /dev/null +++ b/ios/App/MarkdownText.swift @@ -0,0 +1,131 @@ +// Bot replies, rendered. +// +// `Markdown.blocks` does the splitting; this draws each block and hands the +// inline run to Foundation, which knows emphasis, code spans, strikethrough +// and links. SwiftUI makes the links tappable on its own, which is most of +// why this is worth doing at all — a reply full of sources was previously a +// wall of bracketed URLs. +// +// Only bot messages get this. The desktop makes the same split: what you +// typed is shown as you typed it, because markdown you did not intend is +// worse than markdown you did. +import SwiftUI +import CompanionCore + +struct MarkdownText: View { + let source: String + /// Draws a caret after the last block. The streaming bubble sets this so + /// the live reply and the settled one are the same view with the same + /// layout — a caret bolted on outside would put it on its own line the + /// moment the reply ends in a list item. + var caret: Bool = false + + var body: some View { + let blocks = Markdown.blocks(source) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(blocks.enumerated()), id: \.offset) { item in + view(for: item.element, tail: caret && item.offset == blocks.count - 1) + } + } + } + + @ViewBuilder + private func view(for block: MarkdownBlock, tail: Bool) -> some View { + switch block { + case let .paragraph(text): + inline(text, tail: tail) + .font(.system(size: 17)) + .fixedSize(horizontal: false, vertical: true) + + case let .heading(level, text): + // Three sizes, not six. A chat bubble is not a document, and an + // h4 that looks exactly like body text is a heading that failed. + inline(text, tail: tail) + .font(.system(size: level <= 1 ? 21 : level == 2 ? 19 : 17, weight: .semibold)) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + + case let .bullet(indent, text): + marker("•", indent: indent, text: text, tail: tail) + + case let .ordered(indent, number, text): + marker("\(number).", indent: indent, text: text, tail: tail) + + case let .quote(text): + HStack(alignment: .top, spacing: 8) { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color.secondary.opacity(0.4)) + .frame(width: 3) + inline(text, tail: tail) + .font(.system(size: 17)) + .foregroundStyle(Color.secondary) + } + .fixedSize(horizontal: false, vertical: true) + + case let .code(language, text): + VStack(alignment: .leading, spacing: 4) { + if let language, !language.isEmpty { + Text(language) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(Color.secondary) + } + // Horizontal scroll rather than wrapping: wrapped code is + // harder to read than code you have to push sideways, and + // indentation is most of what a snippet is saying. + ScrollView(.horizontal, showsIndicators: false) { + (Text(text) + caretText(tail)) + .font(.system(size: 14, design: .monospaced)) + .textSelection(.enabled) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.secondary.opacity(0.14)) + ) + + case .rule: + Divider().padding(.vertical, 2) + } + } + + private func marker(_ symbol: String, indent: Int, text: String, tail: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(symbol) + .font(.system(size: 17)) + .foregroundStyle(Color.secondary) + .frame(minWidth: 16, alignment: .trailing) + inline(text, tail: tail).font(.system(size: 17)) + } + .padding(.leading, CGFloat(indent) * 14) + .fixedSize(horizontal: false, vertical: true) + } + + /// Inline markdown via Foundation. `.inlineOnlyPreservingWhitespace` + /// because the blocks are already split — asking for `.full` here would + /// have it re-interpret list markers this has already consumed. + /// + /// Falling back to the raw string on a parse failure is the point: a + /// half-typed link mid-stream should show as the characters the model has + /// sent so far, not vanish until it closes the bracket. + private func inline(_ text: String, tail: Bool = false) -> Text { + let rendered: Text + if let attributed = try? AttributedString( + markdown: text, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + rendered = Text(attributed) + } else { + rendered = Text(text) + } + return rendered + caretText(tail) + } + + /// A figure space then a block, so the caret sits off the last glyph + /// rather than touching it. Empty when not streaming — an empty `Text` + /// concatenated in costs nothing and keeps the callers branch-free. + private func caretText(_ tail: Bool) -> Text { + tail ? Text("\u{2007}▍").foregroundStyle(Color.secondary) : Text("") + } +} diff --git a/ios/App/MausAvatar.swift b/ios/App/MausAvatar.swift new file mode 100644 index 0000000000..22e451819d --- /dev/null +++ b/ios/App/MausAvatar.swift @@ -0,0 +1,278 @@ +// The bot's face — the same silhouette the desktop draws. +// +// The desktop renders Blob Studio's "cursor" mascot from an SVG path +// (`src/components/CursorAvatar.tsx`, `SHAPE.body`). The phone used to draw a +// rounded blob instead, which meant a bot you know by its shape looked like a +// different bot on the two screens. This is that path, verbatim. +// +// Verbatim is the point: the alternative is redrawing it by eye, which starts +// close and drifts every time either side is touched. Copied here rather than +// generated at build time because the phone app has no build step that could +// read the web source, and a 4KB string is a cheap thing to keep in sync by +// hand — it has changed once in the life of this project. +// +// What is NOT copied: the desktop's 25 expressions, blinking, gaze tracking +// and motion. A 28-point avatar in a list is a silhouette and two eyes; the +// animation engine is 1,600 lines and would show up as nothing at this size. +import SwiftUI + +enum MausPalette { + /// src/lib/mascot.ts — MAUS_COLORS + private static let hex: [String: String] = [ + "green": "#009957", + "blue": "#377FE6", + "red": "#D94B52", + "orange": "#E78531", + "purple": "#8057C8", + "cyan": "#0EA5C6", + "pink": "#D84F8B", + "yellow": "#D8A729", + "teal": "#01A492", + "coral": "#E5634E", + ] + + static func color(_ name: String) -> Color { + Color(hex: hex[name] ?? "#8E8E93") + } + +} + +/// The mascot silhouette, as an SVG path. Absolute `M`, `C` and `Z` only — +/// which is what makes the parser below twenty lines rather than a library. +enum MausSilhouette { + static let path = + """ + M0 0 C1.12815992 0.94880479 2.25705591 1.89673511 3.38671875 2.84375 C5.57657936 4.68528228 + 7.75793952 6.53624249 9.93359375 8.39453125 C13.5602214 11.48647103 17.25022962 14.49819427 + 20.9453125 17.5078125 C25.41301487 21.15281776 29.86103386 24.8215994 34.31054688 28.48876953 + C38.00933931 31.5370903 41.70951059 34.58367973 45.4140625 37.625 C52.50037463 43.44570076 + 59.55669812 49.29508834 66.54003906 55.23901367 C70.43289872 58.54377434 74.40406577 61.73568847 + 78.40625 64.90625 C82.05401433 67.85083084 85.6145398 70.89451533 89.18359375 73.93359375 + C92.41424312 76.67774533 95.67698054 79.36747809 99 82 C103.47931906 85.54855146 107.83340036 + 89.22936876 112.18359375 92.93359375 C115.41424312 95.67774533 118.67698054 98.36747809 122 101 + C125.9014198 104.09073517 129.71091352 107.27378506 133.5 110.5 C137.99002543 114.32092614 + 142.53350963 118.0537239 147.15234375 121.71875 C156.74255328 129.40144186 166.1812645 + 137.27326897 175.53833008 145.23754883 C179.4317456 148.54281661 183.40347641 151.73522157 + 187.40625 154.90625 C191.05401433 157.85083084 194.6145398 160.89451533 198.18359375 + 163.93359375 C201.41424312 166.67774533 204.67698054 169.36747809 208 172 C236.43637507 + 194.63776677 236.43637507 194.63776677 238.27050781 209.13867188 C239.19944445 221.27193361 + 237.57124038 231.13444436 230 241 C223.66050278 247.82715086 215.75482398 254.47140646 + 206.04764748 255.13307858 C205.3615811 255.13693349 204.67551472 255.1407884 203.96865845 + 255.14476013 C203.17563324 255.15165863 202.38260803 255.15855713 201.56555176 255.16566467 + C200.27360901 255.16958473 200.27360901 255.16958473 198.95556641 255.17358398 C198.04112762 + 255.180271 197.12668884 255.18695801 196.18453979 255.19384766 C194.19843498 255.20789156 + 192.21231409 255.21978771 190.22618484 255.22979546 C187.07149762 255.24625057 183.91692738 + 255.26949556 180.76229858 255.29469299 C171.79227585 255.36530712 162.82220292 255.42526708 + 153.85205078 255.47680664 C148.36195434 255.5088283 142.87198905 255.55017011 137.38199997 + 255.59700203 C135.30028042 255.61289593 133.21853066 255.62527474 131.13676834 255.63390923 + C104.46972494 255.74602279 80.75351522 259.19455182 60.52978516 278.41845703 C55.75259196 + 283.35727885 51.81217213 289.04473423 47.77441406 294.5859375 C44.62107661 298.87600364 + 41.36381878 303.08685058 38.125 307.3125 C32.82026548 314.26649347 27.55386673 321.24815866 + 22.3125 328.25 C21.07690581 329.89589556 19.84122979 331.5417297 18.60546875 333.1875 + C16.22002164 336.36552908 13.84996009 339.55428087 11.48828125 342.75 C3.0450311 354.10095576 + -5.25712203 365.22607871 -20 368 C-33.42903027 368.85957067 -44.2929604 367.90032788 -55 + 358.9140625 C-63.51513963 350.76480778 -67.79688328 340.99527428 -68.37686157 329.29350281 + C-68.43541887 328.1487851 -68.49397617 327.00406738 -68.55430794 325.82466125 C-68.61453573 + 324.57243271 -68.67476353 323.32020416 -68.73681641 322.0300293 C-68.80423726 320.68369989 + -68.87198477 319.33738681 -68.94003105 317.99108887 C-69.12569162 314.29881586 -69.30666938 + 310.60632592 -69.48688698 306.91378379 C-69.68142908 302.94481208 -69.88036562 298.97606035 + -70.07873535 295.00727844 C-70.55465828 285.46711948 -71.02377004 275.92663022 -71.49235249 + 266.3861084 C-71.71278092 261.90166682 -71.93398876 257.41726373 -72.1552124 252.93286133 + C-72.22122149 251.59460763 -72.22122149 251.59460763 -72.28856409 250.2293185 C-72.37783973 + 248.41936974 -72.46711776 246.6094211 -72.55639815 244.79947257 C-72.7821203 240.22326204 + -73.00777832 235.64704836 -73.23336792 231.0708313 C-73.2783997 230.15734865 -73.32343148 + 229.24386601 -73.36982787 228.30270207 C-73.64581049 222.70253299 -73.92113412 217.10233189 + -74.19602597 211.50210917 C-75.35713101 187.85146938 -76.54638525 164.20245932 -77.76320994 + 140.55462319 C-78.3174362 129.77404232 -78.86176258 118.9929594 -79.40472984 108.2118063 + C-79.83986282 99.58021501 -80.28322749 90.94912395 -80.73695588 82.31848997 C-81.04621068 + 76.42094211 -81.34513355 70.52292566 -81.63612723 64.62444884 C-81.8031421 61.24551841 + -81.97612174 57.86718776 -82.15861511 54.48903847 C-84.29931862 14.73242483 -84.29931862 + 14.73242483 -72.03125 -1.625 C-50.89854752 -24.96559677 -21.34867451 -18.24899383 0 0 Z + """ + + /// Parse into a `Path` normalised to fill `rect`, preserving aspect. + /// + /// The desktop maps this through a `fit` transform into a 228.541-unit + /// face box. That is not reproduced: normalising to the actual bounds is + /// equivalent for a shape drawn on its own, and it does not go stale if + /// the artwork's framing changes. + static func path(in rect: CGRect) -> Path { + var raw = Path() + var numbers: [CGFloat] = [] + var command: Character? + var current = CGPoint.zero + + func flush() { + guard let command else { return } + switch command { + case "M": + guard numbers.count >= 2 else { break } + current = CGPoint(x: numbers[0], y: numbers[1]) + raw.move(to: current) + case "C": + // several curves may follow one C, six numbers each + var i = 0 + while i + 5 < numbers.count { + let to = CGPoint(x: numbers[i + 4], y: numbers[i + 5]) + raw.addCurve( + to: to, + control1: CGPoint(x: numbers[i], y: numbers[i + 1]), + control2: CGPoint(x: numbers[i + 2], y: numbers[i + 3]) + ) + current = to + i += 6 + } + default: + break + } + numbers.removeAll() + } + + var token = "" + func takeNumber() { + if !token.isEmpty, let value = Double(token) { numbers.append(CGFloat(value)) } + token = "" + } + + for character in path { + if character.isNumber || character == "." || character == "e" { + token.append(character) + } else if character == "-" { + // a minus starts a new number unless it is an exponent sign + if token.hasSuffix("e") { token.append(character) } else { takeNumber(); token = "-" } + } else if character == " " || character == "," || character == "\n" { + takeNumber() + } else if character == "Z" || character == "z" { + takeNumber(); flush(); raw.closeSubpath(); command = nil + } else { + takeNumber(); flush(); command = character + } + } + takeNumber() + flush() + + let bounds = raw.boundingRect + guard bounds.width > 0, bounds.height > 0 else { return raw } + let scale = min(rect.width / bounds.width, rect.height / bounds.height) + return raw.applying( + CGAffineTransform(translationX: -bounds.midX, y: -bounds.midY) + .concatenating(CGAffineTransform(scaleX: scale, y: scale)) + .concatenating(CGAffineTransform(translationX: rect.midX, y: rect.midY)) + ) + } +} + +/// A bot, at whatever size the row needs. +struct MausAvatar: View { + let color: String + var size: CGFloat = 52 + + var body: some View { + Canvas { context, canvasSize in + let rect = CGRect(origin: .zero, size: canvasSize) + let body = MausSilhouette.path(in: rect) + context.fill(body, with: .linearGradient( + Gradient(stops: MausPalette.gradientStops(color)), + startPoint: CGPoint(x: rect.maxX, y: rect.minY), + endPoint: CGPoint(x: rect.minX, y: rect.maxY) + )) + + // Eyes, at the desktop's face anchor expressed as a fraction of + // the box: (93, 101) of 228.541. One neutral expression — the + // desktop cycles 25 of them, and at this size the difference + // between any two is a pixel. + let eyeWidth = canvasSize.width * 0.085 + let eyeHeight = eyeWidth * 1.7 + let gap = eyeWidth * 1.9 + let cx = rect.minX + canvasSize.width * 0.407 + let cy = rect.minY + canvasSize.height * 0.442 + for dx in [-gap / 2, gap / 2] { + let eye = Path( + roundedRect: CGRect( + x: cx + dx - eyeWidth / 2, + y: cy - eyeHeight / 2, + width: eyeWidth, + height: eyeHeight + ), + cornerRadius: eyeWidth / 2 + ) + context.fill(eye, with: .color(.white)) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +/// The person, not a bot — the roster header and the settings row. A letter +/// rather than a mascot, deliberately: the mascots mean "this is a bot", and +/// giving the human one too would blur the only distinction the roster makes. +struct ProfileAvatar: View { + let name: String + var size: CGFloat = 34 + + var body: some View { + Circle() + .fill(MausPalette.color("green")) + .frame(width: size, height: size) + .overlay { + Text(initial) + .font(.system(size: size * 0.45, weight: .semibold)) + .foregroundStyle(.white) + } + } + + private var initial: String { + String(name.trimmingCharacters(in: .whitespaces).prefix(1)).uppercased() + } +} + +extension MausPalette { + /// The gradient as raw stops, for `Canvas`, which cannot take a + /// `LinearGradient` directly. + static func gradientStops(_ name: String) -> [Gradient.Stop] { + let base = color(name) + return [ + .init(color: base.mixed(with: .white, amount: 0.55), location: 0), + .init(color: base, location: 0.55), + .init(color: base.mixed(with: .black, amount: 0.42), location: 1), + ] + } +} + +extension Color { + init(hex: String) { + var value: UInt64 = 0 + Scanner(string: hex.replacingOccurrences(of: "#", with: "")).scanHexInt64(&value) + self.init( + .sRGB, + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255, + opacity: 1 + ) + } + + /// Linear mix in sRGB, matching the `mix()` the desktop uses to build its + /// gradient stops. Not perceptually correct, and deliberately so: the + /// point is to land on the same colours as the other screen. + func mixed(with other: Color, amount: Double) -> Color { + #if canImport(UIKit) + let a = UIColor(self), b = UIColor(other) + var ar: CGFloat = 0, ag: CGFloat = 0, ab: CGFloat = 0, aa: CGFloat = 0 + var br: CGFloat = 0, bg: CGFloat = 0, bb: CGFloat = 0, ba: CGFloat = 0 + a.getRed(&ar, green: &ag, blue: &ab, alpha: &aa) + b.getRed(&br, green: &bg, blue: &bb, alpha: &ba) + let t = CGFloat(amount) + return Color( + .sRGB, + red: Double(ar + (br - ar) * t), + green: Double(ag + (bg - ag) * t), + blue: Double(ab + (bb - ab) * t), + opacity: 1 + ) + #else + return self + #endif + } +} diff --git a/ios/App/PairingView.swift b/ios/App/PairingView.swift new file mode 100644 index 0000000000..5d6a83e8b4 --- /dev/null +++ b/ios/App/PairingView.swift @@ -0,0 +1,205 @@ +// Pairing: pick the computer, type the six digits it is showing. +// +// Two ways in, because discovery is allowed to fail. Bonjour finds the +// computer by name when the network cooperates; when it does not — a guest +// network with multicast off, a responder that could not take port 5353 — +// the address the desktop panel prints is typed instead. Neither path is a +// fallback bolted on: the desktop panel changes its own wording to match. +import SwiftUI +import CompanionCore +#if canImport(UIKit) +import UIKit +#endif + +struct PairingView: View { + @EnvironmentObject private var session: Session + @StateObject private var discovery = Discovery() + + @State private var manualAddress = "" + @State private var code = "" + @State private var chosen: Connection? + @State private var pairing = false + @State private var failure: String? + /// "Looking…" forever is not an answer. After a few seconds with nothing + /// found, say the thing that is almost always true. + @State private var searchedLongEnough = false + + var body: some View { + NavigationStack { + Form { + if let chosen { + codeSection(for: chosen) + } else { + discoverySection + manualSection + } + + if let failure { + Section { + Text(failure).foregroundStyle(.red) + } + } + + Section { + Text("On your computer, open OpenMausBot → Settings → Companion, turn it on, and start pairing.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Pair with a computer") + .onAppear { discovery.start() } + .onDisappear { discovery.stop() } + .task { + try? await Task.sleep(nanoseconds: 8_000_000_000) + searchedLongEnough = true + } + } + } + + // MARK: - Choosing a computer + + private var discoverySection: some View { + Section("On this network") { + if let problem = discovery.failure { + Label(problem, systemImage: "wifi.exclamationmark") + .font(.footnote) + .foregroundStyle(.secondary) + } else if discovery.found.isEmpty { + HStack { + ProgressView() + Text("Looking…").foregroundStyle(.secondary) + } + if searchedLongEnough { + // Bonjour is multicast: it does not cross subnets, and + // guest networks usually block it between clients even + // within one. Different Wi-Fi on the two devices is by + // far the most common reason this list stays empty. + Text("Nothing found yet. Check that this phone and your computer are on the same Wi-Fi network — a guest network often blocks them from seeing each other. You can always enter the address below instead.") + .font(.footnote) + .foregroundStyle(.secondary) + // The honest answer when a network refuses to cooperate. + // Tailscale sidesteps the whole problem: both devices get + // an address on a private network of your own, and it + // does not care which Wi-Fi either of them is on. + Text("If it never appears, install Tailscale on both and sign in to the same account — the Companion panel will then show a name ending in .ts.net to enter below.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + ForEach(discovery.found) { service in + Button { + Task { await choose(service) } + } label: { + Label(service.name, systemImage: "desktopcomputer") + } + } + } + } + + private var manualSection: some View { + Section { + TextField("192.168.1.42:8810", text: $manualAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + Button("Continue") { + failure = nil + guard let connection = Self.parse(manualAddress) else { + failure = "That should look like 192.168.1.42:8810." + return + } + chosen = connection + } + .disabled(manualAddress.trimmingCharacters(in: .whitespaces).isEmpty) + } header: { + Text("Or enter the address") + } footer: { + Text("Whatever the Companion panel shows — an address on this network, or a Tailscale name like macbook.tail1234.ts.net:8810, which works from anywhere.") + } + } + + private func choose(_ service: Discovery.Found) async { + failure = nil + do { + chosen = try await discovery.resolve(service) + } catch { + failure = error.localizedDescription + } + } + + // MARK: - The code + + private func codeSection(for connection: Connection) -> some View { + Section(connection.name) { + TextField("000000", text: $code) + .keyboardType(.numberPad) + .font(.system(.title, design: .monospaced)) + .multilineTextAlignment(.center) + .onChange(of: code) { _, value in + code = String(value.filter(\.isNumber).prefix(6)) + } + + Button { + Task { await submit(connection) } + } label: { + if pairing { + ProgressView() + } else { + Text("Pair") + } + } + .disabled(code.count != 6 || pairing) + + Button("Choose a different computer", role: .cancel) { + chosen = nil + code = "" + failure = nil + } + } + } + + private func submit(_ connection: Connection) async { + pairing = true + failure = nil + defer { pairing = false } + do { + try await session.pair(with: connection, code: code, deviceName: Self.deviceName()) + } catch { + // the harness's own wording ("that code is not right", "too many + // incorrect codes — start pairing again") is better than ours + failure = error.localizedDescription + code = "" + } + } + + // MARK: - Helpers + + static func deviceName() -> String { + #if canImport(UIKit) + return UIDevice.current.name + #else + return "Companion" + #endif + } + + /// "192.168.1.42:8810", or a bare host on the default companion port. + static func parse(_ text: String) -> Connection? { + var trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + // people paste what they see, and what they see may be a URL + for prefix in ["http://", "https://"] where trimmed.hasPrefix(prefix) { + trimmed.removeFirst(prefix.count) + } + if trimmed.hasSuffix("/") { trimmed.removeLast() } + guard !trimmed.isEmpty else { return nil } + + let parts = trimmed.split(separator: ":") + let host = String(parts[0]) + guard !host.isEmpty, !host.contains("/") else { return nil } + // 8810 is the companion's default. It is not 8800, which is the + // harness's webhook receiver — a bare hostname sent there would get + // a 404 from a server that is not this one. + let port = parts.count > 1 ? Int(parts[1]) : 8810 + guard let port, (1...65535).contains(port) else { return nil } + return Connection(name: host, host: host, port: port) + } +} \ No newline at end of file diff --git a/ios/App/Session.swift b/ios/App/Session.swift new file mode 100644 index 0000000000..414f510859 --- /dev/null +++ b/ios/App/Session.swift @@ -0,0 +1,395 @@ +// The app's one long-lived object: who we are paired with, what we know, +// and the stream that keeps it current. +// +// The parsing and folding live in CompanionCore. What lives here is the +// part that cannot be unit-tested and is the actual hard problem in a phone +// client — lifecycle. A phone loses its connection constantly: it locks, it +// backgrounds, it moves between wifi and cellular. So the stream is torn +// down deliberately when the app leaves the screen, and on the way back the +// server is asked what was missed rather than being asked for everything. +import Foundation +import OSLog +import SwiftUI +import CompanionCore + +/// Stream lifecycle, in Console.app and the Xcode console. A companion that +/// is silently not connected looks exactly like one with nothing to say, so +/// the transitions are worth being able to read. +private let log = Logger(subsystem: "com.openmausbot.companion", category: "stream") + +@MainActor +final class Session: ObservableObject { + enum Status: Equatable { + case unpaired + case connecting + case live + /// The token stopped working — revoked on the computer, most likely. + case unauthorized + case offline(String) + } + + @Published private(set) var state = CompanionState() + @Published private(set) var connection: Connection? + @Published private(set) var status: Status = .unpaired + /// Transient, user-facing failures from an action they just took. + @Published var actionError: String? + + private var client: CompanionClient? + private var streamTask: Task? + private var reconnectDelay: UInt64 = 0 + /// How many computer panels are open. A count rather than a flag: the + /// panel can be pushed twice in a navigation stack, and the last one to + /// close is the one that should turn screens back off. + private var screenWatchers = 0 + + private static let connectionKey = "companion.connection" + + // MARK: - Pairing + + init() { + restore() + } + + private func restore() { + guard let data = UserDefaults.standard.data(forKey: Self.connectionKey), + let saved = try? JSONDecoder().decode(Connection.self, from: data), + let token = Keychain.token(for: saved.id) + else { return } + connection = saved + client = CompanionClient(connection: saved, token: token) + status = .connecting + } + + /// Redeem a pairing code. On success the token goes to the keychain and + /// the connection to defaults — deliberately apart, so the thing that + /// gets backed up is never the credential. + func pair(with connection: Connection, code: String, deviceName: String) async throws { + let paired = try await CompanionClient.pair(connection: connection, code: code, deviceName: deviceName) + // prefer the name the computer calls itself over the Bonjour label + var stored = connection + if !paired.serverName.isEmpty { stored.name = paired.serverName } + + try Keychain.save(paired.token, for: stored.id) + UserDefaults.standard.set(try? JSONEncoder().encode(stored), forKey: Self.connectionKey) + + self.connection = stored + self.client = CompanionClient(connection: stored, token: paired.token) + self.state = CompanionState() + connect() + } + + func signOut() { + streamTask?.cancel() + streamTask = nil + if let id = connection?.id { Keychain.remove(id) } + UserDefaults.standard.removeObject(forKey: Self.connectionKey) + connection = nil + client = nil + state = CompanionState() + status = .unpaired + } + + // MARK: - Lifecycle + + /// Called when the app comes to the front, and once at launch. + func connect() { + guard client != nil, streamTask == nil else { return } + reconnectDelay = 0 + streamTask = Task { [weak self] in await self?.run() } + } + + /// Ask the harness to include this bot's computer in the stream, for as + /// long as something is showing it. + /// + /// This costs a reconnect, which is the right trade: the alternative is + /// a base64 desktop capture arriving every few seconds for the whole + /// session, including on cellular, whether or not anyone is looking. + /// The reconnect resumes from the cursor, so nothing is missed. + func watchScreen(of botId: String) { + screenWatchers += 1 + if screenWatchers == 1 { restartStream() } + } + + func stopWatchingScreen(of botId: String) { + screenWatchers = max(0, screenWatchers - 1) + if screenWatchers == 0 { + state.clearScreen(botId) + restartStream() + } + } + + /// Reopen the stream so its query string matches what we now want. The + /// cursor survives, so this is a gap, not a reset. + private func restartStream() { + guard streamTask != nil else { return } + streamTask?.cancel() + streamTask = nil + connect() + } + + /// Called when the app leaves the screen. iOS will kill the connection + /// anyway; dropping it deliberately means the cursor is written down at + /// a known point instead of wherever the socket happened to die. + func disconnect() { + streamTask?.cancel() + streamTask = nil + } + + private func run() async { + while !Task.isCancelled { + guard let client else { return } + status = .connecting + log.info("opening stream, cursor=\(self.state.cursor ?? "none", privacy: .public)") + do { + // The query is fixed when the connection opens, so changing + // it means a new connection — `restartStream()` cancels this + // task and starts another. Cancellation is the only exit; + // breaking out here instead would fall through to the "the + // harness went away" path and flash a lost-connection banner + // on what is actually a deliberate reconnect. + for try await frame in try client.events(since: state.cursor, screens: screenWatchers > 0) { + if Task.isCancelled { return } + reconnectDelay = 0 + + if case let .hello(_, resumed) = frame.frame { + log.info("stream live, resumed=\(resumed, privacy: .public)") + state.apply(frame) + // false means the server could not replay the gap — + // the one case that costs a full hydrate + if !resumed { await hydrate() } + status = .live + continue + } + state.apply(frame) + state.advance(to: frame.seq) + } + // the stream ended without an error — the harness went away + log.notice("stream ended without an error") + status = .offline("Lost the connection.") + } catch let error as APIError where error.isUnauthorized { + log.error("stream refused: unauthorized") + status = .unauthorized + return + } catch { + // backgrounding cancels the stream on purpose; that is not a + // failure to report, and it must not be retried + if Task.isCancelled || error is CancellationError { + log.info("stream closed by us") + return + } + log.error("stream failed: \(error.localizedDescription, privacy: .public)") + status = .offline(error.localizedDescription) + } + + if Task.isCancelled { return } + // 1s, 2s, 4s… to 15s. A phone that woke on a network which is + // not the laptop's should not hammer it. + reconnectDelay = reconnectDelay == 0 ? 1 : min(reconnectDelay * 2, 15) + try? await Task.sleep(nanoseconds: reconnectDelay * 1_000_000_000) + } + } + + private func hydrate() async { + guard let client else { return } + do { + let fleet = try await client.fleet(messages: 50) + log.info("hydrated \(fleet.bots.count, privacy: .public) bots, \(fleet.groups.count, privacy: .public) rooms") + state.hydrate(fleet) + } catch let error as APIError where error.isUnauthorized { + status = .unauthorized + } catch { + status = .offline(error.localizedDescription) + } + } + + // MARK: - Actions + // + // Each of these does the thing and lets the event stream deliver the + // result. Nothing here writes to `state` optimistically: the harness is + // the source of truth, and a phone that draws its own version of events + // is a phone that disagrees with the laptop. + + func send(_ text: String, to chat: Chat) async { + await perform { + switch chat { + case let .bot(bot): try await $0.send(text: text, toBot: bot.id) + case let .room(room): try await $0.send(text: text, toRoom: room.id) + } + } + } + + func answer(threadId: String, card: OptionCard, choice: String) async { + guard let requestId = card.requestId else { return } + await perform { + // Permission cards answer allow/deny; a question answers with + // the chosen text. The harness tells them apart by `behavior`. + if card.isPermission { + try await $0.respond( + threadId: threadId, + requestId: requestId, + behavior: choice.lowercased() == "allow" ? "allow" : "deny" + ) + } else { + try await $0.respond(threadId: threadId, requestId: requestId, behavior: "answer", message: choice) + } + } + } + + /// "Always allow" — the grant key comes from the card, never from + /// anything derived here, so the phone and the harness cannot disagree + /// about what was just permitted. + func alwaysAllow(bot: Bot, card: OptionCard) async { + guard let key = card.allowKey else { return } + let keys = Array(Set((bot.alwaysAllow ?? []) + [key])) + await perform { try await $0.alwaysAllow(botId: bot.id, keys: keys) } + } + + /// Make a new bot. The harness chooses its name, colour and greeting, so + /// one made here is indistinguishable from one made on the desktop. + /// + /// Creating a bot does not broadcast — the desktop adds it optimistically + /// too — so the new bot is folded in here rather than waited for. Return + /// it so the caller can open it, which is the only reason anyone taps the + /// button. + @discardableResult + func createBot() async -> Bot? { + guard let client else { return nil } + do { + let bot = try await client.createBot() + state.apply(.bot(bot)) + return bot + } catch { + actionError = error.localizedDescription + return nil + } + } + + func interrupt(bot: Bot) async { + await perform { try await $0.interrupt(botId: bot.id) } + } + + func markRead(_ chat: Chat) async { + await perform(quietly: true) { + switch chat { + case let .bot(bot): try await $0.markRead(botId: bot.id) + case let .room(room): try await $0.markRead(roomId: room.id) + } + } + } + + func loadOlder(threadId: String) async { + guard let client, let oldest = state.transcript(forThread: threadId).first else { return } + do { + let page = try await client.messages(threadId: threadId, before: oldest.id, limit: 50) + state.prepend(page, toThread: threadId) + } catch { + actionError = error.localizedDescription + } + } + + func image(threadId: String, messageId: String) async -> Data? { + try? await client?.image(threadId: threadId, messageId: messageId) + } + + private func perform(quietly: Bool = false, _ body: (CompanionClient) async throws -> Void) async { + guard let client else { return } + do { + try await body(client) + } catch let error as APIError where error.isUnauthorized { + status = .unauthorized + } catch { + if !quietly { actionError = error.localizedDescription } + } + } +} + +/// A chat is a bot or a room. They share a thread, which is what every +/// message, approval and page is keyed by. +enum Chat: Identifiable, Hashable { + case bot(Bot) + case room(Room) + + var id: String { + switch self { + case let .bot(bot): return bot.id + case let .room(room): return room.id + } + } + + var threadId: String { + switch self { + case let .bot(bot): return bot.threadId + case let .room(room): return room.threadId + } + } + + var name: String { + switch self { + case let .bot(bot): return bot.name + case let .room(room): return room.name + } + } + + var subtitle: String { + switch self { + case let .bot(bot): return bot.title + case let .room(room): return "\(room.memberIds.count) bots" + } + } + + var unread: Bool { + switch self { + case let .bot(bot): return bot.unread + case let .room(room): return room.unread + } + } + + var busy: Bool { + switch self { + case let .bot(bot): return bot.busy ?? false + case let .room(room): return room.busyBotId != nil + } + } + + var color: String { + switch self { + case let .bot(bot): return bot.color + case .room: return "blue" + } + } +} + +extension CompanionState { + /// Everything worth showing in the chat list: pinned first, then unread, + /// then most recently active. Hidden bots stay hidden. + var chats: [Chat] { + let bots = self.bots.filter { $0.hidden != true }.map(Chat.bot) + let rooms = self.rooms.map(Chat.room) + return (bots + rooms).sorted { left, right in + let leftPinned = pinned(left), rightPinned = pinned(right) + if leftPinned != rightPinned { return leftPinned } + if left.unread != right.unread { return left.unread } + return lastActivity(left) > lastActivity(right) + } + } + + private func pinned(_ chat: Chat) -> Bool { + if case let .bot(bot) = chat { return bot.pinned ?? false } + return false + } + + func lastActivity(_ chat: Chat) -> Double { + transcript(forThread: chat.threadId).last?.at ?? 0 + } + + func preview(_ chat: Chat) -> String { + guard let last = transcript(forThread: chat.threadId).last else { return "" } + switch last.kind { + case .text: return last.text ?? "" + case .options: return last.card?.isPending == true ? "Waiting on you" : (last.card?.title ?? "") + case .activity: return last.tool?.name ?? "" + case .screen: return "Screenshot" + case .unknown: return last.text ?? "" + } + } +} diff --git a/ios/App/SettingsView.swift b/ios/App/SettingsView.swift new file mode 100644 index 0000000000..0926d8025e --- /dev/null +++ b/ios/App/SettingsView.swift @@ -0,0 +1,58 @@ +// What little the phone gets to configure. +// +// Almost nothing, on purpose: companion settings, API keys and pairing all +// live on the computer, because losing the phone must not mean losing the +// ability to lock it out. This is a status page with an unpair button. +import SwiftUI +import CompanionCore + +struct SettingsView: View { + @EnvironmentObject private var session: Session + @State private var confirmingSignOut = false + + var body: some View { + Form { + Section("Computer") { + if let connection = session.connection { + LabeledContent("Name", value: connection.name) + LabeledContent("Address", value: "\(connection.host):\(connection.port)") + } + LabeledContent("Connection", value: statusText) + } + + Section { + Button("Unpair this phone", role: .destructive) { confirmingSignOut = true } + } footer: { + Text("Removes the pairing from this phone only. To stop it reaching the computer at all, remove the device in OpenMausBot → Settings → Companion.") + } + + Section("Not here") { + Text("API keys, pairing and the Local VM are managed on the computer. This phone is deliberately not allowed to change them.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog( + "Unpair this phone?", + isPresented: $confirmingSignOut, + titleVisibility: .visible + ) { + Button("Unpair", role: .destructive) { session.signOut() } + Button("Cancel", role: .cancel) {} + } message: { + Text("You'll need a new pairing code to connect again.") + } + } + + private var statusText: String { + switch session.status { + case .live: return "Connected" + case .connecting: return "Connecting…" + case .unpaired: return "Not paired" + case .unauthorized: return "Unpaired on the computer" + case let .offline(reason): return reason + } + } +} diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 0000000000..d60c7cba00 --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// CompanionCore is everything the phone knows that is not a view: the wire +// types, the SSE parser, the API client, and the fold that maintains state. +// It is a package rather than app-target source so it can be built and +// tested with `swift test` alone — no Xcode, no simulator, no signing — +// which is also what lets the decoding tests run against fixtures captured +// from a real harness. +let package = Package( + name: "CompanionCore", + // macOS 13 rather than 14: the core needs nothing newer than + // URLSession.bytes (macOS 12), and `swift test` should run on whatever + // Mac is to hand. The app's iOS 17 floor lives in project.yml. + platforms: [.iOS(.v17), .macOS(.v13)], + products: [ + .library(name: "CompanionCore", targets: ["CompanionCore"]) + ], + targets: [ + .target(name: "CompanionCore"), + .testTarget( + name: "CompanionCoreTests", + dependencies: ["CompanionCore"], + resources: [.copy("Fixtures")] + ), + ] +) diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000000..5cd95ec0d9 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,159 @@ +# OpenMausBot companion (iOS) + +Your bots keep running on the laptop. This is the phone you watch them from, +answer their approvals on, and send them the next thing. + +The laptop stays the only machine that owns agent processes, credentials, +transcripts and computers. The phone owns nothing — it is a second client on the +same harness the desktop app talks to, over the companion listener described in +[`docs/ios-companion.md`](../docs/ios-companion.md). + +## Status + +Built, run, and verified on an iPhone against a real harness: Bonjour discovery, +pairing, the roster, sending, and — the one that matters — an approval raised by +a bot on the Mac, answered on the phone, with the bot carrying on. + +It was written in an environment with no Swift toolchain, so the first run on a +Mac was also the first compile. Three bugs came out of that, all in the same few +lines between "URLSession has bytes" and "the app has frames", and all invisible +to the parser tests because the parser was never the thing that was wrong: + +1. `timeoutInterval = .greatestFiniteMagnitude` — reads as "never time out", + actually produces a request that opens and delivers nothing, because + URLSession turns a timeout into a deadline by adding it to the current time. +2. Keeping only the derived line iterator while letting `URLSession.AsyncBytes` + go out of scope. AsyncBytes cancels its data task when released, so the + connection died the moment the first frame was returned. +3. Reading with `bytes.lines`, which folds consecutive newlines into one + separator and therefore never reports the blank line that terminates an SSE + event. Zero frames, no error, a healthy-looking connection at both ends. + +`EventStreamTests` exists to catch that class — it is the only test here that +drives a real `URLSession`. [`TESTING.md`](TESTING.md) is the runbook, and its +"If the phone sits on Connecting…" section is what actually isolated bug 3. + +## Layout + +``` +ios/ + Package.swift CompanionCore + its tests + project.yml XcodeGen spec for the app target + Sources/CompanionCore/ no UI, no Apple frameworks beyond Foundation + Models.swift the harness's wire types + Frames.swift SSE frames, unknown kinds absorbed + SSE.swift line parser + URLSession event stream + Client.swift every call the phone is allowed to make + Store.swift the fold: frames → state + Tests/CompanionCoreTests/ + Fixtures/ captured from a real server — do not hand-edit + DecodingTests.swift the contract with the harness + SSETests.swift the parser, which is where this goes wrong + StoreTests.swift the fold + App/ SwiftUI, and everything that needs a device + CompanionApp.swift entry; owns when the stream lives and dies + Session.swift connection, lifecycle, actions + Discovery.swift NWBrowser for _openmausbot._tcp + Keychain.swift the device token + MausAvatar.swift the mascot face, in the desktop's palette + PairingView.swift find a computer, type the six digits + ChatListView.swift roster, with "waiting on you" pulled to the top + ChatView.swift transcript, approval cards, composer + SettingsView.swift status, and unpair +``` + +## Building + +The core needs nothing but a Swift toolchain: + +```sh +cd ios +swift test +``` + +The app needs Xcode. The `.xcodeproj` is generated rather than committed: + +```sh +brew install xcodegen +cd ios && xcodegen generate && open OpenMausCompanion.xcodeproj +``` + +**Re-run `xcodegen generate` after pulling any change that adds a file to +`App/`.** The spec says `sources: App`, but XcodeGen resolves that to explicit +file references when it generates, so a new file is simply absent from the +target until you regenerate — and the build fails with `Cannot find 'X' in +scope`, which reads like a code error and is not one. + +If you'd rather not install XcodeGen, make an iOS App target by hand, add the +`App/` folder and the local `CompanionCore` package, and copy the Info.plist +keys out of `project.yml` — `NSLocalNetworkUsageDescription` and +`NSBonjourServices` especially. Without them `NWBrowser` returns no results at +all, *silently*, which looks exactly like "no computers on this network". + +## Regenerating the fixtures + +Whenever the companion API changes: + +```sh +node scripts/capture-companion-fixtures.mjs # from the repo root +``` + +It boots a real harness against a throwaway home directory, drives the real +pairing handshake over the real network socket, and writes down what came back. +Commit the diff — a change there is a change to the contract, and reviewing it +is the point. + +## What the phone may and may not do + +Enforced server-side by `remoteDenial()` in `server/index.ts`, and mirrored here +by simply not having the methods: + +| Allowed | Refused | +|---|---| +| 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 | +| Interrupt a bot, mark chats read | Reach `/api/internal/*` | +| Fetch screen images on demand | Load the packaged desktop UI | + +Companion settings stay on the computer on purpose: losing the phone must not +mean losing the ability to lock it out. + +## Design notes + +- **Zero third-party dependencies.** SSE over `URLSession.bytes.lines` is a + page of code; Keychain, `NWBrowser` and notifications are all first-party. +- **Thin client.** The harness already folds provider events into settled + messages, so this listens to `message` / `message.patch` / `bot` and skips + `runtime` entirely. Token-by-token streaming is a later nicety, not a + prerequisite. +- **`screens=off`.** The harness would otherwise push a base64 desktop capture + every few seconds to a device on cellular. +- **Reconnect by cursor.** The stream is resumable: hold the `:` + cursor, and on reconnect the server replays what was missed or says + `resumed: false`, which is the signal to hydrate. Lifecycle — not the parser — + is the hard part of a phone client, which is why the stream is torn down + deliberately on backgrounding rather than left for iOS to kill. +- **No optimistic state.** Actions call the harness and let the event stream + deliver the result. A phone that draws its own version of what just happened + is a phone that disagrees with the laptop. +- **Messaging-app shape, not settings-list shape.** Mascot faces at roster size, + the bot's role as a chip beside its name, timestamps that say "Yesterday" + rather than a date, and a gap-based separator in the transcript instead of a + stamp on every message. The palette in `MausAvatar.swift` is copied verbatim + from `src/lib/mascot.ts`: a bot the user knows as "the orange one" should be + the same orange on both screens. +- **Return sends, Shift+Return breaks the line**, via `.onKeyPress`. Returning + `.ignored` for the shifted case hands the keypress back to the text field, + which is the only thing that can insert the newline once Return is claimed. + Software keyboards have no Shift+Return, so there `.onSubmit` sends. +- **No affordance without a feature behind it.** The reference design this was + modelled on has a composer mic and a "+" for new chats; there is no dictation + here and creating bots belongs on the computer, so neither is drawn. Search + is real and filters the roster. + +## Not in this version + +Foreground only, same network only. No push (the app must be open to hear +anything), no Tailscale guidance yet, no token-by-token streaming, no computer +panel, no voice. Those are phase 4 in the architecture doc. diff --git a/ios/Sources/CompanionCore/Client.swift b/ios/Sources/CompanionCore/Client.swift new file mode 100644 index 0000000000..c1ccd76c9d --- /dev/null +++ b/ios/Sources/CompanionCore/Client.swift @@ -0,0 +1,273 @@ +// The companion API client. +// +// Everything the phone can do to the harness, in one place. The rules it +// encodes come from `remoteDenial()` in server/index.ts: a paired phone may +// chat, answer approvals, and manage tasks and rooms — it may not touch +// credentials, pairing, or the Local VM. Those routes are simply absent +// here rather than present and failing at runtime. +import Foundation + +/// Where a companion connects, and with what. The token is *not* held here +/// — it lives in the keychain and is handed to the client at construction, +/// so a `Connection` can be written to disk without writing a credential. +public struct Connection: Codable, Hashable, Identifiable, Sendable { + public var id: String + /// What the computer calls itself, e.g. "Ada Lovelace's computer". + public var name: String + public var host: String + public var port: Int + + public init(id: String = UUID().uuidString, name: String, host: String, port: Int) { + self.id = id + self.name = name + self.host = host + self.port = port + } + + public var baseURL: URL? { + var components = URLComponents() + components.scheme = "http" + components.host = host + components.port = port + return components.url + } +} + +public enum APIError: Error, LocalizedError, Sendable { + /// The harness answered, and said no. + case status(code: Int, message: String?) + /// Could not reach it at all. + case transport(String) + case badURL + + public var errorDescription: String? { + switch self { + case let .status(code, message): + if let message { return message } + switch code { + case 401: return "This phone is not paired with that computer." + case 403: return "That can only be done on the computer itself." + case 404: return "That is no longer there." + case 409: return "The bot is busy — stop it first." + default: return "The computer answered with an error (\(code))." + } + case let .transport(detail): + return detail + case .badURL: + return "That address doesn't look right." + } + } + + /// The one error that means "stop retrying and send them back to + /// pairing" rather than "try again in a moment". + public var isUnauthorized: Bool { + if case let .status(code, _) = self { return code == 401 } + return false + } +} + +public struct CompanionClient: Sendable { + public let connection: Connection + private let token: String? + private let session: URLSession + + public init(connection: Connection, token: String?, session: URLSession = .shared) { + self.connection = connection + self.token = token + self.session = session + } + + // MARK: - Requests + + private func makeRequest(_ method: String, _ path: String, query: [URLQueryItem] = [], body: [String: Any]? = nil) throws -> URLRequest { + guard let base = connection.baseURL, + var components = URLComponents(url: base, resolvingAgainstBaseURL: false) + else { throw APIError.badURL } + components.path = path + components.queryItems = query.isEmpty ? nil : query + guard let url = components.url else { throw APIError.badURL } + + var request = URLRequest(url: url) + request.httpMethod = method + // Short on purpose. These are calls to a computer on the same + // network; if it does not answer in twenty seconds it is not going + // to. The default sixty leaves someone watching a spinner long + // enough to assume the app is broken rather than the address wrong. + request.timeoutInterval = 20 + if let token { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + } + return request + } + + @discardableResult + private func send(_ request: URLRequest, as type: T.Type) async throws -> T { + let (data, response) = try await perform(request) + try Self.check(response, data) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw APIError.transport("The computer sent something this app couldn't read.") + } + } + + private func send(_ request: URLRequest) async throws { + let (data, response) = try await perform(request) + try Self.check(response, data) + } + + private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) { + do { + return try await session.data(for: request) + } catch { + throw APIError.transport(error.localizedDescription) + } + } + + /// Turn a non-2xx into an `APIError` carrying the harness's own message. + /// Those messages are written for people ("pair this device in + /// OpenMausBot → Settings → Companion"), so passing them through beats + /// inventing a worse one here. + static func check(_ response: URLResponse, _ data: Data) throws { + guard let http = response as? HTTPURLResponse else { return } + guard !(200...299).contains(http.statusCode) else { return } + let message = try? JSONDecoder().decode(APIErrorBody.self, from: data).error + throw APIError.status(code: http.statusCode, message: message) + } + + // MARK: - Pairing + + /// Redeem a code for a device token. The only call made without one. + public static func pair( + connection: Connection, + code: String, + deviceName: String, + session: URLSession = .shared + ) async throws -> PairResponse { + let client = CompanionClient(connection: connection, token: nil, session: session) + let pairRequest = try client.makeRequest("POST", "/api/pair", body: ["code": code, "deviceName": deviceName]) + return try await client.send(pairRequest, as: PairResponse.self) + } + + // MARK: - Reading + + /// Hydrate. `messages` opts into the paged shape — the newest n per + /// thread, with screen captures reduced to a flag. + public func fleet(messages: Int? = 50) async throws -> Fleet { + let query = messages.map { [URLQueryItem(name: "messages", value: String($0))] } ?? [] + return try await send(try makeRequest("GET", "/api/bots", query: query), as: Fleet.self) + } + + /// Scrollback: the page before a message already held. + public func messages(threadId: String, before: String? = nil, limit: Int = 50) async throws -> ThreadPage { + var query = [URLQueryItem(name: "limit", value: String(limit))] + if let before { query.append(URLQueryItem(name: "before", value: before)) } + return try await send(try makeRequest("GET", "/api/threads/\(threadId)/messages", query: query), as: ThreadPage.self) + } + + public func instances() async throws -> [Instance] { + try await send(try makeRequest("GET", "/api/instances"), as: InstanceList.self).instances + } + + public func config() async throws -> ConfigStatus { + try await send(try makeRequest("GET", "/api/config"), as: ConfigStatus.self) + } + + /// The pixels of one screen message. + public func image(threadId: String, messageId: String) async throws -> Data { + let imageRequest = try makeRequest("GET", "/api/threads/\(threadId)/messages/\(messageId)/image") + let (data, response) = try await perform(imageRequest) + try Self.check(response, data) + return data + } + + // MARK: - Doing + + /// Make a new bot. The harness picks its name, colour and greeting — the + /// phone deliberately does not, so a bot created here is indistinguishable + /// from one created on the desktop. + public func createBot() async throws -> Bot { + try await send(try makeRequest("POST", "/api/bots"), as: CreatedBot.self).bot + } + + public func send(text: String, toBot botId: String) async throws { + try await send(try makeRequest("POST", "/api/bots/\(botId)/messages", body: ["text": text])) + } + + public func send(text: String, toRoom groupId: String) async throws { + try await send(try makeRequest("POST", "/api/groups/\(groupId)/messages", body: ["text": text])) + } + + /// Answer an approval or a question. + /// + /// Addressed by thread rather than by bot on purpose: a request raised + /// inside a room belongs to whichever member is speaking, and the + /// harness already knows which that is. + public func respond(threadId: String, requestId: String, behavior: String, message: String? = nil) async throws { + var body: [String: Any] = ["requestId": requestId, "behavior": behavior] + if let message { body["message"] = message } + try await send(try makeRequest("POST", "/api/threads/\(threadId)/respond", body: body)) + } + + /// Remember a grant so the same tool stops asking. The harness decides + /// the key and puts it on the card; the phone never derives its own. + public func alwaysAllow(botId: String, keys: [String]) async throws { + try await send(try makeRequest("PATCH", "/api/bots/\(botId)", body: ["alwaysAllow": keys])) + } + + public func interrupt(botId: String) async throws { + try await send(try makeRequest("POST", "/api/bots/\(botId)/interrupt")) + } + + public func markRead(botId: String) async throws { + try await send(try makeRequest("PATCH", "/api/bots/\(botId)", body: ["unread": false])) + } + + public func markRead(roomId: String) async throws { + try await send(try makeRequest("PATCH", "/api/groups/\(roomId)", body: ["unread": false])) + } + + // MARK: - Events + + /// A session for a connection that is meant to stay open for hours. + /// + /// `timeoutIntervalForRequest` is the *idle* timeout — the gap between + /// bytes, not the lifetime of the request — so 90s is comfortably above + /// the harness's 25-second keepalive comment while still noticing a + /// connection that genuinely died. + /// + /// Emphatically NOT `request.timeoutInterval = .greatestFiniteMagnitude`, + /// which is what this used to be. It reads like "never time out", but + /// URLSession turns a timeout into a deadline by adding it to the current + /// time, and 1.8e308 does not survive that arithmetic: the request opens + /// and then never delivers a byte. The stream appeared to hang forever + /// with no error to show for it. + private static let streaming: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 90 + configuration.waitsForConnectivity = true + // no caching for an event stream — it would only ever be wrong + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: configuration) + }() + + /// The event stream, resuming from `cursor` when there is one. + /// + /// `screens` defaults off, and should stay off unless something is + /// actually showing them: the harness pushes a base64 desktop capture + /// every few seconds to every client that asks, which is a poor thing to + /// send a phone on cellular. The computer panel turns it on for exactly + /// as long as it is open, which costs a reconnect — cheap, because the + /// stream resumes from its cursor and loses nothing. + public func events(since cursor: String?, screens: Bool = false) throws -> AsyncThrowingStream { + var query = [URLQueryItem(name: "screens", value: screens ? "on" : "off")] + if let cursor { query.append(URLQueryItem(name: "since", value: cursor)) } + var streamRequest = try makeRequest("GET", "/api/events", query: query) + streamRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") + return eventStream(request: streamRequest, session: Self.streaming) + } +} diff --git a/ios/Sources/CompanionCore/Frames.swift b/ios/Sources/CompanionCore/Frames.swift new file mode 100644 index 0000000000..4ac72adaad --- /dev/null +++ b/ios/Sources/CompanionCore/Frames.swift @@ -0,0 +1,164 @@ +// The SSE frames the harness broadcasts, as one enum. +// +// Every frame is `{ "kind": …, "seq": n, … }` with a payload keyed by kind +// (see `broadcast` in server/index.ts). Decoding switches on `kind`, and — +// this is the important part — an unrecognised kind decodes to `.unknown` +// rather than throwing. The harness gains frame kinds over time, and a +// phone from last month must keep folding the ones it does understand +// instead of tearing down its stream over one it does not. +import Foundation + +public struct NotificationFrame: Codable, Hashable, Sendable { + /// approval · question · done · routine-failed + public var kind: String + public var botId: String + public var botName: String + public var threadId: String + public var title: String + public var body: String + + /// A bot blocked on you, as opposed to one reporting in. + public var isBlocking: Bool { kind == "approval" || kind == "question" } +} + +/// A canonical runtime event. The server has already folded these into +/// messages, so a client only needs them for token-by-token streaming. +public struct RuntimeEvent: Codable, Hashable, Sendable { + public var type: String + public var threadId: String + /// content.delta only + public var delta: String? + public var streamKind: String? +} + +public enum Frame: Sendable { + /// First frame on every connection. `resumed` is the server saying + /// whether it could replay the gap; false means hydrate. + case hello(cursor: String, resumed: Bool) + case message(threadId: String, message: Message) + case messagePatch(threadId: String, message: Message) + /// A branch switch: which leaf of the conversation is now visible. + case thread(threadId: String, activeLeafId: String?) + case bot(Bot) + case botDeleted(botId: String) + case room(Room) + case roomDeleted(groupId: String) + /// Something worth interrupting for. + case notify(NotificationFrame) + /// A live frame of a bot's computer (base64). Only sent to clients that + /// did not pass `screens=off`. + case screen(botId: String, png: String, mime: String) + /// The bot's cloud computer is being provisioned. + case computer(botId: String, state: String) + case config + case runtime(RuntimeEvent) + case unknown(kind: String) +} + +extension Frame: Decodable { + private enum CodingKeys: String, CodingKey { + case kind, cursor, resumed, threadId, message, activeLeafId + case bot, botId, group, groupId, notification, png, mime, state, event + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + + switch kind { + case "hello": + self = .hello( + cursor: try container.decode(String.self, forKey: .cursor), + resumed: try container.decodeIfPresent(Bool.self, forKey: .resumed) ?? false + ) + case "message": + self = .message( + threadId: try container.decode(String.self, forKey: .threadId), + message: try container.decode(Message.self, forKey: .message) + ) + case "message.patch": + self = .messagePatch( + threadId: try container.decode(String.self, forKey: .threadId), + message: try container.decode(Message.self, forKey: .message) + ) + case "thread": + self = .thread( + threadId: try container.decode(String.self, forKey: .threadId), + activeLeafId: try container.decodeIfPresent(String.self, forKey: .activeLeafId) + ) + case "bot": + self = .bot(try container.decode(Bot.self, forKey: .bot)) + case "bot.deleted": + self = .botDeleted(botId: try container.decode(String.self, forKey: .botId)) + case "group": + self = .room(try container.decode(Room.self, forKey: .group)) + case "group.deleted": + self = .roomDeleted(groupId: try container.decode(String.self, forKey: .groupId)) + case "notify": + self = .notify(try container.decode(NotificationFrame.self, forKey: .notification)) + case "screen": + self = .screen( + botId: try container.decode(String.self, forKey: .botId), + png: try container.decode(String.self, forKey: .png), + mime: try container.decodeIfPresent(String.self, forKey: .mime) ?? "image/png" + ) + case "computer": + self = .computer( + botId: try container.decode(String.self, forKey: .botId), + state: try container.decodeIfPresent(String.self, forKey: .state) ?? "" + ) + case "config": + self = .config + case "runtime": + self = .runtime(try container.decode(RuntimeEvent.self, forKey: .event)) + default: + // routines, and whatever the harness adds next + self = .unknown(kind: kind) + } + } +} + +extension Frame { + /// The thread this frame concerns, when it concerns one. + public var threadId: String? { + switch self { + case let .message(threadId, _): + return threadId + case let .messagePatch(threadId, _): + return threadId + case let .thread(threadId, _): + return threadId + case let .notify(notification): + return notification.threadId + case let .runtime(event): + return event.threadId + default: + return nil + } + } +} + +/// A frame plus its position in the stream. +/// +/// `seq` lives out here rather than inside the enum because an enum cannot +/// carry a stored property, and because the two answer different questions: +/// the frame says what happened, the sequence says where we are — which is +/// the only thing a reconnect needs. +public struct StreamFrame: Decodable, Sendable { + public var frame: Frame + public var seq: Int? + + private enum CodingKeys: String, CodingKey { + case seq + } + + public init(from decoder: Decoder) throws { + self.frame = try Frame(from: decoder) + self.seq = try decoder.container(keyedBy: CodingKeys.self).decodeIfPresent(Int.self, forKey: .seq) + } + + public init(frame: Frame, seq: Int?) { + self.frame = frame + self.seq = seq + } +} diff --git a/ios/Sources/CompanionCore/Markdown.swift b/ios/Sources/CompanionCore/Markdown.swift new file mode 100644 index 0000000000..16964a4042 --- /dev/null +++ b/ios/Sources/CompanionCore/Markdown.swift @@ -0,0 +1,138 @@ +// Markdown, split into blocks. +// +// The desktop renders bot replies with react-markdown + GFM. The phone was +// showing the source: `**bold**`, `- ` bullets and `[text](url)` arriving as +// literal characters, which is worse than no markdown at all — it is the +// author's intent visible but not applied. +// +// Foundation can already do the *inline* half. `AttributedString(markdown:)` +// handles emphasis, code spans, strikethrough and links, and SwiftUI renders +// the links tappable for free. What it will not do is lay out blocks: with +// `.full` it records a presentation intent and leaves you to draw the bullet, +// the indent and the spacing yourself. So this splits the text into blocks +// and the view draws each one, handing the inline run back to Foundation. +// +// Deliberately not a full CommonMark implementation. It covers what a model +// actually emits into a chat bubble — paragraphs, lists, headings, fences, +// quotes, rules — and treats anything it does not recognise as text, which is +// the failure mode that loses nothing. +import Foundation + +public enum MarkdownBlock: Equatable, Sendable { + case paragraph(String) + /// `indent` is nesting depth, 0 for a top-level item. + case bullet(indent: Int, text: String) + case ordered(indent: Int, number: Int, text: String) + case heading(level: Int, text: String) + /// A fenced block. `language` is whatever followed the opening fence. + case code(language: String?, text: String) + case quote(String) + case rule +} + +public enum Markdown { + /// Split into blocks. Never throws and never drops input: an unparseable + /// line ends up in a paragraph, which is what the reader wanted anyway. + public static func blocks(_ source: String) -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + var paragraph: [String] = [] + + func flushParagraph() { + guard !paragraph.isEmpty else { return } + // GFM: a single newline inside a paragraph is a soft break, which + // renders as a space. The desktop does not enable `breaks`, so + // neither does this — the two should wrap the same way. + blocks.append(.paragraph(paragraph.joined(separator: " "))) + paragraph.removeAll() + } + + var lines = source.components(separatedBy: .newlines)[...] + while let line = lines.first { + lines = lines.dropFirst() + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // A fence runs to its closing fence, or to the end — a reply still + // streaming has an open one, and showing it as code beats showing + // three backticks and waiting. + if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") { + flushParagraph() + let marker = String(trimmed.prefix(3)) + let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces) + var body: [String] = [] + while let next = lines.first { + lines = lines.dropFirst() + if next.trimmingCharacters(in: .whitespaces).hasPrefix(marker) { break } + body.append(next) + } + blocks.append(.code(language: language.isEmpty ? nil : language, text: body.joined(separator: "\n"))) + continue + } + + if trimmed.isEmpty { + flushParagraph() + continue + } + + // --- or *** or ___, three or more, nothing else on the line + if trimmed.count >= 3, "-*_".contains(trimmed.first!), + trimmed.allSatisfy({ $0 == trimmed.first! }) { + flushParagraph() + blocks.append(.rule) + continue + } + + if let heading = heading(trimmed) { + flushParagraph() + blocks.append(heading) + continue + } + + if trimmed.hasPrefix(">") { + flushParagraph() + blocks.append(.quote(String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces))) + continue + } + + if let item = listItem(line) { + flushParagraph() + blocks.append(item) + continue + } + + paragraph.append(trimmed) + } + flushParagraph() + return blocks + } + + private static func heading(_ trimmed: String) -> MarkdownBlock? { + let hashes = trimmed.prefix(while: { $0 == "#" }).count + guard hashes >= 1, hashes <= 6 else { return nil } + let rest = String(trimmed.dropFirst(hashes)) + // "#hashtag" is not a heading; ATX requires the space + guard rest.hasPrefix(" ") else { return nil } + return .heading(level: hashes, text: rest.trimmingCharacters(in: .whitespaces)) + } + + private static func listItem(_ line: String) -> MarkdownBlock? { + let leading = line.prefix(while: { $0 == " " || $0 == "\t" }).count + // two spaces per level, which is what a model emits and what GFM + // treats as nesting for a tight list + let indent = min(leading / 2, 4) + let trimmed = line.trimmingCharacters(in: .whitespaces) + + for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) { + return .bullet(indent: indent, text: String(trimmed.dropFirst(2))) + } + + // "1. " / "12) " + let digits = trimmed.prefix(while: \.isNumber) + if !digits.isEmpty, digits.count <= 9 { + let rest = trimmed.dropFirst(digits.count) + if rest.hasPrefix(". ") || rest.hasPrefix(") ") { + return .ordered(indent: indent, number: Int(digits) ?? 1, text: String(rest.dropFirst(2))) + } + } + return nil + } +} diff --git a/ios/Sources/CompanionCore/Models.swift b/ios/Sources/CompanionCore/Models.swift new file mode 100644 index 0000000000..a41d1ff30f --- /dev/null +++ b/ios/Sources/CompanionCore/Models.swift @@ -0,0 +1,288 @@ +// The harness's wire types, in Swift. +// +// These mirror `server/store.ts` and the payloads in `server/index.ts`. +// There is no shared type system across the two languages, so the contract +// is pinned by fixtures instead: `Tests/CompanionCoreTests/Fixtures` holds +// real responses captured from a running server, and the decoding tests +// read them. When the server changes a payload, a test here fails. +// +// Everything the server may omit is optional, and nothing is decoded more +// strictly than it has to be — a phone that refuses to show a conversation +// because one message gained a field is worse than one that ignores it. +import Foundation + +// MARK: - Messages + +public struct OptionCard: Codable, Hashable, Sendable { + public var title: String + public var subtitle: String + public var options: [String] + public var answered: String? + public var dismissed: Bool? + /// Present when this card is a live provider ask — the thing that makes + /// it answerable rather than historical. + public var requestId: String? + public var tool: String? + /// Why auto mode stopped to ask anyway. + public var held: String? + /// The narrow grant "always allow" would remember, e.g. `Bash:git`. + public var allowKey: String? + + /// A card is actionable while it is unanswered and still has a request + /// behind it. Everything else is transcript. + public var isPending: Bool { + requestId != nil && answered == nil && dismissed != true + } + + /// Permission cards carry a tool; questions do not. + public var isPermission: Bool { tool != nil } +} + +public struct ToolActivity: Codable, Hashable, Sendable { + public var name: String + public var ok: Bool? + /// The same chip as a phrase a voice can read. + public var spoken: String? + /// Marks an error fixed by installing something, not by retrying. + public var setup: Bool? +} + +public struct Sender: Codable, Hashable, Sendable { + public var botId: String + public var name: String + public var color: String +} + +public struct Reaction: Codable, Hashable, Sendable { + public var emoji: String + public var by: String +} + +public struct CommChip: Codable, Hashable, Sendable { + public var groupId: String + public var withBotId: String + public var withName: String + public var withColor: String +} + +public struct Message: Codable, Hashable, Identifiable, Sendable { + public enum Kind: String, Codable, Sendable { + case text, options, activity, screen + /// A kind this build has never heard of. + /// + /// Not decorative. `kind` is not optional, so without this a single + /// unrecognised message fails the decode of the whole response it + /// arrived in — the thread does not render one message oddly, it + /// does not render. The harness gains message kinds on its own + /// schedule and the phone is updated on the App Store's, so "newer + /// computer than phone" is the normal state of things, not an edge + /// case. Degrading to the text a message carries is worth more than + /// being right about its shape. + case unknown + + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = Kind(rawValue: raw) ?? .unknown + } + } + + public enum Role: String, Codable, Sendable { + case bot, user + + /// Same reasoning, and `bot` rather than a third case: an unplaceable + /// message drawn as yours would be the phone claiming you said + /// something you did not. + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = Role(rawValue: raw) ?? .bot + } + } + + public var id: String + public var role: Role + public var kind: Kind + public var at: Double + public var text: String? + public var card: OptionCard? + public var tool: ToolActivity? + /// The message this one follows; nil at the thread root. Two messages + /// sharing a parent are a fork. + public var parentId: String? + /// Rooms: which member said this. + public var from: Sender? + public var reactions: [Reaction]? + public var comm: CommChip? + /// Screen messages in the paged shape: the pixels live behind + /// `/api/threads/:threadId/messages/:id/image` rather than inline. + public var hasImage: Bool? + /// Screen messages in the full shape: base64 pixels, inline. + public var png: String? + public var mime: String? + + public var date: Date { Date(timeIntervalSince1970: at / 1000) } +} + +// MARK: - Bots and rooms + +public struct ModelSelection: Codable, Hashable, Sendable { + public var instanceId: String + public var model: String +} + +public struct BotTask: Codable, Hashable, Sendable { + public var threadId: String + public var title: String + public var createdAt: Double +} + +public struct Bot: Codable, Hashable, Identifiable, Sendable { + public var id: String + public var threadId: String + public var name: String + public var title: String + public var description: String + public var notifications: Bool + public var color: String + public var unread: Bool + public var modelSelection: ModelSelection + public var createdAt: Double + public var busy: Bool? + public var pinned: Bool? + public var hidden: Bool? + public var chiefOfStaff: Bool? + public var autoApprove: Bool? + public var alwaysAllow: [String]? + public var computer: String? + public var speakReplies: Bool? + public var voice: String? + public var mascotExpression: String? + public var tasks: [BotTask]? + public var messages: [Message]? + public var activeLeafId: String? + /// Paged responses only: there is more transcript above what you got. + public var hasMore: Bool? +} + +public struct GroupResponder: Codable, Hashable, Sendable { + public var kind: String + public var botId: String? +} + +public struct Room: Codable, Hashable, Identifiable, Sendable { + public var id: String + public var threadId: String + public var name: String + public var memberIds: [String] + public var defaultResponder: GroupResponder + public var bulletin: String + public var unread: Bool + public var createdAt: Double + public var dm: Bool? + public var busyBotId: String? + public var messages: [Message]? + public var hasMore: Bool? +} + +// MARK: - Responses + +public struct Fleet: Codable, Sendable { + public var bots: [Bot] + public var groups: [Room] +} + +public struct ThreadPage: Codable, Sendable { + public var messages: [Message] + public var hasMore: Bool? +} + +public struct PairedDevice: Codable, Hashable, Identifiable, Sendable { + public var id: String + public var name: String + public var createdAt: Double + public var lastSeenAt: Double +} + +public struct PairResponse: Codable, Sendable { + public var token: String + public var device: PairedDevice + /// What the computer calls itself — worth showing so someone with two + /// paired machines can tell them apart. + public var serverName: String +} + +public struct ProviderSnapshot: Codable, Hashable, Sendable { + public var state: String + public var reason: String? + public var authenticated: Bool? + public var version: String? + + public var isAvailable: Bool { state == "available" } +} + +public struct ModelOption: Codable, Hashable, Identifiable, Sendable { + public var id: String + public var label: String +} + +public struct ModelCatalog: Codable, Hashable, Sendable { + public var `default`: String + public var options: [ModelOption] +} + +public struct Instance: Codable, Hashable, Identifiable, Sendable { + public var instanceId: String + public var driverKind: String + public var displayName: String? + public var snapshot: ProviderSnapshot + public var models: ModelCatalog + + public var id: String { instanceId } +} + +public struct InstanceList: Codable, Sendable { + public var instances: [Instance] +} + +public struct ConfigFlag: Codable, Hashable, Sendable { + public var configured: Bool + public var apiKeyConfigured: Bool? + public var ready: Bool? + public var voice: String? +} + +public struct Profile: Codable, Hashable, Sendable { + public var name: String + public var email: String +} + +public struct ConfigStatus: Codable, Sendable { + public var composio: ConfigFlag? + public var box: ConfigFlag? + public var tts: ConfigFlag? + public var profile: Profile? +} + +/// The harness's error body. Every non-2xx response carries one. +public struct APIErrorBody: Codable, Sendable { + public var error: String +} + +/// One frame of a bot's computer, as it arrives on the stream. +public struct ScreenFrame: Hashable, Sendable { + public var png: String + public var mime: String + + public init(png: String, mime: String) { + self.png = png + self.mime = mime + } + + /// Decoded pixels, or nil if the base64 was not what it claimed to be. + /// Returning nil rather than throwing keeps the caller a view. + public var data: Data? { Data(base64Encoded: png) } +} + +/// `POST /api/bots` — the harness answers with the bot it made. +public struct CreatedBot: Codable, Sendable { + public var bot: Bot +} diff --git a/ios/Sources/CompanionCore/SSE.swift b/ios/Sources/CompanionCore/SSE.swift new file mode 100644 index 0000000000..c4559f1ffd --- /dev/null +++ b/ios/Sources/CompanionCore/SSE.swift @@ -0,0 +1,146 @@ +// Server-sent events, by hand. +// +// Two pieces, deliberately separated: a parser that turns lines into events +// with no I/O in it at all, and a connection that drives it from a +// URLSession byte stream. The parser is where the bugs live — multi-line +// data, comments used as keepalives, the optional space after the colon — +// so it is the part that can be tested without a server. +// +// The harness's stream is documented in `server/index.ts`: each frame is +// `id: :` followed by one `data:` line, separated by a blank +// line, with `: keepalive` comments every 25 seconds. +import Foundation + +/// One event off the wire, before it is understood as a `Frame`. +public struct SSEEvent: Equatable, Sendable { + public var id: String? + public var data: String + + public init(id: String? = nil, data: String) { + self.id = id + self.data = data + } +} + +/// Line-oriented SSE parser. Feed it every line as it arrives; it returns +/// an event when a blank line closes the block, and nil otherwise. +public struct SSEParser: Sendable { + private var fields: [(name: String, value: String)] = [] + + public init() {} + + public mutating func line(_ raw: String) -> SSEEvent? { + // AsyncLineSequence strips the newline but not a CR from CRLF + let line = raw.hasSuffix("\r") ? String(raw.dropLast()) : raw + + guard !line.isEmpty else { + let event = Self.event(from: fields) + fields.removeAll() + return event + } + // ": keepalive" — a comment, and the only reason this stream + // survives a NAT with a short idle timeout + guard !line.hasPrefix(":") else { return nil } + guard let colon = line.firstIndex(of: ":") else { + // a bare field name with no value is legal and carries nothing + return nil + } + var value = String(line[line.index(after: colon)...]) + // exactly one optional leading space after the colon, per the spec + if value.hasPrefix(" ") { value.removeFirst() } + fields.append((name: String(line[line.startIndex.. SSEEvent? { + var id: String? + var dataLines: [String] = [] + for field in fields { + switch field.name { + case "data": dataLines.append(field.value) + case "id": id = field.value + default: break + } + } + guard !dataLines.isEmpty else { return nil } + return SSEEvent(id: id, data: dataLines.joined(separator: "\n")) + } +} + +/// A live connection to `/api/events`, as an async sequence of frames. +/// +/// Built as an `AsyncThrowingStream` around a single Task, rather than as a +/// hand-written `AsyncSequence`, for one specific reason: `URLSession.AsyncBytes` +/// owns the underlying data task and **cancels it when the sequence is +/// released**. An earlier version here kept only the derived line iterator and +/// let the `AsyncBytes` value go out of scope at the end of `next()`. The +/// server saw the connection open and immediately close; the client saw +/// NSURLErrorCancelled; the app reconnected forever and showed a spinner. The +/// whole class of bug disappears when one Task holds `bytes` for the entire +/// lifetime of the connection, which is what the loop below does. +/// +/// Reconnection is *not* handled here — that decision belongs to whatever +/// knows whether the app is even on screen. This runs until the stream ends +/// or the consuming task is cancelled. +public func eventStream( + request: URLRequest, + session: URLSession = .shared +) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let (bytes, response) = try await session.bytes(for: request) + if let http = response as? HTTPURLResponse, http.statusCode != 200 { + throw APIError.status(code: http.statusCode, message: nil) + } + + var parser = SSEParser() + let decoder = JSONDecoder() + var line = [UInt8]() + + // Split on newlines by hand rather than using `bytes.lines`. + // + // `AsyncLineSequence` does not emit empty lines — it folds + // consecutive newlines into one separator. In almost any other + // format that is a convenience; in SSE it is fatal, because a + // blank line is exactly what terminates an event. Using it + // meant `SSEParser` was never told an event had ended, so the + // stream delivered zero frames, forever, while looking + // perfectly healthy at both ends: the server had an open + // connection and the client had no error to report. + // + // `bytes` also stays in scope for this whole loop, which is + // the other half of keeping this connection alive — see above. + for try await byte in bytes { + guard byte == 0x0A else { + line.append(byte) + continue + } + // \r\n is legal; the parser tolerates a stray \r too + if line.last == 0x0D { line.removeLast() } + let text = String(decoding: line, as: UTF8.self) + line.removeAll(keepingCapacity: true) + + guard let event = parser.line(text) else { continue } + guard let data = event.data.data(using: .utf8) else { continue } + // A frame we cannot decode is one frame lost, not a dead + // stream: `Frame` already absorbs unknown kinds, so this + // only catches genuinely malformed JSON. + guard let frame = try? decoder.decode(StreamFrame.self, from: data) else { continue } + continuation.yield(frame) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } +} diff --git a/ios/Sources/CompanionCore/Store.swift b/ios/Sources/CompanionCore/Store.swift new file mode 100644 index 0000000000..ff3a5aa3b5 --- /dev/null +++ b/ios/Sources/CompanionCore/Store.swift @@ -0,0 +1,270 @@ +// The client's state, and the fold that maintains it. +// +// This mirrors the reducer in `src/state/store.tsx`, and is deliberately a +// plain struct with a pure `apply(_:)` rather than anything observable: the +// fold is the part worth testing, and it should be testable without a +// server, a socket, or a UI. +// +// The harness has already turned provider events into settled messages, so +// the work here is small — append, patch, replace. That is the whole reason +// a phone client is a weekend of work rather than a rewrite. +import Foundation + +public struct CompanionState: Sendable { + public var bots: [Bot] = [] + public var rooms: [Room] = [] + /// Transcripts by thread, which is the key both bots and rooms share. + public var messages: [String: [Message]] = [:] + /// Whether there is more transcript above what we hold, per thread. + public var hasMore: [String: Bool] = [:] + /// The last frame we folded — what a reconnect resumes from. + public var cursor: String? + /// Notifications that arrived while connected, newest last. + public var notifications: [NotificationFrame] = [] + /// The reply being typed, per thread — cleared when it settles into a + /// `Message`. Not persisted and not hydrated: it is what is happening + /// right now, and a reconnect that missed it gets the settled message + /// instead, which is strictly better. + public var streaming: [String: String] = [:] + /// The bot's reasoning, per thread, when the provider emits it. Kept + /// apart from `streaming` because it is not the answer — running them + /// together reads as the bot contradicting itself mid-sentence. + public var reasoning: [String: String] = [:] + /// The latest frame of each bot's computer, base64, while something is + /// watching. Only ever populated when the stream was opened with + /// `screens=on`, and only the newest frame is kept — these are hundreds + /// of kilobytes each and a history of them is worth nothing. + public var screens: [String: ScreenFrame] = [:] + + public init() {} + + // MARK: - Reading + + /// Named `transcript`, not `messages`: sharing a base name with the + /// stored property compiles but reads as if one shadows the other. + public func transcript(forThread threadId: String) -> [Message] { + messages[threadId] ?? [] + } + + public func bot(_ id: String) -> Bot? { + bots.first { $0.id == id } + } + + public func bot(forThread threadId: String) -> Bot? { + bots.first { $0.threadId == threadId } + } + + public func room(forThread threadId: String) -> Room? { + rooms.first { $0.threadId == threadId } + } + + /// Every unanswered approval or question, newest first. This is the + /// screen the whole companion exists for. + public var pendingApprovals: [(threadId: String, message: Message)] { + var out: [(threadId: String, message: Message)] = [] + for (threadId, thread) in messages { + for message in thread where message.card?.isPending == true { + out.append((threadId: threadId, message: message)) + } + } + return out.sorted { $0.message.at > $1.message.at } + } + + /// Chats worth a badge. + public var unreadCount: Int { + bots.filter { $0.unread && $0.hidden != true }.count + rooms.filter(\.unread).count + } + + // MARK: - Hydrating + + /// Replace everything from a `GET /api/bots` response. + public mutating func hydrate(_ fleet: Fleet) { + bots = fleet.bots + rooms = fleet.groups + messages.removeAll() + hasMore.removeAll() + for bot in fleet.bots { + messages[bot.threadId] = bot.messages ?? [] + hasMore[bot.threadId] = bot.hasMore ?? false + } + for room in fleet.groups { + messages[room.threadId] = room.messages ?? [] + hasMore[room.threadId] = room.hasMore ?? false + } + } + + /// Prepend an older page fetched for scrollback. + public mutating func prepend(_ page: ThreadPage, toThread threadId: String) { + let existing = messages[threadId] ?? [] + let known = Set(existing.map(\.id)) + messages[threadId] = page.messages.filter { !known.contains($0.id) } + existing + hasMore[threadId] = page.hasMore ?? false + } + + // MARK: - Folding + + public mutating func apply(_ streamFrame: StreamFrame) { + apply(streamFrame.frame) + } + + public mutating func apply(_ frame: Frame) { + switch frame { + case let .hello(cursor, _): + // The caller decides what to do about `resumed`; either way this + // is where we now are in the stream. + self.cursor = cursor + + case let .message(threadId, message): + append(message, to: threadId) + // A settled reply supersedes whatever was streaming into it. + // Without this the live bubble survives alongside the real one: + // the tail renders below any card or chip that settled next, and + // the next block's deltas append onto the duplicated tail + // instead of starting fresh. The desktop client learned this the + // hard way; no reason to learn it twice. + if message.role == .bot, message.kind == .text { + clearStream(threadId) + } + + case let .messagePatch(threadId, message): + var thread = messages[threadId] ?? [] + if let index = thread.firstIndex(where: { $0.id == message.id }) { + thread[index] = message + messages[threadId] = thread + } else { + // a patch for something we never saw — the append is more + // useful than dropping it, and dedupes on id anyway + append(message, to: threadId) + } + + case let .thread(threadId, activeLeafId): + if let index = bots.firstIndex(where: { $0.threadId == threadId }) { + bots[index].activeLeafId = activeLeafId + } + + case let .bot(bot): + // Frames carry the bot record without its transcript, so merge + // rather than replace: assigning would wipe the messages the + // hydrate put there. + if let index = bots.firstIndex(where: { $0.id == bot.id }) { + var merged = bot + merged.messages = bots[index].messages + merged.activeLeafId = bot.activeLeafId ?? bots[index].activeLeafId + bots[index] = merged + } else { + bots.append(bot) + if messages[bot.threadId] == nil { + messages[bot.threadId] = bot.messages ?? [] + } + } + + case let .botDeleted(botId): + if let index = bots.firstIndex(where: { $0.id == botId }) { + messages.removeValue(forKey: bots[index].threadId) + hasMore.removeValue(forKey: bots[index].threadId) + bots.remove(at: index) + } + + case let .room(room): + if let index = rooms.firstIndex(where: { $0.id == room.id }) { + var merged = room + merged.messages = rooms[index].messages + rooms[index] = merged + } else { + rooms.append(room) + if messages[room.threadId] == nil { + messages[room.threadId] = room.messages ?? [] + } + } + + case let .roomDeleted(groupId): + if let index = rooms.firstIndex(where: { $0.id == groupId }) { + messages.removeValue(forKey: rooms[index].threadId) + hasMore.removeValue(forKey: rooms[index].threadId) + rooms.remove(at: index) + } + + case let .notify(notification): + notifications.append(notification) + + case let .runtime(event): + apply(runtime: event) + + case let .screen(botId, png, mime): + screens[botId] = ScreenFrame(png: png, mime: mime) + + // Nothing to fold: config and provisioning state are not part of + // this client's job yet. + case .computer, .config, .unknown: + break + } + } + + /// Live text, before the server has settled it into a `Message`. + /// + /// The harness folds provider events into settled messages and also + /// relays the raw deltas, so a client can have the reply as it is typed + /// and the authoritative record when the turn ends. Rendering only the + /// settled message — which is what this did until now — means a long + /// answer looks like nothing is happening for thirty seconds. + private mutating func apply(runtime event: RuntimeEvent) { + switch event.type { + case "content.delta": + guard let delta = event.delta, !delta.isEmpty else { return } + switch event.streamKind { + case "assistant_text": + streaming[event.threadId, default: ""] += delta + case "reasoning_text": + reasoning[event.threadId, default: ""] += delta + default: + // an unknown stream kind is not ours to guess at; dropping it + // is better than showing thinking as if it were the answer + break + } + case "turn.completed", "turn.failed", "turn.aborted": + clearStream(event.threadId) + default: + break + } + } + + /// Forget a bot's screen. Called when the panel closes, so the next one + /// opens on a live frame rather than on however the desktop looked when + /// it was last watched. + public mutating func clearScreen(_ botId: String) { + screens.removeValue(forKey: botId) + } + + /// Drop a thread's live text. The settled message that triggers this + /// already contains every token it held. + public mutating func clearStream(_ threadId: String) { + streaming.removeValue(forKey: threadId) + reasoning.removeValue(forKey: threadId) + } + + /// Append, unless we already hold it. Replaying a resumed stream can + /// legitimately deliver a message twice — the cursor is the last frame + /// *received*, and a frame in flight when the socket dropped arrives + /// again on reconnect. + private mutating func append(_ message: Message, to threadId: String) { + var thread = messages[threadId] ?? [] + if let index = thread.firstIndex(where: { $0.id == message.id }) { + thread[index] = message + } else { + thread.append(message) + } + messages[threadId] = thread + } +} + +extension CompanionState { + /// Advance the cursor to a frame's sequence, keeping the stream id. + /// + /// The cursor is `:` and opaque to us except for this: + /// the id half must be carried forward, because it is what stops the + /// server replaying a previous run's frames into our state. + public mutating func advance(to seq: Int?) { + guard let seq, let cursor, let streamId = cursor.split(separator: ":").first else { return } + self.cursor = "\(streamId):\(seq)" + } +} diff --git a/ios/TESTING.md b/ios/TESTING.md new file mode 100644 index 0000000000..2f75183166 --- /dev/null +++ b/ios/TESTING.md @@ -0,0 +1,280 @@ +# Testing the companion locally + +All four stages have been run end to end on a Mac and an iPhone. Keep this as +the runbook for the next person, the next machine, and anything Layer 4 adds. + +Stages, cheapest signal first. Each one is worth completing before starting the +next — a Swift compile error found in stage 1 costs a minute, the same error +found while chasing a Bonjour problem on a phone costs an hour. Stage 5 is the +way out when the network itself is the problem. + +## What you need + +| Stage | Needs | +|---|---| +| 1 — core tests | a Mac with Xcode command line tools (`xcode-select --install`) | +| 2 — desktop half | + Node 24+, pnpm, and one agent CLI (`claude`, `codex`, or `grok`) signed in | +| 3 — app builds | + full Xcode | +| 4 — end to end | + an iPhone on the same Wi-Fi as the Mac | +| 5 — off this network | + Tailscale on both, same account | + +Stages 1 and 2 are worth doing even if you never get to a phone: stage 1 is +where the Swift errors are, and stage 2 exercises the companion listener the +desktop app now ships. + +--- + +## Stage 0 — get the branch + +The work is on `claude/open-mouse-ios-companion-mhzsdu` in `mnthr7/OpenMausMobile`. + +Fresh clone: + +```sh +git clone https://github.com/mnthr7/OpenMausMobile +cd OpenMausMobile +git checkout claude/open-mouse-ios-companion-mhzsdu +``` + +Or, if you already have the repo: + +```sh +cd OpenMausMobile +git fetch origin claude/open-mouse-ios-companion-mhzsdu +git checkout claude/open-mouse-ios-companion-mhzsdu +git pull origin claude/open-mouse-ios-companion-mhzsdu +``` + +Sanity check — `ios/` and the companion server files should be present: + +```sh +ls ios/Sources/CompanionCore server/devices.ts server/mdns.ts +``` + +--- + +## Stage 1 — the core compiles and its tests pass + +No Xcode, no simulator, no phone. This is the stage that finds the most and +costs the least, because **none of the Swift has ever been compiled**. + +```sh +cd ios +swift build # expect errors; they are the point +swift test +``` + +Expect real errors. They should be small and local — a wrong label, a missing +`@ViewBuilder`, an optional that needs unwrapping — because the shapes were +written against real captured bytes rather than guessed. `Sources/` is the only +thing `swift test` touches; `App/` is not in the package and is not compiled +until stage 3. + +A trailing `Test run with 0 tests in 0 suites passed` is expected and not a +problem: that is swift-testing finding none of its own tests, because these are +XCTest. + +**What passing means.** The decoding tests read +`Tests/CompanionCoreTests/Fixtures/*.json`, which were captured from a real +running harness. Green means the client agrees with what the server actually +sends — not with anyone's memory of it. + +If a decoding test fails while the others pass, suspect the fixture is stale +before suspecting the model: re-capture with +`node scripts/capture-companion-fixtures.mjs` from the repo root and read the +diff. + +--- + +## Stage 2 — the desktop side, on its own + +Prove the harness half works before a phone is in the picture. + +```sh +pnpm install +pnpm dev:server # 127.0.0.1:8799 +pnpm dev # 127.0.0.1:5199 +pnpm dev:desktop # Electron +``` + +In the app: **Settings → Companion**. Turn it on. You should see either + +- *"Your phone will find this computer as …"* — Bonjour is advertising, or +- *"Listening on 192.168.x.x:8810 — enter that on your phone."* — it is not. + +Both are workable; the second just means typing an address. Then **Start +pairing** and check the six-digit code counts down and cancels cleanly. + +Verify from a second terminal that the socket is real and refuses strangers: + +```sh +curl -s http://192.168.x.x:8810/api/bots # expect 401 + "pair this device…" +curl -s http://127.0.0.1:8799/api/remote | jq # enabled, discovery, devices +dns-sd -B _openmausbot._tcp # macOS: should list the service +``` + +### If discovery says it is not advertising + +This is the likeliest snag on macOS, and it is not a bug in the phone. + +- **Port 5353 is owned by mDNSResponder.** The harness asks for `SO_REUSEADDR` + and normally shares it fine, but if something else grabbed it exclusively the + advertisement cannot start. `sudo lsof -i :5353` shows who. +- **The firewall is prompting.** System Settings → Network → Firewall. Incoming + connections to `node`/OpenMausBot must be allowed, or the phone reaches + nothing on 8810 even with a correct address. +- Neither blocks testing: use the typed address instead. Discovery failing is + designed to be a fallback, not a dead end — that is worth confirming too. + +--- + +## Stage 3 — the app builds + +```sh +brew install xcodegen +cd ios && xcodegen generate && open OpenMausCompanion.xcodeproj +``` + +Build for the simulator first — it is a faster loop for compile errors. + +**Re-run `xcodegen generate` whenever a pull adds a file to `App/`.** The +generated project lists source files explicitly, so a new one is missing from +the target until you regenerate, and the build fails with `Cannot find 'X' in +scope` — which looks like a code error and is not one. + +### If the app is letterboxed inside black bars + +Everything drawn oversized, content floating in the middle of the screen, black +above and below: that is iOS compatibility scaling, and it means the built +Info.plist has no `UILaunchScreen` key. Check the built product rather than the +spec — `plutil -p` the Info.plist inside the .app — because +`INFOPLIST_KEY_UILaunchScreen_Generation` is *silently ignored* for a target +that supplies its own Info.plist, which is how this shipped the first time. + +Then switch to a **real device**. The simulator shares the Mac's network stack, +so Bonjour there proves less than it appears to, and the local-network +permission prompt behaves differently. Signing needs a free Apple ID team; no +paid account is required to run on your own phone. + +--- + +## Stage 4 — the thing actually working + +On the phone, in order: + +1. **Pair.** The computer should appear by name. Tap it, type the code. + - If the list stays empty, check in this order: + 1. **Local Network permission.** iOS asks once, and a denial is + permanent and silent. Settings → OpenMausBot → Local Network. If the + toggle is not even there, the prompt never fired — which points at the + Info.plist. Deleting the app and reinstalling resets the decision and + asks again. + 2. **The built Info.plist** actually carries + `NSLocalNetworkUsageDescription` and `NSBonjourServices`. Without them + `NWBrowser` returns nothing at all, silently, and it looks exactly like + an empty network: `plutil -p` the Info.plist inside the built .app. + 3. **The phone and the Mac are on the same Wi-Fi.** Check the actual + SSID on both, not just "is Wi-Fi on" — one device on the guest network + and the other on the main one is the single most common cause, and the + two look identical from the phone. Bonjour is multicast: it does not + cross subnets, and guest networks usually isolate clients from each + other on top of that, which blocks the typed address too. Cellular + carries no Bonjour either, so a phone that fell back to 5G finds + nothing. Settings → Wi-Fi → ⓘ shows the phone's IP; if it is not on + the same /24 as the Mac, that is the answer. + - Then use the typed address as a fallback and keep going. Pairing by + address exercises everything except discovery. + - **If the typed address does not work either**, stop diagnosing the + network and go to stage 5. When both devices are demonstrably on the + same SSID and neither discovery nor a typed address gets through, the + network is isolating its clients and there is nothing to fix on either + machine. +2. **The roster loads**, matching what the desktop shows. +3. **Send a message** from the phone. It should appear on the desktop too — same + harness, two clients. +4. **The approval.** This is the whole product. Ask a bot to do something that + needs permission (`run \`ls\` in my home directory` is enough for most + engines). The card should reach the phone; answering it there should + unblock the bot on the laptop. +5. **Reconnect.** Background the app for a minute while the bot keeps working, + then come back. The transcript should catch up *without* a visible reload — + that is the resumable stream doing its job. Watch the harness log to confirm + it replayed rather than re-hydrated. +6. **Revoke.** Remove the device in Settings → Companion on the computer. The + phone should land on "This phone was unpaired" rather than silently failing. + +--- + +## Stage 5 — off this network, via Tailscale + +Everything above assumes the phone and the Mac can reach each other directly. +Sometimes they cannot, and no amount of checking the SSID fixes it: a guest +network that isolates its clients will let both devices online, show them the +same network name, and still drop every packet between them. Bonjour finds +nothing and the typed address times out, which reads exactly like a broken app. + +Tailscale makes that class of problem go away rather than diagnosing it. Both +devices join a private network of your own and get an address in `100.64.0.0/10` +that does not depend on which Wi-Fi either of them is on — or on Wi-Fi at all, +so this is also how the phone reaches the Mac over cellular. + +1. **On the Mac:** install Tailscale (`brew install --cask tailscale`, or the + App Store build) and sign in. +2. **On the phone:** install Tailscale from the App Store, sign in to the *same* + account, and turn the VPN on. +3. **In OpenMausBot → Settings → Companion:** with the toggle on, the panel now + prints the tailnet name — something like `macbook.tail1234.ts.net:8810`, with + the LAN address listed separately underneath. If it still only shows a + `192.168.x.x` address, the harness could not find the Tailscale CLI; restart + `pnpm dev:server` after Tailscale is running. +4. **On the phone:** pair by typing that name. Discovery does not help here — + Bonjour is multicast and a tailnet does not carry it — so the typed address + is the path, and it is the one path that works from anywhere. + +**Use the name, not the address.** Both reach the harness, but only the name +gets past App Transport Security. iOS exempts local networking, and `100.64/10` +is CGNAT space rather than one of the private ranges that exemption covers, so +a plain-HTTP request to a bare tailnet address is refused by the OS before it +reaches the network. `ios/project.yml` exempts `ts.net` by name instead. The +symptom if you use the address anyway is a connection that fails instantly with +a policy error rather than a timeout. + +Once paired over a tailnet, nothing else changes: the same stream, the same +approvals, the same reconnect behaviour. It is the same listener on the same +port — only the route to it is different. + +--- + +## What is expected not to work + +Not built yet, so not bugs: + +- **Nothing arrives while the app is closed.** No push until APNs. +- **No token-by-token streaming.** Replies land whole, when the turn settles. +- **No computer panel, no voice, no routines.** + +## If the phone sits on "Connecting…" + +The two sides now say what they think is happening, and comparing them is +usually the whole diagnosis: + +- **Harness log** (the `pnpm dev:server` terminal): `companion stream opened` + when a phone's event stream connects, and `companion stream closed` when it + goes. No "opened" line means the request never arrived. +- **Xcode console**, subsystem `com.openmausbot.companion`: `opening stream`, + then `stream live, resumed=…`, then `hydrated N bots`. Whichever of those is + missing is where it stopped. + +Opened on the server but never live on the phone means the bytes are not +reaching the client. Never opened means the request never left it. A repeating +`opened` / `closed` pair on the server with `stream failed: cancelled` on the +phone means the client is tearing its own connection down — that was a real bug +(`URLSession.AsyncBytes` cancels its task when the sequence is released), and +`EventStreamTests` now guards against its whole class. + +## Reporting back + +For Swift errors, the compiler's own output is the most useful thing — file, +line, and message. For runtime problems, the harness log is usually more +informative than the phone: it is where the pairing, auth and stream decisions +are actually made. diff --git a/ios/Tests/CompanionCoreTests/DecodingTests.swift b/ios/Tests/CompanionCoreTests/DecodingTests.swift new file mode 100644 index 0000000000..0da17c2f53 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/DecodingTests.swift @@ -0,0 +1,274 @@ +// Decoding, against bytes the server actually sent. +// +// These fixtures are bytes the server sent, captured by +// `scripts/capture-companion-fixtures.mjs` against a harness started on a +// temporary HOME. That matters more than it sounds: hand-written test JSON +// tests our idea of the API, and the whole risk in a two-language client is +// that our idea drifts from the API without anything failing. When a server +// payload changes, re-capturing makes these fail, which is the alarm. +// +// One exception, and it is marked in the script: options-card.json needs a +// bot to actually ask for approval, which needs a real provider and a real +// turn, so it is not regenerated by a run. +import XCTest +@testable import CompanionCore + +final class DecodingTests: XCTestCase { + // MARK: - Fixtures + + func fixture(_ name: String) throws -> Data { + guard let url = Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") + ?? Bundle.module.url(forResource: name, withExtension: "json") + else { + XCTFail("missing fixture \(name).json — run scripts/capture-companion-fixtures.mjs") + throw CocoaError(.fileNoSuchFile) + } + return try Data(contentsOf: url) + } + + func decode(_ type: T.Type, _ name: String) throws -> T { + try JSONDecoder().decode(type, from: try fixture(name)) + } + + // MARK: - Hydration + + func testDecodesThePagedFleet() throws { + let fleet = try decode(Fleet.self, "bots-paged") + XCTAssertFalse(fleet.bots.isEmpty) + + let bot = try XCTUnwrap(fleet.bots.first) + XCTAssertFalse(bot.id.isEmpty) + XCTAssertFalse(bot.threadId.isEmpty) + XCTAssertFalse(bot.name.isEmpty) + XCTAssertNotNil(bot.messages) + + // the paged shape caps each thread and says whether there is more + let room = try XCTUnwrap(fleet.groups.first) + XCTAssertEqual(room.messages?.count, 3) + XCTAssertEqual(room.hasMore, true) + } + + func testDecodesTheFullFleetToo() throws { + // omitting ?messages must stay decodable by the same types — it is + // what the desktop gets, and what a phone falls back to + let fleet = try decode(Fleet.self, "bots-full") + XCTAssertFalse(fleet.bots.isEmpty) + XCTAssertNil(fleet.bots.first?.hasMore) + } + + func testNeverDecodesProviderSessionCursors() throws { + // resumeCursors is harness bookkeeping and must not be on the wire. + // Asserted against the raw bytes, because a Swift type that simply + // lacks the field would hide it. + for name in ["bots-full", "bots-paged", "sse-frames"] { + let raw = String(decoding: try fixture(name), as: UTF8.self) + XCTAssertFalse(raw.contains("resumeCursors"), "\(name) carries provider session cursors") + } + } + + func testDecodesAThreadPage() throws { + let page = try decode(ThreadPage.self, "thread-page") + XCTAssertEqual(page.messages.count, 2) + XCTAssertEqual(page.hasMore, true) + // pages arrive oldest-first, which is what makes prepending correct + let times = page.messages.map(\.at) + XCTAssertEqual(times, times.sorted()) + } + + // MARK: - Messages and cards + + func testDecodesAnOptionsCard() throws { + let message = try decode(Message.self, "options-card") + XCTAssertEqual(message.kind, .options) + XCTAssertEqual(message.role, .bot) + let card = try XCTUnwrap(message.card) + XCTAssertFalse(card.options.isEmpty) + // the onboarding card has no request behind it, so it is history + XCTAssertFalse(card.isPending) + XCTAssertFalse(card.isPermission) + } + + func testAPendingApprovalIsActionableAndAnAnsweredOneIsNot() throws { + // The shape a live permission request takes, which the fixture rig + // cannot produce without a real provider attached. + let json = """ + { + "id": "m1", "role": "bot", "kind": "options", "at": 1786742413762, + "card": { + "title": "Approval needed", "subtitle": "rm -rf ./build", + "options": ["Allow", "Deny"], "requestId": "req-1", + "tool": "Bash", "allowKey": "Bash:rm" + } + } + """ + let card = try XCTUnwrap(try JSONDecoder().decode(Message.self, from: Data(json.utf8)).card) + XCTAssertTrue(card.isPending) + XCTAssertTrue(card.isPermission) + XCTAssertEqual(card.allowKey, "Bash:rm") + + var answered = card + answered.answered = "Allow" + XCTAssertFalse(answered.isPending, "an answered card must stop offering buttons") + + var dismissed = card + dismissed.dismissed = true + XCTAssertFalse(dismissed.isPending) + } + + func testDecodesAMessageThatGainedAFieldWeDoNotKnow() throws { + // The harness ships ahead of the app. An unknown key must not cost + // the user their conversation. + let json = """ + {"id":"m2","role":"user","kind":"text","at":1,"text":"hi","somethingNew":{"a":1}} + """ + let message = try JSONDecoder().decode(Message.self, from: Data(json.utf8)) + XCTAssertEqual(message.text, "hi") + } + + // MARK: - Pairing and errors + + func testDecodesThePairResponse() throws { + let paired = try decode(PairResponse.self, "pair-response") + XCTAssertTrue(paired.token.hasPrefix("omb_")) + XCTAssertEqual(paired.device.name, "Ada's iPhone") + XCTAssertFalse(paired.serverName.isEmpty) + } + + func testDecodesTheHarnessErrorBodies() throws { + // these strings are written for people, and the client shows them + // rather than inventing its own + XCTAssertTrue(try decode(APIErrorBody.self, "unauthorized").error.contains("pair")) + XCTAssertFalse(try decode(APIErrorBody.self, "forbidden").error.isEmpty) + XCTAssertFalse(try decode(APIErrorBody.self, "pair-rejected").error.isEmpty) + } + + func testDecodesInstancesAndConfig() throws { + let instances = try decode(InstanceList.self, "instances").instances + let ghost = try XCTUnwrap(instances.first) + XCTAssertFalse(ghost.snapshot.isAvailable) + XCTAssertNotNil(ghost.snapshot.reason) + + let config = try decode(ConfigStatus.self, "config") + XCTAssertEqual(config.profile?.name, "Ada Lovelace") + XCTAssertEqual(config.box?.configured, false) + } + + // MARK: - Frames + + func testDecodesEveryCapturedFrame() throws { + let frames = try decode([StreamFrame].self, "sse-frames") + XCTAssertFalse(frames.isEmpty) + + var kinds: [String] = [] + for streamFrame in frames { + switch streamFrame.frame { + case let .hello(cursor, resumed): + kinds.append("hello") + XCTAssertTrue(cursor.contains(":")) + XCTAssertFalse(resumed, "a cold connection has nothing to resume") + XCTAssertNil(streamFrame.seq, "hello is not a replayable frame") + case let .message(threadId, message): + kinds.append("message") + XCTAssertFalse(threadId.isEmpty) + XCTAssertFalse(message.id.isEmpty) + XCTAssertNotNil(streamFrame.seq) + case let .bot(bot): + kinds.append("bot") + XCTAssertFalse(bot.id.isEmpty) + XCTAssertNil(bot.messages, "bot frames carry no transcript") + case let .unknown(kind): + XCTFail("unhandled frame kind in fixtures: \(kind)") + default: + kinds.append("other") + } + } + XCTAssertTrue(kinds.contains("hello")) + XCTAssertTrue(kinds.contains("message")) + XCTAssertTrue(kinds.contains("bot")) + } + + func testAnUnknownFrameKindIsAbsorbedRatherThanThrown() throws { + // the harness will add frame kinds; an old app must keep folding the + // ones it knows instead of tearing down its stream + let frame = try JSONDecoder().decode( + StreamFrame.self, + from: Data(#"{"kind":"routine.run","run":{"id":"r1"},"seq":9}"#.utf8) + ) + XCTAssertEqual(frame.seq, 9) + guard case let .unknown(kind) = frame.frame else { + return XCTFail("expected .unknown, got \(frame.frame)") + } + XCTAssertEqual(kind, "routine.run") + } + + func testDecodesANotifyFrame() throws { + let json = """ + {"kind":"notify","seq":12,"notification":{ + "kind":"approval","botId":"b1","botName":"Scout","threadId":"t1", + "title":"Scout needs approval","body":"rm -rf ./build"}} + """ + let frame = try JSONDecoder().decode(StreamFrame.self, from: Data(json.utf8)) + guard case let .notify(notification) = frame.frame else { + return XCTFail("expected .notify") + } + XCTAssertTrue(notification.isBlocking) + XCTAssertEqual(notification.threadId, "t1") + XCTAssertEqual(frame.frame.threadId, "t1") + } + + // MARK: - A newer computer than the phone + + // The harness gains message kinds when it ships; the phone gains them + // when the App Store gets round to it. So the phone talking to a newer + // computer is the ordinary case, and it must survive one. + + func testAnUnknownMessageKindDecodes() throws { + let json = """ + {"id":"m1","role":"bot","kind":"webhook","at":1,"text":"Stripe fired"} + """ + let message = try JSONDecoder().decode(Message.self, from: Data(json.utf8)) + XCTAssertEqual(message.kind, .unknown) + // and keeps what it can show + XCTAssertEqual(message.text, "Stripe fired") + } + + func testAnUnknownRoleIsNotAttributedToYou() throws { + let json = """ + {"id":"m1","role":"system","kind":"text","at":1,"text":"hello"} + """ + let message = try JSONDecoder().decode(Message.self, from: Data(json.utf8)) + XCTAssertEqual(message.role, .bot) + } + + /// The one that matters. `kind` is not optional, so before this a single + /// unrecognised message failed the decode of the entire response — the + /// thread did not render one message oddly, it did not render. + func testOneUnknownMessageDoesNotSinkThePage() throws { + let json = """ + {"messages":[ + {"id":"m1","role":"user","kind":"text","at":1,"text":"go"}, + {"id":"m2","role":"bot","kind":"something-new","at":2,"text":"working"}, + {"id":"m3","role":"bot","kind":"text","at":3,"text":"done"} + ],"hasMore":false} + """ + let page = try JSONDecoder().decode(ThreadPage.self, from: Data(json.utf8)) + XCTAssertEqual(page.messages.count, 3) + XCTAssertEqual(page.messages.map(\.kind), [.text, .unknown, .text]) + XCTAssertEqual(page.messages.map(\.id), ["m1", "m2", "m3"]) + } + + /// Same page, arriving one message at a time down the stream. + func testAnUnknownMessageArrivesOverTheStream() throws { + let json = """ + {"kind":"message","seq":3,"threadId":"t1", + "message":{"id":"m9","role":"bot","kind":"routine.run","at":9,"text":"ran"}} + """ + let frame = try JSONDecoder().decode(StreamFrame.self, from: Data(json.utf8)) + guard case let .message(threadId, message) = frame.frame else { + return XCTFail("expected .message, got \(frame.frame)") + } + XCTAssertEqual(threadId, "t1") + XCTAssertEqual(message.kind, .unknown) + XCTAssertEqual(message.text, "ran") + } +} diff --git a/ios/Tests/CompanionCoreTests/EventStreamTests.swift b/ios/Tests/CompanionCoreTests/EventStreamTests.swift new file mode 100644 index 0000000000..ed69912c6b --- /dev/null +++ b/ios/Tests/CompanionCoreTests/EventStreamTests.swift @@ -0,0 +1,193 @@ +// The event stream against a real URLSession. +// +// This file exists because of two bugs that shipped past every other test +// here, both in the few lines between "URLSession has bytes" and "the app +// has frames": +// +// 1. `request.timeoutInterval = .greatestFiniteMagnitude` — reads as +// "never time out", actually produces a request that opens and then +// delivers nothing. +// 2. Keeping only the line iterator and letting `URLSession.AsyncBytes` +// go out of scope — AsyncBytes cancels its data task when released, +// so the connection died after the first frame. +// 3. Reading with `bytes.lines`, which folds consecutive newlines into +// one separator and so never reports the blank line that terminates +// an SSE event. Zero frames, no error, connection healthy at both +// ends. +// +// Both need a real URLSession to reproduce: the parser tests pass either +// way, because the parser was never wrong. A stub protocol that delivers +// chunks *over time* is the smallest thing that catches this class — the +// symptom of both bugs is a stream that stops early. +import XCTest +@testable import CompanionCore + +/// A fake origin server that dribbles out an SSE response. +/// +/// "Has this request been torn down?" is deliberately **per instance**, not +/// static. URLSession calls `stopLoading()` asynchronously, so a previous +/// test's teardown can land in the middle of the next one — which, when this +/// flag was shared, silently truncated the next test's stream and turned a +/// clean failure into an out-of-range crash. +final class StreamingStub: URLProtocol { + /// Chunks to deliver, in order, with a small gap between them. + static var chunks: [String] = [] + static var status = 200 + /// How many requests have been torn down — the cancellation assertion, + /// counted rather than flagged so it cannot be reset by a straggler. + static var stopCount = 0 + + private let stopped = Stopped() + + /// A tiny box, because `stopLoading()` may be called from another thread + /// than the one delivering chunks. + final class Stopped { + private let lock = NSLock() + private var value = false + var isSet: Bool { + lock.lock(); defer { lock.unlock() } + return value + } + func set() { + lock.lock(); defer { lock.unlock() } + value = true + } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = HTTPURLResponse( + url: request.url!, + statusCode: Self.status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/event-stream"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + // Strong `self` on purpose. URLSession does not promise to keep a + // protocol instance alive past `startLoading()` returning, and with a + // weak capture this block woke up to a nil self and delivered nothing + // — every test after the first one saw an empty stream. The instance + // is the thing doing the work, so it holds itself up until done. + let stopped = self.stopped + DispatchQueue.global().async { + for chunk in Self.chunks { + // a real connection that has been cancelled delivers no more + if stopped.isSet { return } + self.client?.urlProtocol(self, didLoad: Data(chunk.utf8)) + Thread.sleep(forTimeInterval: 0.03) + } + if !stopped.isSet { self.client?.urlProtocolDidFinishLoading(self) } + } + } + + override func stopLoading() { + stopped.set() + Self.stopCount += 1 + } +} + +final class EventStreamTests: XCTestCase { + var session: URLSession! + + override func setUp() { + super.setUp() + StreamingStub.chunks = [] + StreamingStub.status = 200 + StreamingStub.stopCount = 0 + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StreamingStub.self] + session = URLSession(configuration: configuration) + } + + override func tearDown() { + // a URLSession keeps itself alive until told otherwise, and a live + // one from a finished test has no business still holding a request + session?.invalidateAndCancel() + session = nil + super.tearDown() + } + + private var request: URLRequest { + URLRequest(url: URL(string: "http://127.0.0.1:8800/api/events")!) + } + + private func collect(limit: Int) async throws -> [StreamFrame] { + var frames: [StreamFrame] = [] + for try await frame in eventStream(request: request, session: session) { + frames.append(frame) + if frames.count >= limit { break } + } + return frames + } + + func testDeliversEveryFrameAsItArrives() async throws { + // the shape the harness writes: hello, then frames over time + StreamingStub.chunks = [ + "data: {\"kind\":\"hello\",\"cursor\":\"abc12345:0\",\"resumed\":false}\n\n", + ": keepalive\n\n", + "id: abc12345:1\ndata: {\"kind\":\"bot\",\"seq\":1,\"bot\":{\"id\":\"b1\",\"threadId\":\"t1\",\"name\":\"Scout\",\"title\":\"\",\"description\":\"\",\"notifications\":true,\"color\":\"green\",\"unread\":false,\"modelSelection\":{\"instanceId\":\"i\",\"model\":\"m\"},\"createdAt\":1}}\n\n", + "id: abc12345:2\ndata: {\"kind\":\"message\",\"seq\":2,\"threadId\":\"t1\",\"message\":{\"id\":\"m1\",\"role\":\"user\",\"kind\":\"text\",\"at\":1,\"text\":\"hi\"}}\n\n", + ] + + let frames = try await collect(limit: 3) + + // Three frames arriving in three separate reads is the whole + // assertion: a cancelled-after-first-frame stream yields one. Guard + // rather than index — a short stream is the failure under test, and + // it should read as one instead of trapping. + guard frames.count == 3 else { + return XCTFail("expected 3 frames, got \(frames.count) — the stream stopped early") + } + guard case .hello = frames[0].frame else { return XCTFail("expected hello first") } + guard case .bot = frames[1].frame else { return XCTFail("expected bot") } + guard case .message = frames[2].frame else { return XCTFail("expected message") } + XCTAssertEqual(frames[2].seq, 2) + } + + func testSurvivesAFrameSplitAcrossReads() async throws { + // TCP does not respect frame boundaries, and neither should we + StreamingStub.chunks = [ + "data: {\"kind\":\"hel", + "lo\",\"cursor\":\"abc12345:0\",\"resumed\":true}", + "\n\n", + ] + let frames = try await collect(limit: 1) + guard case let .hello(cursor, resumed) = frames.first?.frame else { + return XCTFail("expected a hello reassembled from three reads") + } + XCTAssertEqual(cursor, "abc12345:0") + XCTAssertTrue(resumed) + } + + func testReportsAnUnauthorizedStreamRatherThanEndingQuietly() async { + StreamingStub.status = 401 + StreamingStub.chunks = ["{\"error\":\"pair this device\"}"] + do { + _ = try await collect(limit: 1) + XCTFail("a 401 must surface, not look like an empty stream") + } catch let error as APIError { + XCTAssertTrue(error.isUnauthorized) + } catch { + XCTFail("expected an APIError, got \(error)") + } + } + + func testCancellingTheConsumerTearsDownTheRequest() async throws { + StreamingStub.chunks = [ + "data: {\"kind\":\"hello\",\"cursor\":\"abc12345:0\",\"resumed\":false}\n\n", + "data: {\"kind\":\"config\",\"seq\":1}\n\n", + "data: {\"kind\":\"config\",\"seq\":2}\n\n", + ] + // Assert the frame arrived, not just that teardown happened: this + // test passed for three rounds while the stream delivered nothing at + // all, because it only ever checked the teardown half. + let frames = try await collect(limit: 1) + XCTAssertEqual(frames.count, 1, "the stream should have delivered its first frame") + // give the teardown a moment to propagate through URLSession + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertGreaterThan(StreamingStub.stopCount, 0, "leaving the stream should cancel the request") + } +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/bots-full.json b/ios/Tests/CompanionCoreTests/Fixtures/bots-full.json new file mode 100644 index 0000000000..2df21e7b61 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/bots-full.json @@ -0,0 +1,55 @@ +{ + "bots": [ + { + "id": "4f254cf4-98c5-45ca-907e-967e2bbcb8ac", + "threadId": "1f3a20e3-94f2-432b-8af5-db9664ed74a2", + "name": "Pesto", + "title": "", + "description": "", + "notifications": true, + "color": "green", + "unread": false, + "modelSelection": { + "instanceId": "", + "model": "" + }, + "createdAt": 1786742441013, + "tasks": [ + { + "threadId": "1f3a20e3-94f2-432b-8af5-db9664ed74a2", + "title": "New task", + "createdAt": 1786742441013 + } + ], + "messages": [ + { + "id": "0c537eab-3088-4bd3-ab26-2a25ff83b91d", + "at": 1786742441015, + "parentId": null, + "role": "bot", + "kind": "text", + "text": "Hey — I'm Pesto. Nice to meet you." + }, + { + "id": "a5f66904-863b-4e5f-bb22-0139b49e6103", + "at": 1786742441016, + "parentId": "0c537eab-3088-4bd3-ab26-2a25ff83b91d", + "role": "bot", + "kind": "options", + "card": { + "title": "What do you mostly want help with?", + "subtitle": "Pick whatever's closest; we can always expand from there.", + "options": [ + "Work & projects", + "Writing & research", + "Life admin", + "A bit of everything" + ] + } + } + ], + "activeLeafId": "a5f66904-863b-4e5f-bb22-0139b49e6103" + } + ], + "groups": [] +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/bots-paged.json b/ios/Tests/CompanionCoreTests/Fixtures/bots-paged.json new file mode 100644 index 0000000000..2f5db83844 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/bots-paged.json @@ -0,0 +1,99 @@ +{ + "bots": [ + { + "id": "4f254cf4-98c5-45ca-907e-967e2bbcb8ac", + "threadId": "1f3a20e3-94f2-432b-8af5-db9664ed74a2", + "name": "Pesto", + "title": "", + "description": "", + "notifications": true, + "color": "green", + "unread": true, + "modelSelection": { + "instanceId": "", + "model": "" + }, + "createdAt": 1786742441013, + "tasks": [ + { + "threadId": "1f3a20e3-94f2-432b-8af5-db9664ed74a2", + "title": "New task", + "createdAt": 1786742441013 + } + ], + "messages": [ + { + "id": "0c537eab-3088-4bd3-ab26-2a25ff83b91d", + "at": 1786742441015, + "parentId": null, + "role": "bot", + "kind": "text", + "text": "Hey — I'm Pesto. Nice to meet you." + }, + { + "id": "a5f66904-863b-4e5f-bb22-0139b49e6103", + "at": 1786742441016, + "parentId": "0c537eab-3088-4bd3-ab26-2a25ff83b91d", + "role": "bot", + "kind": "options", + "card": { + "title": "What do you mostly want help with?", + "subtitle": "Pick whatever's closest; we can always expand from there.", + "options": [ + "Work & projects", + "Writing & research", + "Life admin", + "A bit of everything" + ] + } + } + ], + "activeLeafId": "a5f66904-863b-4e5f-bb22-0139b49e6103", + "hasMore": false + } + ], + "groups": [ + { + "id": "e87fe538-e4a9-4ed5-b46e-d4bc7a580bab", + "threadId": "58c85a55-425d-4180-944f-d03f38f9bbbf", + "name": "Fixtures", + "memberIds": [ + "4f254cf4-98c5-45ca-907e-967e2bbcb8ac" + ], + "defaultResponder": { + "kind": "mentions" + }, + "bulletin": "", + "unread": false, + "createdAt": 1786742441201, + "busyBotId": null, + "messages": [ + { + "id": "5d92912c-d2f7-40cf-878b-341464cce922", + "at": 1786742441221, + "parentId": "459edaa5-f290-4770-aeb1-e0908b2b8b4a", + "role": "user", + "kind": "text", + "text": "fixture message 3" + }, + { + "id": "10726f98-3a08-44da-8240-e8d84b1f0459", + "at": 1786742441223, + "parentId": "5d92912c-d2f7-40cf-878b-341464cce922", + "role": "user", + "kind": "text", + "text": "fixture message 4" + }, + { + "id": "f2525bc3-4594-4394-8b2b-8df19d6cb72d", + "at": 1786742441225, + "parentId": "10726f98-3a08-44da-8240-e8d84b1f0459", + "role": "user", + "kind": "text", + "text": "fixture message 5" + } + ], + "hasMore": true + } + ] +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/config.json b/ios/Tests/CompanionCoreTests/Fixtures/config.json new file mode 100644 index 0000000000..c5faa9b024 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/config.json @@ -0,0 +1,21 @@ +{ + "xai": { + "configured": false + }, + "composio": { + "configured": false, + "apiKeyConfigured": false + }, + "box": { + "configured": false + }, + "tts": { + "configured": false, + "ready": false, + "voice": "" + }, + "profile": { + "name": "Ada Lovelace", + "email": "ada@example.com" + } +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/forbidden.json b/ios/Tests/CompanionCoreTests/Fixtures/forbidden.json new file mode 100644 index 0000000000..faa5586f14 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/forbidden.json @@ -0,0 +1,3 @@ +{ + "error": "API keys can only be changed on your computer" +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/instances.json b/ios/Tests/CompanionCoreTests/Fixtures/instances.json new file mode 100644 index 0000000000..dd19432249 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/instances.json @@ -0,0 +1,21 @@ +{ + "instances": [ + { + "instanceId": "ghost", + "driverKind": "not-a-real-driver", + "displayName": "Ghost", + "snapshot": { + "state": "unavailable", + "reason": "unknown driver \"not-a-real-driver\" — kept as configured, unavailable here" + }, + "models": { + "default": "", + "options": [] + }, + "capabilities": { + "computerMcp": false, + "agentsMcp": false + } + } + ] +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/options-card.json b/ios/Tests/CompanionCoreTests/Fixtures/options-card.json new file mode 100644 index 0000000000..0872fb39a6 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/options-card.json @@ -0,0 +1,17 @@ +{ + "id": "a5f66904-863b-4e5f-bb22-0139b49e6103", + "at": 1786742441016, + "parentId": "0c537eab-3088-4bd3-ab26-2a25ff83b91d", + "role": "bot", + "kind": "options", + "card": { + "title": "What do you mostly want help with?", + "subtitle": "Pick whatever's closest; we can always expand from there.", + "options": [ + "Work & projects", + "Writing & research", + "Life admin", + "A bit of everything" + ] + } +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/pair-rejected.json b/ios/Tests/CompanionCoreTests/Fixtures/pair-rejected.json new file mode 100644 index 0000000000..6b6995f053 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/pair-rejected.json @@ -0,0 +1,3 @@ +{ + "error": "no pairing is in progress — open Companion settings on your computer" +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/pair-response.json b/ios/Tests/CompanionCoreTests/Fixtures/pair-response.json new file mode 100644 index 0000000000..d6a0153579 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/pair-response.json @@ -0,0 +1,10 @@ +{ + "token": "omb_REDACTED", + "device": { + "id": "0bbd0e55-db33-4900-9ca7-2e5fa19cc0ba", + "name": "Ada's iPhone", + "createdAt": 1786742441179, + "lastSeenAt": 1786742441179 + }, + "serverName": "Ada Lovelace's computer" +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/sse-frames.json b/ios/Tests/CompanionCoreTests/Fixtures/sse-frames.json new file mode 100644 index 0000000000..1d6dd7ba39 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/sse-frames.json @@ -0,0 +1,111 @@ +[ + { + "kind": "hello", + "cursor": "f5696cc9:3", + "resumed": false + }, + { + "kind": "message", + "threadId": "58c85a55-425d-4180-944f-d03f38f9bbbf", + "message": { + "id": "9f1acf7e-b628-43dc-a53a-b1fe8761f9c6", + "at": 1786742441212, + "parentId": null, + "role": "user", + "kind": "text", + "text": "fixture message 0" + }, + "seq": 4 + }, + { + "kind": "message", + "threadId": "58c85a55-425d-4180-944f-d03f38f9bbbf", + "message": { + "id": "27403904-171d-4ea2-80a5-2a571a3586bd", + "at": 1786742441215, + "parentId": "9f1acf7e-b628-43dc-a53a-b1fe8761f9c6", + "role": "user", + "kind": "text", + "text": "fixture message 1" + }, + "seq": 5 + }, + { + "kind": "message", + "threadId": "58c85a55-425d-4180-944f-d03f38f9bbbf", + "message": { + "id": "459edaa5-f290-4770-aeb1-e0908b2b8b4a", + "at": 1786742441219, + "parentId": "27403904-171d-4ea2-80a5-2a571a3586bd", + "role": "user", + "kind": "text", + "text": "fixture message 2" + }, + "seq": 6 + }, + { + "kind": "message", + "threadId": "58c85a55-425d-4180-944f-d03f38f9bbbf", + "message": { + "id": "5d92912c-d2f7-40cf-878b-341464cce922", + "at": 1786742441221, + "parentId": "459edaa5-f290-4770-aeb1-e0908b2b8b4a", + "role": "user", + "kind": "text", + "text": "fixture message 3" + }, + "seq": 7 + }, + { + "kind": "message", + "threadId": "58c85a55-425d-4180-944f-d03f38f9bbbf", + "message": { + "id": "10726f98-3a08-44da-8240-e8d84b1f0459", + "at": 1786742441223, + "parentId": "5d92912c-d2f7-40cf-878b-341464cce922", + "role": "user", + "kind": "text", + "text": "fixture message 4" + }, + "seq": 8 + }, + { + "kind": "message", + "threadId": "58c85a55-425d-4180-944f-d03f38f9bbbf", + "message": { + "id": "f2525bc3-4594-4394-8b2b-8df19d6cb72d", + "at": 1786742441225, + "parentId": "10726f98-3a08-44da-8240-e8d84b1f0459", + "role": "user", + "kind": "text", + "text": "fixture message 5" + }, + "seq": 9 + }, + { + "kind": "bot", + "bot": { + "id": "4f254cf4-98c5-45ca-907e-967e2bbcb8ac", + "threadId": "1f3a20e3-94f2-432b-8af5-db9664ed74a2", + "name": "Pesto", + "title": "", + "description": "", + "notifications": true, + "color": "green", + "unread": true, + "modelSelection": { + "instanceId": "", + "model": "" + }, + "createdAt": 1786742441013, + "tasks": [ + { + "threadId": "1f3a20e3-94f2-432b-8af5-db9664ed74a2", + "title": "New task", + "createdAt": 1786742441013 + } + ] + }, + "seq": 10 + } +] diff --git a/ios/Tests/CompanionCoreTests/Fixtures/sse-hello.json b/ios/Tests/CompanionCoreTests/Fixtures/sse-hello.json new file mode 100644 index 0000000000..3eb347a36c --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/sse-hello.json @@ -0,0 +1,5 @@ +{ + "kind": "hello", + "cursor": "f5696cc9:3", + "resumed": false +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/thread-page.json b/ios/Tests/CompanionCoreTests/Fixtures/thread-page.json new file mode 100644 index 0000000000..2cf197dcb1 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/thread-page.json @@ -0,0 +1,21 @@ +{ + "messages": [ + { + "id": "10726f98-3a08-44da-8240-e8d84b1f0459", + "at": 1786742441223, + "parentId": "5d92912c-d2f7-40cf-878b-341464cce922", + "role": "user", + "kind": "text", + "text": "fixture message 4" + }, + { + "id": "f2525bc3-4594-4394-8b2b-8df19d6cb72d", + "at": 1786742441225, + "parentId": "10726f98-3a08-44da-8240-e8d84b1f0459", + "role": "user", + "kind": "text", + "text": "fixture message 5" + } + ], + "hasMore": true +} diff --git a/ios/Tests/CompanionCoreTests/Fixtures/unauthorized.json b/ios/Tests/CompanionCoreTests/Fixtures/unauthorized.json new file mode 100644 index 0000000000..c315fbdaa7 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/Fixtures/unauthorized.json @@ -0,0 +1,3 @@ +{ + "error": "pair this device from the OpenMausBot companion on your computer" +} diff --git a/ios/Tests/CompanionCoreTests/MarkdownTests.swift b/ios/Tests/CompanionCoreTests/MarkdownTests.swift new file mode 100644 index 0000000000..fc342cef34 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/MarkdownTests.swift @@ -0,0 +1,197 @@ +// The block splitter. +// +// The view half — fonts, bullets, the caret — needs a simulator to judge and +// is not tested here. The split is the part with decisions in it, and every +// decision below is one the desktop's react-markdown + remark-gfm already +// made, so the two clients wrap the same reply the same way. +// +// The streaming cases matter most. Every one of these strings is a state the +// phone renders for real, several times a second, on the way to a finished +// reply: half a fence, half a bold run, a bullet with nothing after it. A +// parser that throws away input in those states makes text flicker. +import XCTest +@testable import CompanionCore + +final class MarkdownTests: XCTestCase { + func testPlainTextIsOneParagraph() { + XCTAssertEqual(Markdown.blocks("just a reply"), [.paragraph("just a reply")]) + } + + func testEmptyInputProducesNothing() { + XCTAssertEqual(Markdown.blocks(""), []) + XCTAssertEqual(Markdown.blocks("\n\n \n"), []) + } + + /// GFM without `breaks`, which is how the desktop is configured: a lone + /// newline is a soft break and renders as a space. Joining with "\n" + /// instead would wrap differently on the two clients for the same text. + func testSoftBreaksBecomeSpaces() { + XCTAssertEqual( + Markdown.blocks("one line\nand its continuation"), + [.paragraph("one line and its continuation")] + ) + } + + func testBlankLineSeparatesParagraphs() { + XCTAssertEqual( + Markdown.blocks("first\n\nsecond"), + [.paragraph("first"), .paragraph("second")] + ) + } + + // MARK: - Headings + + func testHeadingLevels() { + XCTAssertEqual(Markdown.blocks("# Title"), [.heading(level: 1, text: "Title")]) + XCTAssertEqual(Markdown.blocks("### Deeper"), [.heading(level: 3, text: "Deeper")]) + } + + /// ATX requires the space. Without this check every "#1 pick" and every + /// "#hashtag" in a reply becomes a 21pt heading. + func testHashWithoutSpaceIsNotAHeading() { + XCTAssertEqual(Markdown.blocks("#hashtag"), [.paragraph("#hashtag")]) + XCTAssertEqual(Markdown.blocks("####### seven"), [.paragraph("####### seven")]) + } + + // MARK: - Lists + + func testBulletMarkers() { + XCTAssertEqual( + Markdown.blocks("- one\n* two\n+ three"), + [ + .bullet(indent: 0, text: "one"), + .bullet(indent: 0, text: "two"), + .bullet(indent: 0, text: "three"), + ] + ) + } + + func testNestedBulletsCountIndent() { + XCTAssertEqual( + Markdown.blocks("- top\n - nested\n - deeper"), + [ + .bullet(indent: 0, text: "top"), + .bullet(indent: 1, text: "nested"), + .bullet(indent: 2, text: "deeper"), + ] + ) + } + + func testOrderedListsKeepTheirNumbers() { + XCTAssertEqual( + Markdown.blocks("1. first\n2. second\n10) tenth"), + [ + .ordered(indent: 0, number: 1, text: "first"), + .ordered(indent: 0, number: 2, text: "second"), + .ordered(indent: 0, number: 10, text: "tenth"), + ] + ) + } + + /// A year or a price at the start of a sentence is not a list. The + /// delimiter is what makes it one. + func testNumberWithoutDelimiterIsProse() { + XCTAssertEqual(Markdown.blocks("2026 was the year"), [.paragraph("2026 was the year")]) + XCTAssertEqual(Markdown.blocks("3.14 is pi"), [.paragraph("3.14 is pi")]) + } + + /// Inline emphasis is Foundation's job, not this one — the splitter must + /// hand the markers through untouched or the view has nothing to render. + func testInlineSyntaxSurvivesTheSplit() { + XCTAssertEqual( + Markdown.blocks("- **bold** and `code` and [link](https://x.test)"), + [.bullet(indent: 0, text: "**bold** and `code` and [link](https://x.test)")] + ) + } + + // MARK: - Fences + + func testFencedCodeKeepsItsLanguageAndItsWhitespace() { + XCTAssertEqual( + Markdown.blocks("```swift\nlet x = 1\n indented\n```"), + [.code(language: "swift", text: "let x = 1\n indented")] + ) + } + + func testFenceWithoutLanguage() { + XCTAssertEqual(Markdown.blocks("```\nplain\n```"), [.code(language: nil, text: "plain")]) + } + + /// Mid-stream, a fence is open for as long as the snippet takes to + /// arrive. Rendering it as code from the first line means the block grows + /// downward; waiting for the closing fence means three backticks sit on + /// screen and then the whole thing reflows at once. + func testUnclosedFenceRunsToTheEnd() { + XCTAssertEqual( + Markdown.blocks("here:\n```py\nprint(1)"), + [.paragraph("here:"), .code(language: "py", text: "print(1)")] + ) + } + + /// Markers inside a fence are code, not structure. + func testFenceContentIsNotReparsed() { + XCTAssertEqual( + Markdown.blocks("```\n# not a heading\n- not a bullet\n```"), + [.code(language: nil, text: "# not a heading\n- not a bullet")] + ) + } + + // MARK: - Quotes and rules + + func testQuote() { + XCTAssertEqual(Markdown.blocks("> quoted"), [.quote("quoted")]) + } + + func testHorizontalRules() { + XCTAssertEqual(Markdown.blocks("---"), [.rule]) + XCTAssertEqual(Markdown.blocks("***"), [.rule]) + XCTAssertEqual(Markdown.blocks("___"), [.rule]) + } + + /// Two hyphens are not a rule, and "- " is a bullet regardless. + func testRuleNeedsThreeAndNothingElse() { + XCTAssertEqual(Markdown.blocks("--"), [.paragraph("--")]) + XCTAssertEqual(Markdown.blocks("-- dashes --"), [.paragraph("-- dashes --")]) + } + + // MARK: - Streaming + + /// The invariant that keeps the bubble from flickering: whatever arrives, + /// something renders, and the characters the model has sent are in it. + func testPartialInputAlwaysRendersSomething() { + for prefix in ["#", "# ", "# Head", "- ", "- it", "**bo", "```", "```sw\nlet", "[link](htt"] { + XCTAssertFalse( + Markdown.blocks(prefix).isEmpty, + "dropped everything for \(prefix.debugDescription)" + ) + } + } + + /// Growing the source one character at a time must never lose text. This + /// is the whole stream, replayed at the granularity the deltas arrive at. + func testNoPrefixOfAReplyLosesCharacters() { + let reply = "# Result\n\nRan **two** checks:\n\n- `pnpm test` passed\n- `pnpm lint` passed\n\n```sh\npnpm test\n```\n\n> nothing else to report" + for length in 1...reply.count { + let partial = String(reply.prefix(length)) + let rendered = Markdown.blocks(partial).map(text).joined() + // Compare on non-whitespace: the splitter deliberately drops + // markers, indentation and blank lines, and it joins soft breaks + // with a space. What it must not drop is content. + let sent = partial.filter { !$0.isWhitespace && !"#->`*_".contains($0) } + let shown = rendered.filter { !$0.isWhitespace && !"#->`*_".contains($0) } + XCTAssertEqual(shown, sent, "lost content at \(length) characters") + } + } + + private func text(_ block: MarkdownBlock) -> String { + switch block { + case let .paragraph(text): return text + case let .bullet(_, text): return text + case let .ordered(_, number, text): return "\(number)" + text + case let .heading(_, text): return text + case let .code(language, text): return (language ?? "") + text + case let .quote(text): return text + case .rule: return "" + } + } +} diff --git a/ios/Tests/CompanionCoreTests/SSETests.swift b/ios/Tests/CompanionCoreTests/SSETests.swift new file mode 100644 index 0000000000..a17121cafa --- /dev/null +++ b/ios/Tests/CompanionCoreTests/SSETests.swift @@ -0,0 +1,85 @@ +// The SSE parser, which is where a hand-rolled event client goes wrong. +import XCTest +@testable import CompanionCore + +final class SSETests: XCTestCase { + /// Feed lines the way `AsyncLineSequence` would — newlines stripped. + func events(_ lines: [String]) -> [SSEEvent] { + var parser = SSEParser() + return lines.compactMap { parser.line($0) } + } + + func testReadsOneFrameTheHarnessActuallySends() { + // exactly the shape server/index.ts writes + let parsed = events(["id: 778d5d30:4", #"data: {"kind":"bot","seq":4}"#, ""]) + XCTAssertEqual(parsed.count, 1) + XCTAssertEqual(parsed[0].id, "778d5d30:4") + XCTAssertEqual(parsed[0].data, #"{"kind":"bot","seq":4}"#) + } + + func testSwallowsKeepaliveComments() { + // a 25-second `: keepalive` is the only reason this stream survives + // a NAT, and it must never surface as an event + XCTAssertTrue(events([": keepalive", ""]).isEmpty) + let parsed = events([": keepalive", "", "data: {}", ""]) + XCTAssertEqual(parsed.count, 1) + } + + func testEmitsNothingUntilTheBlankLineArrives() { + var parser = SSEParser() + XCTAssertNil(parser.line("id: s:1")) + XCTAssertNil(parser.line("data: {}")) + // a frame split across two network chunks must not be emitted early + XCTAssertNotNil(parser.line("")) + } + + func testJoinsMultipleDataLines() { + let parsed = events(["data: {", #"data: "kind": "hello""#, "data: }", ""]) + XCTAssertEqual(parsed.first?.data, "{\n\"kind\": \"hello\"\n}") + } + + func testHandlesTheOptionalSpaceAndCarriageReturns() { + XCTAssertEqual(events(["data:tight", ""]).first?.data, "tight") + XCTAssertEqual(events(["data: padded", ""]).first?.data, " padded", "only one space is the separator") + XCTAssertEqual(events(["data: crlf\r", "\r"]).first?.data, "crlf") + } + + func testIgnoresFieldsItDoesNotUseAndBlocksWithoutData() { + XCTAssertTrue(events(["event: ping", "retry: 3000", ""]).isEmpty) + XCTAssertTrue(events(["", "", ""]).isEmpty) + XCTAssertTrue(events(["garbage-with-no-colon", ""]).isEmpty) + } + + func testDoesNotLeakFieldsFromOneEventIntoTheNext() { + var parser = SSEParser() + _ = parser.line("id: s:1") + _ = parser.line("data: first") + let first = parser.line("") + _ = parser.line("data: second") + let second = parser.line("") + + XCTAssertEqual(first?.id, "s:1") + XCTAssertEqual(second?.data, "second") + XCTAssertNil(second?.id, "the second event never carried an id") + } + + func testAFullStreamDecodesIntoFrames() throws { + let lines = [ + #"data: {"kind":"hello","cursor":"abc12345:0","resumed":false}"#, "", + ": keepalive", "", + "id: abc12345:1", + #"data: {"kind":"message","threadId":"t1","seq":1,"message":{"id":"m1","role":"user","kind":"text","at":1}}"#, + "", + ] + let decoder = JSONDecoder() + let frames = events(lines).compactMap { event in + try? decoder.decode(StreamFrame.self, from: Data(event.data.utf8)) + } + XCTAssertEqual(frames.count, 2) + guard case let .hello(cursor, resumed) = frames[0].frame else { return XCTFail("expected hello") } + XCTAssertEqual(cursor, "abc12345:0") + XCTAssertFalse(resumed) + guard case .message = frames[1].frame else { return XCTFail("expected message") } + XCTAssertEqual(frames[1].seq, 1) + } +} diff --git a/ios/Tests/CompanionCoreTests/StoreTests.swift b/ios/Tests/CompanionCoreTests/StoreTests.swift new file mode 100644 index 0000000000..31d3acbe35 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/StoreTests.swift @@ -0,0 +1,323 @@ +// The fold. Everything here is a claim about what the user sees after a +// frame lands, so it is written against frames rather than internals. +import XCTest +@testable import CompanionCore + +final class StoreTests: XCTestCase { + func fleet() throws -> Fleet { + let url = try XCTUnwrap( + Bundle.module.url(forResource: "bots-paged", withExtension: "json", subdirectory: "Fixtures") + ?? Bundle.module.url(forResource: "bots-paged", withExtension: "json") + ) + return try JSONDecoder().decode(Fleet.self, from: try Data(contentsOf: url)) + } + + func message(_ id: String, at: Double = 1, text: String = "hello") -> Message { + var message = Message(id: id, role: .user, kind: .text, at: at) + message.text = text + return message + } + + func hydrated() throws -> CompanionState { + var state = CompanionState() + state.hydrate(try fleet()) + return state + } + + // MARK: - Hydration + + func testHydrateIndexesEveryThread() throws { + let state = try hydrated() + XCTAssertFalse(state.bots.isEmpty) + for bot in state.bots { + XCTAssertNotNil(state.messages[bot.threadId]) + } + for room in state.rooms { + XCTAssertEqual(state.transcript(forThread: room.threadId).count, room.messages?.count) + XCTAssertEqual(state.hasMore[room.threadId], room.hasMore) + } + } + + // MARK: - Messages + + func testAppendsAndPatchesInPlace() throws { + var state = try hydrated() + let threadId = try XCTUnwrap(state.bots.first).threadId + let before = state.transcript(forThread: threadId).count + + state.apply(.message(threadId: threadId, message: message("new-1"))) + XCTAssertEqual(state.transcript(forThread: threadId).count, before + 1) + + var patched = message("new-1") + patched.text = "edited" + state.apply(.messagePatch(threadId: threadId, message: patched)) + XCTAssertEqual(state.transcript(forThread: threadId).count, before + 1, "a patch must not append") + XCTAssertEqual(state.transcript(forThread: threadId).last?.text, "edited") + } + + func testAReplayedMessageDoesNotAppearTwice() throws { + // resuming redelivers whatever was in flight when the socket died + var state = try hydrated() + let threadId = try XCTUnwrap(state.bots.first).threadId + let before = state.transcript(forThread: threadId).count + + state.apply(.message(threadId: threadId, message: message("dupe"))) + state.apply(.message(threadId: threadId, message: message("dupe"))) + XCTAssertEqual(state.transcript(forThread: threadId).count, before + 1) + } + + func testScrollbackPrependsWithoutDuplicating() throws { + var state = CompanionState() + state.messages["t1"] = [message("c"), message("d")] + state.prepend(ThreadPage(messages: [message("a"), message("b"), message("c")], hasMore: true), toThread: "t1") + + XCTAssertEqual(state.transcript(forThread: "t1").map(\.id), ["a", "b", "c", "d"]) + XCTAssertEqual(state.hasMore["t1"], true) + } + + // MARK: - Bots + + func testABotFrameMergesRatherThanWipingTheTranscript() throws { + // bot frames carry no messages; assigning one would empty the chat + var state = try hydrated() + var bot = try XCTUnwrap(state.bots.first) + let threadId = bot.threadId + state.apply(.message(threadId: threadId, message: message("keep-me"))) + let count = state.transcript(forThread: threadId).count + + bot.messages = nil + bot.busy = true + bot.unread = true + state.apply(.bot(bot)) + + XCTAssertEqual(state.bot(bot.id)?.busy, true) + XCTAssertEqual(state.transcript(forThread: threadId).count, count) + XCTAssertNotNil(state.bot(bot.id)?.messages, "the merged bot keeps the transcript it had") + } + + func testDeletingABotTakesItsTranscriptWithIt() throws { + var state = try hydrated() + let bot = try XCTUnwrap(state.bots.first) + state.apply(.botDeleted(botId: bot.id)) + + XCTAssertNil(state.bot(bot.id)) + XCTAssertTrue(state.transcript(forThread: bot.threadId).isEmpty) + XCTAssertNil(state.hasMore[bot.threadId]) + } + + func testAnUnknownBotFrameAddsIt() throws { + var state = CompanionState() + var bot = try XCTUnwrap(try hydrated().bots.first) + bot.id = "brand-new" + bot.threadId = "brand-new-thread" + state.apply(.bot(bot)) + XCTAssertEqual(state.bots.count, 1) + XCTAssertNotNil(state.messages["brand-new-thread"]) + } + + // MARK: - Approvals + + func testPendingApprovalsAreTheUnansweredOnesNewestFirst() { + var state = CompanionState() + func card(_ id: String, at: Double, requestId: String?, answered: String? = nil) -> Message { + var message = Message(id: id, role: .bot, kind: .options, at: at) + message.card = OptionCard( + title: "Approval needed", subtitle: "rm -rf ./build", options: ["Allow", "Deny"], + answered: answered, dismissed: nil, requestId: requestId, tool: "Bash", + held: nil, allowKey: "Bash:rm" + ) + return message + } + state.messages["t1"] = [ + card("old", at: 1, requestId: "r1"), + card("answered", at: 2, requestId: "r2", answered: "Allow"), + card("history", at: 3, requestId: nil), + ] + state.messages["t2"] = [card("new", at: 9, requestId: "r3")] + + let pending = state.pendingApprovals + XCTAssertEqual(pending.map(\.message.id), ["new", "old"]) + XCTAssertEqual(pending.first?.threadId, "t2") + } + + // MARK: - Cursor + + func testTheCursorFollowsTheStreamAndKeepsItsStreamId() { + var state = CompanionState() + state.apply(.hello(cursor: "abc12345:7", resumed: true)) + XCTAssertEqual(state.cursor, "abc12345:7") + + state.advance(to: 8) + XCTAssertEqual(state.cursor, "abc12345:8", "the stream id is what stops a stale replay") + + // hello frames carry no seq, and nothing should move without one + state.advance(to: nil) + XCTAssertEqual(state.cursor, "abc12345:8") + } + + func testAdvancingBeforeAnyHelloDoesNothing() { + var state = CompanionState() + state.advance(to: 4) + XCTAssertNil(state.cursor, "without a stream id there is no cursor worth keeping") + } + + // MARK: - Notifications + + func testNotificationsCollectInOrder() { + var state = CompanionState() + let approval = NotificationFrame( + kind: "approval", botId: "b1", botName: "Scout", threadId: "t1", + title: "Scout needs approval", body: "rm -rf" + ) + let done = NotificationFrame( + kind: "done", botId: "b1", botName: "Scout", threadId: "t1", + title: "Scout finished", body: "pushed" + ) + state.apply(.notify(approval)) + state.apply(.notify(done)) + + XCTAssertEqual(state.notifications.count, 2) + XCTAssertTrue(state.notifications[0].isBlocking) + XCTAssertFalse(state.notifications[1].isBlocking) + } + + // MARK: - Frames with nothing to fold + + func testFramesThisClientIgnoresAreHarmless() throws { + var state = try hydrated() + let before = state.bots.count + state.apply(.screen(botId: "b1", png: "AAAA", mime: "image/png")) + state.apply(.computer(botId: "b1", state: "provisioning")) + state.apply(.config) + state.apply(.runtime(RuntimeEvent(type: "content.delta", threadId: "t1", delta: "hi", streamKind: "assistant_text"))) + state.apply(.unknown(kind: "routine.run")) + XCTAssertEqual(state.bots.count, before) + } +} + +// MARK: - Live text + +/// The harness relays raw provider deltas alongside the settled messages it +/// folds. These pin the handover between the two, which is where every +/// streaming bug in this project's desktop client has lived. +final class StreamingTests: XCTestCase { + private func delta(_ text: String, thread: String = "t1", kind: String = "assistant_text") -> Frame { + .runtime(RuntimeEvent(type: "content.delta", threadId: thread, delta: text, streamKind: kind)) + } + + func testDeltasAccumulateIntoLiveText() { + var state = CompanionState() + state.apply(delta("Hel")) + state.apply(delta("lo, ")) + state.apply(delta("world")) + XCTAssertEqual(state.streaming["t1"], "Hello, world") + } + + func testReasoningIsKeptApartFromTheAnswer() { + var state = CompanionState() + state.apply(delta("thinking…", kind: "reasoning_text")) + state.apply(delta("the answer")) + XCTAssertEqual(state.reasoning["t1"], "thinking…") + XCTAssertEqual(state.streaming["t1"], "the answer") + } + + func testAnUnknownStreamKindIsDroppedRatherThanGuessedAt() { + var state = CompanionState() + state.apply(delta("???", kind: "some_future_kind")) + XCTAssertNil(state.streaming["t1"]) + XCTAssertNil(state.reasoning["t1"]) + } + + func testASettledReplyReplacesTheLiveText() { + // The bug this prevents: the live bubble surviving next to the real + // one, so the tail renders below whatever settled after it and the + // next turn's deltas append onto a duplicated fragment. + var state = CompanionState() + state.apply(delta("partial answer")) + XCTAssertNotNil(state.streaming["t1"]) + + state.apply(.message(threadId: "t1", message: Message( + id: "m1", role: .bot, kind: .text, at: 1, text: "partial answer, completed" + ))) + XCTAssertNil(state.streaming["t1"], "the settled message already contains those tokens") + XCTAssertEqual(state.transcript(forThread: "t1").count, 1) + } + + func testOnlyASettledBotReplyClearsIt() { + var state = CompanionState() + state.apply(delta("mid-answer")) + // the user's own message, and a tool chip, both land mid-turn + state.apply(.message(threadId: "t1", message: Message( + id: "u1", role: .user, kind: .text, at: 1, text: "another question" + ))) + state.apply(.message(threadId: "t1", message: Message( + id: "a1", role: .bot, kind: .activity, at: 2 + ))) + XCTAssertEqual(state.streaming["t1"], "mid-answer", "neither of those is the reply") + } + + func testTheTurnEndingClearsEvenWithoutASettledMessage() { + // A failed or interrupted turn may never produce one. Leaving the + // caret blinking forever is the failure mode worth avoiding. + for ending in ["turn.completed", "turn.failed", "turn.aborted"] { + var state = CompanionState() + state.apply(delta("half a sentence")) + state.apply(.runtime(RuntimeEvent(type: ending, threadId: "t1", delta: nil, streamKind: nil))) + XCTAssertNil(state.streaming["t1"], "\(ending) should end the live bubble") + } + } + + func testThreadsStreamIndependently() { + var state = CompanionState() + state.apply(delta("for one", thread: "t1")) + state.apply(delta("for two", thread: "t2")) + state.apply(.runtime(RuntimeEvent(type: "turn.completed", threadId: "t1", delta: nil, streamKind: nil))) + XCTAssertNil(state.streaming["t1"]) + XCTAssertEqual(state.streaming["t2"], "for two", "one bot finishing must not silence another") + } +} + +// MARK: - The computer panel + +/// Screen frames are the one thing this client asks the server *not* to send +/// by default — they are hundreds of kilobytes each and arrive every few +/// seconds. These pin the fold; the turning-on is the session's job. +final class ScreenTests: XCTestCase { + private func frame(_ png: String, bot: String = "b1") -> Frame { + .screen(botId: bot, png: png, mime: "image/png") + } + + func testOnlyTheNewestFrameIsKept() { + // A history of desktop captures is worth nothing and costs megabytes. + var state = CompanionState() + state.apply(frame("AAAA")) + state.apply(frame("BBBB")) + state.apply(frame("CCCC")) + XCTAssertEqual(state.screens["b1"]?.png, "CCCC") + XCTAssertEqual(state.screens.count, 1) + } + + func testBotsAreTrackedSeparately() { + var state = CompanionState() + state.apply(frame("one", bot: "b1")) + state.apply(frame("two", bot: "b2")) + XCTAssertEqual(state.screens["b1"]?.png, "one") + XCTAssertEqual(state.screens["b2"]?.png, "two") + } + + func testClosingThePanelForgetsTheFrame() { + // Otherwise the panel reopens on however the desktop looked last + // time, which reads as a live view of a stale moment. + var state = CompanionState() + state.apply(frame("stale")) + state.clearScreen("b1") + XCTAssertNil(state.screens["b1"]) + } + + func testBadBase64DecodesToNilRatherThanCrashing() { + let good = ScreenFrame(png: "aGVsbG8=", mime: "image/png") + XCTAssertEqual(good.data.map { String(decoding: $0, as: UTF8.self) }, "hello") + // the view treats nil as "no frame yet", which is the right fallback + XCTAssertNil(ScreenFrame(png: "not base64 at all!!", mime: "image/png").data) + } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000000..a0b0c88da1 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,86 @@ +# XcodeGen spec. The .xcodeproj is generated, not committed — a project +# file is a merge-conflict machine and nothing in it is worth reviewing. +# +# brew install xcodegen && cd ios && xcodegen generate && open OpenMausCompanion.xcodeproj +# +# XcodeGen is a development tool, not a dependency of the app: the app +# itself has none. If you'd rather not install it, make an iOS App target +# in Xcode by hand, add the App/ folder, and add the local CompanionCore +# package — the Info.plist keys below are the part that is easy to miss. +name: OpenMausCompanion +options: + bundleIdPrefix: com.openmausbot + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + +packages: + CompanionCore: + path: . + +targets: + OpenMausCompanion: + type: application + platform: iOS + sources: + - path: App + dependencies: + - package: CompanionCore + product: CompanionCore + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.openmausbot.companion + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "5.9" + TARGETED_DEVICE_FAMILY: "1,2" + # The catalog lives in App/, which is already a source path. Generated + # by `node scripts/make-app-icon.mjs` from the same mascot the app + # draws, so the icon cannot drift from the thing it depicts. + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + info: + path: App/Info.plist + properties: + # What appears under the icon. Distinct from the desktop app on + # purpose: someone may well have both, and two identical labels on + # one person's devices is a small cruelty. + CFBundleDisplayName: OpenMausMobile + # REQUIRED, and easy to lose. Without a launch screen iOS runs the + # app in compatibility scaling: letterboxed inside black bars, with + # everything drawn oversized, on every modern iPhone. An empty + # dictionary is enough — it just has to exist. + # + # This cannot be an INFOPLIST_KEY_* build setting. Those are only + # read when Xcode generates the Info.plist itself, and this target + # supplies one, so they are silently ignored. + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + # Without these two, NWBrowser returns no results at all — silently. + # It looks exactly like "no computers on this network", which is a + # confusing hour if you don't know to look here. + NSLocalNetworkUsageDescription: >- + OpenMausBot finds your computer on this network so your bots can + reach you here. + NSBonjourServices: + - _openmausbot._tcp + # The companion listener is plain HTTP on a local address. ATS has + # to be told, and this is the narrowest way to say it: local + # networking only, not a blanket exception. + # + # NSAllowsLocalNetworking covers the private ranges — 10/8, + # 172.16/12, 192.168/16, .local — and that is the whole LAN story. + # It does *not* cover Tailscale: a tailnet address lives in + # 100.64.0.0/10, the CGNAT range, which ATS treats as ordinary + # public internet and blocks over plain HTTP. The address cannot be + # exempted (ATS exceptions are by name, not by subnet), but the + # MagicDNS name can be, and every tailnet name ends in ts.net — so + # connect by name and this one entry covers every machine you own. + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + NSExceptionDomains: + ts.net: + NSIncludesSubdomains: true + NSExceptionAllowsInsecureHTTPLoads: true diff --git a/scripts/capture-companion-fixtures.mjs b/scripts/capture-companion-fixtures.mjs new file mode 100644 index 0000000000..4be637911c --- /dev/null +++ b/scripts/capture-companion-fixtures.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +// Capture the iOS test fixtures from a real harness. +// +// node scripts/capture-companion-fixtures.mjs +// +// The fixtures in ios/Tests/CompanionCoreTests/Fixtures are bytes the server +// actually sent. That is the whole point of them: hand-written test JSON +// tests our idea of the API, and the entire risk in a two-language client is +// that our idea drifts from the API without anything failing. Re-running this +// after a server change makes the Swift tests fail if a payload moved, which +// is the alarm we want. +// +// Everything is disposable. A harness is started against a temporary HOME +// with a fabricated profile, so nothing here reads or writes your real +// ~/.openmausbot and no real name, key or token can end up in a fixture. The +// pairing token is redacted on the way out regardless. +// +// One fixture is not captured: options-card.json needs a bot to actually ask +// for approval, which needs a real provider and a real turn. It is left +// alone, and the run says so rather than quietly writing a worse version. +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const OUT = join(ROOT, "ios", "Tests", "CompanionCoreTests", "Fixtures"); + +const base = 19100 + Math.floor(Math.random() * 3000); +const HARNESS_PORT = base; +const COMPANION_PORT = base + 10; +const CONTROL_PORT = base + 11; +const HARNESS = `http://127.0.0.1:${HARNESS_PORT}`; +const SIDECAR = `http://127.0.0.1:${COMPANION_PORT}`; +const CONTROL = `http://127.0.0.1:${CONTROL_PORT}`; + +// A profile invented here rather than read from disk, so the fixtures name +// nobody real and stay byte-stable between machines. +const PROFILE = { name: "Ada Lovelace", email: "ada@example.com" }; + +const children = []; +let home = ""; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const write = (name, value) => { + writeFileSync(join(OUT, `${name}.json`), `${JSON.stringify(value, null, 2)}\n`); + console.log(` ${name}.json`); +}; + +/** Start a process and keep its stderr, so a failure to boot says why. */ +const start = (label, args, env) => { + const child = spawn(process.execPath, args, { + cwd: ROOT, + env: { ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...env }, + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.on("data", (c) => (child.err = (child.err ?? "") + c)); + children.push({ label, child }); + return child; +}; + +const waitFor = async (url, label, child) => { + const deadline = Date.now() + 30_000; + for (;;) { + try { + if ((await fetch(url)).ok) return; + } catch { + /* not up yet */ + } + if (child.exitCode !== null) throw new Error(`${label} exited ${child.exitCode}:\n${child.err ?? ""}`); + if (Date.now() > deadline) throw new Error(`${label} never came up:\n${child.err ?? ""}`); + await sleep(150); + } +}; + +const json = async (url, init) => { + const res = await fetch(url, init); + return { status: res.status, body: await res.json() }; +}; + +/** Read the event stream until it has produced `wanted` frames, running + * `while` alongside so there is something to capture. */ +async function captureFrames(wanted, during) { + const controller = new AbortController(); + const res = await fetch(`${HARNESS}/api/events`, { signal: controller.signal }); + const frames = []; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + const pump = (async () => { + while (frames.length < wanted) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let cut; + // events are separated by a blank line; the id: line is not data + while ((cut = buffer.indexOf("\n\n")) !== -1) { + const event = buffer.slice(0, cut); + buffer = buffer.slice(cut + 2); + for (const line of event.split("\n")) { + if (!line.startsWith("data:")) continue; + try { + frames.push(JSON.parse(line.slice(5).trim())); + } catch { + /* a comment or a heartbeat */ + } + } + } + } + })(); + + await during(); + await Promise.race([pump, sleep(8000)]); + controller.abort(); + return frames.slice(0, wanted); +} + +async function main() { + mkdirSync(OUT, { recursive: true }); + home = mkdtempSync(join(tmpdir(), "companion-fixtures-")); + mkdirSync(join(home, ".openmausbot"), { recursive: true }); + writeFileSync(join(home, ".openmausbot", "config.json"), JSON.stringify({ profile: PROFILE })); + + console.log(`harness on ${HARNESS_PORT}, companion on ${COMPANION_PORT}`); + const harness = start("harness", [join(ROOT, "server", "index.ts")], { + HOME: home, + USERPROFILE: home, + OMB_PORT: String(HARNESS_PORT), + // the receiver would otherwise take the port above, which is nothing + // to do with this but makes the log noisy + OMB_WEBHOOK_PORT: String(base + 1), + }); + await waitFor(`${HARNESS}/api/health`, "harness", harness); + + // ── a bot, and a few messages for it to have said ────────────────────── + const created = await json(`${HARNESS}/api/bots`, { method: "POST" }); + const bot = created.body.bot; + if (!bot) throw new Error(`could not create a bot: ${JSON.stringify(created.body)}`); + + console.log("capturing frames"); + const frames = await captureFrames(4, async () => { + for (let i = 0; i < 3; i++) { + await fetch(`${HARNESS}/api/bots/${bot.id}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ text: `fixture message ${i}` }), + }); + await sleep(120); + } + }); + if (frames.length === 0) throw new Error("no frames arrived on /api/events"); + + console.log("writing:"); + write("sse-frames", frames); + const hello = frames.find((f) => f.kind === "hello"); + if (hello) write("sse-hello", hello); + + // ── the shapes the app hydrates from ─────────────────────────────────── + write("bots-full", (await json(`${HARNESS}/api/bots`)).body); + write("bots-paged", (await json(`${HARNESS}/api/bots?messages=2`)).body); + write("thread-page", (await json(`${HARNESS}/api/threads/${bot.threadId}/messages?limit=2`)).body); + write("config", (await json(`${HARNESS}/api/config`)).body); + write("instances", (await json(`${HARNESS}/api/instances`)).body); + + // ── and the shapes only the sidecar produces ─────────────────────────── + const sidecar = start("companion", [join(ROOT, "companion", "src", "index.ts")], { + HOME: home, + USERPROFILE: home, + OMB_PORT: String(HARNESS_PORT), + OMB_WEBHOOK_PORT: String(base + 1), + OMB_COMPANION_PORT: String(COMPANION_PORT), + OMB_CONTROL_PORT: String(CONTROL_PORT), + OMB_COMPANION_DIR: join(home, "companion"), + }); + await waitFor(`${CONTROL}/state`, "companion", sidecar); + + write("unauthorized", (await json(`${SIDECAR}/api/bots`)).body); + write("pair-rejected", (await json(`${SIDECAR}/api/pair`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: "000000", deviceName: "Ada's iPhone" }), + })).body); + + const { code } = (await json(`${CONTROL}/pairing`, { method: "POST" })).body; + const paired = await json(`${SIDECAR}/api/pair`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code, deviceName: "Ada's iPhone" }), + }); + if (!paired.body.token) throw new Error(`pairing failed: ${JSON.stringify(paired.body)}`); + // The token is a live credential for as long as that registry exists. + // It is thrown away with the temp directory below, but a fixture is a file + // people copy, so it never gets written in the first place. + write("pair-response", { ...paired.body, token: "omb_REDACTED" }); + + write("forbidden", (await json(`${SIDECAR}/api/config`, { + method: "PUT", + headers: { "content-type": "application/json", authorization: `Bearer ${paired.body.token}` }, + body: JSON.stringify({ xai: { apiKey: "not-a-real-key" } }), + })).body); + + console.log("\nnot captured: options-card.json — needs a real approval from a real turn"); +} + +const cleanup = () => { + for (const { child } of children) child.kill("SIGKILL"); + if (home) rmSync(home, { recursive: true, force: true }); +}; + +main().then( + () => (cleanup(), console.log("done")), + (error) => { + cleanup(); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }, +); diff --git a/scripts/make-app-icon.mjs b/scripts/make-app-icon.mjs new file mode 100644 index 0000000000..a68ba0d8c3 --- /dev/null +++ b/scripts/make-app-icon.mjs @@ -0,0 +1,312 @@ +// Generates the iOS app icon, matching the desktop one in build/icon.svg. +// +// node scripts/make-app-icon.mjs +// +// Same artwork, two differences that iOS requires: +// +// - **Full bleed.** The macOS icon grid puts an 824-unit tile inside a +// 1024 canvas with its own rounded corners, because a full-bleed tile +// renders noticeably larger than everything else in the Dock. iOS masks +// the corners itself and expects art to reach the edge, so the grid inset +// and the rounded clip are dropped here. +// - **No alpha.** The App Store rejects an icon with an alpha channel. +// +// Everything is drawn by hand — bezier flattening, scanline fill, the PNG +// encoder — because the alternative is an image-processing dependency in a +// project that has none, for a file that changes about never. The numbers +// below mirror build/icon.svg; if that changes, change these. +import { deflateSync } from "node:zlib"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const OUT_DIR = join(ROOT, "ios", "App", "Assets.xcassets", "AppIcon.appiconset"); +const SIZE = 1024; +const SS = 4; // supersampling; 4 is plenty at this size + +// ── the artwork, from build/icon.svg ─────────────────────────────────── +const TILE_STOPS = [ + { at: 0, color: [0x20, 0x22, 0x27] }, + { at: 0.56, color: [0x13, 0x14, 0x19] }, + { at: 1, color: [0x09, 0x0a, 0x0d] }, +]; +const MASCOT_STOPS = [ + { at: 0, color: [0xff, 0xff, 0xff] }, + { at: 0.28, color: [0xe6, 0xe8, 0xeb] }, + { at: 0.58, color: [0xb7, 0xbb, 0xc2] }, + { at: 0.82, color: [0x84, 0x8a, 0x94] }, + { at: 1, color: [0x5b, 0x61, 0x6b] }, +]; +const FACE = [0x10, 0x11, 0x14]; + +const MASCOT = + "M93.4 40.6 L43 151.1 Q38 162 48.4 156 L91.4 131 Q100 126 108.7 131 " + + "L151.6 156 Q162 162 157 151.1 L106.6 40.6 Q100 26 93.4 40.6 Z"; +const SMILE = "M86 106 Q101 124 117 105"; +const SMILE_WIDTH = 7; +const EYES = [ + { x: 84.5, y: 75, w: 9, h: 24, r: 4.5, spin: -15, about: [89, 87] }, + { x: 108.5, y: 75, w: 9, h: 24, r: 4.5, spin: -7, about: [113, 87] }, +]; + +// `` +// maps the artwork's own coordinates onto the canvas; the group inside is +// rotated 25° about (100,100) first. +const VIEW_SCALE = 1300 / 180; +const artToCanvas = ([x, y]) => [(x - 10) * VIEW_SCALE - 260, (y - 10) * VIEW_SCALE - 140]; +const rotate = ([x, y], degrees, [cx, cy]) => { + const a = (degrees * Math.PI) / 180; + const dx = x - cx; + const dy = y - cy; + return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)]; +}; +/** Artwork point → canvas, through the 25° group rotation. */ +const place = (point) => artToCanvas(rotate(point, 25, [100, 100])); + +// ── path handling ────────────────────────────────────────────────────── +/** M/L/Q/Z absolute, which is all build/icon.svg uses. */ +function flatten(d, steps = 32) { + const tokens = d.match(/[MLQZmlqz]|-?\d*\.?\d+/g) ?? []; + const points = []; + let cursor = [0, 0]; + let i = 0; + while (i < tokens.length) { + const command = tokens[i]; + if (command === "M" || command === "L") { + cursor = [+tokens[i + 1], +tokens[i + 2]]; + points.push(cursor); + i += 3; + } else if (command === "Q") { + const control = [+tokens[i + 1], +tokens[i + 2]]; + const end = [+tokens[i + 3], +tokens[i + 4]]; + for (let s = 1; s <= steps; s++) { + const t = s / steps; + const u = 1 - t; + points.push([ + u * u * cursor[0] + 2 * u * t * control[0] + t * t * end[0], + u * u * cursor[1] + 2 * u * t * control[1] + t * t * end[1], + ]); + } + cursor = end; + i += 5; + } else { + i += 1; + } + } + return points; +} + +/** A rounded rect as a polygon, in its own coordinates. */ +function roundedRect({ x, y, w, h, r }, steps = 10) { + const points = []; + const corners = [ + [x + w - r, y + r, -90, 0], + [x + w - r, y + h - r, 0, 90], + [x + r, y + h - r, 90, 180], + [x + r, y + r, 180, 270], + ]; + for (const [cx, cy, from, to] of corners) { + for (let s = 0; s <= steps; s++) { + const a = ((from + ((to - from) * s) / steps) * Math.PI) / 180; + points.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); + } + } + return points; +} + +/** A stroked polyline as a fillable polygon: one side up, the other back, + * with round caps approximated by the joint circles below. */ +function strokeToPolygons(points, width) { + const half = width / 2; + const polys = []; + for (let i = 0; i < points.length - 1; i++) { + const [x0, y0] = points[i]; + const [x1, y1] = points[i + 1]; + const dx = x1 - x0; + const dy = y1 - y0; + const len = Math.hypot(dx, dy); + if (len === 0) continue; + const nx = (-dy / len) * half; + const ny = (dx / len) * half; + polys.push([ + [x0 + nx, y0 + ny], + [x1 + nx, y1 + ny], + [x1 - nx, y1 - ny], + [x0 - nx, y0 - ny], + ]); + } + // round joins and caps + for (const [x, y] of points) { + const circle = []; + for (let a = 0; a < 24; a++) { + const t = (a / 24) * Math.PI * 2; + circle.push([x + Math.cos(t) * half, y + Math.sin(t) * half]); + } + polys.push(circle); + } + return polys; +} + +/** Even-odd would hole out overlapping stroke quads, so: coverage by union. */ +function rasterise(polygons, width) { + const mask = new Uint8Array(width * width); + for (const poly of polygons) { + const ys = poly.map((p) => p[1]); + const top = Math.max(0, Math.floor(Math.min(...ys))); + const bottom = Math.min(width - 1, Math.ceil(Math.max(...ys))); + for (let y = top; y <= bottom; y++) { + const sy = y + 0.5; + const xs = []; + for (let i = 0; i < poly.length; i++) { + const [x0, y0] = poly[i]; + const [x1, y1] = poly[(i + 1) % poly.length]; + if (y0 === y1) continue; + if (sy < Math.min(y0, y1) || sy >= Math.max(y0, y1)) continue; + xs.push(x0 + ((sy - y0) / (y1 - y0)) * (x1 - x0)); + } + xs.sort((a, b) => a - b); + for (let i = 0; i + 1 < xs.length; i += 2) { + const from = Math.max(0, Math.ceil(xs[i] - 0.5)); + const to = Math.min(width - 1, Math.floor(xs[i + 1] - 0.5)); + for (let x = from; x <= to; x++) mask[y * width + x] = 1; + } + } + } + return mask; +} + +const sample = (stops, t) => { + const clamped = Math.min(1, Math.max(0, t)); + for (let i = 0; i < stops.length - 1; i++) { + if (clamped <= stops[i + 1].at) { + const a = stops[i]; + const b = stops[i + 1]; + const local = (clamped - a.at) / (b.at - a.at || 1); + return a.color.map((c, k) => c + (b.color[k] - c) * local); + } + } + return stops[stops.length - 1].color; +}; + +// ── draw ─────────────────────────────────────────────────────────────── +const big = SIZE * SS; +const toBig = ([x, y]) => [(x / 1024) * big, (y / 1024) * big]; + +const bodyPoly = flatten(MASCOT).map(place).map(toBig); +const eyePolys = EYES.map((eye) => + roundedRect(eye) + .map((p) => rotate(p, eye.spin, eye.about)) + .map(place) + .map(toBig), +); +const smilePolys = strokeToPolygons( + flatten(SMILE).map(place).map(toBig), + // stroke width travels through the same scale the artwork does + (SMILE_WIDTH * VIEW_SCALE * big) / 1024, +); + +const bodyMask = rasterise([bodyPoly], big); +const faceMask = rasterise([...eyePolys, ...smilePolys], big); + +// the mascot's linear gradient runs 18%,10% → 84%,94% of its own bounding box +const bx = bodyPoly.map((p) => p[0]); +const by = bodyPoly.map((p) => p[1]); +const bounds = { x0: Math.min(...bx), x1: Math.max(...bx), y0: Math.min(...by), y1: Math.max(...by) }; +const gradFrom = [bounds.x0 + (bounds.x1 - bounds.x0) * 0.18, bounds.y0 + (bounds.y1 - bounds.y0) * 0.1]; +const gradTo = [bounds.x0 + (bounds.x1 - bounds.x0) * 0.84, bounds.y0 + (bounds.y1 - bounds.y0) * 0.94]; +const gradVec = [gradTo[0] - gradFrom[0], gradTo[1] - gradFrom[1]]; +const gradLenSq = gradVec[0] ** 2 + gradVec[1] ** 2; + +// the tile's radial gradient: 68%,36% of the canvas, radius 92% +const tileCentre = [big * 0.68, big * 0.36]; +const tileRadius = big * 0.92; + +const rgb = Buffer.alloc(SIZE * SIZE * 3); +for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + let acc = [0, 0, 0]; + for (let sy = 0; sy < SS; sy++) { + for (let sx = 0; sx < SS; sx++) { + const px = x * SS + sx; + const py = y * SS + sy; + const index = py * big + px; + let colour; + if (faceMask[index] && bodyMask[index]) { + colour = FACE; + } else if (bodyMask[index]) { + const t = + ((px - gradFrom[0]) * gradVec[0] + (py - gradFrom[1]) * gradVec[1]) / (gradLenSq || 1); + colour = sample(MASCOT_STOPS, t); + } else { + const d = Math.hypot(px - tileCentre[0], py - tileCentre[1]) / tileRadius; + colour = sample(TILE_STOPS, d); + } + acc = acc.map((c, i) => c + colour[i]); + } + } + const at = (y * SIZE + x) * 3; + const samples = SS * SS; + rgb[at] = Math.round(acc[0] / samples); + rgb[at + 1] = Math.round(acc[1] / samples); + rgb[at + 2] = Math.round(acc[2] / samples); + } +} + +// ── PNG ──────────────────────────────────────────────────────────────── +let CRC_TABLE = null; +function crc32(buf) { + if (!CRC_TABLE) { + CRC_TABLE = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + CRC_TABLE[n] = c; + } + } + let c = -1; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return c ^ -1; +} + +function encodePng(pixels, size) { + const raw = Buffer.alloc((size * 3 + 1) * size); + for (let y = 0; y < size; y++) { + raw[y * (size * 3 + 1)] = 0; // filter: none + pixels.copy(raw, y * (size * 3 + 1) + 1, y * size * 3, (y + 1) * size * 3); + } + const chunk = (type, data) => { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body) >>> 0); + return Buffer.concat([length, body, crc]); + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // truecolour, no alpha + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +mkdirSync(OUT_DIR, { recursive: true }); +writeFileSync(join(OUT_DIR, "icon-1024.png"), encodePng(rgb, SIZE)); +writeFileSync( + join(OUT_DIR, "Contents.json"), + JSON.stringify( + { + images: [{ filename: "icon-1024.png", idiom: "universal", platform: "ios", size: "1024x1024" }], + info: { author: "xcode", version: 1 }, + }, + null, + 2, + ) + "\n", +); +console.log(`wrote ${SIZE}×${SIZE} icon to ios/App/Assets.xcassets/AppIcon.appiconset/`); From fb33192c84713a8d791738fa1334359a60dd9d72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:57:30 +0000 Subject: [PATCH 04/32] Wait for the harness to exit before deleting its home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI went red on ubuntu-latest with an EACCES from rmSync in the afterAll of server/index.test.ts — every assertion in the file had passed. The suite died cleaning up after itself, which is the least informative way a run can fail. Two races, one symptom. The teardown asked the child to die and then immediately deleted the directory it was writing into: setTimeout(() => (child.kill("SIGKILL"), resolve()), 5_000) That resolve() fires in the same tick as the kill, so rmSync could start while the process was still alive. And rmSync had no retry, so the first transient EACCES failed the file — even though a temp directory that outlives a test says nothing about the code under test. server/testing/setup.ts had already met this and grown a retry-and-warn loop for it. That fix just never reached the two suites that spawn a real harness. Lift it into server/testing/cleanup.ts alongside a waitForExit that escalates to SIGKILL only after a grace period and then keeps waiting for close, and use both from all three teardowns. companion/test/proxy.test.ts carried the same copy of the racing teardown, so it gets the same fix before it can fail the same way. Verified: an undeletable path warns and returns instead of throwing; a child that ignores SIGTERM is waited out through the escalation rather than raced; a clean exit still resolves promptly instead of stalling for the full grace period. Full suite green — 64 files, 542 passed, 8 skipped. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/test/proxy.test.ts | 19 ++-------- server/index.test.ts | 11 ++---- server/testing/cleanup.ts | 72 ++++++++++++++++++++++++++++++++++++ server/testing/setup.ts | 25 +++---------- 4 files changed, 86 insertions(+), 41 deletions(-) create mode 100644 server/testing/cleanup.ts diff --git a/companion/test/proxy.test.ts b/companion/test/proxy.test.ts index b74ff55a54..578e8ca119 100644 --- a/companion/test/proxy.test.ts +++ b/companion/test/proxy.test.ts @@ -7,12 +7,13 @@ // through, and the harness's loopback gate rejecting a proxied request. import { spawn, type ChildProcess } from "node:child_process"; import { createServer, request, type Server } from "node:http"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { removeTempDir, waitForExit } from "../../server/testing/cleanup.ts"; import { createProxyHandler } from "../src/proxy.ts"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -117,20 +118,8 @@ beforeAll(async () => { afterAll(async () => { await new Promise((r) => (sidecar ? sidecar.close(() => r()) : r())); harness?.kill("SIGTERM"); - await new Promise((resolve) => { - if (!harness || harness.exitCode !== null) return resolve(); - harness.on("close", () => resolve()); - // SIGKILL, then keep waiting for `close`. Resolving in the same tick as - // the signal — which is what this did — starts deleting the home - // directory out from under a process that has not died yet, and the - // delete is what fails. A loaded CI runner loses that race; a laptop - // wins it every time, which is why it reads as a phantom. - setTimeout(() => harness.kill("SIGKILL"), 5_000).unref?.(); - // and a floor, so a process that somehow survives SIGKILL cannot hang - // the suite instead - setTimeout(resolve, 10_000).unref?.(); - }); - rmSync(home, { recursive: true, force: true }); + await waitForExit(harness); + await removeTempDir(home); }); describe("the sidecar in front of an unmodified harness", () => { diff --git a/server/index.test.ts b/server/index.test.ts index 1d8aecd649..b4fe4ed230 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -5,12 +5,13 @@ // the shadow-instance behavior end to end while it's at it. import { spawn, type ChildProcess } from "node:child_process"; import { createServer, request, type Server } from "node:http"; -import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { removeTempDir, waitForExit } from "./testing/cleanup.ts"; import { openSse } from "./testing/sse.ts"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); @@ -101,12 +102,8 @@ beforeAll(async () => { afterAll(async () => { boxStub?.close(); child?.kill("SIGTERM"); - await new Promise((resolve) => { - if (!child || child.exitCode !== null) return resolve(); - child.on("close", () => resolve()); - setTimeout(() => (child.kill("SIGKILL"), resolve()), 5_000).unref?.(); - }); - rmSync(home, { recursive: true, force: true }); + await waitForExit(child); + await removeTempDir(home); }); describe("harness HTTP API", () => { diff --git a/server/testing/cleanup.ts b/server/testing/cleanup.ts new file mode 100644 index 0000000000..fa2e1b2375 --- /dev/null +++ b/server/testing/cleanup.ts @@ -0,0 +1,72 @@ +// Teardown primitives for suites that spawn a real process against a +// throwaway home directory. +// +// Both halves of that pattern have a race in them, and both races surface +// as a red suite whose assertions all passed — the most expensive kind of +// failure to read. These two helpers exist so the fix lives in one place +// instead of being re-derived (or forgotten) per suite. +import type { ChildProcess } from "node:child_process"; +import { rmSync } from "node:fs"; + +/** + * Wait for a killed child to actually be gone. + * + * `kill()` asks; it does not wait. A caller that proceeds straight from the + * kill call to deleting the child's home directory is racing a process that + * may still be mid-write, and on Linux that surfaces as an EACCES from `rm` + * rather than anything that names the real cause. + * + * So: resolve on `close`, escalate to SIGKILL only after `graceMs`, and keep + * waiting for `close` even then — a SIGKILL is not an exit either, it just + * makes one imminent. The final backstop bounds the whole thing so a wedged + * child can never hang the suite. + * + * A loaded CI runner loses that race; a laptop wins it every time, which is + * why it reads as a phantom. + */ +export function waitForExit(child: ChildProcess | undefined, graceMs = 5_000): Promise { + return new Promise((resolve) => { + // signalCode, not just exitCode: a process killed by a signal reports its + // death in the former and leaves the latter null. + if (!child || child.exitCode !== null || child.signalCode !== null) return resolve(); + + let timer: ReturnType | undefined; + const done = () => { + if (timer) clearTimeout(timer); + resolve(); + }; + child.on("close", done); + + timer = setTimeout(() => { + child.kill("SIGKILL"); + timer = setTimeout(done, 2_000); + timer.unref?.(); + }, graceMs); + timer.unref?.(); + }); +} + +/** + * Remove a temp directory, and never fail a green suite over one. + * + * A just-killed child lets go of its files a beat after the kill returns, and + * `rmSync`'s own `maxRetries` does not cover an EACCES/EPERM on the directory + * itself. Retry briefly; if the directory still will not go, warn and leave + * it for the OS to reap. A leaked temp dir is a non-event — a red CI run that + * says nothing about the code under test is not. + */ +export async function removeTempDir(dir: string): Promise { + let lastError: unknown; + for (let i = 0; i < 20; i++) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + await new Promise((r) => setTimeout(r, 100)); + } + } + console.warn( + `test cleanup could not remove ${dir}: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ); +} diff --git a/server/testing/setup.ts b/server/testing/setup.ts index f961df95fd..9b3a5fce86 100644 --- a/server/testing/setup.ts +++ b/server/testing/setup.ts @@ -2,30 +2,17 @@ // DATA_DIR (~/.openmausbot) never touches the real one. os.homedir() // reads HOME (POSIX) / USERPROFILE (Windows) at call time, and this file // runs before any test module imports server/config.ts. -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll } from "vitest"; +import { removeTempDir } from "./cleanup.ts"; + const home = mkdtempSync(join(tmpdir(), "omb-test-home-")); process.env.HOME = home; process.env.USERPROFILE = home; -afterAll(async () => { - // Windows holds a directory that is a live process's cwd, and a - // just-killed CLI lets go a beat after the kill call returns (rmSync's own - // maxRetries does not cover an EPERM on the directory itself). Retry - // briefly — and never fail a green suite over a temp dir. - let lastError: unknown; - for (let i = 0; i < 20; i++) { - try { - return rmSync(home, { recursive: true, force: true }); - } catch (error) { - lastError = error; - await new Promise((r) => setTimeout(r, 100)); - } - } - console.warn( - `test cleanup could not remove ${home}: ${lastError instanceof Error ? lastError.message : String(lastError)}`, - ); -}); +// Windows holds a directory that is a live process's cwd, and a just-killed +// CLI lets go a beat after the kill call returns — see removeTempDir. +afterAll(() => removeTempDir(home)); From cddec783c3fee8cfd6a7714f76bc2e348f985aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:43:20 +0000 Subject: [PATCH 05/32] Fail closed when a response cannot be scrubbed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy prepared every JSON body under one try/catch: try { text = JSON.stringify(scrub(JSON.parse(body))); } catch { /* not JSON after all — send what we were given */ } The comment describes one failure. The block covers three, and they do not mean the same thing. A body that will not parse was never JSON and there is nothing in it to redact, so forwarding it verbatim is right. A body that parses but will not scrub is the opposite: it is structured, and scrub is the only thing keeping resume cursors off the wire to a device. Falling back to the raw body there sends exactly what the scrubber exists to withhold. Not a hypothetical. scrub recurses once per level, so a body nested a few thousand deep throws RangeError while JSON.parse handles it without complaint — at depth 5000 on this runtime, parse succeeds and scrub throws. The old code caught that as "not JSON after all" and forwarded the original. Split the two: parse failure still passes through, scrub or stringify failure answers 502 and sends nothing. Response re-framing moves into a local `forward` so both paths share it — which also fixes it honouring the upstream status rather than hardcoding the captured one. The new test asserts the invariant rather than the mechanism: whatever comes back, it is never a 200 carrying the field the scrubber removes. That holds on any stack size. Against the previous code it fails on exactly that line. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/index.ts | 1 + companion/src/proxy.ts | 55 +++++++++++--- companion/test/proxy-response.test.ts | 103 ++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 11 deletions(-) mode change 100644 => 100755 companion/src/index.ts create mode 100644 companion/test/proxy-response.test.ts diff --git a/companion/src/index.ts b/companion/src/index.ts old mode 100644 new mode 100755 index 7ef0960d35..35026f4639 --- a/companion/src/index.ts +++ b/companion/src/index.ts @@ -1,3 +1,4 @@ +#!/usr/bin/env node // The sidecar, as one command. // // node companion/src/index.ts diff --git a/companion/src/proxy.ts b/companion/src/proxy.ts index 46c09f40ae..f89747048b 100644 --- a/companion/src/proxy.ts +++ b/companion/src/proxy.ts @@ -176,27 +176,60 @@ export function createProxyHandler(options: ProxyOptions) { harness.on("error", () => res.destroy()); harness.on("end", () => { const body = Buffer.concat(chunks).toString("utf8"); - let text = body; + + // Two failures live here and they are not the same failure. + // + // A body that does not parse was never JSON — content-type lied, + // or the harness sent an empty 204. There is nothing to redact in + // bytes we cannot read as an object, so forwarding them verbatim + // is correct. + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + parsed = undefined; + } + if (parsed === undefined) { + forward(body, harness.headers, harness.statusCode ?? 200); + return; + } + + // A body that parses but will not scrub is the opposite case. We + // know it is structured, and we know `scrub` is the only thing + // keeping internal fields — resume cursors — off the wire to a + // device. Falling back to the raw body there, which is what a + // single try around parse-and-scrub did, sends exactly what the + // scrubber exists to withhold. + // + // Not hypothetical: `scrub` recurses, so a body nested a few + // thousand deep throws RangeError while JSON.parse handles it + // fine. See proxy-response.test.ts. + let text: string; try { - text = JSON.stringify(scrub(JSON.parse(body))); + text = JSON.stringify(scrub(parsed)); } catch { - /* not JSON after all — send what we were given */ + sendJson(res, 502, { error: "the response could not be prepared for this device" }); + return; } - const headers = { ...harness.headers }; - // The body was re-serialised, so nothing the harness said about - // its framing survives. `transfer-encoding` matters most: leaving - // it alongside the content-length set below is a protocol - // violation, and Node's own parser rejects the response outright - // rather than tolerating it. + forward(text, harness.headers, harness.statusCode ?? 200); + }); + + /** Re-frame and send. The body was re-serialised, so nothing the + * harness said about its framing survives. `transfer-encoding` + * matters most: leaving it alongside the content-length set here is + * a protocol violation, and Node's own parser rejects the response + * outright rather than tolerating it. */ + function forward(text: string, upstreamHeaders: IncomingMessage["headers"], status: number): void { + const headers = { ...upstreamHeaders }; delete headers["content-length"]; delete headers["content-encoding"]; delete headers["transfer-encoding"]; - res.writeHead(harness.statusCode ?? 200, { + res.writeHead(status, { ...headers, "content-length": Buffer.byteLength(text), }); res.end(text); - }); + } }, ); diff --git a/companion/test/proxy-response.test.ts b/companion/test/proxy-response.test.ts new file mode 100644 index 0000000000..c51affeba6 --- /dev/null +++ b/companion/test/proxy-response.test.ts @@ -0,0 +1,103 @@ +// How the proxy prepares a response body, against a stub harness. +// +// proxy.test.ts boots the real harness and is the right place for anything +// about the seam between the two. This file is the opposite: a harness stub +// that can be made to return exactly the pathological body a test needs, +// which is the only way to reach the failure branches below. +import { createServer, type Server, type ServerResponse } from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createProxyHandler } from "../src/proxy.ts"; + +const TOKEN = "omb_test_token"; + +let harness: Server; +let sidecar: Server; +let sidecarPort = 0; +/** What the stub harness answers with next. Set per test. */ +let respond: (res: ServerResponse) => void = (res) => res.end(); + +const listen = (server: Server): Promise => + new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve((server.address() as { port: number }).port))); + +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 res = await fetch(`http://127.0.0.1:${sidecarPort}${path}`, { + headers: { authorization: `Bearer ${TOKEN}` }, + }); + return { status: res.status, text: await res.text() }; +}; + +beforeAll(async () => { + harness = createServer((_req, res) => respond(res)); + const harnessPort = await listen(harness); + + sidecar = createServer( + createProxyHandler({ + harnessPort, + authenticate: (t) => t === TOKEN, + redeem: () => ({ error: "not used here" }), + serverName: () => "Test computer", + }), + ); + sidecarPort = await listen(sidecar); +}); + +afterAll(async () => { + await close(sidecar); + await close(harness); +}); + +describe("preparing a harness response for a device", () => { + 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 + // the whole bug: parse-then-scrub under one try/catch treated the throw + // as "not JSON after all" and sent the untouched body on to the phone. + let body = JSON.stringify({ resumeCursors: { agent: "cursor-value" } }); + for (let i = 0; i < 6_000; i++) body = `{"a":${body}}`; + + respond = (res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(body); + }; + + const { status, text } = await device(); + + // The invariant, stated so it holds on any stack size: whatever comes + // back, it is not a success carrying the field the scrubber removes. On + // a runtime deep enough to scrub this, that is a scrubbed 200; on one + // that throws, a 502. Never the raw body. + expect(status === 200 && text.includes("resumeCursors")).toBe(false); + expect(status).toBe(502); + }); + + it("passes a body through untouched when it was never JSON", async () => { + // The tolerant half of the same branch, and the reason it cannot simply + // fail closed on everything: a content-type that lies is common enough, + // and there is nothing to redact in bytes we cannot read as an object. + respond = (res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("this is not JSON at all"); + }; + + const { status, text } = await device(); + expect(status).toBe(200); + expect(text).toBe("this is not JSON at all"); + }); + + it("scrubs a well-formed body and re-frames it", async () => { + respond = (res) => { + res.writeHead(200, { "content-type": "application/json", "transfer-encoding": "chunked" }); + res.end(JSON.stringify({ bots: [{ id: "b1" }], resumeCursors: { agent: "cursor-value" } })); + }; + + const { status, text } = await device(); + expect(status).toBe(200); + expect(JSON.parse(text)).toEqual({ bots: [{ id: "b1" }] }); + expect(text).not.toContain("cursor-value"); + }); +}); From 88c2ee75e4c4407510ed309ac4a6065d16cd0f80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:43:30 +0000 Subject: [PATCH 06/32] Serialize companion start and stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both transitions guard with a check and then await, which is not a guard at all once two of them overlap. Three ways it goes wrong, all ending with the toggle and reality disagreeing: - two concurrent starts both pass `if (proc)` and fork two sidecars - a start that fails overwrites the `proc` a start that succeeded just set - a stop issued during startup finds `proc` still null, so it kills nothing — and the start it raced then publishes a sidecar the user has already switched off The last one is the one a user would actually hit, by double-clicking the toggle, and it leaves a process listening off-machine after the UI says it is off. Queue every transition on a promise chain so one finishes before the next begins. The chain absorbs rejections rather than propagating them, or a single failed start would poison every transition after it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- electron/companion.mjs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/electron/companion.mjs b/electron/companion.mjs index b665c94814..9c151bbd7a 100644 --- a/electron/companion.mjs +++ b/electron/companion.mjs @@ -55,7 +55,38 @@ export function companionRunning() { return proc !== null; } -export async function startCompanion({ resourcesPath, harnessPort, log }) { +// Every lifecycle transition runs to completion before the next one begins. +// +// Without this the guards below look sufficient and are not, because each one +// is a check followed by an await. Three things go wrong, and all of them end +// with the toggle and reality disagreeing: two concurrent starts both pass +// `if (proc)` and fork two sidecars; a failed start overwrites the `proc` a +// successful one just published; and a stop issued mid-startup finds `proc` +// still null, so it kills nothing and the start it raced then publishes a +// sidecar the user has already asked to shut down. +let transition = Promise.resolve(); + +/** Queue a lifecycle transition behind whatever is already in flight. */ +const serialize = (work) => { + const next = transition.then(work, work); + // The chain itself must never carry a rejection forward, or one failed + // transition would poison every transition after it. + transition = next.then( + () => {}, + () => {}, + ); + return next; +}; + +export function startCompanion(options) { + return serialize(() => start(options)); +} + +export function stopCompanion() { + return serialize(() => stop()); +} + +async function start({ resourcesPath, harnessPort, log }) { if (proc) return companionState(); lastError = null; const entry = entryPoint(resourcesPath); @@ -114,7 +145,7 @@ export async function startCompanion({ resourcesPath, harnessPort, log }) { return companionState(); } -export async function stopCompanion() { +async function stop() { const child = proc; proc = null; lastError = null; From 14fec64ea856a8d2551de56b9951390fd08e4c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:43:40 +0000 Subject: [PATCH 07/32] Refuse cross-origin state changes on the control plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binding loopback is not a defence against a browser. Any page on the internet can aim a form POST at http://127.0.0.1:8811/pairing, and the Host header on that request is the loopback one this server already approves. A form POST needs no preflight, so nothing stops it leaving. Same-origin policy hides the reply, so the attacker never reads the pairing code. That is not the whole harm: the window still opens, and a six-digit code is then sitting on the victim's screen waiting to be talked out of them. Require a loopback Origin, or none, for anything that is not GET or HEAD. Absence is the Electron main process and the phone's own client — not browsers, and not what a CSRF check is aimed at. The literal string "null", which a sandboxed iframe and a file:// page both send, is refused: treating it as absent would hand the hole straight back. Safe methods are untouched, since the SOP already stops a foreign page reading a reply and this server sets no CORS headers to weaken that. proxy.ts already refuses any Origin outright. The control plane should not have been the laxer of the two. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/control.ts | 41 ++++++++++++++ companion/test/control.test.ts | 99 ++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 companion/test/control.test.ts diff --git a/companion/src/control.ts b/companion/src/control.ts index 6cbd2d002a..dfeecd7402 100644 --- a/companion/src/control.ts +++ b/companion/src/control.ts @@ -31,6 +31,28 @@ const json = (res: ServerResponse, status: number, body: unknown) => { res.end(text); }; +/** + * Is this `Origin` one we are willing to accept a state change from? + * + * Absent counts as yes: a non-browser client (the Electron main process, curl, + * the phone's own app) sends no Origin, and those are exactly the callers a + * CSRF check is not aimed at. A browser always sends one on a cross-site + * request and cannot forge it, so an Origin that parses to loopback is the + * page this server itself served. + */ +export function originIsLoopback(origin: string | undefined): boolean { + if (!origin) return true; + // "null" is what a sandboxed iframe or a file:// page sends. It is not + // loopback, and treating the string as absent would reopen the hole. + let hostname: string; + try { + ({ hostname } = new URL(origin)); + } catch { + return false; + } + return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1"; +} + export function companionState(options: ControlOptions) { const addresses = lanAddresses(); const tailscale = tailscaleAddress(addresses); @@ -62,6 +84,25 @@ export function createControlServer(options: ControlOptions): Server { return json(res, 403, { error: "forbidden: loopback only" }); } + // A loopback bind is not a defence against a browser. Any page on the + // internet can submit a form to http://127.0.0.1:/pairing, and the + // Host header on that request is the one we just approved. The same-origin + // policy hides our reply from the attacker, which stops them reading the + // pairing code — but the window still opens, and the code is then sitting + // on the victim's screen waiting to be social-engineered out of them. + // + // So: state-changing methods must come from loopback or from no origin at + // all. The page this server serves is itself loopback, and the Electron + // main process sends no Origin because it is not a browser. Safe methods + // are left alone — the SOP already stops a foreign page reading a reply, + // and this server sets no CORS headers to weaken that. + // + // proxy.ts refuses any Origin outright for the same reason. This is the + // control plane; it should not be the laxer of the two. + if (method !== "GET" && method !== "HEAD" && !originIsLoopback(req.headers.origin)) { + return json(res, 403, { error: "forbidden: cross-origin request" }); + } + if (method === "GET" && (path === "/" || path === "/index.html")) { const html = page(); res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html) }); diff --git a/companion/test/control.test.ts b/companion/test/control.test.ts new file mode 100644 index 0000000000..f0dd2a1588 --- /dev/null +++ b/companion/test/control.test.ts @@ -0,0 +1,99 @@ +// The control plane's own front door. +// +// This server binds loopback and serves the pairing page, which makes it feel +// unreachable from outside. It is not: a browser on the victim's machine is +// inside that boundary, and any page on the internet can aim a form at it. +// These tests pin the rule that keeps that from mattering. +import { type Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createControlServer, originIsLoopback } from "../src/control.ts"; +import { DeviceRegistry } from "../src/devices.ts"; + +let control: Server; +let port = 0; + +const ask = async ( + method: string, + path: string, + headers: Record = {}, +): Promise<{ status: number; body: any }> => { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { method, headers }); + const text = await res.text(); + try { + return { status: res.status, body: JSON.parse(text) }; + } catch { + return { status: res.status, body: text }; + } +}; + +beforeAll(async () => { + control = createControlServer({ + devices: new DeviceRegistry(), + companionPort: 8810, + discovery: () => ({ advertising: false, name: "Test computer" }), + }); + port = await new Promise((resolve) => + control.listen(0, "127.0.0.1", () => resolve((control.address() as { port: number }).port)), + ); +}); + +afterAll(async () => { + await new Promise((resolve) => control.close(() => resolve())); +}); + +describe("origins the control server will change state for", () => { + 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. + // Same-origin policy hides the reply, so the code is never read — but a + // pairing window opens on the victim's screen regardless. + const { status, body } = await ask("POST", "/pairing", { origin: "https://evil.example" }); + expect(status).toBe(403); + expect(body.error).toContain("cross-origin"); + // and it did not happen anyway + expect((await ask("GET", "/state")).body.pairing).toBeNull(); + }); + + it("refuses an opaque origin", async () => { + // A sandboxed iframe and a file:// page both send the literal string + // "null". Treating that as absent would hand the hole straight back. + expect((await ask("POST", "/pairing", { origin: "null" })).status).toBe(403); + expect((await ask("DELETE", "/pairing", { origin: "null" })).status).toBe(403); + }); + + it("allows the loopback page it serves, on any port", async () => { + const { status } = await ask("POST", "/pairing", { origin: `http://127.0.0.1:${port}` }); + expect(status).toBe(201); + expect((await ask("GET", "/state")).body.pairing).not.toBeNull(); + await ask("DELETE", "/pairing", { origin: `http://127.0.0.1:${port}` }); + }); + + it("allows a client that sends no origin at all", async () => { + // The Electron main process, which is not a browser and is the normal + // desktop path. A CSRF check aimed at it would break the toggle. + expect((await ask("POST", "/pairing")).status).toBe(201); + await ask("DELETE", "/pairing"); + }); + + it("leaves safe methods alone", async () => { + // Nothing to protect: the SOP stops a foreign page reading the reply and + // this server sends no CORS headers to weaken that. + expect((await ask("GET", "/state", { origin: "https://evil.example" })).status).toBe(200); + }); +}); + +describe("originIsLoopback", () => { + it("accepts loopback and absence, and nothing else", () => { + expect(originIsLoopback(undefined)).toBe(true); + expect(originIsLoopback("http://127.0.0.1:8811")).toBe(true); + expect(originIsLoopback("http://localhost:3000")).toBe(true); + expect(originIsLoopback("http://[::1]:8811")).toBe(true); + + expect(originIsLoopback("null")).toBe(false); + expect(originIsLoopback("https://evil.example")).toBe(false); + // the prefix trick: a hostname that merely starts with the loopback one + expect(originIsLoopback("https://127.0.0.1.evil.example")).toBe(false); + expect(originIsLoopback("https://localhost.evil.example")).toBe(false); + }); +}); From f0a17c02eb45794574fe21950edeff69b4040227 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:43:47 +0000 Subject: [PATCH 08/32] Do not let a lastSeenAt write failure sign a phone out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authenticate() refreshes a "last seen" timestamp and persists it. The write was unguarded, so a full disk or a read-only home turned a decoration in a settings panel into a thrown exception on the authentication path — every request, for every paired device, with nothing in the failure that points at the real cause. Catch it. The token is still valid; the timestamp can be stale. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/devices.ts | 11 ++++++++++- companion/test/devices.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/companion/src/devices.ts b/companion/src/devices.ts index 34501611ec..c05490f0f0 100644 --- a/companion/src/devices.ts +++ b/companion/src/devices.ts @@ -175,7 +175,16 @@ export class DeviceRegistry { if (now - (this.lastSeenWrites.get(device.id) ?? 0) > LAST_SEEN_WRITE_MS) { device.lastSeenAt = now; this.lastSeenWrites.set(device.id, now); - this.persist(); + // lastSeenAt decorates a row in a settings panel. A full disk or a + // read-only home is a reason for it to be stale, never a reason for an + // already-valid token to stop authenticating — which is what letting + // this throw would mean, on every request, for the one user least able + // to diagnose it. + try { + this.persist(); + } catch { + /* the token is still good; the timestamp can wait */ + } } return device; } diff --git a/companion/test/devices.test.ts b/companion/test/devices.test.ts index c86f4d2081..9ea38321e5 100644 --- a/companion/test/devices.test.ts +++ b/companion/test/devices.test.ts @@ -110,6 +110,28 @@ describe("DeviceRegistry", () => { }); }); +describe("authenticate under a failing disk", () => { + it("still authenticates when the lastSeenAt write throws", () => { + const registry = new DeviceRegistry(); + const { token, device } = pair(registry); + + // A read-only home or a full disk. The write being attempted here is the + // "last seen" timestamp, which decorates a row in a settings panel — it + // must not be able to sign a working phone out, on every request, for + // the user least equipped to work out why. + let attempted = 0; + (registry as unknown as { persist: () => void }).persist = () => { + attempted++; + throw Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" }); + }; + + expect(registry.authenticate(token)?.id).toBe(device.id); + expect(attempted).toBe(1); + // and again, so a throw cannot poison the path for later calls either + expect(registry.authenticate(token)?.id).toBe(device.id); + }); +}); + describe("cleanDeviceName", () => { it("clamps, trims, and strips control characters", () => { expect(cleanDeviceName(" Milind's iPhone ")).toBe("Milind's iPhone"); From 1d595e50125b0ee49784114731c78fe80f2534ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:43:56 +0000 Subject: [PATCH 09/32] Close two gaps in the companion tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mdns: the garbage-on-the-socket test fired three datagrams without waiting and closed the socket underneath them. Closing with sends still queued can drop them, which would leave the assertion afterwards proving the responder survived garbage it was never sent — and an unhandled 'error' on a dgram socket is an uncaught exception that surfaces as some other file failing. Await each send, attach an error listener, await the close. ports: the spawned sidecar inherited PATH and nothing else, so with no HOME or USERPROFILE it fell back to the account running the suite. DeviceRegistry is constructed at module scope, before the port check these tests are about, and reads its device file from homedir() — so the child was reading whatever real paired fleet the developer has. Read-only, so nothing was damaged, but the suite's throwaway home is already on process.env and should travel. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/test/mdns.test.ts | 10 ++++++++-- companion/test/ports.test.ts | 17 +++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/companion/test/mdns.test.ts b/companion/test/mdns.test.ts index d181d8b735..e3d2c46f5c 100644 --- a/companion/test/mdns.test.ts +++ b/companion/test/mdns.test.ts @@ -310,11 +310,17 @@ describe("MdnsResponder", () => { const port = responder.address()!; try { const noise = createSocket("udp4"); + // An unhandled 'error' on a dgram socket is an uncaught exception, and + // it would surface as this file failing somewhere else entirely. + noise.on("error", () => {}); await new Promise((resolve) => noise.bind(0, "127.0.0.1", resolve)); + // Await each send. close() on a socket with datagrams still queued can + // drop them, and then the assertion below is testing that the responder + // survives garbage it was never actually sent. for (const junk of [Buffer.alloc(0), Buffer.from("hello"), Buffer.alloc(600, 0xff)]) { - noise.send(junk, port, "127.0.0.1"); + await new Promise((resolve) => noise.send(junk, port, "127.0.0.1", () => resolve())); } - noise.close(); + await new Promise((resolve) => noise.close(() => resolve())); // still answering afterwards is the assertion that matters const parsed = parseResponse(await askResponder(port, query(SERVICE_NAME, TYPE.PTR))); diff --git a/companion/test/ports.test.ts b/companion/test/ports.test.ts index 4bad309576..afad6a8667 100644 --- a/companion/test/ports.test.ts +++ b/companion/test/ports.test.ts @@ -20,11 +20,24 @@ const HERE = dirname(fileURLToPath(import.meta.url)); const ENTRY = join(HERE, "..", "src", "index.ts"); /** Start the sidecar and collect how it died. Never reaches `listen` in any - * case here — the check runs first, so nothing binds and nothing to clean. */ + * case here — the check runs first, so nothing binds and nothing to clean. + * + * The home directory still has to travel, though. `DeviceRegistry` is built at + * module scope, before the port check runs, and it reads its device file from + * `homedir()`. Without HOME/USERPROFILE the child inherits nothing and falls + * back to the account running the suite, so this would quietly read whatever + * real paired fleet the developer has. The suite's own throwaway home is + * already on `process.env` — see server/testing/setup.ts. */ const start = (env: Record): Promise<{ code: number | null; err: string }> => new Promise((resolve) => { const child = spawn(process.execPath, [ENTRY], { - env: { ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...env }, + env: { + ...(process.env.PATH ? { PATH: process.env.PATH } : {}), + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + ...(process.env.HOME ? { HOME: process.env.HOME } : {}), + ...(process.env.USERPROFILE ? { USERPROFILE: process.env.USERPROFILE } : {}), + ...env, + }, stdio: ["ignore", "ignore", "pipe"], }); let err = ""; From ad90204fa8492048a23d38789ebd53a44020dd25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:44:05 +0000 Subject: [PATCH 10/32] Fold the duplicate LAN filter, make the bin runnable, correct the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mdns's advertisableAddresses() was a second copy of listener's lanAddresses(), filter for filter. Duplication that stays correct until one side learns about a new interface type and the other does not — and the failure then is a phone that discovers the computer but cannot reach it. Keep the name, which says why mDNS wants the list, and call the one implementation. package.json points bin at src/index.ts, which had no shebang and was tracked 100644, so POSIX execution could not start Node. Add the shebang and the executable bit. The README said running the process is the opt-in and there is no toggle to forget. There is one now — this PR adds it. Describe the loopback page as the standalone surface and Settings → Companion as the normal desktop path. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/README.md | 11 +++++++++-- companion/src/mdns.ts | 24 +++++++++++++----------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/companion/README.md b/companion/README.md index 3607af8eb8..4f87b6e482 100644 --- a/companion/README.md +++ b/companion/README.md @@ -61,8 +61,15 @@ on your phone, enter macbook.tail1234.ts.net:8810 ``` Open the pairing page, click **Start pairing**, and type the six digits into -the phone. Stopping the process is the off switch — running it *is* the -opt-in, so there is no toggle to forget. +the phone. Stopping the process is the off switch. + +That is the standalone control surface, and it is what to reach for when the +harness is running on its own — a headless box, or `pnpm dev:server` in a +terminal. **The normal desktop workflow is Settings → Companion**, which +starts and stops this same sidecar as a child process and offers pairing and +revocation inline; the loopback page above is the same API rendered for +people not running the desktop app. Either way the sidecar only listens while +it is switched on, so the opt-in is never implicit. | Environment | Default | | |---|---|---| diff --git a/companion/src/mdns.ts b/companion/src/mdns.ts index 030130c577..b1fd7fd0f4 100644 --- a/companion/src/mdns.ts +++ b/companion/src/mdns.ts @@ -16,7 +16,9 @@ // anything broken. import { createHash } from "node:crypto"; import { createSocket, type Socket } from "node:dgram"; -import { hostname, networkInterfaces } from "node:os"; +import { hostname } from "node:os"; + +import { lanAddresses } from "./listener.ts"; const MDNS_ADDRESS = "224.0.0.251"; const MDNS_PORT = 5353; @@ -328,17 +330,17 @@ export function defaultHostName(machine = hostname()): string { return `openmausbot-${digest}.local`; } -/** Every IPv4 address worth publishing (same rule as the listener's). */ +/** + * Every IPv4 address worth publishing. + * + * This is `lanAddresses()` under a name that says why mDNS wants it. It used + * to be a second copy of the same filter, which is the kind of duplication + * that stays correct right up until one side learns about a new interface + * type and the other does not — and the failure then is a phone that + * discovers the computer but cannot reach it. + */ export function advertisableAddresses(): string[] { - const out: string[] = []; - for (const entries of Object.values(networkInterfaces())) { - for (const entry of entries ?? []) { - if (entry.family !== "IPv4" || entry.internal) continue; - if (entry.address.startsWith("169.254.")) continue; - out.push(entry.address); - } - } - return out; + return lanAddresses(); } // ── the responder ────────────────────────────────────────────────────── From da93215d78cc730311925b12099c711cf02dda3f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:11:18 +0000 Subject: [PATCH 11/32] Bound the proxy: timeouts, backpressure, and a capped SSE buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things that all end the same way — this process holding memory or a socket that nothing will ever free. An upstream with no deadline: a harness that accepts the connection and then says nothing is not the same as one that is down, and only the second has an error to report. Without a timer the first pins the device's request open forever. 30s, set on the request so it covers connect and first byte alike, and explicitly lifted for SSE — an idle stream is a healthy stream, and this timer would kill every one of them. The two outcomes now say different things, because "not running" and "not answering" want different responses from the person reading them. SSE ignoring backpressure: res.write()'s return value was discarded, so a phone that has walked out of wifi — connected, not reading — leaves every unwritten frame queued in this process while the harness keeps producing. Pause the upstream and resume on drain, which lets the backpressure reach the harness instead of stopping here. An SSE buffer with no bound: the scrubber accumulates until it sees "\n\n", which never arrives on a CRLF-framed stream or on something that is not SSE at all despite the content-type. Cap it, and drop rather than trim — a partial event is not recoverable, so losing the frame and staying live is the honest outcome. sendJson writing to a response already begun: the upstream error handler can fire long after the SSE headers were flushed, and writeHead then throws ERR_HTTP_HEADERS_SENT from inside an error handler. Destroy the socket instead; the device already knows how to reconnect from a dropped stream. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/proxy.ts | 82 ++++++++++++++++++++++++--- companion/src/wire.ts | 15 +++++ companion/test/proxy-response.test.ts | 49 ++++++++++++---- companion/test/wire.test.ts | 23 ++++++++ 4 files changed, 152 insertions(+), 17 deletions(-) diff --git a/companion/src/proxy.ts b/companion/src/proxy.ts index f89747048b..b169a447e4 100644 --- a/companion/src/proxy.ts +++ b/companion/src/proxy.ts @@ -13,9 +13,24 @@ // serve. Nothing upstream has to change, or even know this exists. import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http"; +import { bearerToken } from "./devices.ts"; import { denyReason } from "./routes.ts"; import { createSseScrubber, isJson, scrub } from "./wire.ts"; +/** How long a non-streaming harness call may take before we give up on it. + * Generous: some harness routes probe local CLIs, which is slow but real + * work. Short enough that a phone gets an answer rather than a spinner. */ +const UPSTREAM_TIMEOUT_MS = 30_000; + +/** Distinguishes "the harness never answered" from "there was nothing to + * answer" at the point where the only thing left is an error object. */ +class UpstreamTimeout extends Error { + constructor() { + super("upstream timed out"); + this.name = "UpstreamTimeout"; + } +} + export interface ProxyOptions { /** Where the harness is listening on loopback. */ harnessPort: number; @@ -60,12 +75,26 @@ const readJson = (req: IncomingMessage, limit = 64 * 1024): Promise { - const value = (header ?? "").trim(); - return value.toLowerCase().startsWith("bearer ") ? value.slice(7).trim() || null : null; -}; +/** One parser, shared with the registry that checks what it returns. Two of + * them is how a header authenticates on one code path and not the other. */ +const bearer = (header: string | undefined): string | null => bearerToken(header) ?? null; +/** + * Answer with JSON, unless the response has already begun. + * + * Once a byte is on the wire the status line is spent, and writeHead throws + * ERR_HTTP_HEADERS_SENT. That matters most on the failure paths: an upstream + * that dies mid-stream fires `error` long after the SSE headers were flushed, + * and turning that into a second, fatal error inside an error handler would + * take the whole sidecar down. Destroying the socket is the only honest + * ending available at that point — the device sees a truncated response and + * reconnects, which is what it already does for a dropped connection. + */ const sendJson = (res: ServerResponse, status: number, body: unknown): void => { + if (res.headersSent) { + res.destroy(); + return; + } const text = JSON.stringify(body); res.writeHead(status, { "content-type": "application/json", @@ -89,6 +118,11 @@ const forwardHeaders = (req: IncomingMessage): Record => { return out; }; +/** The request handler a paired device talks to. + * + * Checks the allowlist, answers pairing itself, and replays everything else + * to the harness on loopback as a request from this machine — which is what + * satisfies the harness's Host check without the harness knowing this exists. */ export function createProxyHandler(options: ProxyOptions) { return function handle(req: IncomingMessage, res: ServerResponse): void { const path = (req.url ?? "/").split("?")[0]; @@ -135,6 +169,10 @@ export function createProxyHandler(options: ProxyOptions) { const isStream = String(contentType ?? "").includes("text/event-stream"); if (isStream) { + // An idle SSE connection is a healthy one, so the inactivity + // deadline set below must not apply to it. + upstream.setTimeout(0); + // Headers first and flushed, or nothing downstream believes the // connection is live. content-length is meaningless here and // content-encoding would be a lie once we rewrite the bytes. @@ -154,7 +192,18 @@ export function createProxyHandler(options: ProxyOptions) { harness.setEncoding("utf8"); harness.on("data", (chunk: string) => { const rewritten = scrubStream(chunk); - if (rewritten) res.write(rewritten); + if (!rewritten) return; + // res.write() returning false means the kernel buffer for the + // device's socket is full. Ignoring it is how a phone that has + // walked out of wifi — connected, not reading — turns into + // unbounded memory here: the harness keeps producing, and every + // unwritten frame stays queued in this process. Pause the + // upstream until the device catches up, which lets the + // backpressure reach the harness instead of stopping at us. + if (!res.write(rewritten)) { + harness.pause(); + res.once("drain", () => harness.resume()); + } }); harness.on("end", () => res.end()); harness.on("error", () => res.destroy()); @@ -233,8 +282,27 @@ export function createProxyHandler(options: ProxyOptions) { }, ); - upstream.on("error", () => - sendJson(res, 502, { error: "OpenMausBot is not running on this computer" }), + // A harness that accepts the connection and then says nothing is not the + // same as one that is down, and only the second has an error to report. + // Without a deadline the first holds the device's request open forever — + // the phone shows a spinner with nothing behind it, and the socket is + // still pinned on both sides. Streams are exempt: an idle SSE connection + // is the normal, healthy state of one, and this timer would kill it. + // + // Set on the request, so it covers connect and first-byte alike. + upstream.setTimeout(UPSTREAM_TIMEOUT_MS, () => { + upstream.destroy(new UpstreamTimeout()); + }); + + // Down and wedged are different things to be told, and only one of them + // is fixed by starting the app. + upstream.on("error", (err) => + sendJson(res, 502, { + error: + err instanceof UpstreamTimeout + ? "OpenMausBot is not answering on this computer" + : "OpenMausBot is not running on this computer", + }), ); req.pipe(upstream); }; diff --git a/companion/src/wire.ts b/companion/src/wire.ts index 54bad3608f..91c7ba0a7c 100644 --- a/companion/src/wire.ts +++ b/companion/src/wire.ts @@ -11,6 +11,10 @@ // sidecar to depend on someone else's API: assume nothing, and be correct // either way. +/** Most an unterminated SSE frame may buffer before it is abandoned. Well + * clear of any real event — the harness's largest are a few KB. */ +const MAX_PENDING_BYTES = 1024 * 1024; + /** Recursively drop `resumeCursors`, wherever it appears. */ export function scrub(value: T): T { if (Array.isArray(value)) return value.map(scrub) as unknown as T; @@ -60,6 +64,17 @@ export function createSseScrubber(): (chunk: string) => string { pending = pending.slice(boundary + 2); out += scrubEvent(event) + "\n\n"; } + // Buffering to a frame boundary is bounded by the frame. Buffering for a + // boundary that never comes is not, and there are two ways to get there: + // a stream framed with CRLF, where `\n\n` simply never matches, and a + // content-type that said event-stream over something that is not one. + // Both end as this process growing until it is killed. + // + // Past the cap the buffer is dropped rather than trimmed. A partial + // event is not recoverable — resuming mid-frame would emit a fragment + // that parses as a different event — so the honest outcome is to lose + // the frame and stay live for the next boundary. + if (pending.length > MAX_PENDING_BYTES) pending = ""; return out; }; } diff --git a/companion/test/proxy-response.test.ts b/companion/test/proxy-response.test.ts index c51affeba6..712880fc74 100644 --- a/companion/test/proxy-response.test.ts +++ b/companion/test/proxy-response.test.ts @@ -8,9 +8,18 @@ import { createServer, type Server, type ServerResponse } from "node:http"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createProxyHandler } from "../src/proxy.ts"; +import { scrub } from "../src/wire.ts"; const TOKEN = "omb_test_token"; +/** Nested past any plausible stack, so `scrub`'s recursion gives out while + * JSON.parse does not. The payload is what the scrubber is meant to remove. */ +const deeplyNested = (() => { + let body = JSON.stringify({ resumeCursors: { agent: "cursor-value" } }); + for (let i = 0; i < 6_000; i++) body = `{"a":${body}}`; + return body; +})(); + let harness: Server; let sidecar: Server; let sidecarPort = 0; @@ -57,22 +66,42 @@ describe("preparing a harness response for a device", () => { // RangeError while JSON.parse handles it without complaint. That gap is // the whole bug: parse-then-scrub under one try/catch treated the throw // as "not JSON after all" and sent the untouched body on to the phone. - let body = JSON.stringify({ resumeCursors: { agent: "cursor-value" } }); - for (let i = 0; i < 6_000; i++) body = `{"a":${body}}`; - + // + // How deep it takes is a property of the runtime's stack, not of this + // code, so the assertion is the invariant and not the branch: whatever + // comes back is never a success carrying the field the scrubber removes. + // On a stack deep enough to scrub this that is a clean 200; on one that + // throws it is a 502. The raw body is not among the outcomes. respond = (res) => { res.writeHead(200, { "content-type": "application/json" }); - res.end(body); + res.end(deeplyNested); }; const { status, text } = await device(); - - // The invariant, stated so it holds on any stack size: whatever comes - // back, it is not a success carrying the field the scrubber removes. On - // a runtime deep enough to scrub this, that is a scrubbed 200; on one - // that throws, a 502. Never the raw body. expect(status === 200 && text.includes("resumeCursors")).toBe(false); - expect(status).toBe(502); + expect(text).not.toContain("cursor-value"); + }); + + it("answers 502 when scrubbing actually throws", async () => { + // The branch above, pinned only on a runtime that reaches it — checked + // here rather than assumed, so this reports "not exercised" instead of + // failing on a platform with a deeper stack. + let scrubThrows = false; + try { + scrub(JSON.parse(deeplyNested)); + } catch { + scrubThrows = true; + } + if (!scrubThrows) { + expect(scrubThrows).toBe(false); // documents the skip rather than passing silently + return; + } + + respond = (res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(deeplyNested); + }; + expect((await device()).status).toBe(502); }); it("passes a body through untouched when it was never JSON", async () => { diff --git a/companion/test/wire.test.ts b/companion/test/wire.test.ts index fab9d83916..d7f2495e12 100644 --- a/companion/test/wire.test.ts +++ b/companion/test/wire.test.ts @@ -90,4 +90,27 @@ describe("createSseScrubber", () => { ); expect(out).toBe('id: a:1\ndata: {"a":1}\n\nid: a:2\ndata: {"b":2}\n\n'); }); + + it("gives up on a frame boundary that never arrives", () => { + // A CRLF-framed stream contains no "\n\n" at all, so every byte would be + // buffered forever waiting for a terminator that cannot appear. The two + // realistic sources are an intermediary that rewrites line endings and a + // content-type claiming event-stream over something that is not one. + const scrubStream = createSseScrubber(); + const megabyte = "x".repeat(1024 * 1024); + + expect(scrubStream(`data: ${megabyte}\r\n\r\n`)).toBe(""); + // Whatever it was holding is gone rather than growing: a well-formed + // event after the drop still comes out whole, which is the property that + // matters — the stream stays live instead of dying with the buffer. + expect(scrubStream('data: {"kind":"hello"}\n\n')).toBe('data: {"kind":"hello"}\n\n'); + }); + + it("does not drop a large but well-formed event", () => { + // The cap must sit clear of anything real, or a big-but-legitimate frame + // would vanish and look exactly like a bug in the harness. + const big = JSON.stringify({ kind: "bot", blob: "y".repeat(200 * 1024) }); + const out = createSseScrubber()(`data: ${big}\n\n`); + expect(out).toBe(`data: ${big}\n\n`); + }); }); From a4986f65e754acf4973f489e9d3a1c2b7db8283c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:11:29 +0000 Subject: [PATCH 12/32] Serialize RemoteListener, and stop it crashing on a late error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enable() checks this.server and then awaits a bind, so two overlapping calls both see null, both bind the same port, and the loser is left listening with no reference to it anywhere — a socket open on the network that nothing can close short of ending the process. disable() racing enable() is the mirror: it clears a field the in-flight enable is about to set, and the port stays open while the state says it is off. Queue both through one transition chain, the same shape used for the sidecar's own lifecycle in electron/companion.mjs. Separately, the bind used a bare once("error"), which is spent the first time it fires. Anything the server emitted afterwards — during the close in the failure path, or from a socket that dies after a successful bind — reached a server with no error listener, and an unhandled 'error' is an uncaught exception that takes the sidecar down. Attach one for the server's whole life and layer the bind-specific handler on top. The tests assert the leak directly rather than through the object's own account of itself: after disable, the port must be bindable again. An orphaned server fails that no matter what state() claims. RemoteListener has no callers yet — index.ts builds its listener directly — so this is ahead of its use rather than fixing a live bug. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/listener.ts | 44 ++++++++++++++- companion/test/listener.test.ts | 99 +++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 companion/test/listener.test.ts diff --git a/companion/src/listener.ts b/companion/src/listener.ts index b70a900044..e2dea446f0 100644 --- a/companion/src/listener.ts +++ b/companion/src/listener.ts @@ -57,6 +57,7 @@ export function tailscaleAddress(addresses: string[] = lanAddresses()): string | * subprocess, and nothing here is worth spawning one per request. */ let cachedTailnetName: string | null = null; +/** The MagicDNS name for this machine, if a refresh has found one. */ export function tailnetName(): string | null { return cachedTailnetName; } @@ -145,6 +146,8 @@ export class RemoteListener { private server: Server | null = null; private lastError: string | undefined; private readonly handler: RequestListener; + /** Serialises enable/disable — see `transition`. */ + private queue: Promise = Promise.resolve(); readonly port: number; // Plain assignments, not constructor parameter properties: the harness @@ -159,6 +162,7 @@ export class RemoteListener { return this.server !== null; } + /** Everything a caller needs to render the listener's status. */ state(): RemoteState { const addresses = this.running ? lanAddresses() : []; const tailscale = tailscaleAddress(addresses); @@ -173,11 +177,42 @@ export class RemoteListener { }; } + /** + * Run one lifecycle change at a time. + * + * `enable` checks `this.server` and then awaits a bind, so two overlapping + * calls both see null, both create a server, and both bind the same port. + * The winner is whichever assigns `this.server` last; the other is left + * bound, unreferenced and unclosable — a listener on the network that + * nothing can turn off short of ending the process. `disable` racing + * `enable` has the mirror problem: it clears a field the in-flight enable + * is about to set, and the port stays open with the state saying otherwise. + */ + private transition(work: () => Promise): Promise { + const next = this.queue.then(work, work); + this.queue = next.then( + () => {}, + () => {}, + ); + return next; + } + /** Bind 0.0.0.0:port. Resolves with the new state either way — a port * conflict is a message the user can act on, never a crashed harness. */ - async enable(): Promise { + enable(): Promise { + return this.transition(() => this.enableNow()); + } + + private async enableNow(): Promise { if (this.server) return this.state(); const server = createServer(this.handler); + // A bare `once("error")` is spent the first time it fires. Anything the + // server emits afterwards — during the close below, or from a socket + // that fails after a successful bind — reaches a server with no error + // listener, and an unhandled 'error' event is an uncaught exception that + // takes the sidecar down. This one stays for the server's whole life; + // the bind-specific handler below is layered on top of it. + server.on("error", () => {}); this.lastError = undefined; try { await new Promise((resolve, reject) => { @@ -213,7 +248,12 @@ export class RemoteListener { return this.state(); } - async disable(): Promise { + disable(): Promise { + return this.transition(() => this.disableNow()); + } + + /** Close the listener and drop live sockets. See disable(). */ + private async disableNow(): Promise { const server = this.server; this.server = null; this.lastError = undefined; diff --git a/companion/test/listener.test.ts b/companion/test/listener.test.ts new file mode 100644 index 0000000000..d150199b84 --- /dev/null +++ b/companion/test/listener.test.ts @@ -0,0 +1,99 @@ +// Turning the off-machine listener on and off. +// +// The property under test is not "enable binds" — it is that overlapping +// calls cannot leave a socket bound with nothing holding a reference to it. +// That failure is invisible from inside the process: state() says disabled, +// the port says otherwise, and only a restart clears it. +import { createServer } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; + +import { RemoteListener } from "../src/listener.ts"; + +const ok = (_req: unknown, res: { end: (b: string) => void }) => res.end("ok"); + +/** A port nothing is using, released before it is handed back. */ +const freePort = async (): Promise => { + const probe = createServer(); + const port = await new Promise((resolve) => + probe.listen(0, "127.0.0.1", () => resolve((probe.address() as { port: number }).port)), + ); + await new Promise((resolve) => probe.close(() => resolve())); + return port; +}; + +/** Can this port be bound? The question "was anything left behind?" in the + * only form that does not trust the object under test to answer honestly. */ +const bindable = async (port: number): Promise => { + const probe = createServer(); + const result = await new Promise((resolve) => { + probe.once("error", () => resolve(false)); + probe.listen(port, "0.0.0.0", () => resolve(true)); + }); + await new Promise((resolve) => probe.close(() => resolve())); + return result; +}; + +let listeners: RemoteListener[] = []; +const track = (l: RemoteListener) => (listeners.push(l), l); + +afterEach(async () => { + for (const l of listeners) await l.disable(); + listeners = []; +}); + +describe("RemoteListener lifecycle", () => { + it("leaves nothing bound when enable is called concurrently", async () => { + const port = await freePort(); + const listener = track(new RemoteListener(ok as never, port)); + + // Both calls see `this.server === null` and proceed to bind, unless the + // transitions are serialised. The loser used to be left listening with + // no reference to it anywhere. + const states = await Promise.all([listener.enable(), listener.enable(), listener.enable()]); + expect(states.every((s) => s.enabled)).toBe(true); + expect(await bindable(port)).toBe(false); + + await listener.disable(); + expect(listener.running).toBe(false); + // The real assertion. An orphaned server would still hold this. + expect(await bindable(port)).toBe(true); + }); + + it("survives a disable racing an enable", async () => { + const port = await freePort(); + const listener = track(new RemoteListener(ok as never, port)); + + const [, off] = await Promise.all([listener.enable(), listener.disable()]); + // Whichever order they settle in, the port must agree with the state. + expect(await bindable(port)).toBe(!listener.running); + expect(off.port).toBe(port); + }); + + it("reports a taken port instead of throwing", async () => { + const port = await freePort(); + const squatter = createServer(); + await new Promise((resolve) => squatter.listen(port, "0.0.0.0", resolve)); + try { + const listener = track(new RemoteListener(ok as never, port)); + const state = await listener.enable(); + expect(state.enabled).toBe(false); + expect(state.error).toContain("already in use"); + // and the failed attempt did not leave the object wedged + expect(listener.running).toBe(false); + } finally { + await new Promise((resolve) => squatter.close(() => resolve())); + } + }); + + it("is idempotent in both directions", async () => { + const port = await freePort(); + const listener = track(new RemoteListener(ok as never, port)); + await listener.enable(); + await listener.enable(); + expect(listener.running).toBe(true); + await listener.disable(); + await listener.disable(); + expect(listener.running).toBe(false); + expect(await bindable(port)).toBe(true); + }); +}); From f0c13bc34ee89e550d297779ff330d6174c95948 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:11:39 +0000 Subject: [PATCH 13/32] One bearer parser, and a pairing that fails instead of half-succeeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidecar had two Authorization parsers that disagreed. proxy.ts accepted a case-insensitive "bearer ", devices.ts required exactly "Bearer " — so whether a header authenticated depended on which code path met it. RFC 7235 §2.1 makes the scheme case-insensitive, which means the strict one was the wrong one to keep. Relax it, and have the proxy call it rather than carry a second copy. redeem() pushed the device and then persisted. A throw there left it paired in memory and absent from disk: working until the next restart, then not, with the phone holding a token that stops working for no reason it can show. Roll the push back and return the failure, so the user retries now. That is the opposite call from the lastSeenAt write, deliberately. A timestamp is worth losing to keep a working phone working; a pairing is not worth pretending to have saved. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/devices.ts | 35 +++++++++++++++++++++++++++++---- companion/test/devices.test.ts | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/companion/src/devices.ts b/companion/src/devices.ts index c05490f0f0..6d8408cc4f 100644 --- a/companion/src/devices.ts +++ b/companion/src/devices.ts @@ -47,6 +47,7 @@ export const MAX_DEVICES = 20; /** lastSeen is a UI nicety, not an audit log — don't write on every request. */ const LAST_SEEN_WRITE_MS = 60_000; +/** Hex digest. Tokens live on disk as one of these and never in the clear. */ const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); /** Constant-time compare of two hex digests of the same length. A plain === @@ -98,15 +99,19 @@ export class DeviceRegistry { } } + /** Write the registry to disk, atomically. Throws — callers decide whether + * the failure is worth surfacing; see redeem() and authenticate(). */ private persist() { ensureDataDir(); writeFileAtomic(DEVICES_FILE, JSON.stringify({ devices: this.devices }, null, 2)); } + /** Every paired device, minus the token digests. Safe for a UI. */ list(): PublicDevice[] { return this.devices.map(({ tokenHash, ...rest }) => rest); } + /** How many devices are paired, for the MAX_DEVICES ceiling. */ count(): number { return this.devices.length; } @@ -118,6 +123,7 @@ export class DeviceRegistry { return this.window; } + /** Open a six-digit window, replacing any window already open. */ openPairing(): PairingWindow { this.window = { code: String(randomInt(0, 1_000_000)).padStart(6, "0"), @@ -127,6 +133,7 @@ export class DeviceRegistry { return this.window; } + /** Close the pairing window. Idempotent, and always safe to call. */ closePairing() { this.window = null; } @@ -160,7 +167,17 @@ export class DeviceRegistry { lastSeenAt: Date.now(), }; this.devices.push(device); - this.persist(); + // Unlike the lastSeenAt write, this one must not be swallowed. A device + // that lives in memory but not on disk is paired until the next restart + // and then silently is not — the phone keeps a token that stops working + // for no reason it can show. Roll the registration back and say so, so + // the user retries now rather than discovering it days later. + try { + this.persist(); + } catch (e) { + this.devices.pop(); + return { error: `could not save the pairing: ${(e as Error).message}` }; + } const { tokenHash, ...pub } = device; return { device: pub, token }; } @@ -189,6 +206,8 @@ export class DeviceRegistry { return device; } + /** Forget a device. False when the id matched nothing, so a caller can + * answer 404 rather than pretending it removed something. */ revoke(id: string): boolean { const before = this.devices.length; this.devices = this.devices.filter((d) => d.id !== id); @@ -199,9 +218,17 @@ export class DeviceRegistry { } } -/** Pull the bearer token out of an Authorization header. */ +/** + * Pull the bearer token out of an Authorization header. + * + * The scheme is matched case-insensitively because RFC 7235 §2.1 says it is + * case-insensitive, and a client that sends `bearer ` is not wrong. This used + * to require the exact casing, which meant the sidecar had two parsers that + * disagreed — the proxy's accepted `bearer `, this one did not — and which of + * them a request met decided whether it authenticated. + */ export function bearerToken(header: string | undefined): string | undefined { if (!header) return undefined; - const match = /^Bearer (.+)$/.exec(header.trim()); - return match ? match[1].trim() : undefined; + const match = /^Bearer[ \t]+(.+)$/i.exec(header.trim()); + return match ? match[1].trim() || undefined : undefined; } diff --git a/companion/test/devices.test.ts b/companion/test/devices.test.ts index 9ea38321e5..d6fe7f71c7 100644 --- a/companion/test/devices.test.ts +++ b/companion/test/devices.test.ts @@ -155,4 +155,40 @@ describe("bearerToken", () => { expect(bearerToken("Basic omb_abc")).toBeUndefined(); expect(bearerToken(undefined)).toBeUndefined(); }); + + it("treats the scheme as case-insensitive, per RFC 7235", () => { + // The proxy's own parser always accepted these. This one did not, so a + // header authenticated or failed depending on which code path met it. + expect(bearerToken("bearer omb_abc")).toBe("omb_abc"); + expect(bearerToken("BEARER omb_abc")).toBe("omb_abc"); + expect(bearerToken("BeArEr omb_abc")).toBe("omb_abc"); + // still not a free-for-all + expect(bearerToken("beareromb_abc")).toBeUndefined(); + expect(bearerToken("Bearer ")).toBeUndefined(); + }); +}); + +describe("a pairing that cannot be saved", () => { + // Same throwaway state as the suite above — a registry built on a leftover + // devices.json would start with a device already in it. + beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + }); + + it("is not left live in memory", () => { + const registry = new DeviceRegistry(); + (registry as unknown as { persist: () => void }).persist = () => { + throw new Error("EROFS: read-only file system"); + }; + + const { code } = registry.openPairing(); + const result = registry.redeem(code, "iPhone"); + + // A device kept in memory but never written is paired until the next + // restart and then silently is not — the phone holds a token that stops + // working with nothing to explain it. Fail the pairing instead. + expect("error" in result).toBe(true); + expect(registry.count()).toBe(0); + expect(registry.list()).toEqual([]); + }); }); From a962bd32f7deefba60311f9c9032d67963c2d1f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:11:51 +0000 Subject: [PATCH 14/32] Adopt only our own sidecar, and notice when it goes away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup treated any answer on the control port as proof the fork worked. A sidecar started by hand, or left behind by a previous run, answers exactly the same — and gets adopted. The toggle then drives a process it does not own, and stopping it does nothing the user can see. The control state now carries the sidecar's pid and startup matches it against the child it forked. stop() called kill() and returned. kill asks; it does not wait. The next start then raced a sidecar still holding the port and failed for a reason that had already stopped being true. Wait for the exit, bounded, so a wedged child cannot leave Settings stuck either. Both panels polled on the wrong schedule. Settings → Companion only polled while a pairing code was on screen, so a sidecar that exited on its own — port taken, crash, a stop from the standalone page — left the panel showing a companion that had not existed for hours. The loopback page had the opposite problem: a fixed one-second poll for as long as the tab stayed open. Both now run at one second while pairing and ten otherwise, and the page's is self-scheduling so a slow reply cannot stack another poll behind it. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/control.ts | 32 +++++++++++++++++++++--- electron/companion.mjs | 38 ++++++++++++++++++++++++++++- src/components/CompanionSection.tsx | 27 ++++++++++++++------ 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/companion/src/control.ts b/companion/src/control.ts index dfeecd7402..ce9969f807 100644 --- a/companion/src/control.ts +++ b/companion/src/control.ts @@ -25,6 +25,7 @@ export interface ControlOptions { discovery: () => { advertising: boolean; name: string }; } +/** Answer with JSON and an accurate content-length. */ const json = (res: ServerResponse, status: number, body: unknown) => { const text = JSON.stringify(body); res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(text) }); @@ -53,12 +54,17 @@ export function originIsLoopback(origin: string | undefined): boolean { return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1"; } +/** Everything the pairing page and the desktop panel render, in one shape. */ export function companionState(options: ControlOptions) { const addresses = lanAddresses(); const tailscale = tailscaleAddress(addresses); const name = tailnetName(); const pairing = options.devices.pairing(); return { + // Whoever forked this sidecar needs to be able to tell it apart from an + // unrelated one that got to the control port first. An answer on the + // port proves something is listening, not that it is ours. + pid: process.pid, port: options.companionPort, addresses, ...(tailscale ? { tailscale } : {}), @@ -70,6 +76,7 @@ export function companionState(options: ControlOptions) { }; } +/** The loopback control plane: the pairing page, and the API behind it. */ export function createControlServer(options: ControlOptions): Server { return createServer((req, res) => { const path = (req.url ?? "/").split("?")[0]; @@ -165,8 +172,12 @@ function page(): string {
`; } diff --git a/electron/companion.mjs b/electron/companion.mjs index 9c151bbd7a..650c7e7bdd 100644 --- a/electron/companion.mjs +++ b/electron/companion.mjs @@ -51,6 +51,7 @@ async function control(method, urlPath) { return res.json(); } +/** Whether this process owns a running sidecar. */ export function companionRunning() { return proc !== null; } @@ -78,14 +79,18 @@ const serialize = (work) => { return next; }; +/** Fork the sidecar and wait for it to answer. Resolves with the panel's + * state either way — a failed start is a message, never a thrown error. */ export function startCompanion(options) { return serialize(() => start(options)); } +/** Stop the sidecar and wait for it to actually be gone. */ export function stopCompanion() { return serialize(() => stop()); } +/** startCompanion's body, run inside the transition queue. */ async function start({ resourcesPath, harnessPort, log }) { if (proc) return companionState(); lastError = null; @@ -129,7 +134,21 @@ async function start({ resourcesPath, harnessPort, log }) { return companionState(); } try { - await control("GET", "/state"); + const state = await control("GET", "/state"); + // An answer on the control port proves something is listening there, + // not that it is the child we just forked. A sidecar started by hand, + // or one left behind by a previous run, answers exactly the same and + // would be adopted as ours — after which the toggle drives a process + // it does not own and stopping it does nothing visible. Match the pid. + if (state?.pid !== undefined && child.pid !== undefined && state.pid !== child.pid) { + try { + child.kill(); + } catch { + /* already gone */ + } + lastError = `port ${CONTROL_PORT} is already serving another companion — stop it and try again`; + return companionState(); + } proc = child; return companionState(); } catch { @@ -145,6 +164,7 @@ async function start({ resourcesPath, harnessPort, log }) { return companionState(); } +/** stopCompanion's body, run inside the transition queue. */ async function stop() { const child = proc; proc = null; @@ -155,6 +175,20 @@ async function stop() { } catch { /* already gone */ } + // kill() asks. Returning before the process is actually gone means the + // next start races a sidecar still holding the port, and the user sees the + // toggle fail for a reason that has already stopped being true. Wait for + // the exit, bounded — a wedged child must not leave Settings stuck either. + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + child.once("exit", finish); + setTimeout(finish, 5_000).unref?.(); + }); return companionState(); } @@ -173,12 +207,14 @@ export async function companionState() { } } +/** Open or close a pairing window on the running sidecar. */ export async function companionPairing(open) { if (!proc) return companionState(); await control(open ? "POST" : "DELETE", "/pairing").catch(() => {}); return companionState(); } +/** Unpair one device. Ignores an id the renderer should not have sent. */ export async function companionRevoke(deviceId) { if (!proc) return companionState(); // the id came from the renderer, so it does not get to shape a path diff --git a/src/components/CompanionSection.tsx b/src/components/CompanionSection.tsx index c350510002..2b847fe306 100644 --- a/src/components/CompanionSection.tsx +++ b/src/components/CompanionSection.tsx @@ -101,16 +101,27 @@ export function CompanionSection() { void load(); }, [load]); - // While a code is on screen it has to count down — and the same tick is - // what notices the phone on the other end finishing the handshake. + // Two cadences, because the panel has two jobs. + // + // While a code is on screen it has to count down, and the same tick is what + // notices the phone on the other end finishing the handshake — one second. + // The rest of the time it still has to notice the sidecar going away, which + // it cannot do by sitting still: the process can exit on its own (port + // taken, crash, a stop from the standalone page) and nothing pushes that + // here. Polling only during pairing left the panel showing a companion that + // had not existed for hours. Ten seconds is cheap on loopback and well + // inside how long anyone looks at a settings pane before believing it. + const pairing = Boolean(state?.pairing); useEffect(() => { - if (!state?.pairing) return; - const timer = window.setInterval(() => { - setNow(Date.now()); - void load(); - }, 1000); + const timer = window.setInterval( + () => { + setNow(Date.now()); + void load(); + }, + pairing ? 1_000 : 10_000, + ); return () => window.clearInterval(timer); - }, [state?.pairing, load]); + }, [pairing, load]); if (!bridge()) { return ( From 9231871b72439bfafd76411dae03ef9c7b9278b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:11:59 +0000 Subject: [PATCH 15/32] Document the companion's exported surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring gate reads 56% against an 80% threshold, and the gap is real: whole files of exported functions with nothing saying what they are for. Says what each one is and, where it is not obvious, why it exists — the compression pointers in the mDNS encoder, the dedupe key that deliberately excludes TTL, which of the two registry write paths swallows a failure and which does not. No behavior change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- companion/src/index.ts | 9 +++++++++ companion/src/mdns.ts | 7 +++++++ companion/src/routes.ts | 5 +++++ companion/src/state.ts | 1 + 4 files changed, 22 insertions(+) diff --git a/companion/src/index.ts b/companion/src/index.ts index 35026f4639..8b63b51081 100755 --- a/companion/src/index.ts +++ b/companion/src/index.ts @@ -25,6 +25,7 @@ import { lanAddresses, refreshTailnetName, tailnetName, tailscaleAddress } from import { advertisableAddresses, defaultHostName, dnsLabel, MdnsResponder, type ServiceInfo } from "./mdns.ts"; import { createProxyHandler } from "./proxy.ts"; +/** Read a port from the environment, falling back when it is unset or junk. */ const num = (value: string | undefined, fallback: number): number => { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : fallback; @@ -48,6 +49,7 @@ const HARNESS_PORTS = new Map([ [WEBHOOK_PORT, "the harness's webhook receiver"], ]); +/** Name the harness port this one collides with, or null when it is clear. */ const conflict = (name: string, port: number): string | null => { const owner = HARNESS_PORTS.get(port); return owner ? `${name} is set to port ${port}, which is ${owner}` : null; @@ -65,8 +67,11 @@ const conflict = (name: string, port: number): string | null => { * label, and no part of pairing depends on it. */ let cachedName = process.env.OMB_COMPANION_NAME?.trim() || ""; +/** What the phone should call this computer, once known. */ const machineName = (): string => cachedName || "OpenMausBot"; +/** Ask the harness whose computer this is. Best effort: a name is a nicety, + * and the sidecar must come up without one. */ async function refreshMachineName(): Promise { if (cachedName) return; // an explicit override is not ours to second-guess try { @@ -85,6 +90,7 @@ async function refreshMachineName(): Promise { const devices = new DeviceRegistry(); const mdns = new MdnsResponder(); +/** The Bonjour record this sidecar publishes. */ const service = (): ServiceInfo => ({ // one DNS label: no dots, and inside the 63-byte limit name: dnsLabel(machineName()), @@ -113,6 +119,7 @@ const control = createControlServer({ discovery: () => ({ advertising: mdns.advertising, name: service().name }), }); +/** Bind, turning EADDRINUSE into a sentence naming the variable to change. */ const listen = (server: ReturnType, port: number, host: string): Promise => new Promise((resolve, reject) => { const onError = (error: NodeJS.ErrnoException) => { @@ -138,6 +145,7 @@ const listen = (server: ReturnType, port: number, host: str server.listen(port, host); }); +/** Bind both ports, then advertise. Order matters — see refreshMachineName. */ async function main(): Promise { const clash = conflict("OMB_COMPANION_PORT", COMPANION_PORT) ?? conflict("OMB_CONTROL_PORT", CONTROL_PORT); @@ -179,6 +187,7 @@ async function main(): Promise { } } +/** Stop advertising and close both sockets before exiting. */ const shutdown = async (signal: string): Promise => { console.log(`\n${signal} — stopping`); await mdns.stop().catch(() => {}); diff --git a/companion/src/mdns.ts b/companion/src/mdns.ts index b1fd7fd0f4..cfc5d979a8 100644 --- a/companion/src/mdns.ts +++ b/companion/src/mdns.ts @@ -61,6 +61,7 @@ export type ResourceRecord = | { name: string; type: 16; data: string[] } | { name: string; type: 33; data: { port: number; target: string } }; +/** A DNS name as length-prefixed labels, NUL-terminated (RFC 1035 §3.1). */ export function encodeName(name: string): Buffer { const chunks: Buffer[] = []; for (const label of name.split(".").filter(Boolean)) { @@ -134,6 +135,8 @@ export function decodeMessage(buf: Buffer): DnsMessage | null { } } +/** One resource record on the wire. `ttlOverride` is how a goodbye packet + * retracts a record without building a different one: same bytes, TTL 0. */ function encodeRecord(record: ResourceRecord, ttlOverride?: number): Buffer { let rdata: Buffer; let ttl: number; @@ -182,6 +185,7 @@ function encodeRecord(record: ResourceRecord, ttlOverride?: number): Buffer { return Buffer.concat([name, fixed, rdata]); } +/** A complete mDNS response: header, echoed questions, then the answers. */ export function encodeResponse( answers: ResourceRecord[], additionals: ResourceRecord[] = [], @@ -226,9 +230,12 @@ export interface ServiceInfo { txt: string[]; } +/** Identity for deduping — name, type and payload, deliberately not TTL. */ const recordKey = (record: ResourceRecord) => `${record.name.toLowerCase()}|${record.type}|${JSON.stringify(record.data)}`; +/** Drop repeats and anything already known, keeping order. Sending the same + * answer twice in one packet is legal and makes a responder look broken. */ function dedupe(records: ResourceRecord[], exclude: ResourceRecord[] = []): ResourceRecord[] { const seen = new Set(exclude.map(recordKey)); const out: ResourceRecord[] = []; diff --git a/companion/src/routes.ts b/companion/src/routes.ts index 351d19c17e..9074509944 100644 --- a/companion/src/routes.ts +++ b/companion/src/routes.ts @@ -91,6 +91,11 @@ const EXPLAINED: ReadonlyArray<{ path: RegExp; error: string }> = [ { path: /^\/api\/teams(\/|$)/, error: "teams are imported and exported on your computer" }, ]; +/** Why this request may not be forwarded, or null when it may. + * + * Allowlist, not blocklist: a route nobody here has heard of is denied. That + * is the property the whole module exists for, and the one that quietly + * stopped being true once before. */ export function denyReason({ path, method, authenticated }: RouteRequest): Denial | null { // Pairing is the one thing a device does before it has a credential. if (method === "POST" && path === "/api/pair") return null; diff --git a/companion/src/state.ts b/companion/src/state.ts index 0c90af29ad..70adb59cc0 100644 --- a/companion/src/state.ts +++ b/companion/src/state.ts @@ -13,6 +13,7 @@ import { join } from "node:path"; /** OMB_COMPANION_DIR isolates a test rig from a real paired fleet. */ export const DATA_DIR = process.env.OMB_COMPANION_DIR ?? join(homedir(), ".openmausbot-companion"); +/** Create the data directory if it is not there yet. Idempotent. */ export function ensureDataDir(): void { mkdirSync(DATA_DIR, { recursive: true }); } From ee72fa10a2ce44776a60daeaaaa58ff64f84867d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 02:23:38 +0000 Subject: [PATCH 16/32] Sweep the rest of the racing teardowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI went red again on ubuntu, the same shape as before and a different file: ENOTEMPTY from rmSync in the afterAll of server/comms.test.ts, every assertion passed. comms.test.ts held the same copy-pasted teardown I fixed in index.test.ts and proxy.test.ts — kill, resolve in the same tick, then delete the directory the process is still writing into. Fixing the two files that had failed and stopping there was the mistake. The pattern was in five files, so this sweeps for it instead of waiting to be told about the next one: - comms, unattended, branching: the exact child-process teardown, now waitForExit + removeTempDir. Every "SIGKILL then resolve() alongside it" in the repo is gone. - env-path, and the acp/claude/codex/opencode-go driver tests: no such race, but they delete scratch directories that a spawned CLI was using moments earlier, which is the same hazard one step removed. They get the retrying remove. Left alone: fifteen rmSync calls that clear an in-process DATA_DIR or EVENTS_DIR with no child anywhere near them. Nothing to race, and rewriting them would be churn rather than a fix. Verified: full suite twice, clean both times, with no EACCES/ENOTEMPTY/EPERM in either log — 67 files, 561 passed, 8 skipped. typecheck, check:electron, build:companion and the production UI build all pass. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ACfMX71nKJyzHU5Z3by3dd --- server/branching.test.ts | 11 ++++------- server/comms.test.ts | 11 ++++------- server/drivers/acp/acp.test.ts | 15 ++++++++------- server/drivers/acp/opencode-go.test.ts | 7 ++++--- server/drivers/claude.test.ts | 5 +++-- server/drivers/codex.test.ts | 5 +++-- server/env-path.test.ts | 7 +++++-- server/unattended.test.ts | 11 ++++------- 8 files changed, 35 insertions(+), 37 deletions(-) diff --git a/server/branching.test.ts b/server/branching.test.ts index a3c67af08b..960aec80c6 100644 --- a/server/branching.test.ts +++ b/server/branching.test.ts @@ -9,11 +9,12 @@ // // Same POSIX gating as comms.test.ts (the fake CLI is a shebang script). import { spawn, type ChildProcess } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { removeTempDir, waitForExit } from "./testing/cleanup.ts"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const FAKE_CLI = join(SERVER_DIR, "testing", "fake-acp-cli.ts"); @@ -112,12 +113,8 @@ posixOnly("conversation branching e2e (fake ACP fleet)", () => { afterAll(async () => { child?.kill("SIGTERM"); - await new Promise((resolve) => { - if (!child || child.exitCode !== null) return resolve(); - child.on("close", () => resolve()); - setTimeout(() => (child.kill("SIGKILL"), resolve()), 5_000).unref?.(); - }); - rmSync(home, { recursive: true, force: true }); + await waitForExit(child); + await removeTempDir(home); }); it( diff --git a/server/comms.test.ts b/server/comms.test.ts index e8c014b816..4a1474cc72 100644 --- a/server/comms.test.ts +++ b/server/comms.test.ts @@ -10,13 +10,14 @@ // turned it into `node