Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
61b864e
Add Settings → Companion, a toggle that starts the sidecar
mnthr7 Aug 16, 2026
1ada750
Add companion/: a sidecar that lets a paired phone reach the harness
mnthr7 Aug 16, 2026
13b71fa
Add ios/: the SwiftUI companion app
mnthr7 Aug 16, 2026
fb33192
Wait for the harness to exit before deleting its home
claude Aug 17, 2026
cddec78
Fail closed when a response cannot be scrubbed
claude Aug 17, 2026
88c2ee7
Serialize companion start and stop
claude Aug 17, 2026
14fec64
Refuse cross-origin state changes on the control plane
claude Aug 17, 2026
f0a17c0
Do not let a lastSeenAt write failure sign a phone out
claude Aug 17, 2026
1d595e5
Close two gaps in the companion tests
claude Aug 17, 2026
ad90204
Fold the duplicate LAN filter, make the bin runnable, correct the README
claude Aug 17, 2026
da93215
Bound the proxy: timeouts, backpressure, and a capped SSE buffer
claude Aug 17, 2026
a4986f6
Serialize RemoteListener, and stop it crashing on a late error
claude Aug 17, 2026
f0c13bc
One bearer parser, and a pairing that fails instead of half-succeeding
claude Aug 17, 2026
a962bd3
Adopt only our own sidecar, and notice when it goes away
claude Aug 17, 2026
9231871
Document the companion's exported surface
claude Aug 17, 2026
ee72fa1
Sweep the rest of the racing teardowns
claude Aug 17, 2026
33a56ad
Close the last review threads: ports, clocks, signals, fences
claude Aug 17, 2026
cc20b5a
companion: close the gaps review found in the sidecar
claude Aug 17, 2026
2847bee
companion: the rest of the review, and one thing it did not ask for
claude Aug 17, 2026
764364f
companion: keep the test's ports out of the ephemeral range
claude Aug 17, 2026
8609283
companion: wait for the request, not for 250ms
claude Aug 17, 2026
9db2578
server: stop deleting a test's home out from under a live process
claude Aug 17, 2026
84ec6f2
companion: make two guards actually guard
claude Aug 17, 2026
bed890f
companion: allocate the test's three ports in one call
claude Aug 17, 2026
ca14fef
Address CodeRabbit review on the iOS companion PR
mnthr7 Aug 17, 2026
36bf5f3
Skip the file-mode assertions on Windows
mnthr7 Aug 17, 2026
26e1d64
Address the second CodeRabbit round, and four missed in the first
mnthr7 Aug 17, 2026
19af8e4
Merge upstream main (v0.1.23) into the companion branch
claude Aug 17, 2026
e150a51
Merge upstream main (v0.1.23) into the companion branch
claude Aug 17, 2026
e049d7d
Merge upstream main into the iOS companion branch
mnthr7 Aug 17, 2026
188fa07
server: survive a write to a dying CLI's stdin on Windows
claude Aug 17, 2026
71ee914
Merge upstream main (anti-slop linting, dist-server removal)
claude Aug 17, 2026
025d4e4
Merge upstream main into the companion branch
claude Aug 17, 2026
5b8bedb
Merge upstream main (model picker redesign, teams, bot folders)
claude Aug 17, 2026
b9a215c
Merge upstream main into the iOS companion branch
mnthr7 Aug 17, 2026
56dba58
Bring the iOS testing runbook up to the sidecar layout
mnthr7 Aug 17, 2026
82b8bf7
Merge remote-tracking branch 'upstream/main' into stack/159
mnthr7 Aug 17, 2026
167ee46
companion: union the three lines of review hardening
mnthr7 Aug 17, 2026
a07d119
companion: union the three lines' test suites
mnthr7 Aug 17, 2026
4ff2f74
Merge the reconciled sidecar into the companion toggle branch
mnthr7 Aug 17, 2026
9a73b20
Merge the iOS companion app onto the rebuilt stack
mnthr7 Aug 17, 2026
860cd5e
Merge upstream main (v0.1.24) into the companion branch
mnthr7 Aug 17, 2026
f1d9130
Merge the reconciled sidecar branch (main v0.1.24)
mnthr7 Aug 17, 2026
9e8d64e
Merge branch 'stack/160' into stack/161
mnthr7 Aug 17, 2026
534b251
Make the paged-fleet fixture's room deterministic
mnthr7 Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions companion/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions companion/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading