Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 63 additions & 6 deletions tools/conductor-pocket/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ Pocket never overwrites or merges it silently.
The production setup is defense in depth:

1. The Node relay binds only to `127.0.0.1`.
2. Tailscale **Serve** provides private tailnet HTTPS. Funnel is refused.
2. A dedicated user-mode Tailscale node gives Pocket its own hostname, IP,
certificate, browser origin, and **Serve** configuration. Funnel is refused.
3. Tailscale's authenticated identity header is captured at pairing and must
match on every request.
4. A 192-bit, single-use pairing link expires after 15 minutes and stays in
Expand All @@ -56,6 +57,9 @@ See [SECURITY.md](./SECURITY.md) for trust boundaries and failure behavior.
- macOS with Conductor installed and running.
- Node 22.5 or newer.
- Tailscale signed into the same tailnet on the Mac and iPhone.
- Homebrew's open-source `tailscale` formula for the dedicated Pocket node.
Its background service must remain disabled; Pocket owns a private
user-level daemon, state directory, and Unix socket.
- The Mac awake and online. Conductor can continue working with its window in
the background, but it must have a window available for phone sends.
- One-time macOS Accessibility permission for the relay's Node executable.
Expand All @@ -66,21 +70,38 @@ From this directory:

```sh
npm install
brew install tailscale
npm run sidecar:install
npm run sidecar:login
npm run setup
npm run install:relay
npm run sidecar:cutover
npm run doctor
```

`sidecar:login` opens a one-time Tailscale authorization URL in the Mac browser
and exits as soon as the private daemon owns the request; it does not need an
open terminal while you approve it. If the command or chat is interrupted,
rerun it to recover the still-pending URL from the private socket. If browser
opening is unavailable, explicitly print the secret with
`npm run sidecar:login -- --print-url`. After approving the URL, rerun the
command once to verify the node is authenticated. It creates a separate node
named `conductor-pocket`; it does not log out, rename, or replace the Mac's
normal Tailscale connection. Never run `brew services start tailscale` for
this setup.

`npm run setup` prints two values:

- a single-use pairing URL to open on the iPhone;
- a six-character verification code that must match the phone.

The installer refuses to continue if any Tailscale Funnel exists or if a
Serve configuration would be overwritten. It creates a user LaunchAgent and
a private Serve proxy only after those checks. It first copies the audited
runtime into `~/.config/conductor-pocket/runtimes/`, so archiving or deleting
this source workspace cannot break the installed relay.
The relay installer first copies the audited runtime into
`~/.config/conductor-pocket/runtimes/`, so archiving or deleting this source
workspace cannot break the installed relay. The sidecar installer creates a
separate user LaunchAgent with a `0700` state directory and an explicit private
socket. Cutover refuses an unexpected daemon process or launch argument,
unsafe Tailscale preferences, extra Serve handlers, enabled Funnel state, a
different tailnet, a reused Mac node identity, or an origin mismatch.

Open the pairing URL on the iPhone while Tailscale is connected, compare the
code, enroll Face ID, then use Safari's Share → Add to Home Screen.
Expand All @@ -91,6 +112,42 @@ To pair another phone later:
npm run pair
```

## Migrating from the old shared Mac hostname

The old path-based setup must be retired, not reused. Paths on one hostname
share cookies, WebAuthn authority, IndexedDB, Cache Storage, localStorage, and
service workers.

1. Install and authenticate the dedicated sidecar.
2. Run `npm run install:relay` to install the versioned retirement client.
3. Run `npm run sidecar:cutover` once. This arms server-enforced retirement
and disables new pairing on the old origin.
4. On every old phone, fully close and reopen Pocket while online. In Security
& Devices, confirm the App row says `client 0.2.0`, then sign out **that
same phone**. Close every other Pocket window first. The client sets an
origin tombstone, stops sibling contexts, verifies it is the only remaining
browser window, and erases IndexedDB, Pocket-owned Cache Storage, and the
Pocket root service worker before the Mac records retirement.
5. Remove the old Pocket Home Screen icon.
6. Run `npm run sidecar:cutover` again.
7. Open the new one-time link, enroll a new Face ID passkey, and add the new
dedicated address to the Home Screen.

Cutover rotates the CSRF secret, pairing secret, RP ID, and public origin,
requires a versioned self-purge receipt from every original device, verifies
the exact live config revision over both loopback and HTTPS, and removes only
Pocket's old `/` handler. Remote revocation cannot satisfy retirement. Other
handlers and listeners on the Mac's normal Tailscale hostname are checked
against the complete pre-cutover Serve document and preserved exactly.

If a legacy config already has no paired devices and no retirement record,
cutover refuses to guess. Only when the origin was never paired or every old
copy was independently erased may you explicitly run:

```sh
npm run sidecar:cutover -- --attest-no-old-devices
```

## Development

An insecure loopback-only mode exists for automated browser tests:
Expand Down
52 changes: 43 additions & 9 deletions tools/conductor-pocket/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Pocket. They remain inside the processes and profiles Conductor already owns.
- Alex's logged-in macOS account and its local files.
- The installed Conductor application.
- The loopback interface.
- The authenticated Tailscale Serve proxy after its identity header is
matched to the paired identity.
- The dedicated Pocket Tailscale node and Serve proxy after its identity
header is matched to the paired identity.
- The iPhone platform authenticator after WebAuthn user verification.

### Not trusted
Expand All @@ -38,8 +38,28 @@ Pocket. They remain inside the processes and profiles Conductor already owns.
The HTTP server refuses non-loopback binds in config validation. It validates
the Host header and accepts only the configured tailnet host or explicit
loopback development hosts. Tailscale Serve strips spoofed identity headers
before adding authenticated values. The installer refuses all nonempty
Funnel configurations and will not replace an existing Serve configuration.
before adding authenticated values.

Pocket runs a second, user-mode Tailscale node with a separate node key, IP,
MagicDNS name, certificate, state directory, Unix socket, and Serve
configuration. The normal Mac node is never addressed by the formula CLI, and
every sidecar CLI command carries the explicit private socket. The sidecar
uses userspace networking, accepts neither routes nor DNS, advertises no exit
node or subnet, and exposes exactly one tailnet-only HTTPS root proxy to the
loopback relay. The loaded LaunchAgent arguments and Unix-socket owner must
match the audited daemon profile; DNS, route, SSH, web-client, connector, and
advertising preferences fail closed. Funnel and extra handlers fail
validation.

The one-time sidecar authorization URL is read back from that audited private
socket, accepted only when it is the canonical
`https://login.tailscale.com/a/…` shape, and never persisted by Pocket. It is
opened directly with macOS rather than written to command output unless the
operator explicitly requests `--print-url`. The short-lived CLI helper has
both an internal Tailscale timeout and bounded process cleanup, and Pocket
proves the daemon retained the same request after terminating it. Browser
approval therefore does not depend on a terminal or agent session remaining
alive.

### Authentication

Expand Down Expand Up @@ -93,11 +113,19 @@ Framing, object embedding, referrers, camera, microphone, location, payment,
USB, and serial access are disabled. The UI builds transcript nodes with
`textContent`; transcript Markdown is never injected as HTML.

The service worker caches only the app shell and explicitly excludes `/api/`.
The service worker handles only an allowlist of Pocket shell paths, deletes
only `conductor-pocket-shell-*` caches, and never intercepts sibling routes or
`/api/`.
Device-local transcript snapshots are bounded and are not rendered until
after Face ID unlock. Revocation instructs the connected client to purge
them; a device that never reconnects remains protected by iOS Data
Protection and the app's Face ID gate.
after Face ID unlock. During an origin migration, the server records the
original device set, disables remote revocation, and accepts only a
version-matched self-sign-out. The phone first writes a persistent origin
tombstone, prevents every Pocket context from reopening its cache, verifies
through the service worker that no other browser window remains, and deletes
Pocket's localStorage keys, IndexedDB, Cache Storage, and root service worker.
Blocked deletion fails visibly and keeps the device enrolled. Only then does
the client send its retirement receipt. A device that never reconnects remains
protected by iOS Data Protection and the app's Face ID gate.

## Residual risks

Expand All @@ -106,6 +134,11 @@ Protection and the app's Face ID gate.
the Mac user account.
- macOS Accessibility permission is broad. The relay's Node executable must
be trusted and should not be replaced by an untrusted binary.
- The dedicated Tailscale state contains a node private key. Its directory is
`0700`, files are `0600`, and FileVault remains the at-rest protection.
- Tailscale HTTPS certificate names can appear in public certificate
transparency logs. The hostname reveals the service label, not its content
or access.
- UI automation depends on Conductor's accessible structure. Version changes
fail closed: a missing workspace, session, composer, or enabled Send
control produces an error rather than a guessed click.
Expand All @@ -116,4 +149,5 @@ Protection and the app's Face ID gate.
unauthenticated app shell and pairing endpoint, but cannot pair without the
high-entropy one-time link and matching identity.
- Device-local browser storage cannot be remotely erased while the iPhone is
permanently offline. Revoke plus iOS device security is the recovery path.
permanently offline. iOS device security is the recovery boundary until
that phone reconnects and completes its own retirement purge.
4 changes: 2 additions & 2 deletions tools/conductor-pocket/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions tools/conductor-pocket/package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
{
"name": "@ovo/conductor-pocket",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"check": "node --check src/server.mjs && node --check src/security.mjs && node --check src/conductor-db.mjs && node --check src/accessibility.mjs && node --check src/tailscale-config.mjs && node --check scripts/install-relay.mjs && node --check public/app.js && node --check public/service-worker.js && npm test",
"check": "node --check src/cli.mjs && node --check src/server.mjs && node --check src/security.mjs && node --check src/conductor-db.mjs && node --check src/accessibility.mjs && node --check src/tailscale-config.mjs && node --check src/operation-lock.mjs && node --check scripts/lib/sidecar.mjs && node --check scripts/lib/login.mjs && node --check scripts/lib/cutover.mjs && node --check scripts/install-relay.mjs && node --check scripts/install-sidecar.mjs && node --check scripts/login-sidecar.mjs && node --check scripts/cutover-sidecar.mjs && node --check public/delivery-receipts.js && node --check public/app.js && node --check public/service-worker.js && npm test",
"doctor": "node --no-warnings=ExperimentalWarning src/cli.mjs doctor",
"install:relay": "node --no-warnings=ExperimentalWarning scripts/install-relay.mjs",
"pair": "node --no-warnings=ExperimentalWarning src/cli.mjs pair",
"setup": "node --no-warnings=ExperimentalWarning src/cli.mjs setup",
"sidecar:cutover": "node --no-warnings=ExperimentalWarning scripts/cutover-sidecar.mjs",
"sidecar:install": "node --no-warnings=ExperimentalWarning scripts/install-sidecar.mjs",
"sidecar:login": "node --no-warnings=ExperimentalWarning scripts/login-sidecar.mjs",
"start": "node --no-warnings=ExperimentalWarning src/cli.mjs serve",
"start:ui-fixture": "node --no-warnings=ExperimentalWarning scripts/ui-fixture.mjs",
"test": "node --no-warnings=ExperimentalWarning --test"
Expand Down
Loading
Loading