docs(runner): document authenticated phase 3 transport

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-07 23:52:43 +00:00
1 parent 313c40cd9b
commit 816aedb561
1 file changed
+59 -18
@@ -5,9 +5,12 @@ Rust `paperclip-runnerd` process is the client. The TypeScript mock core is the
remote peer. Neither side imports Paperclip server, UI, database, or shared
control-plane code.
This is a local reliability proof. The mock uses `ws://127.0.0.1` so tests do
not need certificates. A production bridge must use `wss://` and remains a
later, separately reviewed phase.
This is a local reliability and authenticated-transport proof. The mock keeps
the RFC 6455 carrier on `ws://127.0.0.1`, but PRP application frames use a
mutually authenticated, encrypted session. Loopback position and the
WebSocket acceptance header are not authentication. A production bridge should
still use `wss://` for defense in depth and remains a separately reviewed
deployment phase.
## Connection and authentication
@@ -16,23 +19,36 @@ The connection starts in this order:
1. The mock core creates a random bootstrap ticket with a five-second lifetime.
2. The ticket is passed to the runner through
`PAPERCLIP_RUNNER_BOOTSTRAP_TICKET`. It is not a command-line argument.
3. The runner opens an outbound WebSocket and sends the ticket in the HTTP
`Authorization` header.
4. The runner sends a PRP v1 `hello` envelope. It names the stable runner and
environment lease, approved runner version and digest, platform,
capabilities, and durable resume cursors.
5. The mock core consumes the ticket once and sends a `welcome` envelope. The
welcome selects PRP v1, returns a short-lived connection lease, reports the
cumulative committed event cursor, and carries at most one pending command.
6. Later connections authenticate with the connection lease. A real runner
3. The runner opens an unauthenticated WebSocket upgrade with no bearer header,
then sends only a public credential locator, a fresh client nonce, complete
runner/run/session identity, approved runner version and digest, negotiated
protocol range, and durable resume cursors.
4. The core returns a fresh server nonce plus an HMAC-SHA-256 proof over the
complete transcript. The proof binds runner, environment lease, run,
normalized session, turn, item, runner artifact, selected protocol,
credential/connection lease identity, expiry, and revocation epoch.
5. The runner validates that proof before returning its own transcript-bound
proof. The bootstrap ticket or connection lease token itself never crosses
the socket. A failed proof does not consume a one-use bootstrap ticket.
6. Both sides derive directional AES-256-GCM keys from the capability and both
nonces. Strict per-direction counters reject replays and out-of-order frames.
7. Only after mutual authentication does the core send an encrypted `welcome`.
The welcome selects PRP v1, returns a short-lived connection lease, reports
the cumulative committed event cursor, and carries at most one pending
command. Every later ACK, command, revoke, event, and command result remains
inside the encrypted session.
8. Later connections authenticate with the connection lease. A real runner
process restart receives a new one-time bootstrap ticket and loads the same
durable state before it connects.
The runner keeps the live connection lease token in memory only. Its state file
does not contain the bootstrap ticket or connection lease token. The mock core
persists only SHA-256 digests of those capabilities. A used or expired ticket is
rejected with `401`. The mock also rejects a changed runner identity, environment
lease, runner version, or runner digest.
persists domain-separated SHA-256 authentication keys instead of raw
capabilities. Used, expired, revoked, or identity-mismatched credentials fail
closed during the challenge. The runner validates the complete encrypted
welcome and every control envelope against the authenticated connection,
runner, environment lease, run, normalized session, turn, item, protocol,
lease ID, expiry, and revocation metadata before applying an ACK or command.
The daemon captures and removes the bootstrap environment variable before it
parses arguments or starts child work. Secret buffers are overwritten when they
@@ -67,13 +83,19 @@ The state contains:
- stable runner, environment lease, run, session, turn, and item IDs;
- next source event sequence and cumulative acknowledged source sequence;
- unacknowledged event envelopes and their byte counts;
- a processed-command cache with a non-secret command digest and prior result;
- a bounded recent processed-command cache with a SHA-256 command digest and
prior result, plus a fixed-size fail-closed replay filter for compacted IDs;
- lifecycle, reconnect, backpressure, harness generation, and recovery facts;
- bounded, redacted diagnostics.
Authentication capabilities and arbitrary command bodies are not stored. A
command is represented by an FNV-1a comparison digest, its stable ID and
controller sequence, the redacted result, and the logical-effect count.
recent command is represented by a SHA-256 comparison digest from the vetted
RustCrypto implementation, its stable ID and controller sequence, the redacted
result, and the logical-effect count. The exact cache keeps at most 128 entries.
Older IDs are added to a fixed 4 KiB Bloom filter before their exact records are
removed. A possible filter match is rejected with zero effects, so Bloom false
positives can reject new work but can never make an old command effective again.
The durable controller sequence remains monotonic across compaction and restart.
## Event delivery and ACKs
@@ -113,6 +135,25 @@ The trace records `logicalEffectCount`. Every completed command has exactly one
logical effect. A policy rejection, such as a new turn during drain or storage
pressure, has zero effects.
## Residual local trust and revocation window
The authenticated session removes trust in whichever process wins the configured
loopback port: a relay can forward opaque bytes, but it cannot learn a bootstrap
or lease capability, decrypt control data, or forge a welcome, ACK, command, or
revoke frame. The remaining local-host assumption is that the OS protects the
runner process memory, inherited bootstrap environment at launch, private
`0700`/`0600` state paths, and the mock core's derived authentication keys from
other same-user processes with debugging, memory-reading, or filesystem access.
An attacker with those privileges is outside this transport boundary.
Lease expiry is checked locally before control data is applied. Explicit
revocation reaches an already-connected runner through an authenticated revoke
frame; if that frame cannot be delivered, the session remains usable until the
connection closes or the runner reaches the signed lease expiry. The mock uses
a 30-second lease, so that is the maximum demonstrated revocation window. A
production core should close active sessions when it revokes a lease and choose
the lease TTL to match its required revocation bound.
## Restart and reconciliation
A socket drop keeps the same Rust process and in-memory lease. The process