Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
442335385This file is pinned to the selected release. Download the original to open it in your design tool.
# Autonomous device ↔ Mac: direct discovery and original Harness E2EE
The Mac discovers Autonomous OS on the local network and connects **directly to the device**.
No manual IP address, device backend credentials, cloud device registration, or backend relay is
involved in this path. The existing Harness Mac login/start behavior is unchanged; an already
running daemon can pair and operate while its backend connection is offline. Buddy is untouched.
## Discovery and pairing
Autonomous OS already advertises `_autonomous._tcp` using Avahi. CLI uses `bonjour-service` to
browse that existing service for three seconds. It returns stable DNS-SD instance IDs and the
advertised host/port; no new Avahi service or script is needed. Metadata is untrusted discovery,
not authorization. The user selects a discovered device and enters the code displayed by it.
```sh
harness autonomous-device discover --json
harness autonomous-device pair --device '<discovery id>' --code-stdin
harness autonomous-device status --json
harness autonomous-device list --json
harness autonomous-device revoke '<full fingerprint>'
```
Desktop writes the device code to stdin and closes stdin. Terminal users can use
`pair <device-code> --device <discovery-id>`. No response echoes the code. No address entry, listen,
replace or blanket revoke command exists. A stale discovery ID returns DEVICE_NOT_FOUND; if the
selected device has not opened pairing, NO_INTENT. Wrong code fails the original PAKE and closes
the attempted socket; a retry makes a fresh socket and requests a new device intent.
CLI connects `ws://<discovered-host>:<SRV-port>/api/harness/ws`. The service may advertise port80;
its existing nginx must forward WebSocket upgrade for that exact route to OS-server. Never hardcode
OS port5000 or ask the user to type it. First message:
```json
{"type":"machine_select","payload":{"machineId":"stable-harness-machine-id","label":"Mac name"}}
```
OS responds machine_selected. During device pairing it sends original e2e_pair_intent role device;
CLI accepts the human code and delegates to the existing E2eeManager. After original PAKE, OS
sends original e2e_hello on the same direct socket. CLI waits for authentication as the exact newly
paired device before reporting success. On reconnect, OS sends e2e_hello using its stored pin.
The Mac retains only `{discoveryId,fingerprint}` association metadata in
`${ADAPTER_DATA_DIR}/autonomous-device-connections.json`; keys and trust remain exclusively in
original E2eeManager/paired.json. Every 15 seconds it rediscovers disconnected saved devices.
Discovered reconnect identity must match the saved fingerprint, even if a different identity is
already trusted elsewhere. Revocation deletes the association, closes its direct socket and stops
reconnect. Browser/dial pairings retain their existing behavior and are never blanket-revoked.
Direct connections enter the **same** daemon manager via isolated connIds and targeted sends.
Inbound direct whitelist is original device PAKE/cancel/hello/status plus Autonomous device app
RPC, never generic backend/terminal/admin handlers, setup claims or remote-password PAKE.
Pair intent/PAKE is accepted only during an explicit direct pairing attempt. Backend disconnection
drops relay sessions but preserves direct ones. Offline pairing availability is checked for the
specific pending connection, so a direct socket cannot authorize pairing an offline browser slot.
Only an authenticated application hello activates recap generation/notifications.
## Local management facade
All routes remain on the credential-checked loopback hook server. There is no new Mac LAN listener.
| Route | Input/result |
|---|---|
| GET `/api/autonomous-device/discover` | `{devices:[{id,name,host,port}]}` discovered candidates |
| POST `/api/autonomous-device/pair/start` | `{code,device:<discovery-id>}` → `{state:"paired",label,fingerprint}` |
| GET `/api/autonomous-device/pair/status` | existing pending device status idle/waiting/running |
| GET `/api/autonomous-device/status` | `{transport:"direct",connected,paired,sessions,proto:1}`; connected/sessions count authenticated application-ready direct sessions only |
| GET `/api/autonomous-device/list` | `{devices:[{id:fingerprint,fingerprint,label,pairedAt,online,role,current}]}` existing trusted device-role identities |
| POST `/api/autonomous-device/revoke` | `{id:<full fingerprint>}` → `{revoked:1}` |
| GET `/api/autonomous-device/receipt?deviceId=…&idempotencyKey=…` | existing receipt; deviceId here is canonical public key |
Discovery id and trusted fingerprint are distinct identifiers. A user chooses a discovery record
for pairing; revoke targets the exact existing trusted fingerprint. Original `harness pair` and
browser/dial UI behavior are unchanged. Direct pairing is initiated by the named facade above.
When the device revokes its own local trust, it sends the authenticated application request
`{type:"pair.revoke",requestId}` and waits for `pair.revoke_result` before closing its socket.
The CLI then removes that exact device identity and its reconnect metadata. A socket close without
this request remains `offline`, rather than being treated as a revoke, so transient LAN failures do
not unpair the device.
Revoke is bidirectional. When the app removes the device (`harness unpair`, `harness unpair --all`,
`harness autonomous-device revoke`, or the dashboard) while the device's direct session is open, the
CLI seals `{type:"pair.revoke",machineId:<this computer's machineId>}` as an `autonomous_device_event`
over that same E2EE session, then closes the socket gracefully and deletes local trust. It is
best-effort: a send failure never blocks local removal. A device that is offline at that moment
learns it on reconnect, when its pinned `e2e_hello` is answered with `e2e_denied` (`unpaired`).
## Existing encrypted wire, unchanged
Client identity and session use `cli/src/lib/e2ee/core.ts` and `manager.ts` exactly:
- `e2e_pair_intent`, `e2e_pair_intent_result`, `e2e_pair_cancel`, `e2e_pake` payload rounds 1–5;
CPace CI `autonomous-e2e-pair|agent:<machineId>|a:adapter|b:device`.
- `e2e_hello` payload `{identityPub,ephPub,sig}` using existing `helloSig`.
- `e2e_welcome` payload `{webEphPub,ephPub,sig,enc}` using existing `welcomeSig`. `enc` is AEAD
server counter 0, AAD `e2e-welcome`, plaintext `{groupKey,epoch,features}`.
- Existing X25519 sessionKeys, pairwise counters/replay window and `e2e_rekey` behavior.
- No custom canonical hello/welcome signature, challenge, `autonomous_device_finished`, or custom
identity file. The device needs its own existing E2EE identity/pin, separate from Buddy.
After original E2EE session establishment on the direct socket, application RPC uses:
```json
{"type":"autonomous_device_request","machineId":"selected-machine","payload":{"__e2e":{"v":1,"k":"p","n":0,"ct":"..."}}}
```
AAD uses existing wrapPayload: `1|autonomous_device_request||p|`; no dbSessionId on this envelope.
The encrypted payload is the full application request. CLI intercepts before frame logging,
requires an authenticated role-device session and ciphertext, and derives receipt identity from
that session, never from a client-supplied identifier. Results use outer `autonomous_device_result`
and events use outer `autonomous_device_event`, encrypted via existing wrapTarget with the same
empty dbSessionId AAD convention. Machine targeting is explicit in the inner application request. No cloud routing is involved.
First encrypted application request:
```json
{"type":"hello","requestId":"uuid","proto":1,"resume":{"serverInstanceId":"previous","cursor":4}}
```
Result plaintext: `{type:"hello_result",requestId,proto:1,machineId,serverInstanceId,capabilities,
resumed,cursor}`. Resume is optional. This is application capability/resume negotiation, not a new
cryptographic handshake. Existing session proof is the encrypted request. Then replay/resync and
normal requests below follow. Result plaintext retains the service's `<operation>_result` type.
Events retain full `{type:"event",...}` inside their encrypted outer envelope. Old browsers and
dials remain on their existing request/event protocol.
## Application requests, results and events
All requests have UUIDv4 `requestId`. Unknown fields are rejected. Responses use
`{type:"<operation>_result",requestId,...}`. Application failures before acceptance have
`error:{code,message}` without a receipt. Mutations successfully reserved have `status` and receipt;
a duplicate may return any retained receipt state. Supported operations:
| Operation | Additional request fields | Success fields |
|---|---|---|
| `focus.ensure` | none | Same snapshot as `focus.get`; enable-time first-agent fallback acknowledged by Desktop |
| `focus.get` | none | `focus:null\|{machineId,agentId,name?},focusRevision` |
| `focus.step` | `direction:"next"\|"previous",idempotencyKey,focusRevision` | Same snapshot as `focus.get`, after Desktop acknowledged the new agent — see *Stepping focus* |
| `scroll` | `phase:"down"\|"move"\|"up",dy?,velocity?` | none — see *Scrolling the focused terminal* |
| `agents.list` | none | `machineId,agents:[{machineId,agentId,name,engine,state,recap?}]` — `recap` is the agent's newest turn headline (≤200 chars, the same string `recap` returns as `turns[0].recap`); absent until a turn has been summarised |
| `status` | `machineId,agentId` | `machineId,agentId,state,openQuestion:null\|{requestId,questions}` |
| `recap` | `machineId,agentId,n?` (default 3, integer 1–5) | `machineId,agentId,turns:[{kind,text,recap?,fullText?}]` |
| `turn.send` | `machineId,agentId,idempotencyKey,text,focusRevision?` | `status,receipt` |
| `turn.stop` | `machineId,agentId,idempotencyKey` | `status,receipt` |
| `question.answer` | `machineId,agentId,idempotencyKey,questionRequestId,answers,focusRevision?` | `status,receipt` |
| `receipt.get` | `idempotencyKey` | `receipt:null\|Receipt` |
`idempotencyKey` matches `[A-Za-z0-9_-]{1,64}`. Prompt must be nonblank and ≤16 KiB UTF-8;
it is never truncated. Answers is a nonempty object mapping question keys to string answers.
Question ID must still be open. Question answering cannot approve tool permissions.
Agent state currently derives `running`/`idle` from the local registry/turn tracker.
There is no CLI selection operation: the desktop app supplies the voice target through explicit
local `app_focus {agentId}` frames. `app_focus {agentId:null}` clears the owning connection's focus;
disconnecting that connection also clears it, but an older connection cannot clear a newer owner.
Opening terminal streams does not establish voice focus. Before the first app focus, focus is null.
`focus.get` is advertised in hello capabilities and requires only a request ID. `focus.changed`
events carry the same `{focus,focusRevision}` snapshot in their payload. Revisions are opaque strings
unique across server restarts; every change of focus changes the revision, including focus A→B→A. Reannouncing the same target
keeps its revision stable while transferring ownership to the announcing connection.
A removed local agent clears focus when the snapshot is read. Remote app focus is reported with its
remote machine ID; this local-only device facade does not substitute a local target or relay dispatch.
OS can display the snapshot on Monitor Pairing and poll it to recover missed events.
Voice clients pass the snapshot's `focusRevision` with explicit `machineId,agentId` on `turn.send`
and `question.answer`. After checking retained duplicates, the service synchronously verifies both
revision and target before reserving or dispatching new work. Mismatch returns `FOCUS_CHANGED`
without a receipt: that request did not dispatch. A duplicate keeps its original receipt even if
focus moved. Requests without `focusRevision` retain legacy explicit-target behavior. `turn.stop`
does not accept the field, so stopping an existing turn remains tied to its original target.
Receipt:
```json
{"idempotencyKey":"device-1","deliveryId":"<uuid>","operation":"turn.send","state":"queued","machineId":"machine","agentId":"agent","serverInstanceId":"<uuid>","turnId":null,"error":null,"at":1757302040000}
```
States: `queued`, `delivered`, `started`, `completed`, `rejected`, `unknown`. `turnId` is local
receipt correlation generated when start is observed, not an engine-native transcript ID.
`status` is `accepted` or `duplicate`; accepted means a reservation was created, not delivery or
completion. After reservation every result carries a receipt, including revoked or failed requests.
A proven failure is `receipt.state:"rejected"`; failure details remain inside `receipt.error`.
Successful stop/answer transitions its own receipt to completed; an unconfirmed outcome is unknown.
Submit exceptions and ambiguous post-dispatch failures are unknown. Only proven no-delivery cases
may be rejected. `receipt:null` means no information, never proof a request did not run.
Dedupe is reserved synchronously before dispatch, keyed `(deviceId,idempotencyKey)`, comparing all
validated request fields except correlation IDs (normalized to null before canonical SHA-256).
Same key/different intent → `IDEMPOTENCY_CONFLICT`. Receipt capacity is 512 total. Completed and
rejected entries age out after 30 minutes from their last transition. At capacity, the oldest
completed/rejected receipt is evicted, even if younger than 30 minutes; retention is bounded by
both capacity and TTL. Outstanding/unknown entries are never silently evicted: if all 512 are
unresolved, new mutations receive `BACKPRESSURE`. Evicting these entries would permit a duplicate
live prompt; this explicit exception takes precedence over unconditional oldest-entry eviction.
A retired key may be treated as new, so never auto-resend after `receipt:null`. All receipts/events are
RAM-only. Every daemon start has a new UUID `serverInstanceId`; no mutation auto-replay is safe
across restart. Revoke clears the old device's receipts and event replay history.
Events are encrypted full objects:
```json
{"type":"event","eventId":1,"serverInstanceId":"<uuid>","machineId":"machine","agentId":"agent","kind":"receipt.updated","payload":{"receipt":{},"idempotencyKey":"device-1"}}
```
Kinds include `receipt.updated`, `turn.started`, `turn.done`, `turn.error`, `turn.summary`,
`turn.tool`, `agent.error`, `question.open`, `question.close`.
`turn.summary` payload and `recap` turn entries carry three views of one answer, and a consumer should
prefer `turns[].fullText`, then `turn.summary.fullText`, then `text`:
| Field | Limit | What it is |
|---|---|---|
| `recap` | 60 chars | the first prose sentence — a tile headline |
| `text` | 250 chars | the answer flattened to one line and clipped — a glance |
| `fullText` | 8192 bytes UTF-8 | the assistant's final message as shown on screen, markdown and line breaks intact |
`agents.list` inlines only the first of the three, so a list view has a status line per agent without one
`recap` round-trip each. Only the headline, by arithmetic again: the list is uncapped, and `text` or
`fullText` on every row would put a machine with a few dozen agents over the socket's frame limit.
`fullText` is optional and absent when no answer was recorded, so read it defensively. It holds only the
final user-facing response — never tool transcripts, hidden reasoning or terminal output — and it costs
no extra model call: it is the same text the local summarizer already receives.
The 8192-byte cap is arithmetic, not taste: the direct socket accepts 65536 bytes, `recap` may return
five turns at once, and sealed payloads grow by roughly 37% through AEAD and base64. Oversized truncation
is UTF-8 safe and marked with a trailing `…`. `text` and `recap` are unchanged byte-for-byte, and the
shared `commander_event` card is not widened — the USB dial's encoder throws above an 8 KiB frame, so the
field is added only to the events this service emits. Question-open payload is
`{questionRequestId,questions}`. Status uses `openQuestion.requestId` for that same identifier.
Only device-origin turns with known correlation include `idempotencyKey`/`turnId`.
Ring capacity 500; cursor is `(serverInstanceId,eventId)`. Matching retained cursor replays newer
events. Changed instance or stale cursor returns encrypted `{type:"resync",reason:
"instance_changed"|"cursor_too_old",serverInstanceId,cursor}`. First connect also requests resync.
OS re-reads agents/status and reconciles outstanding keys using receipt.get. Queued means wait;
delivered/started/completed means adopt; rejected means report; unknown/null means inspect and ask
before resending. Never automatically replay mutations on reconnect.
Application errors include `INVALID_REQUEST`, `UNSUPPORTED_CAPABILITY`, `MISSING_TARGET`,
`MACHINE_MISMATCH`, `AGENT_NOT_FOUND`, `NO_AGENTS`, `FOCUS_UNAVAILABLE`, `FOCUS_CHANGED`, `PAYLOAD_TOO_LARGE`, `QUESTION_STALE`,
`IDEMPOTENCY_CONFLICT`, `BACKPRESSURE`, `RATE_LIMITED`, `REVOKED`, `INTERNAL`.
A per-relay-connection token bucket permits burst 20, refilling one request/second, with at most four async
requests in flight; a new relay connection starts a new quota. Excess returns an error result.
Direct transport uses an outbound WebSocket; reconnection rediscovers the saved service identity. There is
currently no server-wide request timeout guarantee.
## Implementation and validation
`discovery.ts` browses existing mDNS with bonjour-service; `direct.ts` owns outbound sockets and
non-secret reconnect associations. `relay.ts` is the retained application/E2EE adapter name, not a
network backend dependency; it sends on the direct connId through existing manager wrapTarget.
`service.ts` retains local agent dispatch and bounded receipt/dedupe logic.
Focused CLI tests and typecheck pass, including discovered-target-only pairing, failed-attempt
fresh retry, authentication before success and reconnect identity mismatch. Real mDNS discovery and direct Go OS ↔ CLI integration passed with the backend never connected:
advertised SRV port, wrong-code/fresh retry, original PAKE/session, encrypted list/send/dedupe,
restart/reconnect and revoke/unpair. This is a real local client/server test, not physical-device deployment.
Full CLI regression passed: `npm test -- --maxWorkers=1 --testTimeout=30000 --hookTimeout=30000`
(144 files / 1,871 tests passed; 5 files / 50 tests skipped), plus `npm run typecheck`.
The default 5-second test deadline timed out in existing password/scrypt tests under local load;
the serial run uses command-line deadlines only and does not change test files or configuration.
No physical device deployment.
### Enable-time default agent
The optional negotiated `focus.ensure` capability accepts only `{type:"focus.ensure",requestId}`.
It preserves any current app focus, including focus on a remote machine; the OS remains responsible
for refusing an unsupported remote target. With no focus, it asks one connected local Desktop window
to select the first agent in the local `agents.list` order using its ordinary pane selection path.
Desktop reannounces an existing selection instead of replacing it. Only the existing `app_focus`
acknowledgment establishes authoritative focus; no task is dispatched and no headless target is invented.
Concurrent requests share the same two-second wait. Missing agents return `NO_AGENTS`; no window or
no acknowledgment returns `FOCUS_UNAVAILABLE`. The local `device_focus` frame carries an expiration
so a delayed request cannot open a pane after the wait. This operation is for enabling voice mode,
never for recovering a missing target during an utterance. Older CLIs without this capability require
an explicit app selection. `focus.get`, events, pairing, and normal turn dispatch remain unchanged.
The automatic app acknowledgment carries its original focus revision; CLI discards it if a newer explicit selection arrived while the request was in flight.
### Stepping focus
`focus.step` is one tick of the USB dial's carousel, requested by the paired device instead of a thumb:
```json
{"type":"focus.step","requestId":"<uuid>","idempotencyKey":"<uuid per gesture>","direction":"next","focusRevision":"<from focus.get>"}
```
`direction` is `next` or `previous`; `idempotencyKey` matches `[A-Za-z0-9_-]{1,64}`; `focusRevision`
is required. Success is the same `{focus,focusRevision}` snapshot `focus.get` returns, read **after**
Desktop acknowledged the new selection through its ordinary `app_focus` frame; a `focus.changed` event
carries the same snapshot, as for any other change. No headless target is invented.
The walk is the dial's: the Desktop window's open tiles in tile order (with no window, every agent in
rail order — this computer first, then other machines in wheel order), wrapping at both ends. Agents
without a tile are never stepped onto. With nothing focused, `next` starts at the first tile and
`previous` at the last. A desk of one agent answers with the current snapshot at once; nothing moves.
The move itself is the same `dial_focus` forward the cable dial uses, so the app decides what a
selection means exactly as it does for the dial.
Order of checks: a retained result for `(deviceId,idempotencyKey)` is returned first, success or
error, so a retried gesture is one tick, never two (same key with a different `direction` or
`focusRevision` → `IDEMPOTENCY_CONFLICT`). Only a new key is then checked against the current
revision: a stale `focusRevision` returns `FOCUS_CHANGED` and nothing moves. Retained results are
RAM-only, capped at 512 per daemon, evicted oldest first, and cleared by revoke like receipts.
Errors: `NO_AGENTS` when the walk is empty; `FOCUS_UNAVAILABLE` when no Desktop window is connected
or it did not acknowledge within two seconds (the daemon does not retry — read `focus.get` before
sending again); `INVALID_REQUEST` for a bad direction, key or revision.
Target limits are unchanged. The walk may step onto a tile that belongs to another machine, because
the window can show one; the local daemon then reports the app's focus leaving this machine
(`focus:null`, new revision, or the remote target if Desktop announces it here), and `turn.send`
to that target still returns `MACHINE_MISMATCH`. The OS remains responsible for showing that a
remote target is not supported for dispatch. Pairing, transport, credentials and task dispatch are
untouched; older CLIs do not list `focus.step` in hello capabilities.
### Scrolling the focused terminal
`scroll` is one report of a finger on the paired device's glass, forwarded to the terminal Desktop
has in front — the same `dial_scroll` frame the USB dial sends, so the app treats both alike:
```json
{"type":"scroll","requestId":"<uuid>","phase":"move","dy":-24}
{"type":"scroll","requestId":"<uuid>","phase":"up","dy":-3,"velocity":-900}
```
The device is a touchpad here: it reports **movement**, not a position, because it cannot know how
tall the terminal is; the terminal owns the scrollback and does the arithmetic. A stroke is sent in
pieces — `down` when the finger lands (the window stops any coasting), `move`s carrying `dy` device
pixels travelled since the last report (positive = down the glass), and `up` when it lifts, whose
`velocity` (device px/s, signed like `dy`) becomes the fling. `dy` and `velocity` default to 0 and
must be integers within ±4096 and ±100000. Send `up` for every `down`: a stroke that never closes
holds a drag open on the app side until the next `down`.
A stroke is a stream, not a mutation: there is no `idempotencyKey`, no receipt, nothing retained, and
nothing to retry — a lost `move` is a shorter scroll. Success is an empty `scroll_result`. Only the
terminal Desktop has focused scrolls; a viewer pane does not. Errors: `FOCUS_UNAVAILABLE` when no
Desktop window is connected; `INVALID_REQUEST` for a bad phase, a non-integer or out-of-range value,
or any other field. Older CLIs do not list `scroll` in hello capabilities.