feat(rc): viewer-host safety primitives, ported from #108 with refits (RC-02) - #147
Merged
Conversation
… (RC-02) The security-critical core of the RC viewer host, built against current main rather than merged from PR #108 (OPEN, draft, c7ac06d, DIRTY, 75 files). Provenance and rejection map are in docs/specs/2026-09-06-rc-02-viewer-host.md. device_runtime/identity.ts gains loadEnrollmentMetadata(). loadEnrollment() returns the full EnrollmentRecord including device_token and device_command_key; RC is the process that publishes observation events and must hold neither. The projection is field-by-field rather than a rest spread, so a future secret added to EnrollmentRecord cannot ride along silently. This is refit §5.1: PR #108 defaulted to a synthetic `dev_<uuid>`, which is a self-asserted string that authenticates nothing. rc/receipts.ts decides when the durable cursor may move. PR #108's validator checked batch coverage, duplicate ids, the rejected flag, and positive safe integers — necessary, not sufficient. It would have accepted sequences running backwards within a batch, sequences at or below the cursor the host already recorded, and a receipt carrying a digest for different bytes. The first two let a broker replay stale sequence numbers to make the host drop events it never really stored; the third is the RC_EVENT_ID_CONFLICT binding. All three are now checked, and every ambiguity resolves to preserve-the-batch: re-sending is cheap and deduped broker-side, while advancing wrongly loses a viewer's session permanently. The validator returns a reason rather than a boolean so the host can say honestly why it did not advance. rc/viewer_profile.ts mirrors the Cloud projection merged as 39a16029. Exactly ["observe"], no `controller` presence role, and `transcript` excluded — v1 is a structured-event view, and bounded summaries need a per-session exposure choice that does not exist yet. The control-vocabulary tokenizer splits snake_case, camelCase, PascalCase, dotted and kebab from the start: the Cloud side shipped a tokenizer that treated `emergency_stop` as one opaque word and its own tests caught it, so this mirror begins with the fix. rc/redaction.ts is PR #108's allowlist sanitizer, which was good code and is ported nearly intact. Two changes: the event vocabulary is imported from viewer_profile rather than restated, so the allowlist and the projection cannot drift; and `transcript` is gone with its entry. Detector reuse is unchanged — SENSITIVE_KEY, redactEnvValues and redactInline still come from core/redaction.ts, so a new detector there protects this sink too. Tests: 60, including a source-read guard asserting no RC module references loadEnrollment, device_token, or device_command_key — the same technique child_env.test.ts uses — plus the stale-cursor replay case, the digest-mismatch case, control vocabulary in six naming styles, and a seeded token asserted absent from sanitizer output. SCOPE: this is the primitives layer. The host client itself (transport, outbox persistence, heartbeat, attach/reattach) and the `aether rc` command surface are the next commit in this lane. Refits §5.2 (custody-or-do-not- activate) and §5.4 (failed revoke_pending is a hard quarantine — PR #108 calls persist() there without checking its return) land with the host, since neither is meaningful without it.
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lane
RC-02— the Agent Remote Control viewer host. Built againstmain@28403f0e, not merged from PR #108.Spec:
docs/specs/2026-09-06-rc-02-viewer-host.md· Cloud counterpart:viewer_profile.py(RC-01, merged39a16029).Supersedes #108— see §3 of the spec for the provenance and rejection map. PR #108 is OPEN, draft,c7ac06d8,DIRTY, 75 files.What's here
device_runtime/identity.tsloadEnrollmentMetadata()— identity fields, never the secretsrc/receipts.tsrc/viewer_profile.tsrc/redaction.tsThe three refits worth reviewing
§5.1 — canonical enrollment.
loadEnrollment()returns the fullEnrollmentRecordincludingdevice_tokenanddevice_command_key. RC publishes observation events and must hold neither, so it gets a projection — field-by-field, deliberately not a rest spread, so a future secret added toEnrollmentRecordcan't ride along silently. PR #108 instead defaulted to a syntheticdev_<uuid>: a self-asserted string that authenticates nothing.§5.3 — receipts. PR #108's validator checked batch coverage, duplicate ids, the
rejectedflag, and positive safe integers. Necessary, not sufficient — it would have accepted:The first two let a broker replay stale sequence numbers to make the host drop events it never really stored. The third is the
RC_EVENT_ID_CONFLICTbinding. All three are checked now, and every ambiguity resolves to preserve the batch — re-sending is cheap and deduped broker-side, while advancing wrongly loses a viewer's session permanently. The validator returns a reason, not a boolean, so the host can say honestly why it didn't advance.Digest reuse, not a fourth spelling.
payloadDigestdelegates todevice_runtime/canonical_json.ts. AETHER-CLOUD already carries three functions namedcanonical_digestthat disagree on non-ASCII input; adding a fourth on the boundary that decides whether a viewer's events get dropped would be the worst possible place for it.What I kept from #108, and why
remote_redaction.tswas good code — a real allowlist with per-event-type keys, path relativization, and detector reuse fromcore/redaction.ts. It's ported nearly intact. Two changes: the event vocabulary is now imported fromviewer_profile.tsinstead of restated, so the allowlist and the projection can't drift; andtranscriptis gone with its entry.The tokenizer starts with the Cloud fix already applied: RC-01 shipped one that treated
emergency_stopas a single opaque word and its own tests caught it. No reason to repeat that here.Test plan
Includes a source-read guard asserting no RC module references
loadEnrollment,device_token, ordevice_command_key— the techniquechild_env.test.tsalready uses — plus the stale-cursor replay case, digest mismatch, control vocabulary in six naming styles, and a seeded token asserted absent from sanitizer output.CI is authoritative for the full suite.
⚠ Scope: this is the primitives layer, not the whole lane
Not here: the host client (transport, outbox persistence, heartbeat, attach/reattach) and the
aether rccommand surface.Consequently refits §5.2 and §5.4 are not implemented yet — neither is meaningful without the host:
revoke_pendingwrite must be a hard local quarantine — note PR feat(rc): /rc remote-control host (AETHER-AGENT-LIVE-01 R2, stacked on #107) #108 callsthis.persist()there without checking its return, so that gap is real and still openNothing is wired into a command or the session yet, so this PR changes no runtime behaviour. It lands the pieces the host will be built on, each independently tested, rather than a 559-line port reviewed in one gulp.
READY_FOR_MERGE=true·MERGED=false·ENABLED=false