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] }, +]; + +// `` +// maps the artwork's own coordinates onto the canvas; the group inside is +// rotated 25° about (100,100) first. +const VIEW_SCALE = 1300 / 180; +const artToCanvas = ([x, y]) => [(x - 10) * VIEW_SCALE - 260, (y - 10) * VIEW_SCALE - 140]; +const rotate = ([x, y], degrees, [cx, cy]) => { + const a = (degrees * Math.PI) / 180; + const dx = x - cx; + const dy = y - cy; + return [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)]; +}; +/** Artwork point → canvas, through the 25° group rotation. */ +const place = (point) => artToCanvas(rotate(point, 25, [100, 100])); + +// ── path handling ────────────────────────────────────────────────────── +/** M/L/Q/Z absolute, which is all build/icon.svg uses. */ +function flatten(d, steps = 32) { + const tokens = d.match(/[MLQZmlqz]|-?\d*\.?\d+/g) ?? []; + const points = []; + let cursor = [0, 0]; + let i = 0; + while (i < tokens.length) { + const command = tokens[i]; + if (command === "M" || command === "L") { + cursor = [+tokens[i + 1], +tokens[i + 2]]; + points.push(cursor); + i += 3; + } else if (command === "Q") { + const control = [+tokens[i + 1], +tokens[i + 2]]; + const end = [+tokens[i + 3], +tokens[i + 4]]; + for (let s = 1; s <= steps; s++) { + const t = s / steps; + const u = 1 - t; + points.push([ + u * u * cursor[0] + 2 * u * t * control[0] + t * t * end[0], + u * u * cursor[1] + 2 * u * t * control[1] + t * t * end[1], + ]); + } + cursor = end; + i += 5; + } else { + i += 1; + } + } + return points; +} + +/** A rounded rect as a polygon, in its own coordinates. */ +function roundedRect({ x, y, w, h, r }, steps = 10) { + const points = []; + const corners = [ + [x + w - r, y + r, -90, 0], + [x + w - r, y + h - r, 0, 90], + [x + r, y + h - r, 90, 180], + [x + r, y + r, 180, 270], + ]; + for (const [cx, cy, from, to] of corners) { + for (let s = 0; s <= steps; s++) { + const a = ((from + ((to - from) * s) / steps) * Math.PI) / 180; + points.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]); + } + } + return points; +} + +/** A stroked polyline as a fillable polygon: one side up, the other back, + * with round caps approximated by the joint circles below. */ +function strokeToPolygons(points, width) { + const half = width / 2; + const polys = []; + for (let i = 0; i < points.length - 1; i++) { + const [x0, y0] = points[i]; + const [x1, y1] = points[i + 1]; + const dx = x1 - x0; + const dy = y1 - y0; + const len = Math.hypot(dx, dy); + if (len === 0) continue; + const nx = (-dy / len) * half; + const ny = (dx / len) * half; + polys.push([ + [x0 + nx, y0 + ny], + [x1 + nx, y1 + ny], + [x1 - nx, y1 - ny], + [x0 - nx, y0 - ny], + ]); + } + // round joins and caps + for (const [x, y] of points) { + const circle = []; + for (let a = 0; a < 24; a++) { + const t = (a / 24) * Math.PI * 2; + circle.push([x + Math.cos(t) * half, y + Math.sin(t) * half]); + } + polys.push(circle); + } + return polys; +} + +/** Even-odd would hole out overlapping stroke quads, so: coverage by union. */ +function rasterise(polygons, width) { + const mask = new Uint8Array(width * width); + for (const poly of polygons) { + const ys = poly.map((p) => p[1]); + const top = Math.max(0, Math.floor(Math.min(...ys))); + const bottom = Math.min(width - 1, Math.ceil(Math.max(...ys))); + for (let y = top; y <= bottom; y++) { + const sy = y + 0.5; + const xs = []; + for (let i = 0; i < poly.length; i++) { + const [x0, y0] = poly[i]; + const [x1, y1] = poly[(i + 1) % poly.length]; + if (y0 === y1) continue; + if (sy < Math.min(y0, y1) || sy >= Math.max(y0, y1)) continue; + xs.push(x0 + ((sy - y0) / (y1 - y0)) * (x1 - x0)); + } + xs.sort((a, b) => a - b); + for (let i = 0; i + 1 < xs.length; i += 2) { + const from = Math.max(0, Math.ceil(xs[i] - 0.5)); + const to = Math.min(width - 1, Math.floor(xs[i + 1] - 0.5)); + for (let x = from; x <= to; x++) mask[y * width + x] = 1; + } + } + } + return mask; +} + +const sample = (stops, t) => { + const clamped = Math.min(1, Math.max(0, t)); + for (let i = 0; i < stops.length - 1; i++) { + if (clamped <= stops[i + 1].at) { + const a = stops[i]; + const b = stops[i + 1]; + const local = (clamped - a.at) / (b.at - a.at || 1); + return a.color.map((c, k) => c + (b.color[k] - c) * local); + } + } + return stops[stops.length - 1].color; +}; + +// ── draw ─────────────────────────────────────────────────────────────── +const big = SIZE * SS; +const toBig = ([x, y]) => [(x / 1024) * big, (y / 1024) * big]; + +const bodyPoly = flatten(MASCOT).map(place).map(toBig); +const eyePolys = EYES.map((eye) => + roundedRect(eye) + .map((p) => rotate(p, eye.spin, eye.about)) + .map(place) + .map(toBig), +); +const smilePolys = strokeToPolygons( + flatten(SMILE).map(place).map(toBig), + // stroke width travels through the same scale the artwork does + (SMILE_WIDTH * VIEW_SCALE * big) / 1024, +); + +const bodyMask = rasterise([bodyPoly], big); +const faceMask = rasterise([...eyePolys, ...smilePolys], big); + +// the mascot's linear gradient runs 18%,10% → 84%,94% of its own bounding box +const bx = bodyPoly.map((p) => p[0]); +const by = bodyPoly.map((p) => p[1]); +const bounds = { x0: Math.min(...bx), x1: Math.max(...bx), y0: Math.min(...by), y1: Math.max(...by) }; +const gradFrom = [bounds.x0 + (bounds.x1 - bounds.x0) * 0.18, bounds.y0 + (bounds.y1 - bounds.y0) * 0.1]; +const gradTo = [bounds.x0 + (bounds.x1 - bounds.x0) * 0.84, bounds.y0 + (bounds.y1 - bounds.y0) * 0.94]; +const gradVec = [gradTo[0] - gradFrom[0], gradTo[1] - gradFrom[1]]; +const gradLenSq = gradVec[0] ** 2 + gradVec[1] ** 2; + +// the tile's radial gradient: 68%,36% of the canvas, radius 92% +const tileCentre = [big * 0.68, big * 0.36]; +const tileRadius = big * 0.92; + +const rgb = Buffer.alloc(SIZE * SIZE * 3); +for (let y = 0; y < SIZE; y++) { + for (let x = 0; x < SIZE; x++) { + let acc = [0, 0, 0]; + for (let sy = 0; sy < SS; sy++) { + for (let sx = 0; sx < SS; sx++) { + const px = x * SS + sx; + const py = y * SS + sy; + const index = py * big + px; + let colour; + if (faceMask[index] && bodyMask[index]) { + colour = FACE; + } else if (bodyMask[index]) { + const t = + ((px - gradFrom[0]) * gradVec[0] + (py - gradFrom[1]) * gradVec[1]) / (gradLenSq || 1); + colour = sample(MASCOT_STOPS, t); + } else { + const d = Math.hypot(px - tileCentre[0], py - tileCentre[1]) / tileRadius; + colour = sample(TILE_STOPS, d); + } + acc = acc.map((c, i) => c + colour[i]); + } + } + const at = (y * SIZE + x) * 3; + const samples = SS * SS; + rgb[at] = Math.round(acc[0] / samples); + rgb[at + 1] = Math.round(acc[1] / samples); + rgb[at + 2] = Math.round(acc[2] / samples); + } +} + +// ── PNG ──────────────────────────────────────────────────────────────── +let CRC_TABLE = null; +function crc32(buf) { + if (!CRC_TABLE) { + CRC_TABLE = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + CRC_TABLE[n] = c; + } + } + let c = -1; + for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return c ^ -1; +} + +function encodePng(pixels, size) { + const raw = Buffer.alloc((size * 3 + 1) * size); + for (let y = 0; y < size; y++) { + raw[y * (size * 3 + 1)] = 0; // filter: none + pixels.copy(raw, y * (size * 3 + 1) + 1, y * size * 3, (y + 1) * size * 3); + } + const chunk = (type, data) => { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body) >>> 0); + return Buffer.concat([length, body, crc]); + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // truecolour, no alpha + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +mkdirSync(OUT_DIR, { recursive: true }); +writeFileSync(join(OUT_DIR, "icon-1024.png"), encodePng(rgb, SIZE)); +writeFileSync( + join(OUT_DIR, "Contents.json"), + JSON.stringify( + { + images: [{ filename: "icon-1024.png", idiom: "universal", platform: "ios", size: "1024x1024" }], + info: { author: "xcode", version: 1 }, + }, + null, + 2, + ) + "\n", +); +console.log(`wrote ${SIZE}×${SIZE} icon to ios/App/Assets.xcassets/AppIcon.appiconset/`); diff --git a/server/branching.test.ts b/server/branching.test.ts index b8fc1ea282..736cd9100f 100644 --- a/server/branching.test.ts +++ b/server/branching.test.ts @@ -9,11 +9,13 @@ // // Same POSIX gating as comms.test.ts (the fake CLI is a shebang script). import { spawn, type ChildProcess } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { removeTempDir, waitForExit } from "./testing/cleanup.ts"; + const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const FAKE_CLI = join(SERVER_DIR, "testing", "fake-acp-cli.ts"); @@ -112,13 +114,8 @@ posixOnly("conversation branching e2e (fake ACP fleet)", () => { }, 30_000); afterAll(async () => { - child?.kill("SIGTERM"); - await new Promise((resolve) => { - if (!child || child.exitCode !== null) return resolve(); - child.on("close", () => resolve()); - setTimeout(() => (child.kill("SIGKILL"), resolve()), 5_000).unref?.(); - }); - rmSync(home, { recursive: true, force: true }); + await waitForExit(child, { signal: "SIGTERM" }); + await removeTempDir(home); }); it( diff --git a/server/comms.test.ts b/server/comms.test.ts index d79bd16b8f..d07e356736 100644 --- a/server/comms.test.ts +++ b/server/comms.test.ts @@ -10,13 +10,15 @@ // turned it into `node
+ + ); + } + + if (!state) { + return ( + + + + ); + } + + const secondsLeft = state.pairing ? Math.max(0, Math.round((state.pairing.expiresAt - now) / 1000)) : 0; + // Prefer the tailnet when there is one: it survives changing wifi, works + // away from home, and gets through a guest network that isolates its + // clients — the case where a LAN address looks perfectly correct and + // reaches nothing. The name beats the address because a phone can only + // use the name. + const tailnet = state.tailnetName ?? state.tailscale; + const address = tailnet ?? state.addresses?.[0]; + + return ( +
+ +
+
+
{state.enabled ? "On" : "Off"}
+
+ {!state.enabled + ? "Nothing on this computer is reachable from the network." + : !address + ? `Listening on port ${state.port} — no network address yet.` + : tailnet + ? `Enter ${tailnet}:${state.port} on your phone — that works from anywhere on your tailnet, on any network.` + : state.discovery?.advertising + ? // The address is shown even when Bonjour is working. + // Discovery can be advertising happily and still not + // reach the phone — a guest network that isolates its + // clients blocks multicast — and when that happens the + // typed address is the way out. Hiding it behind a + // failure the panel cannot detect is no help at all. + `Your phone will find this computer as "${state.discovery.name}", or you can enter ${address}:${state.port}.` + : `Listening on ${address}:${state.port} — enter that on your phone.`} +
+
+ +
+ {state.enabled && tailnet && state.lan && ( +
+ On this network only: {state.lan}:{state.port} +
+ )} + {/* A tailnet address with no name is workable on a laptop and not on + a phone, so say so rather than letting pairing fail with an + unexplained policy error. */} + {state.enabled && state.tailscale && !state.tailnetName && ( +
+ You're on a tailnet, but this computer's MagicDNS name couldn't be read from the + Tailscale app — either MagicDNS is off, or the Tailscale command line tool isn't + where we looked. iPhones can't connect to a bare tailnet address, so check the + OpenMausBot log for which paths were tried. +
+ )} + {state.enabled && !state.tailscale && ( +
+ Only reachable on this network. Install Tailscale on both this computer and your phone + to reach it from anywhere — including networks that stop devices from seeing each other. +
+ )} + {(error || state.error) && ( +
{error ?? state.error}
+ )} +
+ + {state.enabled && ( + + {state.pairing ? ( +
+
+
{state.pairing.code}
+
+ Expires in {secondsLeft}s{address ? ` · ${address}:${state.port}` : ""} +
+
+ +
+ ) : ( + + )} +
+ )} + + + {state.devices.length > 0 && ( +
    + {state.devices.map((device) => ( +
  • + +
    +
    {device.name}
    +
    Last seen {relative(device.lastSeenAt)}
    +
    + +
  • + ))} +
+ )} +
+
+ ); +} + +const cnSwitch = (on: boolean) => + `relative h-6 w-11 shrink-0 rounded-full transition-colors disabled:opacity-40 ${on ? "bg-accent" : "bg-raised"}`; +const cnKnob = (on: boolean) => + `absolute top-[3px] h-[18px] w-[18px] rounded-full bg-white transition-all ${on ? "left-[21px]" : "left-[3px]"}`; diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index a2f8223bdf..def148dd6b 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -3,12 +3,13 @@ // is the stuff shared by every bot: who you are, your keys, and the // machine your bots can borrow. import { useEffect, useRef, useState } from "react"; -import { KeyRound, Monitor, Terminal, User, Volume2, X } from "lucide-react"; +import { KeyRound, Monitor, Smartphone, Terminal, User, Volume2, X } from "lucide-react"; import { useStore, type AppSettingsSection } from "@/state/store"; import { ApiKeyRow } from "./ApiKeys"; import { useUpdaterState } from "@/lib/updater"; import { EnginesSettings } from "./EnginesSettings"; import { LocalComputerSection } from "./LocalComputerSection"; +import { CompanionSection } from "./CompanionSection"; import { Card } from "./SettingsPrimitives"; import { VoiceSettings } from "./VoiceSettings"; import { cn } from "@/lib/cn"; @@ -17,6 +18,7 @@ const SECTIONS: Array<{ id: AppSettingsSection; label: string; icon: typeof User { id: "general", label: "General", icon: User }, { id: "connections", label: "Connections", icon: KeyRound }, { id: "engines", label: "Engines", icon: Terminal }, + { id: "companion", label: "Companion", icon: Smartphone }, { id: "computer", label: "Local VM", icon: Monitor }, { id: "voice", label: "Voice", icon: Volume2 }, ]; @@ -221,6 +223,8 @@ export function SettingsModal() { )} + {section === "companion" && } + {section === "voice" && } {section === "computer" && } diff --git a/src/state/store.tsx b/src/state/store.tsx index e606cd36ca..97ca5b52d0 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -224,7 +224,13 @@ export interface InstanceInfo { cliCandidates?: string[]; } -export type AppSettingsSection = "general" | "connections" | "engines" | "voice" | "computer"; +export type AppSettingsSection = + | "general" + | "connections" + | "engines" + | "companion" + | "voice" + | "computer"; interface AppState { bots: Bot[]; diff --git a/tsconfig.companion.build.json b/tsconfig.companion.build.json new file mode 100644 index 0000000000..605ea236c9 --- /dev/null +++ b/tsconfig.companion.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.server.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist-companion", + "rootDir": "companion/src", + "rewriteRelativeImportExtensions": true, + "declaration": false, + "sourceMap": false + }, + "include": [ + "companion/src" + ] +} diff --git a/tsconfig.server.build.json b/tsconfig.server.build.json index e5a0372b8e..0003a53f3b 100644 --- a/tsconfig.server.build.json +++ b/tsconfig.server.build.json @@ -7,5 +7,11 @@ "declaration": false, "sourceMap": false }, - "exclude": ["server/**/*.test.ts", "server/testing"] + "exclude": [ + "server/**/*.test.ts", + "server/testing" + ], + "include": [ + "server" + ] } diff --git a/tsconfig.server.json b/tsconfig.server.json index 187dbea3e6..5bc50a266d 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -1,7 +1,9 @@ { "compilerOptions": { "target": "ES2023", - "lib": ["ES2023"], + "lib": [ + "ES2023" + ], "module": "NodeNext", "moduleResolution": "NodeNext", "allowImportingTsExtensions": true, @@ -12,7 +14,12 @@ "skipLibCheck": true, "isolatedModules": true, "noEmit": true, - "types": ["node"] + "types": [ + "node" + ] }, - "include": ["server"] + "include": [ + "server", + "companion" + ] } diff --git a/vite.config.ts b/vite.config.ts index 263a6e81b2..8044254075 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,12 @@ export default defineConfig({ plugins: [react(), tailwindcss()], test: { environment: "node", - include: ["server/**/*.test.ts", "electron/**/*.test.mjs", "src/**/*.test.ts"], + include: [ + "server/**/*.test.ts", + "electron/**/*.test.mjs", + "src/**/*.test.ts", + "companion/**/*.test.ts", + ], setupFiles: ["server/testing/setup.ts"], // the suite spawns fake provider CLIs and a real harness server; // parallel files introduce load-sensitive flakes for no win