diff --git a/.gitignore b/.gitignore
index f0595c76ea..be3c5db0cb 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
dist-server
*.local
.DS_Store
diff --git a/companion/README.md b/companion/README.md
new file mode 100644
index 0000000000..82d28e5c21
--- /dev/null
+++ b/companion/README.md
@@ -0,0 +1,140 @@
+# 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.
+
+```text
+ 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. |
+
+## Transport security
+
+The device port speaks plain HTTP, and the device token travels in a header on
+every request. Where that is safe depends on how the phone reaches the
+computer, and the two routes are not equivalent:
+
+- **Over a tailnet** — the recommended route, and the only one that works away
+ from home — every packet is inside WireGuard before it touches a network, so
+ the connection is encrypted and authenticated end to end despite the `http`
+ in the URL.
+- **Over a LAN** it is cleartext on that network. Trust it as far as you trust
+ everyone on the wifi: fine at home, not fine on a café or conference network.
+ Pair over the tailnet there instead.
+
+Turning on TLS is not a drop-in improvement. A certificate for a LAN address
+is one nothing can validate, so it would have to be pinned at pairing and
+re-pinned whenever the sidecar regenerated it — real machinery, whose benefit
+on the tailnet path is zero. Pinned TLS is what this would need before it could
+claim to protect the LAN path; until then the LAN path is documented as
+trusted-network-only rather than described as something it is not.
+
+## 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:
+
+```text
+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.
+
+That is the standalone way to run it, 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 | |
+|---|---|---|
+| `OMB_PORT` | `8799` | where the harness is |
+| `OMB_WEBHOOK_PORT` | `OMB_PORT` + 1 | the harness's webhook receiver — refused, not used |
+| `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` | your name, from the harness | what the phone calls this computer |
+
+`OMB_COMPANION_NAME` overrides a name the sidecar otherwise asks the harness
+for at startup — the profile from onboarding, as *"Ada's computer"*. It falls
+back to `OpenMausBot` when the harness is not up or has no profile. Read once
+and cached: the name goes into the Bonjour record, and re-advertising under a
+new one later would show the phone two computers.
+
+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
+
+```text
+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..ebd611804d
--- /dev/null
+++ b/companion/src/control.ts
@@ -0,0 +1,313 @@
+// 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";
+
+/** What the pairing page needs to render itself and act on what you click. */
+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 };
+}
+
+/** The host out of a `Host` header, port removed.
+ *
+ * A bracketed IPv6 literal has colons of its own, so the obvious
+ * `split(":")[0]` turns `[::1]:8811` into `[` — which matches no allowlist,
+ * and refuses the loopback the browser was handed. A malformed authority
+ * comes back unchanged rather than empty, so it fails the check instead of
+ * skipping it. */
+export function hostOf(authority: string): string {
+ if (!authority.startsWith("[")) return authority.split(":")[0].toLowerCase();
+ const end = authority.indexOf("]");
+ // Only a port may follow the bracket. Without that check `[::1].evil.example`
+ // unwraps to `::1` and passes the loopback allowlist — the parser would be
+ // the hole rather than the fix.
+ const rest = end > 1 ? authority.slice(end + 1) : "";
+ const bracketed = end > 1 && (rest === "" || /^:\d+$/.test(rest));
+ return (bracketed ? authority.slice(1, end) : authority).toLowerCase();
+}
+
+/** The only authorities this server answers to. `[::1]` is in the set as
+ * well as `::1` because `new URL()` keeps the brackets on an IPv6 hostname
+ * where `hostOf` strips them, and both spellings mean loopback. */
+const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
+
+/**
+ * Is this `Origin` one this server could plausibly have served itself?
+ *
+ * Absent counts as yes: a non-browser client — the desktop app, curl, the
+ * phone's own app — sends no Origin at all, and those are exactly the callers
+ * a CSRF check is not aimed at. Everything else must parse to a loopback
+ * hostname. An opaque origin, which is what a sandboxed iframe or a `file://`
+ * page sends, arrives as the literal string "null" and does not parse: that is
+ * not a pass, it is precisely the shape an attacker reaches for, so it fails
+ * with everything else foreign. Parsing rather than prefix-matching is what
+ * refuses `https://127.0.0.1.evil.example`, which is not loopback at all.
+ *
+ * This is the floor, not the whole rule — see the caller, which additionally
+ * requires the origin to be *this* server's, not merely some loopback one.
+ */
+export function originIsLoopback(origin: string | undefined): boolean {
+ if (!origin) return true;
+ try {
+ return LOOPBACK_HOSTS.has(new URL(origin).hostname.toLowerCase());
+ } catch {
+ return false;
+ }
+}
+
+/** Send a JSON body with its length, the only response shape this API has. */
+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);
+};
+
+/** Everything the page shows, in one object: where to connect, whether a
+ * pairing window is open, and which phones are paired. Recomputed per request
+ * rather than cached — addresses change when you join another network. */
+export function companionState(options: ControlOptions) {
+ const addresses = lanAddresses();
+ const tailscale = tailscaleAddress(addresses);
+ const name = tailnetName();
+ const pairing = options.devices.pairing();
+ return {
+ // Whoever starts this sidecar as a child process 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 } : {}),
+ ...(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(),
+ };
+}
+
+/** The loopback control plane: the page, its state, and the two writes —
+ * open a pairing window, revoke a device. Bound to 127.0.0.1 by the caller,
+ * and it refuses anything suggesting it was reached from anywhere else. */
+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.
+ // An *absent* Host is HTTP/1.0, which has nothing to check and predates
+ // the attack. A Host that is present is checked, and that includes one
+ // that parses to nothing: a bare `::1` or a lone `:8811` is not a valid
+ // authority, and the previous `host && …` guard waved both through for
+ // exactly the reason they should have been refused — the parser could
+ // make no sense of them, so it declined to have an opinion. Anything
+ // unrecognised is refused now, which is the only safe direction for a
+ // check whose job is to say no.
+ const authority = String(req.headers.host ?? "");
+ const host = hostOf(authority);
+ if (authority && !LOOPBACK_HOSTS.has(host)) {
+ return json(res, 403, { error: "forbidden: loopback only" });
+ }
+
+ // The Host check above stops DNS rebinding. It does not stop a page the
+ // user happens to be reading from posting here directly: 127.0.0.1 is a
+ // real address to a browser, a form POST or a simple fetch to it carries
+ // a perfectly correct Host, and neither is preflighted — so CORS never
+ // gets a say. That page cannot read the reply, but it does not need to.
+ // `POST /pairing` opens a pairing window, and `DELETE /devices/:id`
+ // revokes a phone; both do their damage on the way in.
+ //
+ // Origin is what separates the two callers, and it is the one header page
+ // script cannot forge. The page below is served from this server and its
+ // writes carry this server's origin, so an origin that both parses to
+ // loopback and matches Host — already proven loopback — admits it and
+ // nothing else: not a loopback page on some other port, not an opaque
+ // "null" origin, not a hostname that merely begins with `127.0.0.1`. Not a
+ // blanket refusal, which is what the device proxy can afford: there no
+ // legitimate client is a browser at all, and here exactly one is.
+ //
+ // Safe methods are checked too. Nothing legitimate reads this API
+ // cross-origin either, and a check that has to decide which methods
+ // change state is a check with a list to keep up to date.
+ const origin = req.headers.origin;
+ if (origin && !(originIsLoopback(origin) && origin === `http://${authority}`)) {
+ 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) });
+ 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..48dbd25511
--- /dev/null
+++ b/companion/src/devices.ts
@@ -0,0 +1,277 @@
+// 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";
+
+/** One paired phone, as it is written to disk. */
+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;
+
+/** 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 ===
+ * 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";
+}
+
+/** A timestamp we are willing to render, or a stand-in. `0` and the negatives
+ * are as wrong as a missing field and read worse: they date a device to 1970
+ * in the UI, where "now" is at least true of when we learned of it. */
+const timestamp = (value: unknown, fallback: number): number =>
+ typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
+
+/** Complete a stored record, whatever shape the file had. `lastSeenAt` falls
+ * back to `createdAt` rather than to the clock: a device we have never heard
+ * from since pairing was last seen when it paired. */
+function normalizeDevice(record: Partial & { id: string; tokenHash: string }): DeviceRecord {
+ const createdAt = timestamp(record.createdAt, Date.now());
+ return {
+ id: record.id,
+ tokenHash: record.tokenHash,
+ name: cleanDeviceName(record.name),
+ createdAt,
+ lastSeenAt: timestamp(record.lastSeenAt, createdAt),
+ };
+}
+
+/** The paired fleet: who may reach the harness through the sidecar, and the
+ * one short-lived window in which a new phone may join it. Backed by a file,
+ * loaded once at construction and written on every change. */
+export class DeviceRegistry {
+ private devices: DeviceRecord[] = [];
+ private window: PairingWindow | null = null;
+ private lastSeenWrites = new Map();
+
+ /** Load the paired fleet, normalising as it goes.
+ *
+ * Only `id` and `tokenHash` decide whether a record is a device at all —
+ * without them it can neither be revoked nor authenticate. The rest is
+ * display, and a record missing it is not worth discarding a working phone
+ * over: what a half-written or hand-edited file used to produce was a UI
+ * saying "undefined", last seen "NaN min ago". Defaults are cheaper than
+ * either dropping the device or teaching every reader to doubt the type. */
+ constructor() {
+ try {
+ const parsed = JSON.parse(readFileSync(DEVICES_FILE, "utf8"));
+ if (Array.isArray(parsed?.devices)) {
+ this.devices = parsed.devices
+ .filter(
+ (d: unknown): d is Partial & { id: string; tokenHash: string } =>
+ typeof (d as DeviceRecord)?.id === "string" &&
+ typeof (d as DeviceRecord)?.tokenHash === "string",
+ )
+ .map(normalizeDevice);
+ }
+ } catch {
+ /* first run, or a file we can't read — start with no paired devices */
+ }
+ }
+
+ /** Write the fleet to disk. Atomic, because a torn file reads as empty and
+ * would sign every phone out with no way to tell why. */
+ private persist() {
+ ensureDataDir();
+ writeFileAtomic(DEVICES_FILE, JSON.stringify({ devices: this.devices }, null, 2));
+ }
+
+ /** Every paired device, without the hash — this is what the page renders. */
+ list(): PublicDevice[] {
+ return this.devices.map(({ tokenHash, ...rest }) => rest);
+ }
+
+ /** How many phones are paired, against MAX_DEVICES. */
+ 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;
+ }
+
+ /** Open a fresh window, replacing any that was already open. The code is
+ * from `randomInt`, not `Math.random` — it is a credential for two minutes. */
+ 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 (!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" };
+ }
+ // After the code, not before. Checked first, a full fleet answers every
+ // wrong guess with "too many paired devices" — which tells a guesser
+ // something about this machine, and costs them none of their five
+ // attempts. The window survives, so removing a phone and retyping the
+ // same code still works.
+ if (this.devices.length >= MAX_DEVICES) return { error: "too many paired devices — remove one first" };
+ 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);
+ // Unlike the lastSeenAt write below, 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 };
+ }
+
+ /** 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);
+ // 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;
+ }
+
+ /** Take a phone's access away. False when there was no such device — a
+ * revoke that quietly matched nothing would read as success on the page. */
+ 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.
+ *
+ * The scheme is matched case-insensitively because RFC 7235 §2.1 says it is:
+ * a client sending `bearer ` is within its rights. This used to
+ * require the exact casing while the proxy had a second, laxer parser of its
+ * own — so which of the two a request happened to meet decided whether it
+ * authenticated, and a phone got a 401 it could not explain. One function,
+ * used everywhere a token is read. A header with nothing after the scheme is
+ * `undefined` rather than the empty string, so no caller has to decide
+ * whether "" counts as a credential.
+ */
+export function bearerToken(header: string | undefined): string | undefined {
+ if (!header) return undefined;
+ const match = /^Bearer[ \t]+(.+)$/i.exec(header.trim());
+ return match ? match[1].trim() || undefined : undefined;
+}
diff --git a/companion/src/index.ts b/companion/src/index.ts
new file mode 100755
index 0000000000..06f513287b
--- /dev/null
+++ b/companion/src/index.ts
@@ -0,0 +1,251 @@
+#!/usr/bin/env node
+// 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,
+ clampBytes,
+ defaultHostName,
+ dnsLabel,
+ MdnsResponder,
+ type ServiceInfo,
+} from "./mdns.ts";
+import { createProxyHandler } from "./proxy.ts";
+
+/** A port from the environment, or the default. Anything that is not a whole
+ * number in range is the default — a typo'd port must not become port 0. */
+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"],
+]);
+
+/** A sentence naming what already owns this port, or null when nothing does. */
+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() || "";
+
+/** What this computer is called on the phone. Never empty. */
+const machineName = (): string => cachedName || "OpenMausBot";
+
+/** Ask the harness whose computer this is, once, at startup. Every failure
+ * is survivable: the name is a label, and no part of pairing depends on it. */
+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();
+
+/** This machine as a Bonjour record: one DNS label, the device port, and the
+ * addresses a phone could reach it on. */
+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 — measured in
+ // bytes, since that is the unit the wire format actually counts in, and
+ // `slice` counts UTF-16 code units.
+ txt: ["v=1", `name=${clampBytes(machineName(), 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)),
+ redeem: (code, deviceName) => devices.redeem(code, deviceName),
+ serverName: machineName,
+ }),
+);
+
+const control = createControlServer({
+ devices,
+ companionPort: COMPANION_PORT,
+ discovery: () => ({ advertising: mdns.advertising, name: service().name }),
+});
+
+/** Bind a server, turning a bind failure into a sentence rather than a stack
+ * trace, and leaving a handler behind for the errors that come after. */
+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);
+ // Bound is not safe, and removing the startup handler while leaving
+ // nothing in its place is how a running sidecar dies later. A listening
+ // socket still emits `error` — EMFILE on accept, or an interface
+ // disappearing under it — and an `error` with no listener is re-thrown
+ // as an uncaught exception, which here means the sidecar dies and every
+ // paired phone loses the machine over one refused connection. It is
+ // worth a line on stderr and nothing more: the other listener, and
+ // every connection on this one, carry on.
+ server.on("error", (error: NodeJS.ErrnoException) => {
+ console.warn(`companion: error on ${host}:${port} — ${error.message}`);
+ });
+ resolve();
+ };
+ server.once("error", onError);
+ server.once("listening", onListening);
+ server.listen(port, host);
+ });
+
+/** Start the three-socket arrangement, in the order that makes a failure
+ * legible: refuse impossible ports, bind, learn this machine's name, then
+ * advertise and print where to point the phone. */
+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.`);
+
+ // The sidecar's own two ports, for the same reason as the harness's: bound
+ // in order, the second one loses with a bare EADDRINUSE that reads as
+ // "something else is using it" when the something else is this process.
+ // Worth naming even though the hosts differ — 127.0.0.1 and 0.0.0.0 on one
+ // port collide, and if they somehow did not the control plane would be
+ // sharing a socket with the device port, which is the one thing the three
+ // sockets exist to prevent.
+ if (COMPANION_PORT === CONTROL_PORT) {
+ throw new Error(
+ `OMB_COMPANION_PORT and OMB_CONTROL_PORT are both port ${COMPANION_PORT}, and they cannot share one: ` +
+ `the first is open to your network and the second must never be. 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);
+ }
+}
+
+/** Withdraw the Bonjour record, drop the sockets, exit. Stopping this process
+ * is the off switch, so it has to actually stop. */
+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..ea09a2281e
--- /dev/null
+++ b/companion/src/listener.ts
@@ -0,0 +1,158 @@
+// Where this computer can be reached, and what it is called there.
+//
+// The addresses a phone might dial, the tailnet one told apart from the rest,
+// and the MagicDNS name read out of the Tailscale CLI. Nothing here binds
+// anything: the sidecar owns its own sockets in index.ts, and this file only
+// answers the question the pairing page has to print.
+//
+// It used to hold a `RemoteListener` as well — the socket the harness opened
+// for a phone back when the companion lived inside it. Moving out made it a
+// class with no callers, and a second implementation of a socket lifecycle
+// nobody runs is a thing that rots. index.ts owns the listeners now.
+import { execFile } from "node:child_process";
+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;
+
+/** The cached MagicDNS name, or null until `refreshTailnetName` finds one. */
+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",
+ ];
+}
+
+/** How long the whole CLI hunt may take, across every candidate path. */
+const TAILSCALE_BUDGET_MS = 5000;
+
+/** 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 {
+ // A budget for the whole loop, not per probe. Seven candidates at five
+ // seconds each is thirty-five seconds of startup in the case where several
+ // hang — and they hang together, since the reason is usually the same one.
+ // Nothing here is load-bearing: the address works without a name.
+ const deadline = Date.now() + TAILSCALE_BUDGET_MS;
+ for (const cli of tailscaleCandidates()) {
+ const left = deadline - Date.now();
+ if (left <= 0) {
+ onAttempt?.(cli, "skipped — out of time looking for the Tailscale CLI");
+ continue;
+ }
+ const name = await new Promise((resolve) => {
+ execFile(
+ cli,
+ ["status", "--json"],
+ {
+ timeout: Math.max(250, left),
+ // SIGTERM is a request, and the budget above is only worth as much
+ // as the thing that enforces it: a wedged CLI that ignores the
+ // polite signal would sit there past the deadline it was supposed
+ // to be bounded by. SIGKILL is not a request.
+ killSignal: "SIGKILL",
+ // `status --json` describes every peer in the tailnet, and the
+ // default cap is 1 MiB — a large enough tailnet fails the probe with
+ // ENOBUFS, which the code below reads as "no MagicDNS name" and
+ // which looks from outside exactly like "Tailscale is not
+ // installed". That is a wrong answer rather than a missing one.
+ // Generous, and still a bound: the alternative is a subprocess
+ // deciding how much memory this process uses.
+ maxBuffer: 16 * 1024 * 1024,
+ 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;
+}
diff --git a/companion/src/mdns.ts b/companion/src/mdns.ts
new file mode 100644
index 0000000000..6074cac7b7
--- /dev/null
+++ b/companion/src/mdns.ts
@@ -0,0 +1,634 @@
+// 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";
+
+import { lanAddresses } from "./listener.ts";
+
+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;
+/** How long shutdown waits for the goodbye datagram to leave the socket. */
+const GOODBYE_FLUSH_MS = 250;
+
+/** An IPv4 dotted quad as a 32-bit number, or null if it is not one. */
+function toIpv4(address: string): number | null {
+ // A udp4 socket can hand back the mapped form on some platforms.
+ const plain = address.replace(/^::ffff:/i, "").split("%")[0];
+ const parts = plain.split(".");
+ if (parts.length !== 4) return null;
+ let out = 0;
+ for (const part of parts) {
+ if (!/^\d{1,3}$/.test(part)) return null;
+ const octet = Number(part);
+ if (octet > 255) return null;
+ out = out * 256 + octet;
+ }
+ return out >>> 0;
+}
+
+/**
+ * Is this source address on a network directly attached to this machine?
+ *
+ * RFC 6762 §5.5 is explicit that a multicast DNS responder answers link-local
+ * queries only, and it is not bookkeeping: a responder that replies to
+ * anything that can route to port 5353 is an off-link discovery service for
+ * whoever asks — it will name this computer, its addresses and its owner to a
+ * stranger — and a UDP reflector besides, since §11's answer is larger than
+ * the question and goes wherever the source address says, which is trivially
+ * forged. Neither is something a companion sidecar should be.
+ *
+ * Derived from the interface table rather than from a list of private
+ * prefixes: the prefixes are a guess at which networks this machine is on,
+ * and the netmasks are the answer. Loopback passes, because the internal
+ * interface is as directly attached as it gets and the test rig speaks to
+ * itself.
+ */
+export function isOnLink(from: string, interfaces = networkInterfaces()): boolean {
+ const source = toIpv4(from);
+ if (source === null) return false;
+ for (const entries of Object.values(interfaces)) {
+ for (const entry of entries ?? []) {
+ if (entry.family !== "IPv4") continue;
+ const address = toIpv4(entry.address);
+ const mask = toIpv4(entry.netmask);
+ if (address === null || mask === null) continue;
+ if ((((source ^ address) & mask) >>> 0) === 0) return true;
+ }
+ }
+ return false;
+}
+
+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;
+ }
+}
+
+/** One resource record on the wire. `ttlOverride` is how a goodbye is sent:
+ * the same records, TTL 0, meaning "forget what I told you". */
+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 ───────────────────────────────────────────
+
+/** The service being advertised: what it is called, where it answers, and
+ * the addresses that resolve to this machine. */
+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)}`;
+
+/** Records, each appearing once, minus any already in `exclude` — an answer
+ * repeated in the additional section is wasted bytes in a 1500-byte budget. */
+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;
+}
+
+/** Clamp to a byte budget without splitting a character.
+ *
+ * Both limits in DNS-SD are byte counts — 63 for a label, 255 for a TXT entry
+ * — and `String.prototype.slice` counts UTF-16 code units instead. Two hundred
+ * CJK characters are six hundred bytes, so a name measured the wrong way
+ * produces a record that is not truncated but malformed, and discovery stops
+ * working silently for exactly the people whose names are not Latin.
+ *
+ * Iterating the string yields whole code points, so an emoji or a surrogate
+ * pair is kept or dropped entire rather than cut in half. */
+export function clampBytes(text: string, limit: number): string {
+ if (Buffer.byteLength(text, "utf8") <= limit) return text;
+ let out = "";
+ let size = 0;
+ for (const character of text) {
+ const width = Buffer.byteLength(character, "utf8");
+ if (size + width > limit) break;
+ out += character;
+ size += width;
+ }
+ return out;
+}
+
+/** 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.
+ *
+ * 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[] {
+ return lanAddresses();
+}
+
+// ── the responder ──────────────────────────────────────────────────────
+
+/** Bind port and mode. Both exist for tests: the real thing is 5353,
+ * multicast, and has no reason to be anything else. */
+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;
+}
+
+/** A Bonjour responder, small enough to read: it announces one service, and
+ * answers questions about that service from the local link. No dependency,
+ * because a discovery nicety is not worth a supply chain. */
+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;
+ }
+
+ /** Whether the socket is up. False is normal and not an error. */
+ 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) {
+ // Wait for the datagram to actually leave. `send` is asynchronous and
+ // `close` does not flush a queued one, so firing the goodbye and
+ // closing in the same tick discards it — and the records it was meant
+ // to withdraw sit in every cache on the network for 75 minutes,
+ // pointing a phone at a computer that has stopped answering. Bounded,
+ // because shutdown must not hang on a network that is already gone.
+ await new Promise((resolve) => {
+ let settled = false;
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ resolve();
+ };
+ const timer = setTimeout(finish, GOODBYE_FLUSH_MS);
+ timer.unref?.();
+ try {
+ this.send(socket, encodeResponse(announcement(service), [], { ttl: 0 }), () => {
+ clearTimeout(timer);
+ finish();
+ });
+ } catch {
+ clearTimeout(timer);
+ finish();
+ }
+ });
+ }
+ await new Promise((resolve) => {
+ try {
+ socket.close(() => resolve());
+ } catch {
+ resolve();
+ }
+ });
+ }
+
+ /** Say we are here, unprompted. Sent a few times, because the first packet
+ * is the one most likely to be lost (RFC 6762 §8.3). */
+ 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 */
+ }
+ }
+
+ /** Answer one query, if it is about us and came from somewhere local. */
+ private handle(buf: Buffer, from: string, fromPort: number) {
+ if (!this.socket || !this.service) return;
+ // RFC 6762 §5.5 and §11: a responder answers the local link. Answering
+ // anyone makes this socket a reflector — a spoofed source address turns
+ // a small query into a larger answer aimed wherever the attacker likes,
+ // and the answer is bigger than the question, which is the whole trick.
+ // Dropped before decoding, so a packet from off-link costs nothing.
+ if (!isOnLink(from)) 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 */
+ }
+ }
+
+ /** Multicast a packet to the group.
+ *
+ * Always to 5353, whatever port this responder is bound to: the destination
+ * is where mDNS listens, not where we happen to be. Using the bind port
+ * sent announcements to a port with nobody on it — and threw outright when
+ * that port was 0, which is what an ephemeral bind gives you.
+ *
+ * Unicast mode has no group to announce to, so there is nothing to send;
+ * it exists for tests, which ask directly and are answered in `handle`. */
+ private send(socket: Socket, packet: Buffer, done?: (error: Error | null) => void) {
+ if (!this.multicast) {
+ done?.(null);
+ return;
+ }
+ socket.send(packet, MDNS_PORT, MDNS_ADDRESS, done);
+ }
+}
diff --git a/companion/src/proxy.ts b/companion/src/proxy.ts
new file mode 100644
index 0000000000..0081c64c51
--- /dev/null
+++ b/companion/src/proxy.ts
@@ -0,0 +1,352 @@
+// 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 { bearerToken } from "./devices.ts";
+import { denyReason } from "./routes.ts";
+import { createSseScrubber, isJson, scrub } from "./wire.ts";
+
+/** What the forwarding handler needs from the process around it. */
+export interface ProxyOptions {
+ /** Where the harness is listening on loopback. */
+ harnessPort: number;
+ /** Does this bearer token belong to a paired device? */
+ authenticate: (token: string | undefined) => boolean;
+ /** 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;
+ /** How long the harness may take to produce response *headers*. Optional,
+ * and only ever set by tests — the default is the one that ships. */
+ headersTimeoutMs?: number;
+}
+
+/** The harness has this long to send a status line and headers.
+ *
+ * Headers only. Once they arrive the clock is off and the body may take as
+ * long as it likes, which is the whole point: an SSE stream is a response
+ * that deliberately never ends, and a timeout that could not tell the
+ * difference would cut every live stream at thirty seconds. */
+const HEADERS_TIMEOUT_MS = 30_000;
+
+/** A JSON response has to be buffered whole before it can be scrubbed, so the
+ * buffer is the size of the response and nothing upstream promises that is
+ * small. Far above any real payload — it exists to have a ceiling at all. */
+const MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
+
+/** 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"));
+ }
+ });
+ });
+
+/** Answer with JSON the sidecar wrote itself — a refusal, or a pairing
+ * result. Anything from the harness goes out through the proxy path instead.
+ *
+ * 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, which
+ * on the failure paths — an upstream dying long after SSE headers were
+ * flushed — would be a second, fatal error raised inside an error handler
+ * with nothing to catch it. Dropping the socket is the only honest ending
+ * left there: the device sees a truncated response and reconnects, which is
+ * what it already does for any 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",
+ "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;
+};
+
+/** The device-facing handler: refuse a browser, check the allowlist, check
+ * the token, then replay the request to the harness over loopback and scrub
+ * what comes back. Pairing is the one route that stops here. */
+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,
+ // `bearerToken` is the registry's own parser, imported rather than
+ // reimplemented: this file used to have a second one, and two parsers
+ // that disagree about what a credential looks like means the header a
+ // phone sends authenticates on one code path and not the other.
+ authenticated: options.authenticate(bearerToken(req.headers.authorization)),
+ });
+ 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) => {
+ clearTimeout(headersDeadline);
+ 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) => {
+ let rewritten: string;
+ try {
+ rewritten = scrubStream(chunk);
+ } catch {
+ // The buffer ceiling. Half an event cannot be forwarded safely,
+ // so the stream ends here rather than growing without bound.
+ harness.destroy();
+ res.end();
+ return;
+ }
+ if (!rewritten) return;
+ // A phone on a slow link reads slower than the harness writes,
+ // and the difference has to go somewhere. Ignoring what write()
+ // returns puts it in this process's memory, unbounded, for as
+ // long as the phone stays connected and behind. Pausing pushes it
+ // back to the harness, which is where the backlog belongs.
+ if (!res.write(rewritten)) harness.pause();
+ });
+ res.on("drain", () => harness.resume());
+ 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;
+ }
+
+ const encoding = String(harness.headers["content-encoding"] ?? "")
+ .trim()
+ .toLowerCase();
+ if (!isJson(String(contentType ?? "")) || (encoding && encoding !== "identity")) {
+ // images and anything else: byte-for-byte, no parsing.
+ //
+ // Encoded bodies come through here too. Scrubbing one would mean
+ // decompressing it, and the alternative the buffering branch would
+ // otherwise reach — decode as UTF-8, re-serialise, drop the
+ // content-encoding header — corrupts it silently. `forwardHeaders`
+ // never sends accept-encoding, so this is a guard rather than a
+ // path: if it ever fires, the body passes through unscrubbed and
+ // intact rather than scrubbed and broken.
+ res.writeHead(harness.statusCode ?? 200, harness.headers);
+ // `pipe` does not carry a failure from source to destination. An
+ // upstream that dies part-way through an image would otherwise
+ // leave the phone holding an open connection and a content-length
+ // that will never be satisfied — it waits for the rest forever,
+ // which reads as a frozen app rather than as a failed request.
+ harness.on("error", () => res.destroy());
+ harness.pipe(res);
+ return;
+ }
+
+ const chunks: Buffer[] = [];
+ let size = 0;
+ harness.on("data", (chunk: Buffer) => {
+ size += chunk.length;
+ if (size > MAX_JSON_BODY_BYTES) {
+ harness.destroy();
+ if (res.headersSent) res.destroy();
+ else sendJson(res, 502, { error: "the response from OpenMausBot was too large" });
+ return;
+ }
+ chunks.push(chunk);
+ });
+ harness.on("error", () => res.destroy());
+ harness.on("end", () => {
+ const body = Buffer.concat(chunks).toString("utf8");
+
+ // Two failures live here and they are not the same failure.
+ //
+ // A body that does not parse was never JSON — the content-type
+ // lied, or the harness sent an empty 204. There is nothing to
+ // redact in bytes that do not read as an object, so forwarding
+ // them verbatim is correct.
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ 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 `scrub` is the only thing keeping the
+ // harness's internal fields — the resume cursors — off the wire to
+ // a device. Falling back to the raw body there, which is what one
+ // try around parse-and-scrub used to do, sends exactly what the
+ // scrubber exists to withhold. Not hypothetical: `scrub` recurses,
+ // so a body nested a few thousand deep throws RangeError where
+ // JSON.parse handles it fine.
+ let text: string;
+ try {
+ text = JSON.stringify(scrub(parsed));
+ } catch {
+ sendJson(res, 502, { error: "the response could not be prepared for this device" });
+ return;
+ }
+ 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(status, {
+ ...headers,
+ "content-length": Buffer.byteLength(text),
+ });
+ res.end(text);
+ }
+ },
+ );
+
+ // A phone can go away at any point: before the harness has answered,
+ // while its own request body is still going up, or partway through a
+ // large response. Every one of those leaves the harness producing for
+ // nobody unless the upstream goes with it. Guarded on `writableEnded` so
+ // an ordinary finished response does not tear down a keep-alive socket
+ // on its way out.
+ res.on("close", () => {
+ if (!res.writableEnded) upstream.destroy();
+ });
+ req.on("error", () => upstream.destroy());
+
+ // `http.request` has no deadline of its own for the headers phase: a
+ // harness that accepts the connection and then says nothing holds the
+ // device's request open until one side gives up, which neither does.
+ let timedOut = false;
+ const headersDeadline = setTimeout(() => {
+ timedOut = true;
+ upstream.destroy(new Error("the harness sent no response headers"));
+ }, options.headersTimeoutMs ?? HEADERS_TIMEOUT_MS);
+ headersDeadline.unref?.();
+
+ upstream.on("error", () => {
+ clearTimeout(headersDeadline);
+ // Headers already went out — a stream, or a piped body — or the
+ // response is finished and this is a socket dying afterwards. There is
+ // no status code left to send in either case, and writeHead here throws
+ // ERR_HTTP_HEADERS_SENT out of an event handler with nothing to catch
+ // it, taking the whole sidecar down over one dead connection. Dropping
+ // the socket is the only honest signal, and one a client recovers from.
+ if (res.headersSent || res.writableEnded) {
+ res.destroy();
+ return;
+ }
+ sendJson(
+ res,
+ timedOut ? 504 : 502,
+ timedOut
+ ? { error: "OpenMausBot did not respond" }
+ : { 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..be65b227f0
--- /dev/null
+++ b/companion/src/routes.ts
@@ -0,0 +1,120 @@
+// 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;
+}
+
+/** One request, reduced to what the allowlist decides on. */
+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" },
+];
+
+/** Why this request may not go through, or null when it may.
+ *
+ * Default deny: the answer for anything not on the list is "no route", which
+ * is what keeps a stolen token from mapping the API. An allowlist rather than
+ * a blocklist is the property this 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;
+
+ 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..ddc4276d11
--- /dev/null
+++ b/companion/src/state.ts
@@ -0,0 +1,88 @@
+// 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 {
+ chmodSync,
+ 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");
+
+/** 0700 on the directory, 0600 on the files it holds.
+ *
+ * What lives here is the paired fleet: one record per phone, each holding a
+ * hash rather than a token. A hash is not a credential, so this is posture
+ * rather than a hole — but it is an offline target for anyone who can read
+ * it, and the default 0755/0644 publishes both it and which phones someone
+ * owns to every other account on the machine. This process is the only reader
+ * there has ever been. */
+const DIR_MODE = 0o700;
+export const FILE_MODE = 0o600;
+
+export function ensureDataDir(): void {
+ mkdirSync(DATA_DIR, { recursive: true, mode: DIR_MODE });
+ // mkdirSync's mode applies only to a directory it creates — `recursive`
+ // leaves an existing one's mode alone — and an install from before this
+ // line already has a 0755 one, so it is tightened here rather than at
+ // creation.
+ try {
+ chmodSync(DATA_DIR, DIR_MODE);
+ } catch {
+ /* not ours to chmod, or a filesystem with no such notion — the write still works */
+ }
+}
+
+/**
+ * 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 {
+ // The mode goes on at creation, not after: it is right from the moment
+ // the file exists and survives the rename, where a chmod after the fact
+ // leaves a window in which the contents are already there and readable
+ // for exactly as long as it takes someone to look.
+ fd = openSync(tmp, "w", FILE_MODE);
+ 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..5e2a749941
--- /dev/null
+++ b/companion/src/wire.ts
@@ -0,0 +1,136 @@
+// 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.
+ *
+ * The `+json` suffix counts. `application/problem+json` is JSON by every
+ * measure that matters here, and a scrubber that skips it is a scrubber with
+ * a hole in it: the harness only has to answer one error with RFC 9457 for
+ * `resumeCursors` to reach a phone unscrubbed. Structured suffixes are the
+ * registered way to say "this is JSON underneath" (RFC 6839), so honour it. */
+export const isJson = (contentType: string | undefined): boolean => {
+ const media = (contentType ?? "").split(";")[0].trim().toLowerCase();
+ return media === "application/json" || media.endsWith("+json");
+};
+
+/** How much of a single SSE event the scrubber will hold while waiting for
+ * its terminator. Generous — the largest real frame is a bot payload, orders
+ * of magnitude under this — because the number only exists to be a ceiling. */
+export const MAX_SSE_EVENT_BYTES = 1024 * 1024;
+
+/**
+ * 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=`.
+ *
+ * Both line endings are recognised. The spec allows CRLF, LF, or a bare CR as
+ * a line terminator, and a scrubber that knows only LF does not fail loudly
+ * against a CRLF producer — it buffers the whole stream waiting for a boundary
+ * that never comes, and the phone sits on "Connecting…" against a server that
+ * is streaming perfectly. Whichever ending an event arrived with is the one it
+ * leaves with; this rewrites payloads, not framing.
+ *
+ * Buffering to a frame boundary is bounded by the sender's good behaviour,
+ * which is not a bound. A stream that never sends a terminator in any of its
+ * spellings grows `pending` forever, so the buffer has a ceiling and passing
+ * it throws: there is no safe way to flush half an event — emitting it
+ * unterminated corrupts the stream, emitting it unscrubbed defeats the point
+ * of this file. The caller drops the connection instead.
+ */
+export function createSseScrubber(): (chunk: string) => string {
+ let pending = "";
+ return (chunk: string): string => {
+ pending += chunk;
+ let out = "";
+ for (;;) {
+ const boundary = nextBoundary(pending);
+ if (!boundary) break;
+ const event = pending.slice(0, boundary.index);
+ pending = pending.slice(boundary.index + boundary.terminator.length);
+ out += scrubEvent(event) + boundary.terminator;
+ }
+ if (pending.length > MAX_SSE_EVENT_BYTES) {
+ throw new Error(`SSE event exceeded ${MAX_SSE_EVENT_BYTES} bytes without a terminator`);
+ }
+ return out;
+ };
+}
+
+/** The first event terminator in `text`, whichever spelling it uses.
+ *
+ * A partial terminator at the end of a chunk — `…\r\n\r` — matches neither
+ * and stays buffered, which is the correct answer: the rest is one chunk
+ * away, and splitting an event across a scrub would corrupt it. */
+function nextBoundary(text: string): { index: number; terminator: string } | null {
+ let best: { index: number; terminator: string } | null = null;
+ for (const terminator of ["\n\n", "\r\n\r\n", "\r\r"]) {
+ const index = text.indexOf(terminator);
+ if (index < 0) continue;
+ // Earliest wins, and on a tie the longer terminator does: an LF-only match
+ // inside a CRLF pair would cut the event a byte short of its real end.
+ if (!best || index < best.index || (index === best.index && terminator.length > best.terminator.length)) {
+ best = { index, terminator };
+ }
+ }
+ return best;
+}
+
+/** One complete SSE event, `data:` payload scrubbed, everything else kept. */
+function scrubEvent(event: string): string {
+ // The ending this event arrived with is the one it leaves with.
+ const eol = event.includes("\r\n") ? "\r\n" : event.includes("\r") ? "\r" : "\n";
+ return event
+ .split(/\r\n|\r|\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(eol);
+}
diff --git a/companion/test/control.test.ts b/companion/test/control.test.ts
new file mode 100644
index 0000000000..986c11d200
--- /dev/null
+++ b/companion/test/control.test.ts
@@ -0,0 +1,122 @@
+// 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 page it serves, and no other loopback origin", async () => {
+ // Exactly this server's origin, not merely a loopback one. The page below
+ // is served from here and its writes carry this authority, so nothing is
+ // lost by narrowing — while a loopback origin on another port is another
+ // program's page, which has no more business opening a pairing window
+ // than a page on the internet does.
+ 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}` });
+
+ // A different loopback port is a different origin. So is the same port
+ // under a name that resolves to the same address: `Host` here is
+ // `127.0.0.1:` — what the request was addressed to — and the match
+ // is on the string, because the alternative is a resolver in a CSRF check.
+ expect((await ask("POST", "/pairing", { origin: `http://127.0.0.1:${port + 1}` })).status).toBe(403);
+ expect((await ask("POST", "/pairing", { origin: `http://localhost:${port}` })).status).toBe(403);
+ expect((await ask("GET", "/state")).body.pairing).toBeNull();
+ });
+
+ 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("refuses a foreign origin on a safe method too", async () => {
+ // This line used to expect 200, on the argument that a GET changes
+ // nothing and the same-origin policy already hides the reply. The
+ // stricter rule won the reconciliation, and it is the right one twice
+ // over. Nothing legitimate reads this API cross-origin at all — the only
+ // browser client is the page this server serves itself — so allowing it
+ // buys nothing. And a check that has to decide which methods are "safe"
+ // is a check with a list in it, which is a list that goes stale: the day
+ // a read is added that leaks something (a pairing code, an address, the
+ // device list) the exemption is already in place and nobody revisits it.
+ // So: any cross-origin request, any method, is refused.
+ const { status, body } = await ask("GET", "/state", { origin: "https://evil.example" });
+ expect(status).toBe(403);
+ expect(body.error).toContain("cross-origin");
+ });
+});
+
+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);
+ });
+});
diff --git a/companion/test/devices.test.ts b/companion/test/devices.test.ts
new file mode 100644
index 0000000000..292f6f0268
--- /dev/null
+++ b/companion/test/devices.test.ts
@@ -0,0 +1,274 @@
+// 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, statSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { DATA_DIR } from "../src/state.ts";
+import {
+ bearerToken,
+ cleanDeviceName,
+ DeviceRegistry,
+ MAX_PAIRING_ATTEMPTS,
+ PAIRING_TTL_MS,
+} 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();
+ });
+
+ // A record written by an older build, or edited by hand, can be missing
+ // everything except the two fields that make it a device at all. Reading it
+ // back as-is puts "undefined" and "Last seen NaN" on the pairing page.
+ it("completes a record that is missing its labels", () => {
+ const registry = new DeviceRegistry();
+ const { token, device } = pair(registry);
+
+ const file = join(DATA_DIR, "devices.json");
+ const stored = JSON.parse(readFileSync(file, "utf8"));
+ delete stored.devices[0].name;
+ delete stored.devices[0].lastSeenAt;
+ delete stored.devices[0].createdAt;
+ writeFileSync(file, JSON.stringify(stored));
+
+ const reloaded = new DeviceRegistry();
+ const [listed] = reloaded.list();
+ expect(listed.id).toBe(device.id);
+ expect(listed.name).toBe("Companion");
+ expect(Number.isFinite(listed.lastSeenAt)).toBe(true);
+ expect(Number.isFinite(listed.createdAt)).toBe(true);
+ // and the token it was paired with still works
+ expect(reloaded.authenticate(token)?.id).toBe(device.id);
+ });
+
+ // POSIX only. Windows has no mode bits — `stat` reports a synthesised 0666
+ // for anything not marked read-only, and the mode arguments this asserts on
+ // are ignored when the file is created. Access there is an ACL question,
+ // and the data directory sits under the user's own profile, which is
+ // already not readable by other accounts. Skipped rather than loosened: an
+ // assertion that passes by measuring nothing is worse than no assertion.
+ it.skipIf(process.platform === "win32")(
+ "keeps token hashes out of reach of other accounts on the machine",
+ () => {
+ pair(new DeviceRegistry());
+ // 0700 on the directory, 0600 on the file. A hash is an offline target
+ // for anyone who can read it, and this process is the only reader.
+ expect(statSync(DATA_DIR).mode & 0o777).toBe(0o700);
+ expect(statSync(join(DATA_DIR, "devices.json")).mode & 0o777).toBe(0o600);
+ },
+ );
+
+ 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", () => {
+ // Move the clock, not the object. Ageing the window returned by
+ // openPairing() only works while that object is the registry's own — the
+ // day it hands back a copy, the test would be asserting against something
+ // the registry never reads. The contract is "expiry is evaluated on read
+ // against the wall clock", so the clock is the thing to control.
+ vi.useFakeTimers();
+ try {
+ const registry = new DeviceRegistry();
+ const { code } = registry.openPairing();
+ expect(registry.pairing()).not.toBeNull();
+
+ // one tick short of the TTL: still live, so the assertion below is
+ // about expiry rather than about pairing being broken outright
+ vi.advanceTimersByTime(PAIRING_TTL_MS - 1);
+ expect(registry.pairing()).not.toBeNull();
+
+ vi.advanceTimersByTime(2);
+ expect(registry.pairing()).toBeNull();
+ expect(registry.redeem(code, "iPhone")).toMatchObject({
+ error: expect.stringContaining("no pairing"),
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ 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("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;
+ // SAFETY: `persist` is private, so the type has to be widened to reach
+ // it. Assigning on the instance shadows the prototype method for this
+ // registry only — the failing disk is simulated where the disk is used,
+ // rather than by mocking node:fs for the whole file.
+ (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("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();
+ // SAFETY: as above — the private `persist` shadowed on this one instance,
+ // which is the only way to make the write fail without a filesystem that
+ // really is read-only.
+ (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([]);
+ });
+});
+
+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();
+ });
+
+ // RFC 7235 says the scheme is case-insensitive, and a client sending
+ // "bearer" is within its rights. This is the only parser in the sidecar,
+ // so a phone cannot get a 401 from one half disagreeing with the other —
+ // which is exactly what happened while the proxy carried a second, laxer
+ // one of its own: the same header authenticated on one path and not the
+ // other, depending on which code it happened to meet.
+ it("matches the scheme however it is cased", () => {
+ expect(bearerToken("bearer omb_abc")).toBe("omb_abc");
+ expect(bearerToken("BEARER omb_abc")).toBe("omb_abc");
+ expect(bearerToken("BeArEr omb_abc")).toBe("omb_abc");
+ // a tab separates scheme from credential just as legally as a space
+ expect(bearerToken("BeArEr\tomb_abc")).toBe("omb_abc");
+ // still not a free-for-all: a scheme with nothing after it is not a
+ // credential, however much whitespace is standing in for one
+ expect(bearerToken("Bearer ")).toBeUndefined();
+ expect(bearerToken("Bearer ")).toBeUndefined();
+ expect(bearerToken("Beareromb_abc")).toBeUndefined();
+ });
+});
diff --git a/companion/test/mdns.test.ts b/companion/test/mdns.test.ts
new file mode 100644
index 0000000000..f8e482f8ef
--- /dev/null
+++ b/companion/test/mdns.test.ts
@@ -0,0 +1,438 @@
+// 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,
+ clampBytes,
+ decodeMessage,
+ defaultHostName,
+ dnsLabel,
+ encodeName,
+ encodeResponse,
+ isOnLink,
+ 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");
+ // 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. `send` is asynchronous, so closing on the next line
+ // can beat the datagrams out of the socket — close() on a socket with
+ // datagrams still queued drops them, the responder then never sees the
+ // garbage, and this passes without exercising the path it is named
+ // after.
+ for (const junk of [Buffer.alloc(0), Buffer.from("hello"), Buffer.alloc(600, 0xff)]) {
+ await new Promise((resolve) => noise.send(junk, port, "127.0.0.1", () => resolve()));
+ }
+ 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)));
+ 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();
+ });
+});
+
+// RFC 6762 §5.5: a multicast DNS responder answers link-local queries only.
+// A responder that replies to anything able to route to it is an off-link
+// discovery service for whoever asks — it names this computer, its addresses
+// and its owner — and a UDP reflector besides, since the reply goes wherever
+// the source address claims and that claim is free to make.
+describe("isOnLink", () => {
+ // SAFETY: the literal below carries exactly the four fields `isOnLink`
+ // reads — family, address, netmask, internal — for each interface; the
+ // assertion stands in for the rest of NetworkInterfaceInfo (cidr, mac,
+ // scopeid), which nothing on this path touches.
+ const interfaces = {
+ lo: [{ family: "IPv4", address: "127.0.0.1", netmask: "255.0.0.0", internal: true }],
+ en0: [{ family: "IPv4", address: "192.168.1.42", netmask: "255.255.255.0", internal: false }],
+ ts0: [{ family: "IPv4", address: "100.102.178.88", netmask: "255.192.0.0", internal: false }],
+ } as unknown as ReturnType;
+
+ it("accepts a source on a directly attached subnet", () => {
+ expect(isOnLink("192.168.1.7", interfaces)).toBe(true);
+ expect(isOnLink("192.168.1.42", interfaces)).toBe(true);
+ expect(isOnLink("100.102.178.1", interfaces)).toBe(true);
+ // the test rig, and the desktop app, both speak to this over loopback
+ expect(isOnLink("127.0.0.1", interfaces)).toBe(true);
+ });
+
+ it("refuses one that had to be routed here", () => {
+ expect(isOnLink("192.168.2.7", interfaces)).toBe(false);
+ expect(isOnLink("8.8.8.8", interfaces)).toBe(false);
+ expect(isOnLink("203.0.113.9", interfaces)).toBe(false);
+ });
+
+ it("refuses anything it cannot read as an address", () => {
+ expect(isOnLink("", interfaces)).toBe(false);
+ expect(isOnLink("not-an-address", interfaces)).toBe(false);
+ expect(isOnLink("999.1.1.1", interfaces)).toBe(false);
+ // IPv6 does not reach a udp4 socket except in mapped form, which does
+ expect(isOnLink("::1", interfaces)).toBe(false);
+ expect(isOnLink("::ffff:192.168.1.7", interfaces)).toBe(true);
+ });
+});
+
+describe("clampBytes", () => {
+ it("leaves anything already inside the budget alone", () => {
+ expect(clampBytes("Ada's computer", 200)).toBe("Ada's computer");
+ expect(clampBytes("", 200)).toBe("");
+ });
+
+ // The limit DNS-SD enforces is bytes, and `.slice()` counts UTF-16 code
+ // units — so a name in a non-Latin script overran the record rather than
+ // being truncated by it, and discovery failed silently for its owner.
+ it("counts bytes, not characters", () => {
+ const cjk = "小熊".repeat(60); // 2 chars, 6 bytes, x60
+ expect(cjk.length).toBe(120);
+ const clamped = clampBytes(cjk, 200);
+ expect(Buffer.byteLength(clamped, "utf8")).toBeLessThanOrEqual(200);
+ expect(Buffer.byteLength(clamped, "utf8")).toBeGreaterThan(194);
+ });
+
+ it("never cuts a character in half", () => {
+ // an emoji is one code point in four bytes: with three bytes left it has
+ // to be dropped whole, not turned into a replacement character
+ const clamped = clampBytes("abc\u{1F600}", 6);
+ expect(clamped).toBe("abc");
+ expect(clamped).not.toContain("�");
+ expect(clampBytes("\u{1F600}", 4)).toBe("\u{1F600}");
+ expect(clampBytes("\u{1F600}", 3)).toBe("");
+ });
+});
diff --git a/companion/test/ports.test.ts b/companion/test/ports.test.ts
new file mode 100644
index 0000000000..08bb737746
--- /dev/null
+++ b/companion/test/ports.test.ts
@@ -0,0 +1,126 @@
+// 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.
+ *
+ * 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 — along with the
+ * OMB_COMPANION_DIR that pins the sidecar's own data directory inside it,
+ * which is carried for the same reason and is the one that actually decides
+ * where the child writes. */
+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 } : {}),
+ ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
+ ...(process.env.HOME ? { HOME: process.env.HOME } : {}),
+ ...(process.env.USERPROFILE ? { USERPROFILE: process.env.USERPROFILE } : {}),
+ ...(process.env.OMB_COMPANION_DIR ? { OMB_COMPANION_DIR: process.env.OMB_COMPANION_DIR } : {}),
+ ...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);
+
+ // The sidecar's own two ports. Left to bind order this is an EADDRINUSE
+ // naming a port the person can see nothing on — the something-else using
+ // it is this same process, one line earlier — and the message blames
+ // "another copy of the companion", sending someone to hunt for a process
+ // that is not running.
+ it("refuses to put the device port and the control port on one socket", async () => {
+ const { code, err } = await start({
+ OMB_PORT: "9100",
+ OMB_COMPANION_PORT: "9300",
+ OMB_CONTROL_PORT: "9300",
+ });
+ expect(code).toBe(1);
+ expect(err).toContain("OMB_COMPANION_PORT");
+ expect(err).toContain("OMB_CONTROL_PORT");
+ expect(err).toContain("9300");
+ // named by the check, not discovered by the second bind
+ expect(err).not.toContain("already in use");
+ }, 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-response.test.ts b/companion/test/proxy-response.test.ts
new file mode 100644
index 0000000000..712880fc74
--- /dev/null
+++ b/companion/test/proxy-response.test.ts
@@ -0,0 +1,132 @@
+// 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";
+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;
+/** 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.
+ //
+ // 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(deeplyNested);
+ };
+
+ const { status, text } = await device();
+ expect(status === 200 && text.includes("resumeCursors")).toBe(false);
+ 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 () => {
+ // 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");
+ });
+});
diff --git a/companion/test/proxy.test.ts b/companion/test/proxy.test.ts
new file mode 100644
index 0000000000..440926a6ed
--- /dev/null
+++ b/companion/test/proxy.test.ts
@@ -0,0 +1,755 @@
+// 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, "..", "..");
+
+/** Ports nothing is listening on.
+ *
+ * Two requirements here pull against each other. The port has to be
+ * *verified* free — picking at random and hoping fails whenever something
+ * else is on it, and the failure reads as a bug in the code under test rather
+ * than as bad luck. And it must not come from the operating system's own
+ * ephemeral range, because a port the kernel hands out is a port the kernel
+ * hands out again: anything opening an outbound socket between this probe
+ * closing and the harness binding can take it.
+ *
+ * `listen(0)` — the obvious way to ask for a free port — gives exactly the
+ * wrong half of that. It lands at 49152+ on macOS and Windows, which is that
+ * churn, and it turned a rare collision into a reliable one. Only Linux
+ * survived, its range starting higher up and quieter.
+ *
+ * So: candidates from a fixed range below every platform's dynamic range,
+ * each verified by binding it, retried when taken. `span` reserves that many
+ * consecutive ports — the harness opens a webhook receiver one above itself. */
+const freePorts = async (span = 1): Promise => {
+ const hold = (port: number): Promise =>
+ new Promise((resolve, reject) => {
+ const server = createServer();
+ server.once("error", reject);
+ server.listen(port, "127.0.0.1", () => resolve(server));
+ });
+
+ for (let attempt = 0; attempt < 40; attempt++) {
+ const candidate = 20_000 + Math.floor(Math.random() * 9_000);
+ const held: Server[] = [];
+ let free = true;
+ for (let i = 0; i < span; i++) {
+ try {
+ held.push(await hold(candidate + i));
+ } catch {
+ free = false; // taken — somewhere else, then
+ break;
+ }
+ }
+ for (const server of held) await new Promise((r) => server.close(() => r()));
+ if (free) return candidate;
+ }
+ throw new Error(`could not find ${span} free consecutive ports`);
+};
+
+let HARNESS_PORT = 0;
+let SIDECAR_PORT = 0;
+let HARNESS = "";
+let SIDECAR = "";
+
+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 () => {
+ // Three in a row, from one call: the harness, its webhook receiver one
+ // above it, and the sidecar above that.
+ //
+ // One call rather than two, because two are not independent the way they
+ // look. Each verifies its ports by binding and then releases them for the
+ // real thing to take, so by the time a second call runs, the first call's
+ // ports are free again — and free is exactly what it goes looking for. It
+ // can hand back a port already spoken for, which is the collision this
+ // helper exists to rule out.
+ const base = await freePorts(3);
+ HARNESS_PORT = base;
+ SIDECAR_PORT = base + 2;
+ HARNESS = `http://127.0.0.1:${HARNESS_PORT}`;
+ SIDECAR = `http://127.0.0.1:${SIDECAR_PORT}`;
+
+ 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));
+
+ // Generous, because a cold Windows or macOS runner is much slower than a
+ // laptop at starting a Node process that strips types as it loads.
+ const deadline = Date.now() + 45_000;
+ for (;;) {
+ try {
+ // With a deadline of its own. A bare fetch to a port where something
+ // accepts but never answers hangs forever, and the loop below never
+ // gets to notice its own deadline — which is how a boot failure ends up
+ // reported as "hook timed out" with nothing else to go on.
+ if ((await fetch(`${HARNESS}/api/health`, { signal: AbortSignal.timeout(2_000) })).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",
+ }),
+ );
+ // Not `listen(port, host, resolve)` alone: a bind failure emits `error` and
+ // never calls back, so the hook would sit there until the runner's timeout
+ // and report nothing about why.
+ await new Promise((resolve, reject) => {
+ sidecar.once("error", reject);
+ sidecar.listen(SIDECAR_PORT, "127.0.0.1", () => resolve());
+ });
+}, 90_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()));
+ }
+ });
+
+ // A harness that is *down* is the easy case — the connection is refused and
+ // the phone hears about it immediately. A harness that accepts the socket
+ // and then says nothing is the case with no natural end: `http.request` has
+ // no deadline for the headers phase, so without one the phone waits on a
+ // spinner until the person kills the app.
+ it("gives up on a harness that accepts a connection and then goes quiet", async () => {
+ const mute = createServer(() => {
+ /* accepted, and deliberately never answered */
+ });
+ await new Promise((r) => mute.listen(0, "127.0.0.1", r));
+ const mutePort = (mute.address() as { port: number }).port;
+ const stalled = createServer(
+ createProxyHandler({
+ harnessPort: mutePort,
+ authenticate: () => true,
+ redeem: () => ({ error: "no" }),
+ serverName: () => "Test computer",
+ // the shipped value is 30s; the behaviour under test is the same one
+ headersTimeoutMs: 300,
+ }),
+ );
+ await new Promise((r) => stalled.listen(0, "127.0.0.1", r));
+ const port = (stalled.address() as { port: number }).port;
+ try {
+ const started = Date.now();
+ const res = await fetch(`http://127.0.0.1:${port}/api/bots`, {
+ headers: { authorization: `Bearer ${TOKEN}` },
+ });
+ expect(res.status).toBe(504);
+ expect(((await res.json()) as { error: string }).error).toContain("did not respond");
+ expect(Date.now() - started).toBeLessThan(5_000);
+ } finally {
+ mute.closeAllConnections?.();
+ await new Promise((r) => mute.close(() => r()));
+ await new Promise((r) => stalled.close(() => r()));
+ }
+ });
+
+ // A phone that walks out of range mid-download leaves the harness talking
+ // to nobody. The SSE path already hung up on the upstream; these two did
+ // not, so a large response kept being produced — and, on the JSON path,
+ // kept being buffered.
+ it("hangs up on the harness when the device disappears", async () => {
+ let upstreamClosed: () => void;
+ let upstreamReached: () => void;
+ const closed = new Promise((r) => (upstreamClosed = r));
+ // The disconnect only means anything once the request has actually
+ // arrived upstream. Waiting a fixed 250ms for that is a bet on how fast
+ // the machine is, and this file has already lost one of those.
+ const reached = new Promise((r) => (upstreamReached = r));
+ const slow = createServer((_req, res) => {
+ res.on("error", () => {
+ /* the sidecar hanging up is the pass condition */
+ });
+ res.on("close", () => upstreamClosed());
+ res.writeHead(200, { "content-type": "application/json" });
+ res.write('{"bots":[');
+ // and then keeps the response open, as a slow endpoint does
+ upstreamReached();
+ });
+ await new Promise((r) => slow.listen(0, "127.0.0.1", r));
+ const slowPort = (slow.address() as { port: number }).port;
+ const relay = createServer(
+ createProxyHandler({
+ harnessPort: slowPort,
+ authenticate: () => true,
+ redeem: () => ({ error: "no" }),
+ serverName: () => "Test computer",
+ }),
+ );
+ await new Promise((r) => relay.listen(0, "127.0.0.1", r));
+ const port = (relay.address() as { port: number }).port;
+ const abort = new AbortController();
+ try {
+ // Not awaited: a JSON response is buffered whole before any of it
+ // reaches the device, so this request has no headers to resolve until
+ // the stub ends — and the stub never ends. The disconnect under test
+ // is one that happens while the request is still in flight.
+ const pending = fetch(`http://127.0.0.1:${port}/api/bots`, {
+ headers: { authorization: `Bearer ${TOKEN}` },
+ signal: abort.signal,
+ }).catch(() => {
+ /* aborted on purpose */
+ });
+ await reached;
+ abort.abort();
+ // the upstream response closes because the sidecar dropped it, not
+ // because the stub finished — it never finishes
+ await closed;
+ await pending;
+ } finally {
+ abort.abort();
+ slow.closeAllConnections?.();
+ await new Promise((r) => slow.close(() => r()));
+ await new Promise((r) => relay.close(() => r()));
+ }
+ }, 20_000);
+
+ // The scrubber holds a partial event until its terminator arrives, which is
+ // correct and bounded by nothing. An upstream that opens a `data:` line and
+ // never closes it would otherwise be a memory leak with a straight face.
+ it("ends a stream whose event never terminates, rather than buffering it", async () => {
+ const TWO_MIB = 2 * 1024 * 1024;
+ const flood = createServer((_req, res) => {
+ res.on("error", () => {
+ /* the sidecar hangs up on us — that is the pass condition */
+ });
+ res.writeHead(200, { "content-type": "text/event-stream" });
+ res.write("data: ");
+ const blob = "x".repeat(64 * 1024);
+ let sent = 0;
+ const pump = () => {
+ while (sent < TWO_MIB) {
+ sent += blob.length;
+ if (!res.write(blob)) return void res.once("drain", pump);
+ }
+ };
+ pump();
+ });
+ await new Promise((r) => flood.listen(0, "127.0.0.1", r));
+ const floodPort = (flood.address() as { port: number }).port;
+ const relay = createServer(
+ createProxyHandler({
+ harnessPort: floodPort,
+ authenticate: () => true,
+ redeem: () => ({ error: "no" }),
+ serverName: () => "Test computer",
+ }),
+ );
+ await new Promise((r) => relay.listen(0, "127.0.0.1", r));
+ const port = (relay.address() as { port: number }).port;
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}/api/events`, {
+ headers: { authorization: `Bearer ${TOKEN}` },
+ });
+ expect(res.status).toBe(200);
+ // Terminates, and forwards nothing: half an event cannot go out
+ // scrubbed, and must not go out unscrubbed.
+ const text = await res.text();
+ expect(text).toBe("");
+ } finally {
+ flood.closeAllConnections?.();
+ await new Promise((r) => flood.close(() => r()));
+ await new Promise((r) => relay.close(() => r()));
+ }
+ }, 20_000);
+});
+
+// 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 paired = createServer(
+ createProxyHandler({
+ harnessPort: HARNESS_PORT,
+ authenticate: (t) => Boolean(registry.authenticate(t ?? undefined)),
+ redeem: (code, deviceName) => registry.redeem(code, deviceName),
+ serverName: () => "Ada's computer",
+ }),
+ );
+ await new Promise((r) => paired.listen(0, "127.0.0.1", r));
+ const port = (paired.address() as { port: number }).port;
+ const control = createControlServer({
+ devices: registry,
+ companionPort: port,
+ discovery: () => ({ advertising: false, name: "OpenMausBot" }),
+ });
+ await new Promise((r) => control.listen(0, "127.0.0.1", r));
+ // SAFETY: address() is AddressInfo — an object with a port — for any
+ // IP server that is listening, which the awaited listen guarantees.
+ const controlPort = (control.address() as { port: number }).port;
+ 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
+ // SAFETY: POST /pairing is this sidecar's own API and always answers
+ // with the window's code; the toMatch below fails if the shape drifts.
+ 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);
+ // SAFETY: a 201 from /api/pair carries exactly this shape — the
+ // sidecar's own contract, pinned by the expects that follow.
+ 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
+ // SAFETY: /state's shape is this sidecar's own API, asserted by the
+ // control-server tests above; a drifted shape fails the expects below.
+ 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");
+ // By name, not by index: `devices[0]` is whichever record the registry
+ // happens to have loaded first, and revoking the wrong one would leave
+ // this test passing for the wrong reason.
+ const ada = state.devices.find((d) => d.name === "Ada's iPhone")!;
+ const revoked = await fetch(`${ctl}/devices/${ada.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));
+ // SAFETY: address() is AddressInfo — an object with a port — for any
+ // IP server that is listening, which the awaited listen guarantees.
+ 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()));
+ }
+ });
+
+ // A browser handed `http://[::1]:8811` sends `Host: [::1]:8811`, and the
+ // colons in the literal are not the port separator. Splitting on the first
+ // one refuses the address the person was told to use.
+ it("understands an IPv6 loopback Host", async () => {
+ const { DeviceRegistry } = await import("../src/devices.ts");
+ const { createControlServer, hostOf } = await import("../src/control.ts");
+
+ expect(hostOf("[::1]:8811")).toBe("::1");
+ expect(hostOf("127.0.0.1:8811")).toBe("127.0.0.1");
+ expect(hostOf("localhost")).toBe("localhost");
+ // a malformed authority fails the allowlist rather than skipping it
+ expect(hostOf("[::1")).toBe("[::1");
+
+ 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));
+ // SAFETY: address() is AddressInfo — an object with a port — for any
+ // IP server that is listening, which the awaited listen guarantees.
+ const port = (control.address() as { port: number }).port;
+ try {
+ expect(await withHost(port, `[::1]:${port}`, "/state")).toBe(200);
+ // and the bracket parsing did not open a door: a real name is still out
+ expect(await withHost(port, "[::1].evil.example", "/state")).toBe(403);
+
+ // An authority the parser cannot make sense of is refused, not waved
+ // through. Both of these are malformed — an IPv6 literal has to be
+ // bracketed, and a port needs a host in front of it — and both used to
+ // parse to the empty string, which the check then skipped.
+ expect(await withHost(port, "::1", "/state")).toBe(403);
+ expect(await withHost(port, ":8811", "/state")).toBe(403);
+ expect(await withHost(port, "[]:8811", "/state")).toBe(403);
+ } finally {
+ await new Promise((r) => control.close(() => r()));
+ }
+ });
+
+ // Loopback is not a boundary a browser respects: any page the person is
+ // reading can POST to 127.0.0.1 with a correct Host, unpreflighted, and
+ // CORS only stops it reading the answer. It does not need the answer —
+ // opening a pairing window and revoking a phone both land on the way in.
+ it("refuses a write from a page the person happened to be reading", async () => {
+ const { DeviceRegistry } = await import("../src/devices.ts");
+ const { createControlServer } = await import("../src/control.ts");
+ const registry = new DeviceRegistry();
+ const control = createControlServer({
+ devices: registry,
+ companionPort: 8800,
+ discovery: () => ({ advertising: false, name: "OpenMausBot" }),
+ });
+ await new Promise((r) => control.listen(0, "127.0.0.1", r));
+ // SAFETY: address() is AddressInfo — an object with a port — for any
+ // IP server that is listening, which the awaited listen guarantees.
+ const port = (control.address() as { port: number }).port;
+ const base = `http://127.0.0.1:${port}`;
+ try {
+ const evil = await fetch(`${base}/pairing`, {
+ method: "POST",
+ headers: { origin: "https://evil.example" },
+ });
+ expect(evil.status).toBe(403);
+ // and no window opened on the way to being refused
+ expect(registry.pairing()).toBe(null);
+
+ // A same-origin write is the control page itself, and must still work:
+ // the browser sends Origin on any method that is not GET or HEAD, so a
+ // blanket refusal would break the one legitimate browser there is.
+ const ours = await fetch(`${base}/pairing`, {
+ method: "POST",
+ headers: { origin: base },
+ });
+ expect(ours.status).toBe(201);
+ expect(registry.pairing()).not.toBe(null);
+
+ // Revocation is the other write worth stealing.
+ const stolen = await fetch(`${base}/devices/whatever`, {
+ method: "DELETE",
+ headers: { origin: "https://evil.example" },
+ });
+ expect(stolen.status).toBe(403);
+ } finally {
+ await new Promise((r) => control.close(() => r()));
+ }
+ });
+
+ // The Host check above is not enough on its own. A page anywhere can POST
+ // to 127.0.0.1 without a preflight — it is a simple request — and it
+ // arrives with a loopback Host like everything else. It cannot read the
+ // reply, but opening a pairing window is already the damage: the code is
+ // then on screen for whoever asked for it.
+ it("refuses a cross-origin request even though its Host is loopback", async () => {
+ const { DeviceRegistry } = await import("../src/devices.ts");
+ const { createControlServer } = await import("../src/control.ts");
+ const registry = new DeviceRegistry();
+ const control = createControlServer({
+ devices: registry,
+ companionPort: 8800,
+ discovery: () => ({ advertising: false, name: "OpenMausBot" }),
+ });
+ await new Promise((r) => control.listen(0, "127.0.0.1", r));
+ // SAFETY: address() is AddressInfo — an object with a port — for any
+ // IP server that is listening, which the awaited listen guarantees.
+ const port = (control.address() as { port: number }).port;
+ const host = `127.0.0.1:${port}`;
+ try {
+ for (const origin of ["https://evil.example", "http://127.0.0.1.evil.example", "null"]) {
+ expect(await withHost(port, host, "/state", { origin })).toBe(403);
+ }
+ // no pairing window was opened by any of that
+ expect(registry.pairing()).toBeNull();
+
+ // A loopback origin is not enough either, which is the rule that got
+ // stricter: `http://localhost:` reaches the same socket by the
+ // same route, and is still some other program's page rather than the
+ // one this server serves. Only the exact authority the request was
+ // addressed to passes, because the alternative is putting a name
+ // resolver inside a CSRF check.
+ expect(await withHost(port, host, "/state", { origin: `http://localhost:${port}` })).toBe(403);
+
+ // the control page itself is same-origin, and a native client sends
+ // no Origin at all — both keep working
+ expect(await withHost(port, host, "/state", { origin: `http://${host}` })).toBe(200);
+ expect(await withHost(port, host, "/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/upstream-failure.test.ts b/companion/test/upstream-failure.test.ts
new file mode 100644
index 0000000000..bafa035ee0
--- /dev/null
+++ b/companion/test/upstream-failure.test.ts
@@ -0,0 +1,162 @@
+// What happens to the sidecar when the harness dies underneath it.
+//
+// Not covered by proxy.test.ts, which boots a healthy harness and keeps it
+// that way: the interesting case is the one where the harness goes away
+// *after* the response to the phone has already been opened. There is no way
+// to answer 502 at that point — the status line is long gone — and trying to
+// is not merely futile, it throws ERR_HTTP_HEADERS_SENT out of an error
+// handler where nothing is waiting to catch it. One phone whose stream broke
+// then takes down every other paired device with it.
+//
+// A fake harness rather than the real one, because the failure has to happen
+// on demand and at a chosen moment.
+import { createServer, type Server, type ServerResponse } from "node:http";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { createProxyHandler } from "../src/proxy.ts";
+
+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): Promise =>
+ new Promise((resolve) => {
+ server.closeAllConnections?.();
+ server.close(() => resolve());
+ });
+
+const servers: Server[] = [];
+
+afterEach(async () => {
+ await Promise.all(servers.splice(0).map(close));
+});
+
+/** A sidecar in front of `harness`, both listening. */
+const stand = async (harness: Server): Promise => {
+ servers.push(harness);
+ const harnessPort = await listen(harness);
+ const sidecar = createServer(
+ createProxyHandler({
+ harnessPort,
+ authenticate: () => true,
+ redeem: () => ({ error: "not in this test" }),
+ serverName: () => "Ada's computer",
+ }),
+ );
+ servers.push(sidecar);
+ return `http://127.0.0.1:${await listen(sidecar)}`;
+};
+
+/** Collect anything that escapes to the top level while `body` runs. Vitest
+ * would report these anyway, but only as a mysteriously dead worker — naming
+ * them here is what makes a regression readable. */
+const watchingForCrashes = async (body: () => Promise): Promise => {
+ const escaped: Error[] = [];
+ const onUncaught = (error: Error) => escaped.push(error);
+ process.on("uncaughtException", onUncaught);
+ try {
+ await body();
+ // give the error handlers a tick to run after the response ends
+ await new Promise((r) => setTimeout(r, 200));
+ } finally {
+ process.off("uncaughtException", onUncaught);
+ }
+ return escaped;
+};
+
+describe("an upstream that fails mid-stream", () => {
+ it("does not take the sidecar down with it", async () => {
+ let opened: ServerResponse | undefined;
+ const base = await stand(
+ createServer((_req, res) => {
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache" });
+ res.write('id: a:1\ndata: {"kind":"hello"}\n\n');
+ opened = res;
+ // rip the socket out from under the proxy, which is what a harness
+ // that exits while a phone is connected looks like from here
+ setTimeout(() => res.socket?.destroy(), 50);
+ }),
+ );
+
+ const escaped = await watchingForCrashes(async () => {
+ const res = await fetch(`${base}/api/events`, { headers: { authorization: "Bearer omb_x" } });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
+ try {
+ // read until the stream dies — the point is that it dies here and
+ // not in the sidecar's process
+ for await (const _chunk of res.body as unknown as AsyncIterable) void _chunk;
+ } catch {
+ /* a destroyed connection is exactly what this is provoking */
+ }
+ });
+
+ expect(opened).toBeDefined();
+ expect(escaped).toEqual([]);
+
+ // and the sidecar is still answering, which is the whole claim
+ const after = await fetch(`${base}/api/health`, { headers: { authorization: "Bearer omb_x" } });
+ expect([200, 502]).toContain(after.status);
+ }, 20_000);
+
+ // The branch that carries images through byte-for-byte. `pipe` does not
+ // carry a source failure to the destination, so an interrupted upstream
+ // used to leave the phone holding a connection with a content-length that
+ // would never be satisfied — waiting for the rest of a picture forever,
+ // which reads as a frozen app rather than as a request that failed.
+ it("ends the phone's request when an image stops half way", async () => {
+ const base = await stand(
+ createServer((_req, res) => {
+ res.writeHead(200, { "content-type": "image/png", "content-length": "100000" });
+ res.write(Buffer.alloc(1000, 1));
+ setTimeout(() => res.socket?.destroy(), 40);
+ }),
+ );
+
+ const escaped = await watchingForCrashes(async () => {
+ const res = await fetch(`${base}/api/threads/t1/messages/m1/image`, {
+ headers: { authorization: "Bearer omb_x" },
+ });
+ expect(res.status).toBe(200);
+ // the assertion is that this settles at all, either way — the bug was
+ // that it never did
+ await expect(
+ Promise.race([
+ res.arrayBuffer().then(() => "finished"),
+ new Promise((resolve) => setTimeout(() => resolve("hung"), 4_000)),
+ ]).catch(() => "failed"),
+ ).resolves.not.toBe("hung");
+ });
+ expect(escaped).toEqual([]);
+ }, 20_000);
+
+ it("still says 502 when the harness was never there to begin with", async () => {
+ // The other half of the same handler: nothing has been written yet, so
+ // there is a status line to spend, and a phone deserves the real reason.
+ const dead = createServer(() => {});
+ servers.push(dead);
+ const harnessPort = await listen(dead);
+ await close(dead);
+
+ const sidecar = createServer(
+ createProxyHandler({
+ harnessPort,
+ authenticate: () => true,
+ redeem: () => ({ error: "not in this test" }),
+ serverName: () => "Ada's computer",
+ }),
+ );
+ servers.push(sidecar);
+ const base = `http://127.0.0.1:${await listen(sidecar)}`;
+
+ const escaped = await watchingForCrashes(async () => {
+ const res = await fetch(`${base}/api/health`, { headers: { authorization: "Bearer omb_x" } });
+ expect(res.status).toBe(502);
+ expect((await res.json()) as { error: string }).toEqual({
+ error: "OpenMausBot is not running on this computer",
+ });
+ });
+ expect(escaped).toEqual([]);
+ }, 20_000);
+});
diff --git a/companion/test/wire.test.ts b/companion/test/wire.test.ts
new file mode 100644
index 0000000000..e0a1c405cd
--- /dev/null
+++ b/companion/test/wire.test.ts
@@ -0,0 +1,153 @@
+// 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, MAX_SSE_EVENT_BYTES, 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);
+ });
+
+ // A structured suffix is the registered way of saying "JSON underneath",
+ // and the harness only has to answer one error as RFC 9457 for a body that
+ // skips scrubbing to reach a phone.
+ it("matches structured JSON suffixes too", () => {
+ expect(isJson("application/problem+json")).toBe(true);
+ expect(isJson("application/vnd.openmausbot.bot+json; charset=utf-8")).toBe(true);
+ expect(isJson("APPLICATION/PROBLEM+JSON")).toBe(true);
+ // and does not match something that merely ends in the letters
+ expect(isJson("text/notjson")).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("refuses to buffer an event that never terminates", () => {
+ // "Buffer to the frame boundary" is bounded only by the sender behaving.
+ // An upstream that never sends the blank line — broken, or hostile —
+ // otherwise grows this buffer for as long as the stream stays open.
+ const scrubStream = createSseScrubber();
+ const chunk = "x".repeat(256 * 1024);
+ let sent = 0;
+ expect(() => {
+ for (;;) {
+ scrubStream(chunk);
+ sent += chunk.length;
+ if (sent > MAX_SSE_EVENT_BYTES * 2) throw new Error("buffered without limit");
+ }
+ }).toThrow(/without a terminator/);
+ });
+
+ 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`);
+ });
+
+ 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');
+ });
+
+ // A CRLF producer is legal SSE, and an LF-only scrubber does not fail
+ // loudly against one — it buffers the whole stream waiting for a boundary
+ // that will never arrive, while both ends report a healthy connection.
+ it("terminates events that arrive with CRLF, and keeps them CRLF", () => {
+ const out = createSseScrubber()(
+ 'id: a:1\r\ndata: {"a":1,"resumeCursors":{"g":"s"}}\r\n\r\n',
+ );
+ expect(out).toBe('id: a:1\r\ndata: {"a":1}\r\n\r\n');
+ expect(out).not.toContain("resumeCursors");
+ });
+
+ it("splits a CRLF stream across chunks without cutting an event short", () => {
+ const scrubStream = createSseScrubber();
+ // the chunk ends inside the terminator itself, which is the boundary a
+ // naive indexOf gets wrong
+ expect(scrubStream('id: a:1\r\ndata: {"a":1}\r\n\r')).toBe("");
+ expect(scrubStream('\nid: a:2\r\ndata: {"b":2}\r\n\r\n')).toBe(
+ 'id: a:1\r\ndata: {"a":1}\r\n\r\nid: a:2\r\ndata: {"b":2}\r\n\r\n',
+ );
+ });
+
+ it("still handles a bare CR, which the spec also allows", () => {
+ expect(createSseScrubber()('data: {"a":1,"resumeCursors":{}}\r\r')).toBe('data: {"a":1}\r\r');
+ });
+});
diff --git a/docs/ios-companion.md b/docs/ios-companion.md
new file mode 100644
index 0000000000..db422521f1
--- /dev/null
+++ b/docs/ios-companion.md
@@ -0,0 +1,492 @@
+# 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.
+
+```text
+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 `companion/src/listener.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 the state the control page reads from
+`companionState()`, which 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.**
+
+```text
+ 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. Built, green and pushed if ever wanted | **Moot** — nothing is being asked of upstream any more, because the harness is no longer patched |
+| 6 | `upstreaming/6-resume-cursors` | The `resumeCursors` leak fix, standing alone | **A real bug for every client**, nothing to do with phones — it stands on its own merits |
+| — | — | Layer 3: `ios/` | **None needed** — the sidecar asks nothing of upstream, so this no longer waits on their agreement |
+
+**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/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..650c7e7bdd
--- /dev/null
+++ b/electron/companion.mjs
@@ -0,0 +1,224 @@
+// 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();
+}
+
+/** Whether this process owns a running sidecar. */
+export function companionRunning() {
+ return proc !== null;
+}
+
+// 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;
+};
+
+/** 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;
+ 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 {
+ 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 {
+ await new Promise((r) => setTimeout(r, 150));
+ }
+ }
+ try {
+ child.kill();
+ } catch {
+ /* already gone */
+ }
+ lastError = "the companion did not come up in time";
+ return companionState();
+}
+
+/** stopCompanion's body, run inside the transition queue. */
+async function stop() {
+ const child = proc;
+ proc = null;
+ lastError = null;
+ if (!child) return companionState();
+ try {
+ child.kill();
+ } 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();
+}
+
+/** 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" };
+ }
+}
+
+/** 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
+ 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 dc6d782804..2f6d54b314 100644
--- a/electron/main.mjs
+++ b/electron/main.mjs
@@ -99,6 +99,14 @@ async function secureComposioConfig() {
// 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) {
@@ -372,6 +380,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,
@@ -465,6 +485,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 ec155f7ed4..f47cded1dc 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/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 0000000000..edd66c343f
Binary files /dev/null and b/ios/App/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ
diff --git a/ios/App/ChatListView.swift b/ios/App/ChatListView.swift
new file mode 100644
index 0000000000..9e9f1688c8
--- /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) { summary in
+ NavigationLink(value: summary.chat) {
+ ChatRow(
+ chat: summary.chat,
+ preview: summary.preview,
+ at: summary.lastActivity
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 24)
+ }
+ .refreshable { await session.refresh() }
+ .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: [ChatSummary] {
+ let all = session.state.chatSummaries
+ guard !query.isEmpty else { return all }
+ return all.filter {
+ $0.chat.name.localizedCaseInsensitiveContains(query)
+ || $0.chat.subtitle.localizedCaseInsensitiveContains(query)
+ || $0.preview.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..0adcb31126
--- /dev/null
+++ b/ios/App/ChatView.swift
@@ -0,0 +1,525 @@
+// 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
+// Unconditional, because the uses below are: `Color(uiColor:)` and
+// `UIImage(data:)` are reached on every path through this file. A
+// `canImport` guard around the import alone does not make the file portable
+// — it only moves the failure from "no such module" to "no such type", and
+// hides that this view is iOS-only behind something that looks like it
+// isn't. The App target is iOS; CompanionCore is where the portable half
+// lives.
+import UIKit
+
+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
+
+ /// The option this card offers that means "go ahead".
+ ///
+ /// Deliberately not the literal string "Allow". `options` is whatever the
+ /// harness sent, and it only falls back to ["Allow", "Deny"] when the
+ /// provider event named no choices of its own (`server/index.ts`) — a card
+ /// is free to say "Yes", "Approve", "Allow once". Answering with a string
+ /// the card never offered writes the grant and then hands the harness a
+ /// choice it can reject, so the bot stays stopped with nothing on screen
+ /// to explain it. The conventional label wins when it is present, which
+ /// keeps the ordinary permission card behaving exactly as before.
+ private var allowChoice: String? {
+ guard let options = message.card?.options else { return nil }
+ return options.first { $0.caseInsensitiveCompare("Allow") == .orderedSame }
+ ?? options.first { !Self.isRefusal($0) }
+ }
+
+ /// One definition of "the refusal", shared by the button tint and the
+ /// choice above so the two cannot drift apart.
+ private static func isRefusal(_ option: String) -> Bool {
+ option.caseInsensitiveCompare("Deny") == .orderedSame
+ }
+
+ 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(Self.isRefusal(option) ? 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. The same goes for
+ // the answer: it is one of the options the card offered,
+ // never a string invented here.
+ if card.allowKey != nil, let allow = allowChoice, 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..ab7846137a
--- /dev/null
+++ b/ios/App/ComputerView.swift
@@ -0,0 +1,84 @@
+// 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
+// Unconditional for the same reason as ChatView: `UIImage` is used below
+// without a guard, so a conditional import would only change which error a
+// non-UIKit build fails with.
+import UIKit
+
+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..d6c8b099fa
--- /dev/null
+++ b/ios/App/Keychain.swift
@@ -0,0 +1,120 @@
+// 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)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: connectionId,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+
+ // Add first, and only delete when the add says something is already
+ // there. Deleting up front is the tidier-looking order and it is the
+ // wrong one: if the add then fails — the device locked before first
+ // unlock, the keychain is unavailable mid-restore — the old token is
+ // already gone, and the phone is signed out of a computer it was
+ // perfectly able to reach a second ago. The failure this guards is
+ // the one where re-pairing hurts, because the user has to walk to the
+ // machine to get a new code.
+ var status = SecItemAdd(query as CFDictionary, nil)
+ if status == errSecDuplicateItem {
+ // Update in place rather than delete-then-add: it is one atomic
+ // step, so there is no window in which no token exists at all.
+ let identity: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: connectionId,
+ ]
+ status = SecItemUpdate(
+ identity as CFDictionary,
+ [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ] as CFDictionary
+ )
+ // An item that exists for `add` and is missing for `update` means
+ // something else removed it in between. Adding again is the whole
+ // recovery, and by now nothing is being lost by trying.
+ if status == errSecItemNotFound {
+ status = SecItemAdd(query as CFDictionary, nil)
+ }
+ }
+ guard status == errSecSuccess else {
+ throw KeychainError(status: status)
+ }
+ }
+
+ /// The stored token: nil only when there genuinely is not one.
+ ///
+ /// The distinction between "no token" and "cannot read the token" matters
+ /// far more than it looks. `SecItemCopyMatching` answers
+ /// `errSecInteractionNotAllowed` while the keychain is unavailable — the
+ /// window after a reboot before the phone's first unlock, which is exactly
+ /// when iOS starts apps in the background. Folding that into nil made it
+ /// indistinguishable from "this phone was never paired", so the app
+ /// discarded a perfectly good connection and showed the pairing screen to
+ /// someone who had done nothing but restart their phone. Getting back in
+ /// means walking to the computer for a new code.
+ ///
+ /// So: `errSecItemNotFound` is the only nil. Everything else throws, and
+ /// the caller decides whether to wait or to give up.
+ static func token(for connectionId: String) throws -> 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?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ if status == errSecItemNotFound { return nil }
+ guard status == errSecSuccess else { throw KeychainError(status: status) }
+ // An item that is present but unreadable is a corrupt store, not an
+ // absent pairing — say so rather than silently re-pairing.
+ guard let data = item as? Data, let token = String(data: data, encoding: .utf8) else {
+ throw KeychainError(status: errSecDecode)
+ }
+ return token
+ }
+
+ @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)"
+ }
+
+ /// The keychain is not available *yet* rather than not holding this token.
+ /// True in the window after a reboot before the first unlock, when the
+ /// right move is to wait rather than to treat the phone as unpaired.
+ var isLocked: Bool {
+ status == errSecInteractionNotAllowed
+ }
+}
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..32e9f25e91
--- /dev/null
+++ b/ios/App/MausAvatar.swift
@@ -0,0 +1,296 @@
+// 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
+ """
+
+ /// The parsed silhouette, in its own coordinate space. Parsed once.
+ ///
+ /// This is a four-kilobyte string and a character-at-a-time parser, and
+ /// the shape it produces never changes — but a chat list is hundreds of
+ /// avatars, each redrawn on scroll, and running the parser inside `Canvas`
+ /// ran it for every one of them on every frame. `static let` is lazy and
+ /// evaluated exactly once, so what is left per draw is the affine
+ /// transform below, which is the only part that depends on the rect.
+ private static let parsed: Path = parse()
+
+ /// Its bounding box, cached alongside — `boundingRect` walks the path.
+ private static let parsedBounds: CGRect = parsed.boundingRect
+
+ /// The silhouette 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 {
+ let bounds = parsedBounds
+ guard bounds.width > 0, bounds.height > 0 else { return parsed }
+ let scale = min(rect.width / bounds.width, rect.height / bounds.height)
+ return parsed.applying(
+ CGAffineTransform(translationX: -bounds.midX, y: -bounds.midY)
+ .concatenating(CGAffineTransform(scaleX: scale, y: scale))
+ .concatenating(CGAffineTransform(translationX: rect.midX, y: rect.midY))
+ )
+ }
+
+ /// The SVG path data, once, into a `Path`. Only `M`, `C` and `Z` appear in
+ /// the artwork, so only those are understood.
+ private static func parse() -> 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()
+ return raw
+ }
+}
+
+/// 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..0b364ad435
--- /dev/null
+++ b/ios/App/Session.swift
@@ -0,0 +1,480 @@
+// 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
+ /// A saved connection exists, but its token could not be read yet. Keeps
+ /// "the keychain is locked" from being mistaken for "not paired".
+ private var restorePending = false
+
+ private static let connectionKey = "companion.connection"
+
+ // MARK: - Pairing
+
+ init() {
+ restore()
+ }
+
+ /// Rebuild the last connection at launch.
+ ///
+ /// Three outcomes, and keeping them apart is the whole point. No saved
+ /// connection: stay unpaired. A saved connection whose token reads back:
+ /// connect. A saved connection whose token cannot be read *yet* — the
+ /// locked keychain before a phone's first unlock after reboot, which is
+ /// when iOS is most likely to have launched us in the background — hold
+ /// on to it and try again. Only the middle case is a real pairing, and
+ /// only the first should ever send someone back to the pairing screen.
+ private func restore() {
+ restorePending = false
+ guard let data = UserDefaults.standard.data(forKey: Self.connectionKey),
+ let saved = try? JSONDecoder().decode(Connection.self, from: data)
+ else { return }
+
+ let stored: String?
+ do {
+ stored = try Keychain.token(for: saved.id)
+ } catch {
+ // Keep the connection and say why. `.offline` rather than
+ // `.unpaired` matters: the latter is what puts PairingView on
+ // screen, and asking for a new code is the one recovery that
+ // costs a walk to the computer.
+ connection = saved
+ restorePending = true
+ status = .offline(
+ (error as? KeychainError)?.isLocked == true
+ ? "Unlock this phone to reach your computer."
+ : error.localizedDescription
+ )
+ return
+ }
+ guard let stored else { return } // no token: genuinely not paired
+
+ connection = saved
+ client = CompanionClient(connection: saved, token: stored)
+ 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()
+ // A fresh pairing settles any restore that was still waiting on the
+ // keychain — the token is in hand, so there is nothing left to retry.
+ restorePending = false
+ connect()
+ }
+
+ func signOut() {
+ streamTask?.cancel()
+ streamTask = nil
+ restorePending = false
+ 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() {
+ // A restore that found the keychain locked left `client` nil on
+ // purpose. Coming to the front is the moment worth retrying on: the
+ // app is on screen, so the phone is in someone's hand and unlocked.
+ if client == nil, restorePending { restore() }
+ guard client != nil, streamTask == nil else { return }
+ reconnectDelay = 0
+ streamTask = Task { [weak self] in await self?.run() }
+ }
+
+ /// Pull-to-refresh: reopen the stream, and hold the control open until
+ /// the connection has actually settled one way or the other.
+ ///
+ /// `connect()` returns the moment the task is spawned, so a `refreshable`
+ /// that only calls it snaps the spinner shut before a single byte has
+ /// arrived — the gesture reads as "nothing happened", on precisely the
+ /// occasion it exists for. Waiting for `status` to leave `.connecting`
+ /// makes the spinner mean what it appears to mean; the deadline is there
+ /// so a network that never answers still gives the control back.
+ func refresh() async {
+ restartStream()
+ connect()
+ let deadline = Date().addingTimeInterval(10)
+ while status == .connecting, !Task.isCancelled, Date() < deadline {
+ try? await Task.sleep(nanoseconds: 120_000_000)
+ }
+ }
+
+ /// 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"
+ }
+ }
+}
+
+/// A chat plus the two things a roster row shows that the record itself does
+/// not carry: the preview line, and when the thread last moved. Both come out
+/// of the same message — the last one in the transcript.
+struct ChatSummary: Identifiable, Hashable {
+ let chat: Chat
+ let preview: String
+ let lastActivity: Double
+ let pinned: Bool
+
+ var id: String { chat.id }
+}
+
+extension CompanionState {
+ /// Everything worth showing in the chat list: pinned first, then unread,
+ /// then most recently active. Hidden bots stay hidden.
+ ///
+ /// The derived fields are computed once here rather than asked for as the
+ /// list is sorted and filtered. Each one walks a thread's messages to
+ /// reach the last of them, and a comparator is called O(n log n) times
+ /// while the search predicate runs over every chat on every keystroke —
+ /// so the same transcript was being traversed dozens of times per frame
+ /// to produce an answer that had not changed. One pass, then sort the
+ /// results.
+ var chatSummaries: [ChatSummary] {
+ let bots = self.bots.filter { $0.hidden != true }.map(Chat.bot)
+ let rooms = self.rooms.map(Chat.room)
+ return (bots + rooms)
+ .map { chat in
+ let last = transcript(forThread: chat.threadId).last
+ return ChatSummary(
+ chat: chat,
+ preview: Self.preview(of: last),
+ lastActivity: last?.at ?? 0,
+ pinned: Self.pinned(chat)
+ )
+ }
+ .sorted { left, right in
+ if left.pinned != right.pinned { return left.pinned }
+ if left.chat.unread != right.chat.unread { return left.chat.unread }
+ return left.lastActivity > right.lastActivity
+ }
+ }
+
+ private static func pinned(_ chat: Chat) -> Bool {
+ if case let .bot(bot) = chat { return bot.pinned ?? false }
+ return false
+ }
+
+ /// The one line a roster row shows under the name, from whichever kind of
+ /// message landed last.
+ private static func preview(of last: Message?) -> String {
+ guard let 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..6713457efa
--- /dev/null
+++ b/ios/Sources/CompanionCore/Client.swift
@@ -0,0 +1,306 @@
+// 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
+ }
+
+ /// Plain HTTP, and that is a real limitation rather than an oversight.
+ ///
+ /// The bearer token goes out in a header on every request, so anyone who
+ /// can observe the path between phone and computer can lift it and use it
+ /// until the device is revoked. What that means in practice depends
+ /// entirely on how you reach the computer, and the two supported routes
+ /// are not equivalent:
+ ///
+ /// - **Over a tailnet** — the recommended route, and the only one that
+ /// works away from home — the traffic is inside WireGuard before it
+ /// reaches any network, so it is encrypted and authenticated end to end
+ /// even though this URL says `http`.
+ /// - **Over a LAN**, it is cleartext on that network. Trust it exactly as
+ /// far as you trust everyone on the wifi: fine at home, not fine on a
+ /// café or conference network — pair over the tailnet there instead.
+ ///
+ /// TLS is not a drop-in improvement here, which is why it is not simply
+ /// switched on. A self-signed certificate on a LAN address is a
+ /// certificate nothing can validate, so it would have to be pinned at
+ /// pairing time and re-pinned whenever the sidecar regenerates it — a
+ /// meaningful amount of machinery whose benefit, on the tailnet path, is
+ /// zero. The honest position is: the tailnet carries the encryption, the
+ /// LAN path is documented as trusted-network-only, and pinned TLS is what
+ /// this needs before it could claim otherwise. See `docs/ios-companion.md`.
+ 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")
+ // `makeRequest` stamps every request with the 20 seconds that suit a
+ // call-and-answer API, and a per-request timeout *overrides* the
+ // session's `timeoutIntervalForRequest` rather than deferring to it —
+ // so the 90 seconds configured just above was never in effect here.
+ // The harness sends a keepalive comment every 25 seconds, which is
+ // already past 20: a stream with nothing to say would time out on its
+ // first quiet gap and reconnect, forever, looking like a flaky network
+ // rather than a number in the wrong place.
+ streamRequest.timeoutInterval = 90
+ 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..89c8c31310
--- /dev/null
+++ b/ios/Sources/CompanionCore/Markdown.swift
@@ -0,0 +1,149 @@
+// 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()
+ }
+
+ // Normalise the line endings before splitting, because
+ // `CharacterSet.newlines` contains \r and \n *separately* and
+ // `components(separatedBy:)` breaks on each of them: "a\r\nb" comes
+ // back as ["a", "", "b"], one phantom empty line per CRLF. That empty
+ // line is not cosmetic — it calls `flushParagraph`, so a paragraph
+ // written across several lines arrives as one paragraph per line, and
+ // a fenced block gains a blank line between every line of code. Tool
+ // output and pasted text reach chat bubbles with CRLF intact, so this
+ // is a path real messages take.
+ let normalised = source.replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ var lines = normalised.components(separatedBy: "\n")[...]
+ 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..df02010f7d
--- /dev/null
+++ b/ios/Sources/CompanionCore/SSE.swift
@@ -0,0 +1,182 @@
+// 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
+import OSLog
+
+/// Same subsystem the app's own stream logging uses, so a session reads as
+/// one story in Console rather than two halves under different names.
+private let log = Logger(subsystem: "com.openmausbot.companion", category: "stream")
+
+/// 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.
+ //
+ // A byte at a time reads as expensive, and for a screen frame
+ // — a few hundred kilobytes of base64 PNG — it is a few
+ // hundred thousand iterations. It is not a few hundred
+ // thousand round trips, though: `AsyncBytes` iterates a buffer
+ // URLSession has already filled, so all but one call per chunk
+ // returns without suspending. What is left is call overhead on
+ // an append, against a stream whose frames are otherwise small
+ // and infrequent. Reading the chunks directly means a
+ // `URLSessionDataDelegate` and a session built around it,
+ // which is precisely the ownership the comment above says this
+ // file exists to get right — so it stays as it is until there
+ // is a Mac to prove the replacement on.
+ 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.
+ //
+ // `hello` is the exception that makes the silence
+ // dangerous. `Session` leaves `.connecting` and hydrates
+ // only when a hello arrives, and `Frame.init(from:)`
+ // requires its `cursor` — so a hello missing that field
+ // throws, is dropped here, and the app waits forever on a
+ // stream that is open and healthy, with nothing anywhere
+ // saying why. Naming the kind costs one line and turns
+ // that into something a console can answer.
+ guard let frame = try? decoder.decode(StreamFrame.self, from: data) else {
+ var kind = "unknown"
+ if let object = try? JSONSerialization.jsonObject(with: data),
+ let fields = object as? [String: Any],
+ let named = fields["kind"] as? String {
+ kind = named
+ }
+ log.error("dropped an undecodable frame (kind: \(kind, privacy: .public))")
+ 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..d953304ffd
--- /dev/null
+++ b/ios/Sources/CompanionCore/Store.swift
@@ -0,0 +1,283 @@
+// 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 }) {
+ let threadId = bots[index].threadId
+ messages.removeValue(forKey: threadId)
+ hasMore.removeValue(forKey: threadId)
+ // Everything else keyed by this bot goes too. A deleted bot
+ // whose live text survives is a thread that keeps "typing"
+ // with nothing to type into, and a retained screen frame is
+ // hundreds of kilobytes of a desktop nobody can look at any
+ // more — held for as long as the app runs, because deletion
+ // was the last event that could ever mention this id.
+ clearStream(threadId)
+ clearScreen(botId)
+ 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 }) {
+ let threadId = rooms[index].threadId
+ messages.removeValue(forKey: threadId)
+ hasMore.removeValue(forKey: threadId)
+ // Same reasoning as a deleted bot: the thread is gone, so the
+ // half-written reply streaming into it has nowhere to land.
+ clearStream(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..8dee0cd4b3
--- /dev/null
+++ b/ios/TESTING.md
@@ -0,0 +1,285 @@
+# 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 `upstreaming/9-ios-app` in `mnthr7/OpenMausMobile`.
+
+Fresh clone:
+
+```sh
+git clone https://github.com/mnthr7/OpenMausMobile
+cd OpenMausMobile
+git checkout upstreaming/9-ios-app
+```
+
+Or, if you already have the repo:
+
+```sh
+cd OpenMausMobile
+git fetch origin upstreaming/9-ios-app
+git checkout upstreaming/9-ios-app
+git pull origin upstreaming/9-ios-app
+```
+
+Sanity check — `ios/` and the companion sidecar should be present:
+
+```sh
+ls ios/Sources/CompanionCore companion/src/devices.ts companion/src/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:8811/state | jq # addresses, pairing, devices, discovery
+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 sidecar could not find the Tailscale CLI — it
+ asks once at startup, so turn the Companion toggle off and on again (or
+ restart `pnpm companion` if running it by hand) after Tailscale is up.
+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 voice, no routines.**
+
+(Two entries that used to sit on this list have since shipped: replies stream
+token by token as the provider emits them, and each bot has a computer panel —
+open it from the chat and frames arrive for exactly as long as it is on
+screen.)
+
+## 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..a706b94825
--- /dev/null
+++ b/ios/Tests/CompanionCoreTests/StoreTests.swift
@@ -0,0 +1,327 @@
+// 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")
+ // The failable initialiser rather than `String(decoding:as:)`: the
+ // latter substitutes replacement characters for anything invalid, so
+ // a decode that produced garbage would still assert equal to garbage.
+ // Here a wrong answer should be nil, and nil fails the test.
+ XCTAssertEqual(good.data.flatMap { String(bytes: $0, encoding: .utf8) }, "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/package.json b/package.json
index 56fa2175ad..c144acf36b 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"clean": "node scripts/clean.mjs",
"lint": "oxlint .",
"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",
@@ -34,13 +35,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/scripts/capture-companion-fixtures.mjs b/scripts/capture-companion-fixtures.mjs
new file mode 100644
index 0000000000..bbd9980373
--- /dev/null
+++ b/scripts/capture-companion-fixtures.mjs
@@ -0,0 +1,270 @@
+#!/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() };
+};
+
+/** The device token, once pairing has produced one. Everything the phone
+ * itself would ask for goes through the sidecar carrying this. */
+let deviceToken = "";
+const asDevice = (init = {}) => ({
+ ...init,
+ headers: { ...(init.headers ?? {}), authorization: `Bearer ${deviceToken}` },
+});
+
+/** 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(`${SIDECAR}/api/events`, asDevice({ 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);
+
+ // ── the sidecar, up front, because it is what the phone talks to ───────
+ //
+ // Every client-facing fixture below is captured *through* here rather than
+ // from the harness directly. Capturing from the harness records a response
+ // no phone ever receives: the proxy is what strips `resumeCursors`, what
+ // rewrites the SSE frames on the way past, and what refuses the routes a
+ // device may not have. Fixtures taken upstream of all that test the wrong
+ // contract, and would keep passing through exactly the change they exist to
+ // catch. Direct harness calls are kept only for setup a phone cannot do.
+ 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);
+
+ // ── what an unpaired phone sees, captured before there is a token ──────
+ console.log("writing:");
+ 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);
+
+ // ── pair, exactly as the phone does ────────────────────────────────────
+ 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)}`);
+ deviceToken = paired.body.token;
+ // 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" });
+
+ // ── a bot, and a few messages for it to have said ──────────────────────
+ const created = await json(`${SIDECAR}/api/bots`, asDevice({ 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(`${SIDECAR}/api/bots/${bot.id}/messages`, asDevice({
+ 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");
+
+ write("sse-frames", frames);
+ const hello = frames.find((f) => f.kind === "hello");
+ if (hello) write("sse-hello", hello);
+
+ // ── a room, so the paged fleet has a group in it ───────────────────────
+ //
+ // Created directly on the harness: room creation is deliberately not on
+ // the sidecar's allowlist, so this is setup the phone cannot perform —
+ // like the harness boot itself. Everything after it goes back through the
+ // sidecar. Five messages, so a capture capped at three below has a page
+ // boundary to show: the decoding test pins messages == 3 and
+ // hasMore == true, and this is what makes those numbers deterministic
+ // rather than an accident of whichever harness the fixtures were last
+ // captured against.
+ const madeRoom = await json(`${HARNESS}/api/groups`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "Fixtures", memberIds: [bot.id] }),
+ });
+ const room = madeRoom.body.group;
+ if (!room) throw new Error(`could not create a room: ${JSON.stringify(madeRoom.body)}`);
+ for (let i = 0; i < 5; i++) {
+ await fetch(`${SIDECAR}/api/groups/${room.id}/messages`, asDevice({
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ text: `room fixture message ${i}` }),
+ }));
+ await sleep(120);
+ }
+
+ // ── the shapes the app hydrates from ───────────────────────────────────
+ // messages=3 pairs with the five room messages above; see the room comment.
+ write("bots-full", (await json(`${SIDECAR}/api/bots`, asDevice())).body);
+ write("bots-paged", (await json(`${SIDECAR}/api/bots?messages=3`, asDevice())).body);
+ write(
+ "thread-page",
+ (await json(`${SIDECAR}/api/threads/${bot.threadId}/messages?limit=2`, asDevice())).body,
+ );
+ write("config", (await json(`${SIDECAR}/api/config`, asDevice())).body);
+ write("instances", (await json(`${SIDECAR}/api/instances`, asDevice())).body);
+
+ // ── and the refusal a paired device still gets ─────────────────────────
+ write("forbidden", (await json(`${SIDECAR}/api/config`, asDevice({
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ 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] },
+];
+
+// `