feat(rc): /rc remote-control host (AETHER-AGENT-LIVE-01 R2, stacked on #107) - #108
feat(rc): /rc remote-control host (AETHER-AGENT-LIVE-01 R2, stacked on #107)#108AetherAI3 wants to merge 5 commits into
Conversation
Add the exclusive, outbound-only Remote Control host that lets a phone or browser observe the local Aether Agent session via the AETHER-CLOUD remote-session broker (ADR-0007). Viewer-only Wave 1. - src/core/remote_host.ts: RemoteHostClient — register/attach (409 = one host), durable resume cursor + outbox, host_event_id dedupe on replay, 15s heartbeat, 1s->60s jittered reconnect backoff, and fail-soft /rc off that revokes all grants without stopping the local session. Every broker call is fail-soft. - src/core/remote_redaction.ts: per-event-type payload ALLOWLIST; drops env vars, tokens, file contents, shell history, absolute paths, MCP creds, cookies, and hidden prompts before anything is queued; relativizes paths; 32 KiB frame bound. - src/ui/qr.ts: dependency-free byte-mode QR encoder for the single-use redemption URL (never a reusable bearer token). - src/commands/rc.ts + registry wiring (slash + CLI + manifest): /rc, /rc status, /rc off, optional session name; QR + viewer URL + expiry + revoke controls. Broker base URL is configurable (config baseUrl / AETHER_BASE_URL) — no prod URL hardcoded. Tests use a mock broker. Docs + operator packet inventory updated. Depends on aether-agent PR #107 (product-spine execution/preview foundations); CANNOT merge before #107. Links AETHER-CLOUD ADR-0007 / PR #1321.
Update RC host endpoint constants to R1's actual paths: base is
/remote/sessions (register posts there), with /host/attach, /host/heartbeat,
/host/events, and /revoke sub-resources; add /remote/sessions/{id}/grants and
/remote/grants/redeem constants.
- /host/events body now carries host_secret + events[] to match R1's handler.
- A 409 on /host/events is R1's EventConflictError (payload-bound idempotency,
G2), NOT a host takeover: drop the poisoned batch and keep the session,
rather than misclassify it via the shared reconnect path or requeue forever.
We never re-send changed content under an old host_event_id, so this path is
defensive.
- Capture R1's register grant_token for forward-compat; the QR still targets
the redemption/viewer URL, never the raw token.
Lease tests updated to R1 paths + new EventConflict coverage: 28/28 pass.
…ers)
Reconcile against R1's actual handler contracts (#1328):
- /host/heartbeat reuses the RemoteHostAttach model (extra="forbid"), so it
requires {device_id}; the previous empty body would 422. The host now carries
a stable enrolled device_id on register, attach, AND heartbeat, persisted with
the cursor so a resumed session presents the id the broker recorded. /revoke
takes no body model — unchanged.
- /host/events returns PER-ITEM receipts. A single {rejected:true,
reason:"redaction"} item (G4: broker dropped that one payload as secret-shaped)
is non-fatal: drop that item, count it, keep draining. Only a top-level 409
EventConflictError (G2, whole request) aborts the batch. A redacted item is no
longer misread as a batch failure.
Tests: device_id present on register/attach/heartbeat; per-item G4 drop keeps
the session active and delivers the surviving item. Lease suites 30/30 pass.
Authoritative Cloud broker handoff — do not merge against the current mockPer the Cloud/backend closure audit, #108 was not modified. Cloud #1328 is the only broker implementation, but it is not contract-authoritative or production-ready as written: its required Python check fails, its migration preview was skipped, owner UUID/project authorization and host capability binding are incomplete, and observer fanout/caps are process-local in a two-worker deployment. The current #108 wire is incompatible with #1328: it omits canonical Cloud v1 contract to consume after #1321/#1328 are corrected and frozen
Required #108 adaptation
Recommended stack: corrected #1321 → repaired #1328 → #1325 chaos/integration unskipped → #1326 viewer → #107 → rebase #108 onto #107 plus the published Cloud fixture. |
AETHER-AGENT-LIVE-01 R2 —
/rcRemote Control hostAdds the exclusive, outbound-TLS-only Remote Control host that lets a phone or browser observe the local Aether Agent session through the AETHER-CLOUD remote-session broker. Viewer-only (Wave 1); no controller, no inbound port.
Hard dependency — cannot merge before #107
This branch is stacked on #107 (
codex/product-spine-integration, head121d98dba19823766bc34d71aedc388b74836fd2) for the execution/preview foundations. This PR CANNOT merge before #107. Rebase ontomainonce #107 lands.Frozen contract: AETHER-CLOUD ADR-0007 (remote-session broker) / AETHER-CLOUD PR #1321. Host endpoints and request/response shapes reconciled against the live R1 broker handlers in AETHER-CLOUD PR #1328.
What it does
aether rc//rc— start (or resume) remote control on the current local session, optional session name./rc status— RC indicator (active / reconnecting / failed / off), session name, viewer URL, terminal QR, expiry./rc off— revoke all remote access without stopping the local session.Wire contract (reconciled against R1 / #1328)
All POST:
POST /remote/sessions— body{protocol_version, device_id, session_name?, repo?, execution}; returns session_id + host_secret + viewer_url + grant token + expires_at.POST /remote/sessions/{id}/host/attach— body carriesdevice_id(+ host_secret, last_acked_seq).POST /remote/sessions/{id}/host/heartbeat— body{device_id}(R1 reuses theRemoteHostAttachmodel,extra="forbid"; an empty body would 422).POST /remote/sessions/{id}/host/events— bodyhost_secret+events[](eachhost_event_id+payload).POST /remote/sessions/{id}/revoke— no body model ({}).POST /remote/sessions/{id}/grants· grant redeem →POST /remote/grants/redeem.device_id: the host presents a stable enrolled device id on register / attach / heartbeat, persisted with the durable cursor so a resumed session presents the id the broker recorded.
G2 (whole-request idempotency): R1 returns the original seq for a re-sent
(host_event_id, payload)pair but 409EventConflictErrorfor the same id with a different payload. The host never mutates a queued event's payload — eachpublish()mints a freshhost_event_idover an already-sanitized immutable payload — so replays after a flaky connection always re-send identical content. A top-level 409 on/host/eventsis handled distinctly from an attach/register 409 (takeover): the poisoned batch is dropped, the session survives, never misreported as a takeover or requeued forever.G4 (per-item receipts):
/host/eventsreplies with a per-item receipt array. An item marked{rejected:true, reason:"redaction"}(the broker independently dropped that one payload as secret-shaped) is non-fatal — that single item is dropped and counted, the rest of the batch advances, and the session stays active. A redacted item is never misread as a batch failure.Design invariants (from ADR-0007)
host_event_id, so re-sending never duplicates.config.baseUrl/AETHER_BASE_URL); no production URL is hardcoded.Redaction — what the host does NOT upload (explicit allowlist, enforced before anything is queued)
Event payloads are the bounded, allowlisted shapes from the frozen event set (
session/transcript/plan/subagent/tool_activity/diff_summary/tests/ci/pr_status/artifact/preview/presence/done/error) — identifiers and summaries, not raw content — capped at the 32 KiB frame bound. The broker's own G4 redaction is a defense-in-depth second gate on top of this.Files
New:
src/core/remote_host.ts,src/core/remote_redaction.ts,src/ui/qr.ts,src/commands/rc.ts, and four test files. Minimal wiring edits toslash.ts,slash_registry.ts,cli_registry.ts,command_manifest.ts. Docs:COMMANDS.md,docs/generated/commands.md(regenerated),RELEASE_NOTES.md,docs/releases/OPERATOR-PACKET-v0.3.0.md(pack inventory 612→624).Test result (REAL, on this machine)
npm test— exit 0, zero failures (whole suite, once the CI box freed up).rc_host+rc_redaction+rc_command+qr= 30/30 pass (node --teston compileddist/). Coverage includes R1-path reconciliation,device_idon register/attach/heartbeat, the G2 409 EventConflict poison-batch path, and the G4 per-item redaction drop. A real RS-generator ordering bug in the QR encoder was caught byqr.test.tsand fixed before it could ship an unscannable code.command_docs_parity+release_coherence(13/13),command_manifest+cli_registry+slash_registry+generated_docs+public_docs_truth+slash(53/53).tsc -p tsconfig.json --noEmit— clean.Reviewer checklist
npm testrun to completion — exit 0, zero failures.device_idon register/attach/heartbeat,host_secret+events[]on/host/events, empty-body/revoke, per-item G4 receipts, whole-request G2 409.mainafter Product spine: local, headless, docs, and preview #107 merges; do not merge before Product spine: local, headless, docs, and preview #107.device_idsource: this lane generates + persists a stable per-session device id (no device-enrollment plumbing exists in the CLI yet). If R1 requires a broker-issued or trusted-device id, wire that source in a follow-up.🤖 Generated with Claude Code