Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.
Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay: native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent: host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e: browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth: Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web: browser login + Web Crypto E2E + client-side preview rendering
Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.
NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
993 lines
76 KiB
Markdown
993 lines
76 KiB
Markdown
# P2 — Host Agent (`agent/`) — Implementation Plan
|
||
|
||
> **Plan of the 6** (see [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — the coordination
|
||
> point / analog of [`src/types.ts`](../src/types.ts)). This plan owns the **`agent/` package only**.
|
||
> **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 LOCKED DECISIONS
|
||
> (AUTHORITATIVE, CLOSED) + §3 host-agent lifecycle + §6 distribution.
|
||
> **Style**: [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md) — stable task
|
||
> IDs grouped into dependency waves, an `Owns:` disjoint-file list per task, function-signature-level
|
||
> contracts, TDD (tests FIRST), explicit test cases (incl. security/negative), per-task security notes.
|
||
> **All FROZEN SHARED CONTRACTS are cited by INDEX §-number and NEVER redefined here** — the agent
|
||
> `import`s them read-only from `relay-contracts/`.
|
||
> `PROGRESS_LOG.md` is **orchestrator-only** (subagents return a ready-to-paste entry, G1).
|
||
|
||
---
|
||
|
||
## 0. Scope & non-scope
|
||
|
||
**In scope (the `agent/` package, EXPLORE §3 "Host-agent lifecycle" + §6 "Distribution"):**
|
||
|
||
1. **Enrollment / pairing redemption** — INDEX **§4.5**: `npx web-terminal-agent pair ABCD-1234` generates
|
||
an **Ed25519 keypair LOCALLY** (private key never leaves the host, INV4), redeems a **single-use,
|
||
short-TTL** pairing code, receives the frozen §4.5 `EnrollResult`
|
||
`{ host_id, subdomain, cert, caChain, hostContentSecret }` (FIX 3 — the last field wrapped to the agent
|
||
identity, unwrapped + stored locally for recoverable replay).
|
||
2. **Outbound dial + mTLS** — `wss://relay/agent` on 443 (CGNAT-friendly, EXPLORE §3), mutually
|
||
authenticated with the per-host key + short-lived **auto-rotating** cert (INV14).
|
||
3. **Registration** — relay writes `route:{host_id}` in Redis (P3 side); the agent proves key
|
||
possession and holds the link.
|
||
4. **Holding the §4.1 mux tunnel** — one physical outbound `wss://` carrying N sessions × M mirrored
|
||
clients + heartbeat; frame demux/encode via the **frozen §4.1 codec** (imported from
|
||
`relay-contracts/`, never re-implemented).
|
||
5. **Forwarding logical streams to the UNCHANGED web-terminal** at `ws://127.0.0.1:3000` — one
|
||
`OPEN` ⇒ one fresh loopback socket, replaying the real browser `Origin` (§4.1 `MuxOpen.originHeader`).
|
||
6. **Agent-side E2E endpoint** — INDEX **§4.4**: the host side of the handshake (`host_hello`, transcript
|
||
`sig` under the enrollment key) yielding a **`HandshakeResult` with direction-split `DirectionalKeys`
|
||
(`c2h`/`h2c`)** (FIX 2 — **no** single `sessionKey: CryptoKey`), driven by P4's `createHostHandshake`
|
||
with the **injected** device-auth-proof verifier (FIX 6b); per-stream **synchronous** `sealFrame`/
|
||
`openFrame` over `AeadKey`; and — distinct from live `h2c` frames — sealing every **replay-bound**
|
||
host→client output under the recoverable `K_content` via `sealReplayFrame` (FIX 3). The agent wires the
|
||
**P4 `relay-e2e/` crypto core** (imported, not written here). The agent is one E2E endpoint; plaintext
|
||
exists **only on the customer's own machine**.
|
||
7. **Reconnection / backoff** — reuse the **1/2/4…cap-30s** policy the base app already ships
|
||
(EXPLORE §3; mirrors `public/` reconnect), with jitter; 15s heartbeat miss ⇒ tunnel dead ⇒ reconnect.
|
||
8. **Distribution** — `npx web-terminal-agent pair` (MVP) → single static binary (`bun --compile`);
|
||
**install-as-service** (launchd on macOS, systemd on Linux).
|
||
|
||
**Explicitly NOT in scope (owned by other plans — do not touch):**
|
||
- The **§4.1 frame codec itself**, stream-id allocation, per-stream flow-control *protocol*, and the
|
||
relay data plane → **P1 TRANSPORT** (`term-relay/`). The agent is a **consumer** of §4.1.
|
||
- **Account/host registries, pairing-code issuance, subdomain assignment, the mTLS CA/cert signing,
|
||
routing table** → **P3 CONTROL PLANE**. The agent submits a CSR and *installs* the returned cert.
|
||
- **The E2E crypto primitives** (`sealFrame`/`openFrame`, handshake state machine, HKDF) → **P4
|
||
`relay-e2e/`**. The agent wires them into its splice.
|
||
- **Capability-token issuance/verification, human auth, the CI cross-tenant tripwire, revocation
|
||
policy/DB** → **P5**. The agent only *reacts* to revocation (tears the tunnel down) and never verifies
|
||
capability tokens (the relay authorized the stream before it reached the agent — §4.1 OPEN note).
|
||
- **Browser UI, client-side preview render** → **P6**.
|
||
- **`src/` and `public/`** — byte-for-byte unchanged. The single base-app touch-point (`ALLOWED_ORIGINS`
|
||
gains the subdomain) is applied **by the agent installer at install time as a config/env write**, not
|
||
a code edit (INDEX §0, EXPLORE §3 "one base-app touch-point").
|
||
|
||
---
|
||
|
||
## 1. SECURITY INVARIANTS this plan enforces (INDEX §3)
|
||
|
||
Per INDEX §3, this plan lists the invariants it owns and **ships a test per owned invariant**:
|
||
|
||
| INV | How `agent/` enforces it | Owning task(s) |
|
||
|---|---|---|
|
||
| **INV1** — cross-tenant isolation (agent-side **defense-in-depth**; primary enforcement is P1/P5) | `handleOpen` compares `MuxOpen.subdomain` (§4.1) against the agent's own enrolled `AgentConfig.subdomain` **before** dialing loopback; any mismatch ⇒ RST the stream, never dial, audit-log (metadata-only, INV10). The one place the agent can catch a relay that cross-wires host B's OPEN into host A's tunnel. | T8 |
|
||
| **INV2** — relay handles only ciphertext | Agent seals output → §4.1 `DATA` before it enters the tunnel; the relay only ever sees §4.4 `E2EEnvelope` bytes. Live frames use the ephemeral `DirectionalKeys.h2c`; **replay-bound** output is ALSO sealed under the recoverable `K_content` (`sealReplayFrame`, FIX 3) — still ciphertext to the relay. Plaintext lives only host-local (loopback ↔ agent). Handshake is device-auth-proof-gated (T15 aborts on a forged `ClientHello`, no key derived). | T15, T19 |
|
||
| **INV4** — no shared secrets; per-host asymmetric identity; DB stores only public keys | Ed25519 keypair generated **locally**; private key persisted `0600`, **never transmitted** (only pubkey + CSR leave); mTLS proves possession without sending the key. | T3, T4, T12 |
|
||
| **INV5** — no plaintext/secret at rest (agent-local) | Private key + cert stored with restrictive perms; raw pairing code **never written to disk**; no plaintext shell bytes buffered (pure shuttle). | T3, T4, T8 |
|
||
| **INV9** — secrets in env/secret-manager, validated at startup, never logged | Config validated with Zod at boot; fail-fast on missing/invalid key material; structured logger redacts key/cert/code fields; no `console.log`. | T2, T18 |
|
||
| **INV11** — no terminal parsing in the agent (ciphertext/byte-shuttle discipline) | The agent moves **opaque** bytes; imports **no** xterm/ANSI parser; even after E2E `openFrame` the decrypted bytes are treated as opaque to the loopback WS. | T7, T8, T15 |
|
||
| **INV12** — fast revocation kills live tunnels in seconds | On a `'revoked'` `GOAWAY` (distinguished from the graceful `'operatorDrain'`/`'shutdown'` reasons via the **frozen** 3-value `relay-contracts` `GoAwayReason`/`decodeGoAwayReason`, never a local numeric guess; unknown reason ⇒ fail-closed to revoked) or cert-renewal refusal, the agent tears down all streams + the tunnel immediately and stops reconnect for a `revoked` host. | T13, T14 |
|
||
| **INV14** — mTLS with SPIFFE-style short-lived, auto-rotating certs | Dial uses the client cert; a rotation timer renews **before** expiry via the P3 renewal endpoint; expired/absent cert ⇒ no dial. | T12, T13 |
|
||
|
||
**Cross-cutting discipline** (INDEX §0, coding-style): immutable snapshots (tunnel/stream state swapped
|
||
atomically, never mutated in place); many small files (200–400 lines, 800 hard max); high cohesion / low
|
||
coupling; TypeScript, `camelCase` fns / `PascalCase` types / string-literal unions over enums; **Zod
|
||
validation at every boundary** (pairing response, `MuxOpen`, config); explicit error handling (no
|
||
swallowed errors); **no `console.log`**; TDD 80%+ coverage.
|
||
|
||
---
|
||
|
||
## 2. Files & ownership (the `agent/` package — disjoint from all other plans)
|
||
|
||
```
|
||
agent/
|
||
package.json T1 scaffold (bin: web-terminal-agent)
|
||
tsconfig.json T1
|
||
vitest.config.ts T1
|
||
src/
|
||
cli.ts T5 arg parse → pair | run | status | install | uninstall
|
||
config/agentConfig.ts T2 AgentConfig Zod schema + load/validate + paths
|
||
transport/seams.ts T2 W0 shared seam types: WsLike, RevocationState, RevokeReason (cycle breaker)
|
||
keys/identity.ts T3 Ed25519 keygen, load, fingerprint (enroll_fpr, §4.2)
|
||
keys/keystore.ts T3 0600 on-disk read/write of key + cert (INV5)
|
||
enroll/pair.ts T4 §4.5 REDEEM/RETURN client; CSR build
|
||
enroll/csr.ts T4 PKCS#10 CSR from the Ed25519 key
|
||
transport/dial.ts T12 outbound mTLS wss dial (client cert)
|
||
transport/tunnel.ts T7 holds one §4.1 tunnel: demux loop, encode, GOAWAY
|
||
transport/streamRouter.ts T8 streamId → loopback socket map; OPEN/DATA/CLOSE/RST
|
||
transport/loopback.ts T8 dial ws://127.0.0.1:3000<path>, replay Origin, splice
|
||
transport/heartbeat.ts T9 PING/PONG every 15s; miss ⇒ dead
|
||
transport/backoff.ts T10 1/2/4…cap-30s + jitter reconnection policy
|
||
transport/flowControl.ts T11 per-stream credit (§4.1 WINDOW_UPDATE) consumer
|
||
transport/frpScaffold.ts T6 v0.8 frpc-wrap stepping-stone (retired at v0.9)
|
||
e2e/hostEndpoint.ts T15 §4.4 host side: host_hello + seal/open splice transform
|
||
e2e/replaySeal.ts T19 §4.4 replay surface: K_content seal of replay-bound output (FIX 3)
|
||
certs/rotation.ts T13 short-lived cert renewal timer (INV14)
|
||
lifecycle/revocation.ts T14 GOAWAY/revoke → teardown, refuse reconnect (INV12)
|
||
service/install.ts T17 dispatch by platform
|
||
service/launchd.ts T17 macOS plist writer + load/unload
|
||
service/systemd.ts T17 Linux unit writer + enable/start
|
||
service/originConfig.ts T17 writes ALLOWED_ORIGINS touch-point (base-app config)
|
||
dist/buildBinary.ts T16 bun --compile config + npx wrapper
|
||
log/logger.ts T2 structured redacting logger (no console.log, INV9)
|
||
test/
|
||
<mirrors src, one *.test.ts per module>
|
||
fixtures/ shared test vectors (pairing resp, MuxOpen frames)
|
||
```
|
||
|
||
**Imports (read-only) from other plans' frozen artifacts** — never redeclared here:
|
||
- `relay-contracts/` (INDEX §4): `encodeMuxFrame`/`decodeMuxFrame`, `MuxFrameHeader`, `MuxOpen`,
|
||
`HostRecord`, `HostStatus`, pairing Zod schemas + **§4.5 `EnrollResult`** (now carrying `hostContentSecret`,
|
||
FIX 3); the **frozen §4.4 E2E surface** cited verbatim — `AeadAlg`, `AeadKey` (opaque wrapper, **not**
|
||
`CryptoKey`), `ClientHello`, `HostHello`, `E2EEnvelope`, `DirectionalKeys` (`c2h`/`h2c`), `HandshakeResult`,
|
||
`E2ESession`, `createE2ESession`, and the **synchronous** `sealFrame`/`openFrame`; the **§4.4 replay surface**
|
||
`ReplayKeyParams`/`deriveContentKey`/`sealReplayFrame`/`REPLAY_KDF_INFO` (FIX 3); and the **frozen §4.1**
|
||
`GoAwayReason` (`'operatorDrain' | 'revoked' | 'shutdown'` string-literal union) + `decodeGoAwayReason`
|
||
(numeric reason-code → union: `1→operatorDrain · 2→revoked · 3→shutdown`, else throws), consumed **verbatim**
|
||
by both P1 (sender) and P2/T14 (receiver); the agent never invents any of these shapes or the numeric mapping.
|
||
- `relay-e2e/` (P4): the crypto **impls** re-exported for §4.4 — `sealFrame`, `openFrame`, `sealReplayFrame`,
|
||
`deriveContentKey`, and the **host-handshake wiring `createHostHandshake({ verifyDeviceProof, … })`**.
|
||
**FIX 6b:** the device-auth-proof **verifier is P5-owned** and reaches the agent **injected through P4's
|
||
`createHostHandshake`** — the agent **does NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`** (that
|
||
old T15 import was wrong). The verifier is bound to `{ clientEphPub, clientNonce }` (§4.4 `ClientHello`),
|
||
unified across P2/P4/P5. (`agent/e2e/hostEndpoint.ts` + `agent/e2e/replaySeal.ts` wire these; they contain
|
||
**no** crypto primitives and **no** local verifier.)
|
||
|
||
**Intra-`agent/` shared seam (NOT a cross-plan contract):** `agent/src/transport/seams.ts` (T2, W0) declares
|
||
`WsLike`, `RevocationState`, `RevokeReason` **once**; T7/T10/T12/T14 import them (kills type drift and the
|
||
former T10↔T14 task cycle). Frozen cross-plan contracts still live only in `relay-contracts/`.
|
||
|
||
`agent/` **owns none of** `relay-contracts/`, `term-relay/`, `relay-e2e/`, `relay-web/`, `src/`, `public/`.
|
||
|
||
---
|
||
|
||
## 3. Waves & tasks
|
||
|
||
```
|
||
W0 scaffold + boundaries T1 pkg · T2 config+logger+seams · T3 identity/keystore
|
||
│ (T2 seams.ts freezes WsLike + RevocationState at W0 —
|
||
│ the cycle-breaker every W2/W3 transport task imports)
|
||
▼
|
||
W1 enrollment (v0.8→v0.9) T4 pairing/CSR · T5 CLI · T6 frp scaffold (v0.8 only)
|
||
│
|
||
▼
|
||
W2 native tunnel (v0.9) T7 tunnel · T8 streamRouter+loopback · T9 heartbeat
|
||
T10 backoff · T11 flow-control (all consume P1 §4.1)
|
||
│ (T10 consumes only the W0 isRevoked seam — NO edge to T14)
|
||
▼
|
||
W3 mTLS + lifecycle (v0.9/v0.10) T12 mTLS dial · T13 cert rotation · T14 revocation/GOAWAY
|
||
│ (T14 wires T10's loop one-way; depends on T7/T13, not T10)
|
||
▼
|
||
W4 E2E endpoint (v0.10) T15 host_hello + seal/open splice · T19 replay-frame sealer (K_content)
|
||
│ (both consume P4 relay-e2e/; HARD GATE: BLOCKED until the injected
|
||
│ device-proof verifier + forged-proof vector land — Q#2)
|
||
▼
|
||
W5 distribution + accept T16 static binary · T17 service install+origin · T18 acceptance
|
||
```
|
||
|
||
**Wave-ordering integrity (no back-edges):** every task depends **only on earlier or same-wave** tasks.
|
||
The former T10↔T14 cycle (and the W2→W3 back-edge it implied) is **removed**: the revocation short-circuit
|
||
is an **injected `isRevoked: () => boolean` seam frozen in W0 `seams.ts`**, so T10 (W2) depends on T2 (W0)
|
||
alone for it, and T14 (W3) wires T10's loop in a single forward direction (W3 consuming a W2 function is
|
||
legal; the reverse is not). A builder can topologically order the plan as written.
|
||
|
||
**Cross-plan dependency ordering (INDEX §2):** `relay-contracts/` (§4) is program-W0 and must be frozen
|
||
before this plan's W0 — **including the frozen §4.1 3-value `GoAwayReason`** (`operatorDrain`/`revoked`/`shutdown`) consumed by T14.
|
||
**P1 TRANSPORT** §4.1 must exist before W2 (in v0.8 the T6 frp scaffold lets W1/W5 proceed against a stub;
|
||
the native-mux swap in v0.9 is contract-compatible by construction). **P3 CONTROL PLANE** `/enroll` +
|
||
cert-renewal + `/agent` endpoints must exist before W1 REDEEM (T4) and W3 dial (T12/T13) integrate — unit
|
||
tests mock them. **P4 `relay-e2e/`** must exist before W4 (T15/T19) — and specifically P5's frozen
|
||
device-proof verifier **wired into P4's `createHostHandshake`** (FIX 6b — P2 consumes it injected, never
|
||
imports it) + the cross-plan forged-proof test vector is a **hard pre-W4 gate** (open Q#2), not a soft
|
||
follow-up.
|
||
|
||
---
|
||
|
||
### W0 · Scaffold + boundaries
|
||
|
||
#### T1 · Package scaffold `[ ]` — v0.8
|
||
- **Owns**: `agent/package.json`, `agent/tsconfig.json`, `agent/vitest.config.ts`
|
||
- **Depends**: `relay-contracts/` frozen (INDEX §4). **Parallel-safe**: must be first in this plan.
|
||
- **Steps (TDD-lite; config task)**:
|
||
- [ ] `package.json` with `"bin": { "web-terminal-agent": "dist/cli.js" }`; deps: `ws`, `zod`,
|
||
`relay-contracts` (workspace), `relay-e2e` (workspace, W4-only); dev: `typescript`, `tsx`,
|
||
`vitest`, `@types/ws`, `@types/node`. **No xterm / ANSI parser dep** (INV11 static guarantee).
|
||
- [ ] `tsconfig.json` (ES2022, NodeNext, strict, `noEmit` off, outDir `dist`); path alias to
|
||
`relay-contracts`.
|
||
- [ ] `vitest.config.ts` with `coverage.include: ['src/**']`, node environment.
|
||
- [ ] smoke test: `test/scaffold.test.ts` asserts `relay-contracts` imports resolve and **no**
|
||
transitive dep matches `/xterm|ansi|vt100/` (INV11 tripwire).
|
||
- **Accept**: `npm --prefix agent test` runs (smoke green); `tsc --noEmit` clean.
|
||
- **Security**: INV11 dependency tripwire lives here.
|
||
|
||
#### T2 · Config + redacting logger + shared seams `[ ]` — v0.8
|
||
- **Owns**: `agent/src/config/agentConfig.ts`, `agent/src/log/logger.ts`, `agent/src/transport/seams.ts`,
|
||
`agent/test/agentConfig.test.ts`, `agent/test/logger.test.ts`, `agent/test/seams.test.ts`
|
||
- **Depends**: T1. **Parallel-safe**: with T3.
|
||
- **Contracts**:
|
||
```ts
|
||
export interface AgentConfig {
|
||
readonly relayUrl: string // wss://relay.<domain>/agent — validated wss:// only
|
||
readonly enrollUrl: string // https://<domain>/enroll — https:// only
|
||
readonly stateDir: string // where key/cert live (default ~/.web-terminal-agent)
|
||
readonly localTargetUrl: string // ws://127.0.0.1:3000 (the UNCHANGED web-terminal)
|
||
readonly subdomain: string | null // filled after pairing
|
||
readonly hostId: string | null // filled after pairing (§4.2)
|
||
}
|
||
export const AgentConfigSchema: ZodType<AgentConfig> // wss/https/ws enforced by refine
|
||
export function loadAgentConfig(env: NodeJS.ProcessEnv, argv: Partial<AgentConfig>): AgentConfig
|
||
// logger — redacts key/cert/code/token fields; no console.log
|
||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||
export function createLogger(level: LogLevel): { log(l: LogLevel, msg: string, meta?: Record<string, unknown>): void }
|
||
```
|
||
**`agent/src/transport/seams.ts` — W0 shared injection seams (DEPENDENCY-CYCLE BREAKER).** These are
|
||
intra-`agent/` seam *types only* (no cross-plan frozen contract lives here — those stay in
|
||
`relay-contracts/`). Declaring them at W0 lets W2/W3 tasks consume a stable type **without a task-level
|
||
cycle** (see the T10↔T14 note in §3):
|
||
```ts
|
||
// Minimal WS surface the transport layer needs (dial/tunnel/backoff share it; no per-task redeclare).
|
||
export interface WsLike {
|
||
send(d: Uint8Array): void
|
||
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
|
||
close(): void
|
||
}
|
||
// Revocation seam — T14 IMPLEMENTS it, T10's reconnectLoop only CONSUMES the isRevoked() callback.
|
||
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
|
||
export interface RevocationState { isRevoked(): boolean; markRevoked(reason: RevokeReason): void }
|
||
```
|
||
Both `WsLike` and `RevocationState`/`RevokeReason` are **defined once here** and imported by T7/T10/T12/T14;
|
||
they are **not** redeclared in any W2/W3 task (kills the drift and the cycle at the type level).
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: `relayUrl='http://…'` → throws (only `wss://`); `localTargetUrl` non-loopback host → throws
|
||
(INV: agent forwards **only** to loopback, never an arbitrary target — anti-SSRF).
|
||
- [ ] RED: logger given `{ privateKey, cert, pairingCode, agentToken }` meta → asserts those values
|
||
never appear in emitted string (INV9).
|
||
- [ ] GREEN: implement with Zod `.refine`.
|
||
- **Test cases**: valid config parses; missing `relayUrl` → fail-fast; `localTargetUrl=ws://10.0.0.5:3000`
|
||
→ rejected (loopback-only); `localTargetUrl=ws://127.0.0.1:3000` → ok; logger redaction of each secret
|
||
field; `debug` meta with a `nonce` key passes through (not a secret); **seams**: `seams.ts` exports
|
||
`WsLike`, `RevocationState`, `RevokeReason` and is `import`-able with **no runtime dependency** (type-only
|
||
file — a test asserts the compiled module has no side effects and no `ws`/crypto import), so W2/W3 can
|
||
depend on it without pulling transport runtime.
|
||
- **Security**: INV9 (validated at startup, never logged), loopback-only target (anti-SSRF); the seam file
|
||
is types-only (no secret handling).
|
||
|
||
#### T3 · Agent identity + keystore `[ ]` — v0.9 (contract stub usable in v0.8)
|
||
- **Owns**: `agent/src/keys/identity.ts`, `agent/src/keys/keystore.ts`,
|
||
`agent/test/identity.test.ts`, `agent/test/keystore.test.ts`
|
||
- **Depends**: T1, T2. **Parallel-safe**: with T2.
|
||
- **Contracts** (uses `node:crypto` `generateKeyPairSync('ed25519')`; **no** third-party crypto):
|
||
```ts
|
||
export interface AgentIdentity {
|
||
readonly publicKey: Uint8Array // Ed25519 raw pubkey → stored in host registry (§4.2 agent_pubkey)
|
||
readonly enrollFpr: string // §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by browser E2E TOFU
|
||
sign(message: Uint8Array): Uint8Array // uses private key IN-PROCESS; key never returned/serialized off-host
|
||
}
|
||
export function generateIdentity(): AgentIdentity // private key held in-memory only
|
||
export function computeEnrollFpr(publicKey: Uint8Array): string
|
||
// keystore — persists ONLY to stateDir with 0600 (INV4/INV5)
|
||
export interface Keystore {
|
||
saveIdentity(id: AgentIdentity): void // writes private key PEM 0600 + pubkey
|
||
loadIdentity(): AgentIdentity | null
|
||
saveCert(certPem: string, caChainPem: string): void // 0600
|
||
loadCert(): { certPem: string; caChainPem: string } | null
|
||
saveContentSecret(secret: Uint8Array): void // 0600 — FIX 3: the §4.5 hostContentSecret, UNWRAPPED with the enrollment key
|
||
loadContentSecret(): Uint8Array | null // input to §4.4 deriveContentKey (ReplayKeyParams); never logged/egressed
|
||
}
|
||
export function openKeystore(stateDir: string): Keystore
|
||
```
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: `generateIdentity()` twice → distinct pubkeys; `sign`/verify round-trips against
|
||
`crypto.verify`.
|
||
- [ ] RED: `computeEnrollFpr` is deterministic + matches the §4.2 `enroll_fpr` derivation P4/P6 pin.
|
||
- [ ] RED: `saveIdentity` then read the file mode → **`0600`** (INV5); a world-readable dir triggers
|
||
a hard error, not a silent write.
|
||
- [ ] RED (security): **assert there is no code path that serializes the private key into any network
|
||
payload** — `AgentIdentity` exposes only `publicKey`, `enrollFpr`, `sign` (INV4). Grep test:
|
||
no export/return of raw private-key bytes.
|
||
- [ ] GREEN.
|
||
- **Test cases**: keygen uniqueness; sign/verify; fpr determinism + cross-checks fixture P4/P6 will pin;
|
||
keystore perms `0600`; reload identity yields same pubkey/fpr; corrupt key file → typed error;
|
||
**negative**: no API returns/serializes the private key; **FIX 3**: `saveContentSecret`/`loadContentSecret`
|
||
round-trip the `hostContentSecret` bytes, file mode `0600`, and the secret never appears in any log line.
|
||
- **Security**: **INV4** (private key never leaves host — this is the load-bearing task), **INV5** (0600 at
|
||
rest — key/cert **and** the FIX 3 `hostContentSecret`). Cross-check `enroll_fpr` derivation with P4 (§4.4
|
||
TOFU pin) and P3 (§4.2 storage).
|
||
|
||
---
|
||
|
||
### W1 · Enrollment (v0.8 skeleton → v0.9 real Ed25519)
|
||
|
||
#### T4 · Pairing redemption + CSR `[ ]` — v0.9 (§4.5) — v0.8 ships a token-gate variant
|
||
- **Owns**: `agent/src/enroll/pair.ts`, `agent/src/enroll/csr.ts`,
|
||
`agent/test/pair.test.ts`, `agent/test/csr.test.ts`
|
||
- **Depends**: T3 (identity), T2 (config); **cross-plan**: P3 `/enroll` endpoint (§4.5 BIND/RETURN) —
|
||
mocked in unit tests. **Parallel-safe**: with T5/T6.
|
||
- **Contracts** — implements INDEX **§4.5** steps 2–5 verbatim (agent side), validating the response with
|
||
the **frozen pairing Zod schema** from `relay-contracts/`:
|
||
```ts
|
||
// §4.5 step 3 REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5)
|
||
// §4.5 step 5 RETURN → the FROZEN `EnrollResult` — IMPORTED from relay-contracts, NOT redeclared here (FIX 3).
|
||
// Frozen shape (INDEX §4.5): { hostId, subdomain, cert, caChain, hostContentSecret } — the last field is
|
||
// the FIX 3 host-scoped secret, WRAPPED to this agent's enrolled Ed25519 identity (agent_pubkey) by P3 at BIND.
|
||
import type { EnrollResult } from 'relay-contracts'
|
||
export function buildCsr(id: AgentIdentity, subject: string): string // PKCS#10 over the Ed25519 key
|
||
export function redeemPairingCode(
|
||
enrollUrl: string, code: string, id: AgentIdentity, ks: Keystore, fetchImpl?: typeof fetch,
|
||
): Promise<EnrollResult>
|
||
// On success (FIX 3): UNWRAP EnrollResult.hostContentSecret with the enrollment private key (in-process, INV4)
|
||
// and persist the UNWRAPPED secret 0600 via ks.saveContentSecret (T3) — the WRAPPED bytes are never stored,
|
||
// the unwrapped secret is never logged or re-sent (INV5/INV9). Feeds §4.4 deriveContentKey (T19).
|
||
```
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: `redeemPairingCode` sends `{ code, agentPubkey, csr }`; assert the **private key is NOT in
|
||
the request body** (INV4) — only pubkey + CSR.
|
||
- [ ] RED: raw `code` is **never written to `stateDir`** and never logged (INV5/INV9).
|
||
- [ ] RED: malformed/HTTP-error `/enroll` response → typed `EnrollError` (no swallow); response not
|
||
matching the frozen §4.5 schema → reject (boundary validation).
|
||
- [ ] RED (single-use): a `409 already-redeemed` from P3 → surfaced as `PairingCodeSpentError`
|
||
(agent must not retry-spam a spent code).
|
||
- [ ] RED (**FIX 3**): `EnrollResult.hostContentSecret` (wrapped to the agent identity) is UNWRAPPED with
|
||
the enrollment private key and stored `0600` via `Keystore.saveContentSecret` (T3); assert the
|
||
**wrapped** bytes are never persisted and the unwrapped secret is never logged (INV5/INV9).
|
||
- [ ] GREEN.
|
||
- **Test cases**: happy path returns the frozen §4.5 shape `{hostId, subdomain, cert, caChain,
|
||
hostContentSecret}` and keystore stores cert `0600` **and the unwrapped `hostContentSecret` `0600`**; body
|
||
omits private key; expired-code (`410`) → `PairingCodeExpiredError`; spent-code (`409`) →
|
||
`PairingCodeSpentError`; schema-mismatch response → rejected; `enrollUrl` non-https → refused (T2 guard).
|
||
**v0.8 variant**: when no Ed25519 flow is enabled, `redeemPairingCode` degrades to presenting the shared
|
||
`agentToken` (still hashed server-side by P3) — behind an explicit `EnrollMode = 'token' | 'ed25519'`
|
||
string-literal union; a test asserts `'ed25519'` is the default from v0.9.
|
||
- **Security**: **INV4** (only pubkey+CSR leave), **INV5** (raw code not at rest), boundary validation on
|
||
the enroll response (untrusted external data), single-use respect.
|
||
|
||
#### T5 · CLI entrypoint `[ ]` — v0.8 skeleton → extended each wave
|
||
- **Owns**: `agent/src/cli.ts`, `agent/test/cli.test.ts`
|
||
- **Depends**: T2, T3, T4 (and later T7/T13/T17 handlers). **Parallel-safe**: with T4/T6.
|
||
- **Contracts**:
|
||
```ts
|
||
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
|
||
export interface CliArgs { readonly command: CliCommand; readonly code?: string; readonly flags: Readonly<Record<string, string | boolean>> }
|
||
export function parseArgs(argv: readonly string[]): CliArgs
|
||
export function runCli(args: CliArgs, deps: CliDeps): Promise<number> // returns process exit code
|
||
```
|
||
`runCli` dispatches: `pair <code>` → T4 redeem → keystore + config write → optional T17 install;
|
||
`run` → T7 tunnel loop; `status` → read keystore/config, print host_id/subdomain/online (no secrets);
|
||
`install`/`uninstall` → T17. Dependencies injected (`CliDeps`) so tests avoid real network/FS.
|
||
- **TDD**: RED parse `pair ABCD-1234` → `{command:'pair', code:'ABCD-1234'}`; unknown command → nonzero
|
||
exit + typed error (no throw to bare stack); `status` prints **no** key/cert material (INV9); missing
|
||
`code` on `pair` → usage error. GREEN.
|
||
- **Test cases**: arg parsing matrix; `pair` happy path calls redeem then install (mocked); `status`
|
||
redaction; unknown command exit code; `run` before pairing → "not enrolled" error (fail-fast).
|
||
- **Security**: INV9 (status shows no secrets); fail-fast when unenrolled.
|
||
|
||
#### T6 · frp scaffold (v0.8 stepping-stone) `[ ]` — v0.8 ONLY (retired at v0.9)
|
||
- **Owns**: `agent/src/transport/frpScaffold.ts`, `agent/test/frpScaffold.test.ts`
|
||
- **Depends**: T2, T5. **Parallel-safe**: with T4/T5.
|
||
- **Rationale (EXPLORE §5 "wrap `frpc` as the agent", INDEX §1 v0.8)**: fastest path to the café demo;
|
||
wraps `frpc` (child process) presenting the shared `agentToken`, registering `subdomain`, forwarding to
|
||
`127.0.0.1:3000`. **Explicitly a stepping-stone** — the native mux (T7–T11) replaces it in v0.9; the
|
||
§4.1 frame path is designed so ciphertext (T15) drops in without reshaping.
|
||
- **Contracts**:
|
||
```ts
|
||
export interface FrpScaffold { start(): Promise<void>; stop(): Promise<void>; onExit(cb: (code: number) => void): void }
|
||
export function spawnFrpc(cfg: AgentConfig, frpcPath: string): FrpScaffold // generates frpc.toml, spawns child
|
||
```
|
||
- **TDD**: RED generated `frpc.toml` has `local_ip=127.0.0.1 local_port=3000`, `subdomain=<cfg>`, tls
|
||
enabled; child crash → `onExit` fires and (paired with T10) triggers backoff. GREEN with a mocked
|
||
spawn.
|
||
- **Test cases**: toml generation; child-exit propagation; **negative**: never forwards to a non-loopback
|
||
`local_ip`. **Retirement test**: a v0.9 guard test asserts `frpScaffold` is not imported by `run`
|
||
once `EnrollMode==='ed25519'` (prevents shipping both substrates).
|
||
- **Security**: loopback-only forward; the shared token is a **v0.8-only** shortcut (INDEX §1 — "must reach
|
||
real accounts before the first paid signup").
|
||
|
||
---
|
||
|
||
### W2 · Native mux tunnel (v0.9) — consumes P1 §4.1
|
||
|
||
#### T7 · Tunnel holder `[ ]` — v0.9
|
||
- **Owns**: `agent/src/transport/tunnel.ts`, `agent/test/tunnel.test.ts`
|
||
- **Depends**: T2 (W0 `seams.ts` `WsLike`), T3, dial (T12) at integration; **cross-plan**: P1 §4.1 codec
|
||
(`encodeMuxFrame`/`decodeMuxFrame`) from `relay-contracts/`. **Parallel-safe**: with T8–T11 (disjoint
|
||
files, shared frozen §4.1 interface).
|
||
- **Contracts** (imports §4.1 `MuxFrameHeader`, `MuxFrameType`, codec — **never** re-declared; imports
|
||
`WsLike` + the frozen `GoAwayReason` from `relay-contracts/`):
|
||
```ts
|
||
import { WsLike } from './seams' // W0 shared seam (T2)
|
||
import { GoAwayReason } from 'relay-contracts' // FROZEN §4.1 3-value union: 'operatorDrain'|'revoked'|'shutdown'
|
||
export interface Tunnel {
|
||
send(header: MuxFrameHeader, payload: Uint8Array): void // encodeMuxFrame → socket
|
||
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
|
||
goAway(lastStreamId: number, reason: GoAwayReason): void // §4.1 GOAWAY — reason is the FROZEN union, not a raw int
|
||
close(): void
|
||
}
|
||
export function holdTunnel(socket: WsLike, router: StreamRouter, heartbeat: Heartbeat): Tunnel
|
||
```
|
||
The demux loop: `decodeMuxFrame` each inbound frame → dispatch by `type` to `router` (OPEN/DATA/
|
||
CLOSE/RST) or `heartbeat` (PING/PONG) or connection-level (GOAWAY/WINDOW_UPDATE streamId 0). **Illegal
|
||
transitions** (DATA before OPEN, DATA after CLOSE, unknown streamId) ⇒ **RST that stream, never the
|
||
tunnel** (§4.1 stream lifecycle).
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: feed a byte-exact §4.1 frame fixture → `onFrame` yields the decoded header+payload.
|
||
- [ ] RED: DATA on an unknown `streamId` → agent emits a `CLOSE` with `flags.RST` for that stream and
|
||
the **tunnel stays up** (§4.1).
|
||
- [ ] RED: **INV11** — tunnel imports no ANSI/xterm parser; DATA payload passes through opaque.
|
||
- [ ] RED: a `GOAWAY` frame → in-flight streams allowed to finish, no new OPEN accepted (drain).
|
||
- [ ] GREEN.
|
||
- **Test cases**: round-trip encode→decode using the §4.1 fixture; RST-on-illegal-transition (DATA
|
||
before OPEN, DATA after CLOSE, dup streamId); GOAWAY drain; oversize `payloadLen > maxFrameBytes` →
|
||
frame rejected (boundary guard, safe-int); opaque-passthrough (INV11).
|
||
- **Security**: INV11 (opaque), robust framing (malformed frames can't crash the tunnel or bleed streams).
|
||
|
||
#### T8 · Stream router + loopback forwarder `[ ]` — v0.9
|
||
- **Owns**: `agent/src/transport/streamRouter.ts`, `agent/src/transport/loopback.ts`,
|
||
`agent/test/streamRouter.test.ts`, `agent/test/loopback.test.ts`
|
||
- **Depends**: T7; T2 (loopback target). **Parallel-safe**: with T9–T11.
|
||
- **Contracts** (validates `MuxOpen` with the **frozen §4.1 Zod schema** from `relay-contracts/`):
|
||
```ts
|
||
export interface StreamRouter {
|
||
handleOpen(open: MuxOpen): void // one OPEN ⇒ one loopback socket — AFTER the subdomain boundary check
|
||
handleData(streamId: number, payload: Uint8Array): void
|
||
handleClose(streamId: number, rst: boolean): void
|
||
activeStreamCount(): number
|
||
}
|
||
export function createStreamRouter(cfg: AgentConfig, tunnel: Tunnel, dialLoopback: DialLoopback, transform: FrameTransform): StreamRouter
|
||
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
|
||
// dials ws://127.0.0.1:3000<path>, sets header `Origin: <origin>` from §4.1 MuxOpen.originHeader
|
||
export function dialLoopback(target: string): DialLoopback
|
||
// FrameTransform: identity in v0.9 (plaintext passthrough); replaced by the E2E codec in v0.10 (T15)
|
||
export interface FrameTransform {
|
||
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null // tunnel→local (decrypt in v0.10)
|
||
outbound(streamId: number, plain: Uint8Array): Uint8Array // local→tunnel (encrypt in v0.10)
|
||
openStream(streamId: number): void; closeStream(streamId: number): void
|
||
}
|
||
export const identityTransform: FrameTransform // v0.9: inbound/outbound = passthrough
|
||
```
|
||
**Per-stream state is a fresh allocation** — **no global mutable buffers** (EXPLORE §4b failure-mode #3;
|
||
cross-tenant buffer bleed is impossible because each stream owns its socket + transform state). Closing a
|
||
stream (CLOSE/RST) frees the paired loopback socket (§4.1).
|
||
|
||
**MANDATORY subdomain boundary check (defense-in-depth against a compromised/buggy/misconfigured relay).**
|
||
Per §4.1, authorization is the relay's job (P5) and there is exactly **one physical tunnel per host**, so a
|
||
correctly-functioning relay never delivers a foreign-tenant `OPEN` here. But the whole premise is *don't
|
||
fully trust the relay* — the agent is the last place that can catch a cross-wired frame. Therefore, the
|
||
**first** thing `handleOpen` does — before allocating any state or dialing loopback — is compare the frozen
|
||
`MuxOpen.subdomain` (§4.1) against this agent's own enrolled `AgentConfig.subdomain` (§4.2, filled at
|
||
pairing):
|
||
|
||
```ts
|
||
// handleOpen — first statement, before any allocation or dial:
|
||
if (open.subdomain !== cfg.subdomain) {
|
||
// never proceed to dial; RST this stream and audit-log the mismatch (metadata only, INV10 — no payload)
|
||
tunnel.send(rstHeaderFor(open.streamId), EMPTY) // §4.1 CLOSE + flags.RST
|
||
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
|
||
return
|
||
}
|
||
```
|
||
|
||
This is a one-field comparison; it closes an otherwise-silent cross-tenant-bleed vector (host B's OPEN
|
||
spliced into host A's tunnel) at the single point in the whole system where the agent can detect it. It
|
||
**complements, never replaces**, relay-side authz (INV1, owned by P1/P5) — belt-and-suspenders.
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: `handleOpen` dials `ws://127.0.0.1:3000` at `MuxOpen.requestPath` **replaying
|
||
`MuxOpen.originHeader`** as the `Origin` header (so the UNCHANGED base app's Origin check passes
|
||
against `ALLOWED_ORIGINS` — EXPLORE §3).
|
||
- [ ] RED: loopback output bytes → `transform.outbound` → `tunnel.send(DATA)`; tunnel DATA →
|
||
`transform.inbound` → loopback write (v0.9 passthrough).
|
||
- [ ] RED: CLOSE/RST frees the loopback socket; loopback close → agent sends CLOSE upstream.
|
||
- [ ] RED (**INV5/isolation**): two concurrent streams get **separate** socket + buffer objects; a
|
||
marker written on stream A never appears on stream B's socket (per-connection allocation).
|
||
- [ ] RED (**INV1 defense-in-depth / cross-tenant boundary**): an `OPEN` whose `MuxOpen.subdomain !==
|
||
cfg.subdomain` is **RST immediately and NEVER dialed** — assert `dialLoopback` is not called, the
|
||
stream is RST, and a metadata-only mismatch is logged (INV10). This is the load-bearing negative
|
||
test: it must fail if a future refactor drops the guard.
|
||
- [ ] RED: `MuxOpen` failing the frozen §4.1 schema → stream RST, not a crash.
|
||
- [ ] GREEN.
|
||
- **Test cases**: OPEN→dial with correct path+Origin; bidirectional splice; CLOSE frees socket; RST on
|
||
invalid MuxOpen; **OPEN for a foreign subdomain is RST, never dialed** (cross-tenant boundary — assert no
|
||
loopback dial); **two-stream isolation** (no cross-stream bleed); loopback connect-refused (base app
|
||
down) → RST upstream + typed error (no swallow); backpressure hook calls into T11.
|
||
- **Security**: **INV11** (opaque splice), **INV5** (per-connection allocation, no global buffers),
|
||
**INV1 defense-in-depth** (subdomain-mismatch OPEN is RST before dial — the agent's own last-line check on
|
||
a cross-wired relay frame; complements, never replaces, relay-side authz owned by P1/P5),
|
||
Origin replay preserves CSWSH protection end-to-end (EXPLORE §3 "do not weaken the check").
|
||
|
||
#### T9 · Heartbeat `[ ]` — v0.9
|
||
- **Owns**: `agent/src/transport/heartbeat.ts`, `agent/test/heartbeat.test.ts`
|
||
- **Depends**: T7. **Parallel-safe**: with T8/T10/T11.
|
||
- **Contracts** (§4.1 PING/PONG, streamId 0, **15s** — cited verbatim):
|
||
```ts
|
||
export interface Heartbeat { onPing(token: Uint8Array): void; start(): void; stop(): void; onDead(cb: () => void): void }
|
||
export function createHeartbeat(tunnel: Tunnel, opts?: { intervalMs?: number; timer?: TimerLike }): Heartbeat
|
||
export const HEARTBEAT_INTERVAL_MS = 15_000 // §4.1: "heartbeat every 15s, miss ⇒ tunnel dead"
|
||
```
|
||
- **TDD** (fake timers): RED — inbound PING → agent replies PONG echoing the 8-byte token; no PONG within
|
||
interval → `onDead` fires (⇒ T10 reconnect). GREEN.
|
||
- **Test cases**: PING→PONG echo; missed PONG → dead after `HEARTBEAT_INTERVAL_MS`; `stop()` cancels timer
|
||
(no leak); token echoed byte-exact.
|
||
- **Security**: liveness only; no secret material.
|
||
|
||
#### T10 · Reconnection / backoff `[ ]` — v0.9
|
||
- **Owns**: `agent/src/transport/backoff.ts`, `agent/test/backoff.test.ts`
|
||
- **Depends**: T2 (dial trigger **and** the W0 `seams.ts` `isRevoked` seam). **Does NOT depend on T14** —
|
||
`reconnectLoop` only *consumes* an injected `isRevoked: () => boolean` callback (the seam type is frozen at
|
||
W0 in `seams.ts`), so there is **no task-level cycle** with T14. T14 *implements* `RevocationState` and
|
||
wires its `isRevoked` into this loop at integration; T10 never imports T14. **Parallel-safe**: with
|
||
T8/T9/T11 (and with T14 — no edge either way).
|
||
- **Contracts** — **reuses the base app's 1/2/4…cap-30s** policy (EXPLORE §3 "the *same policy the frontend
|
||
already ships*"):
|
||
```ts
|
||
export interface BackoffPolicy { nextDelayMs(): number; reset(): void }
|
||
export function createBackoff(opts?: { baseMs?: number; capMs?: number; jitter?: boolean }): BackoffPolicy
|
||
export const BACKOFF_BASE_MS = 1_000
|
||
export const BACKOFF_CAP_MS = 30_000 // 1s/2s/4s…cap 30s
|
||
export function reconnectLoop(dial: () => Promise<Tunnel>, backoff: BackoffPolicy, isRevoked: () => boolean): Promise<void>
|
||
```
|
||
- **TDD** (fake timers): RED — delays follow 1s,2s,4s,8s,16s,30s(cap),30s…; `reset()` after a successful
|
||
connect returns to 1s; **`isRevoked()===true` short-circuits the loop (no reconnect)** — INV12. GREEN.
|
||
- **Test cases**: exact backoff sequence + cap; jitter within bounds when enabled; reset-on-success;
|
||
**revoked host does not reconnect** (INV12); dial failure loops, dial success resolves.
|
||
- **Security**: **INV12** (a revoked host must stop trying, not thrash the relay).
|
||
|
||
#### T11 · Per-stream flow control `[ ]` — v0.9
|
||
- **Owns**: `agent/src/transport/flowControl.ts`, `agent/test/flowControl.test.ts`
|
||
- **Depends**: T7, T8. **Parallel-safe**: with T8–T10 (own file).
|
||
- **Contracts** — **consumes** the §4.1 `WINDOW_UPDATE` credit protocol (P1 owns the protocol; the agent
|
||
is a credit-respecting sender/receiver so one heavy `vim`/`top` redraw can't starve another stream):
|
||
```ts
|
||
export interface FlowController {
|
||
consume(streamId: number, bytes: number): boolean // false ⇒ credit exhausted, pause this stream
|
||
grant(streamId: number, credit: number): void // on inbound WINDOW_UPDATE
|
||
initWindow(streamId: number, initialCredit: number): void
|
||
}
|
||
export function createFlowController(): FlowController
|
||
```
|
||
- **TDD**: RED — sending past the initial window returns `false` (pause); a `WINDOW_UPDATE grant` resumes;
|
||
credit is **per-stream** (exhausting A does not pause B). GREEN.
|
||
- **Test cases**: pause at zero credit; resume on grant; per-stream independence (starvation guard);
|
||
connection-level (streamId 0) credit applies to the whole link (§4.1).
|
||
- **Security**: DoS resilience (per-stream fairness); no cross-stream state bleed.
|
||
|
||
---
|
||
|
||
### W3 · mTLS + lifecycle (v0.9 / v0.10)
|
||
|
||
#### T12 · Outbound mTLS dial `[ ]` — v0.9
|
||
- **Owns**: `agent/src/transport/dial.ts`, `agent/test/dial.test.ts`
|
||
- **Depends**: T3 (key), T4 (cert), T2 (relayUrl); **cross-plan**: P3 CA (validates server cert),
|
||
P1 `/agent` endpoint (mocked in unit tests). **Parallel-safe**: with T13/T14.
|
||
- **Contracts** (INV14 — mTLS, **no bearer token on the tunnel**, EXPLORE §4a.1):
|
||
```ts
|
||
import { WsLike } from './seams' // W0 shared seam (T2) — NOT redeclared here
|
||
export function dialRelay(cfg: AgentConfig, ks: Keystore): Promise<WsLike>
|
||
// builds a wss:// client with { cert, key(in-process), ca: caChain }; rejectUnauthorized: true
|
||
```
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: dial constructs a TLS client with the **client cert + in-process private key + CA chain**;
|
||
`rejectUnauthorized` is **true** (never disabled — a common insecure default).
|
||
- [ ] RED: **no bearer/agent token** is placed in a header or the URL (INV4 — mTLS is the auth).
|
||
- [ ] RED: absent/expired cert in keystore → dial refused with `NotEnrolledError`/`CertExpiredError`
|
||
(fail-fast, INV14).
|
||
- [ ] RED: a relay server cert not chaining to the pinned CA → handshake rejected (no MITM).
|
||
- [ ] GREEN (mock TLS layer).
|
||
- **Test cases**: cert+key+CA wired; `rejectUnauthorized` true; no token header; missing cert →
|
||
NotEnrolled; expired cert → refuse; bad server chain → reject.
|
||
- **Security**: **INV14** (mTLS), **INV4** (asymmetric, no shared secret on the wire), pinned CA (anti-MITM).
|
||
|
||
#### T13 · Short-lived cert rotation `[ ]` — v0.9 → hardened v0.10
|
||
- **Owns**: `agent/src/certs/rotation.ts`, `agent/test/rotation.test.ts`
|
||
- **Depends**: T3, T4, T12; **cross-plan**: P3 renewal endpoint (SPIFFE-style, §4.5 signs only pubkeys in
|
||
the registry). **Parallel-safe**: with T14.
|
||
- **Contracts** (INV14 — auto-rotating, seamless):
|
||
```ts
|
||
export interface CertRotator { start(): void; stop(): void; onRotated(cb: () => void): void; onRevoked(cb: () => void): void }
|
||
export function createCertRotator(cfg: AgentConfig, id: AgentIdentity, ks: Keystore, opts?: { renewBeforeMs?: number; timer?: TimerLike }): CertRotator
|
||
// renews via P3 using a fresh CSR (T4) over the SAME Ed25519 key; installs new cert atomically (INV8-style swap)
|
||
```
|
||
- **TDD** (fake timers): RED — timer fires at `expiry - renewBeforeMs`, posts a fresh CSR, installs the
|
||
new cert **atomically** (old cert never partially overwritten); a `403 revoked` from the renewal
|
||
endpoint → `onRevoked` fires (⇒ T14 teardown, INV12); rotation mid-tunnel does **not** drop live
|
||
streams (seamless). GREEN.
|
||
- **Test cases**: renew-before-expiry timing; atomic cert swap (no torn read of cert file); renewal
|
||
refusal (revoked) → onRevoked; renewal uses the same key (pubkey unchanged, only cert rotates);
|
||
network error on renew → retry with backoff, tunnel stays up until cert actually expires.
|
||
- **Security**: **INV14** (short-lived auto-rotation; revocation = "just stop renewing" per EXPLORE §4a.1),
|
||
**INV12** (renewal-refusal path), **INV8** (atomic swap of cert-at-rest).
|
||
|
||
#### T14 · Revocation + GOAWAY teardown `[ ]` — v0.9 (drain) / v0.10 (revoke)
|
||
- **Owns**: `agent/src/lifecycle/revocation.ts`, `agent/test/revocation.test.ts`
|
||
- **Depends**: T7 (tunnel), T13 (renewal-refusal), **T2 (the W0 `seams.ts` `RevocationState`/`RevokeReason`
|
||
seam)**; **cross-plan**: the **frozen INDEX §4.1 3-value `GoAwayReason`** union + `decodeGoAwayReason` in
|
||
`relay-contracts/` (§4.1 line 225 — RESOLVED, no longer an open question). T14 **wires** T10's
|
||
`reconnectLoop` (injects its `isRevoked` into the loop) but does **not** depend on T10 as a task — the
|
||
edge is one-way (T14→consumes T10's function via the W0 seam type), so there is **no cycle**.
|
||
**Parallel-safe**: with T12/T13.
|
||
- **Contracts** (INV12 — fast revocation kills live tunnels in seconds). The `RevocationState`/`RevokeReason`
|
||
seam is **imported from `agent/src/transport/seams.ts` (T2), not redeclared here**; the GOAWAY reason
|
||
mapping is **imported from `relay-contracts/`, never invented locally**:
|
||
```ts
|
||
import { RevocationState, RevokeReason } from '../transport/seams' // W0 seam (T2)
|
||
import { GoAwayReason, decodeGoAwayReason } from 'relay-contracts' // FROZEN §4.1 3-value union + decoder (see below)
|
||
export function createRevocationState(onTeardown: () => void): RevocationState
|
||
// markRevoked ⇒ close all streams + tunnel immediately AND make T10's isRevoked() true (no reconnect)
|
||
// classifyGoAway maps the FROZEN 3-value wire reason to the agent's action:
|
||
// 'operatorDrain' → 'drain' · 'shutdown' → 'drain' · 'revoked' → 'revoked' (an unknown numeric code never
|
||
// reaches here — decodeGoAwayReason throws, and T14 fails closed to 'revoked'; see below).
|
||
export function classifyGoAway(reason: GoAwayReason): 'drain' | 'revoked' // pure exhaustive map over the FROZEN 3-value union
|
||
```
|
||
**Finding-3 fix (RESOLVED) — GOAWAY reason semantics are a FROZEN shared contract, not a local numeric guess.**
|
||
INDEX §4.1 (line 225) now freezes `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal
|
||
union) + `decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes
|
||
P1 `drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
|
||
imports this union + decoder **verbatim** from `relay-contracts/` and **must not** hard-code reason integers
|
||
or redeclare the union locally. This closes the earlier sender/receiver drift where P1 emitted
|
||
`operatorDrain`/`shutdown` while P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never
|
||
emits — a genuine **revoke** misread as a **drain** would have let the agent *reconnect elsewhere* instead of
|
||
*refusing to reconnect*, silently defeating INV12 with **both plans' unit tests still green**. `classifyGoAway`
|
||
is a pure exhaustive map over the frozen 3-value union: the graceful reasons **`operatorDrain` and `shutdown`
|
||
both → `'drain'`** (finish in-flight, reconnect elsewhere — EXPLORE §3 node drain/shutdown) and **`revoked` →
|
||
`'revoked'`** (immediate teardown, no reconnect). An **unknown numeric code** never yields a valid
|
||
`GoAwayReason` — `decodeGoAwayReason` throws — and T14 **fails closed: treats it as `'revoked'`** (a default
|
||
that guesses "drain" is exactly the INV12-defeating failure). No stub/default mapping is permitted.
|
||
- **TDD**: RED — a §4.1 `GOAWAY` decoding (via `decodeGoAwayReason`) to `'revoked'` → **immediate** teardown
|
||
of all streams + tunnel and `isRevoked()` becomes true so T10 does **not** reconnect; an `'operatorDrain'`
|
||
**or** `'shutdown'` GOAWAY → graceful (finish in-flight, reconnect elsewhere); an **unknown/unmapped** reason
|
||
code (`decodeGoAwayReason` throws) → **fail-closed: treated as `'revoked'`** (never silently as drain) +
|
||
typed error surfaced (no swallow). GREEN.
|
||
- **Test cases**: revoke → teardown within one tick (seconds-scale, INV12); the three frozen reasons map
|
||
correctly — **`operatorDrain`→drain, `shutdown`→drain, `revoked`→revoke** — distinguished **only** via the
|
||
frozen `GoAwayReason`/`decodeGoAwayReason` (a test asserts no bare integer literal for a reason appears in
|
||
`revocation.ts`, and that `classifyGoAway` is exhaustive over the 3-value union); **unknown reason code fails
|
||
closed to revoked** (`decodeGoAwayReason` throws, INV12 safety); post-revoke reconnect suppressed (asserts
|
||
the injected `isRevoked` returns true to T10's loop); operator-drain path reconnects elsewhere.
|
||
- **Security**: **INV12** (global + per-host revocation the agent honors within seconds; a revoked host
|
||
neither holds a tunnel nor reconnects; unknown reason codes fail closed to revoked). Distinguish
|
||
node-drain/shutdown (`operatorDrain`/`shutdown` → reconnect elsewhere, EXPLORE §3) from revoke (`revoked` →
|
||
stop) **only** through the frozen §4.1 3-value reason contract — never a locally-chosen mapping (cross-plan
|
||
drift = silent INV12 defeat).
|
||
|
||
---
|
||
|
||
### W4 · Agent-side E2E endpoint (v0.10) — consumes P4 `relay-e2e/`
|
||
|
||
#### T15 · Host E2E endpoint (`host_hello` + seal/open splice) `[ ]` — v0.10
|
||
- **Owns**: `agent/src/e2e/hostEndpoint.ts`, `agent/test/hostEndpoint.test.ts`
|
||
- **Depends**: T3 (identity, for the transcript `sig`), T8 (`FrameTransform` seam), T19 (`ReplaySealer`,
|
||
same wave — authored before T15 wires it); **cross-plan HARD PRE-W4 GATE** — **P4 `relay-e2e/`** must FIRST
|
||
freeze BOTH (a) `sealFrame`/`openFrame` + the `createHostHandshake` host-handshake wiring AND (b) the
|
||
**injected device-auth-proof verifier** — P5-owned `verifyDeviceProof(proof, { clientEphPub, clientNonce })`
|
||
reaching T15 **through** P4's `createHostHandshake` (FIX 6b, **not** a direct `relay-e2e` import) — **plus a
|
||
published cross-plan test vector** (a valid proof + at least one forged/failing proof). Browser side is P6.
|
||
**Parallel-safe**: standalone file — **but see the blocking gate below: T15 does not START coding the
|
||
proof-abort path until (b) lands.**
|
||
|
||
> **BLOCKING GATE (was §6 open Q#2 — now a hard, named pre-W4 gate). The anti-MITM guarantee of the
|
||
> entire authenticated-ECDH-through-the-relay design rests on `deviceAuthProof` verification (§4.4
|
||
> `ClientHello`).** T15 only *consumes* that verifier; its issuance+verification lives in **P5** and reaches
|
||
> T15 **injected through P4's `createHostHandshake`** (FIX 6b) — the agent never `import`s it from
|
||
> `relay-e2e`. Until P5's `verifyDeviceProof` (bound to `{ clientEphPub, clientNonce }`) is frozen, wired
|
||
> into P4's `createHostHandshake`, **and** the shared test vector is published, there is no definition of
|
||
> what "the proof fails" means. **T15 is BLOCKED, NOT DEGRADED**: it must **not** merge or ship against a
|
||
> stubbed / no-op / always-true proof check to unblock the wave. A stubbed verifier would let a relay that
|
||
> injects a forged `ClientHello` complete a full handshake and derive `DirectionalKeys` with the agent —
|
||
> **exactly the threat this locked decision defends against** — silently defeating E2E. If the gate is not
|
||
> yet met when T15 is dispatched, the builder returns `[!] BLOCKED` (never guesses a proof format). The
|
||
> orchestrator resolves it by landing P5's verifier (via P4's wiring) + the vector, then re-dispatches.
|
||
|
||
- **Contracts** — implements the **host side** of INDEX **§4.4** and plugs a real `FrameTransform` into T8
|
||
(replacing `identityTransform`). It **imports** P4's crypto **and drives P4's host-handshake wiring** (the
|
||
device-auth-proof verifier reaches it **injected**, FIX 6b); it **re-declares nothing** from §4.4:
|
||
```ts
|
||
// FIX 6b — verifier is P5-owned and injected via P4's createHostHandshake; NEVER `import { verifyDeviceAuthProof } from 'relay-e2e'`.
|
||
import { sealFrame, openFrame, createHostHandshake } from 'relay-e2e' // P4 — frozen §4.4 primitives + host-handshake wiring
|
||
import { createE2ESession } from 'relay-contracts'
|
||
import type {
|
||
ClientHello, HostHello, HandshakeResult, DirectionalKeys, E2ESession, AeadKey,
|
||
} from 'relay-contracts' // §4.4 FROZEN shapes — cited verbatim, no local redefinition
|
||
// Host-side device-proof verifier — INJECTED (P5 issues+verifies; P4 wires it), bound to { clientEphPub, clientNonce } (FIX 6b):
|
||
export type VerifyDeviceProof =
|
||
(proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }) => Promise<boolean>
|
||
// Host side of §4.4: receive ClientHello (relay DATA) → verify proof FIRST → reply HostHello → derive DirectionalKeys.
|
||
// MITM defense: makeHostHello drives createHostHandshake({ verifyDeviceProof, identity: id }), which calls the injected
|
||
// verifier bound to { clientEphPub, clientNonce } FIRST and ABORTS (throws MitmAbortError, derives NO keys) on failure —
|
||
// there is no code path that skips this call.
|
||
export function makeHostHello(
|
||
clientHello: ClientHello, id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof,
|
||
): Promise<{ hello: HostHello; result: HandshakeResult }>
|
||
// FIX 2 — returns HandshakeResult{ keys: DirectionalKeys{c2h,h2c}, aead, transcript }; NO single sessionKey/CryptoKey.
|
||
// HostHello.sig = id.sign(transcript); HostHello.enrollFpr = id.enrollFpr (§4.4 — browser pins it, TOFU)
|
||
export function createE2ETransform(
|
||
id: AgentIdentity, verifyDeviceProof: VerifyDeviceProof, replay: ReplaySealer, // replay = T19 K_content sealer (FIX 3)
|
||
): FrameTransform
|
||
// per stream: absorb ClientHello → (verify proof → else abort) → emit HostHello →
|
||
// session = createE2ESession('host', result) // host seals with h2c, opens with c2h (§4.4)
|
||
// thereafter: inbound = session.open(dataPayload); outbound(LIVE) = session.seal(plain) [ephemeral h2c];
|
||
// AND every replay-bound output is ALSO sealed via replay.seal(plain) [recoverable K_content, T19] — DISTINCT from live frames (FIX 3).
|
||
// Synchronous AeadKey seal/open; monotonic seq per direction (§4.4 / INV13).
|
||
```
|
||
**Boundary**: after `openFrame` decrypts, the bytes are handed to the loopback WS **as opaque bytes** —
|
||
the agent still **parses no terminal semantics** (INV11). Plaintext exists only host-local.
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: `makeHostHello` produces a `HostHello` whose `sig` verifies under `enrollFpr` and whose ECDH
|
||
yields a `HandshakeResult` whose **`keys: DirectionalKeys{ c2h, h2c }`** match what the P4 client-side
|
||
test vector computes (§4.4 — direction-split, **no single `sessionKey`/`CryptoKey`**).
|
||
- [ ] RED (**anti-MITM, gated on the frozen verifier + vector**): using **P4's published forged-proof
|
||
vector**, a `ClientHello` whose `deviceAuthProof` fails the injected `verifyDeviceProof` → **ABORT**
|
||
(`MitmAbortError`, **no `DirectionalKeys` derived, no `HostHello` emitted**); likewise a transcript
|
||
mismatch → ABORT. This test is written against P4's real vector, **not** a local stand-in; it is the
|
||
load-bearing MITM test.
|
||
- [ ] RED (**no-stub CI guard, INV2 anti-MITM**): a static/CI guard fails the build if `makeHostHello`/
|
||
`createE2ETransform` can reach key derivation **without** calling the real (non-stub) injected
|
||
`verifyDeviceProof` — e.g. assert `hostEndpoint.ts` does **NOT** import any verifier from `relay-e2e`
|
||
(the verifier arrives only via the `verifyDeviceProof` param wired through P4's `createHostHandshake`,
|
||
FIX 6b), and a mutation test that replaces the injected verifier with `async () => true` makes the MITM
|
||
test FAIL. This makes "shipped against a stubbed proof check" an automatic red build.
|
||
- [ ] RED (**INV2**): given a stream's DATA frames, assert the **relay-visible** payload is the §4.4
|
||
`E2EEnvelope` (ciphertext+nonce+tag+seq) — a known plaintext marker fed at the loopback never
|
||
appears in any DATA payload bytes.
|
||
- [ ] RED (**INV13**): `openFrame` rejects a **replayed** envelope (dup seq), a **reordered** seq, and a
|
||
**bit-flipped** ciphertext (AEAD tag fail) → the stream is torn down, not silently dropped-through.
|
||
- [ ] RED (**INV11**): even post-decrypt, no ANSI/xterm import; decrypted bytes forwarded opaque.
|
||
- [ ] GREEN by wiring P4 primitives.
|
||
- **Acceptance criteria (explicit — mirrored in T18):**
|
||
1. **T15 is BLOCKED, not degraded, until the proof-verification contract exists** — no merge against a
|
||
stub/no-op/always-true `verifyDeviceProof` (P5-owned, injected via P4's `createHostHandshake`, FIX 6b);
|
||
the forged-proof vector from P4 must be present and the abort test green.
|
||
2. The no-stub CI guard (mutation test above) is present and passing.
|
||
3. All INV2/INV13/INV11 tests above green.
|
||
- **Test cases**: handshake key-agreement (**`DirectionalKeys{c2h,h2c}`**) vs P4 vector; **forged-proof →
|
||
ABORT (no keys) using P4's forged vector**; **no-stub CI guard (verifier-mutation → MITM test fails)**;
|
||
envelope-only on the wire (INV2); replay/reorder/tamper rejection (INV13); multi-device — two independent
|
||
`ClientHello`s derive two independent `HandshakeResult`s over the authenticated channel (§4.4,
|
||
INV3-adjacent); passthrough remains opaque (INV11).
|
||
- **Security**: **INV2** (relay sees only ciphertext), **INV13** (per-message nonce + monotonic seq),
|
||
**INV11** (opaque even after decrypt), **enrollment-key-bound + device-auth-proof-gated handshake**
|
||
(anti-MITM by a malicious relay — the guarantee is only real once the injected `verifyDeviceProof` is P5's
|
||
real verifier wired via P4's `createHostHandshake`, never a stub; enforced by the no-stub CI guard).
|
||
|
||
#### T19 · Replay-frame sealer (recoverable `K_content`) `[ ]` — v0.10 (FIX 3)
|
||
- **Owns**: `agent/src/e2e/replaySeal.ts`, `agent/test/replaySeal.test.ts`
|
||
- **Depends**: T3 (`Keystore.loadContentSecret`), T2 (config); **cross-plan**: `relay-contracts/` §4.4 replay
|
||
surface (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `REPLAY_KDF_INFO`) + P4 `relay-e2e/`
|
||
impls. **Wired INTO T15's `createE2ETransform`** (same wave — authored before T15 consumes it).
|
||
**Parallel-safe**: standalone file (no edge back to T15).
|
||
- **Rationale (FIX 3 — recoverable replay under E2E).** Live host→client frames are sealed with the
|
||
**ephemeral** `DirectionalKeys.h2c` (forward-secret — gone after reconnect). But the "refresh the page and
|
||
the Claude session is still there" guarantee (EXPLORE §4c) needs the ring-buffer/preview ciphertext to be
|
||
**recoverable** after a reload — so every replay-bound output is ALSO sealed under the **host-scoped
|
||
recoverable** `K_content = deriveContentKey({ hostContentSecret, sessionId, alg })`, **DISTINCT** from the
|
||
live `h2c` frame. The browser re-derives the identical `K_content` (via P5) and calls `openReplayCiphertext`
|
||
(P6 T9). This is the single agent-side consumer of the FIX 3 recoverable key (closes the "ship as
|
||
unconsumed library code" gap noted in RECONCILE FIX 3a).
|
||
- **Contracts** (imports the FROZEN §4.4 replay surface from `relay-contracts/`; re-declares nothing):
|
||
```ts
|
||
import { deriveContentKey, sealReplayFrame } from 'relay-e2e' // P4 impls of the §4.4 replay surface
|
||
import type { ReplayKeyParams, AeadKey, E2EEnvelope, AeadAlg } from 'relay-contracts' // §4.4 FROZEN shapes
|
||
export interface ReplaySealer {
|
||
seal(plaintext: Uint8Array): E2EEnvelope // K_content seal, monotonic seq per session (INV13); NOT the live h2c frame
|
||
}
|
||
// K_content derived once per (host, session): deriveContentKey({ hostContentSecret, sessionId, alg }).
|
||
export function createReplaySealer(hostContentSecret: Uint8Array, sessionId: string, alg: AeadAlg): ReplaySealer
|
||
```
|
||
`hostContentSecret` comes from `Keystore.loadContentSecret()` (T3, unwrapped at T4) — **never** the
|
||
ephemeral key, **never** logged, **never** sent to the relay (INV2/INV9).
|
||
- **TDD (RED→GREEN)**:
|
||
- [ ] RED: `deriveContentKey` with the same `{ hostContentSecret, sessionId, alg }` is **deterministic**
|
||
(a reload re-derives the identical `K_content`); a different `sessionId` → a different key (per-session).
|
||
- [ ] RED: `seal` emits a §4.4 `E2EEnvelope` with **monotonic seq** (INV13); a known plaintext marker
|
||
never appears in the envelope bytes (INV2).
|
||
- [ ] RED: for the same plaintext, the replay seal is **distinct** from the live `session.seal` (h2c) output
|
||
(different key ⇒ different ciphertext) — the two seals are not interchangeable (FIX 3).
|
||
- [ ] RED (cross-plan vector): a `sealReplayFrame` envelope round-trips through P6's `openReplayCiphertext`
|
||
against a shared test vector (same `hostContentSecret`/`sessionId`).
|
||
- [ ] GREEN by wiring P4 impls.
|
||
- **Test cases**: deterministic `K_content` re-derivation; per-session key separation; monotonic seq;
|
||
envelope-only (INV2); replay-seal ≠ live-seal; P6 round-trip vector.
|
||
- **Security**: **INV2** (replay ciphertext still opaque to the relay), **INV13** (monotonic seq on replay
|
||
frames too), **INV5/INV9** (`hostContentSecret` at rest `0600`, never logged/egressed). The recoverable
|
||
`K_content` is **host-scoped + per-host revocable** (P3 can invalidate the wrap, INDEX §4.5) — **not** an
|
||
account-wide secret.
|
||
|
||
---
|
||
|
||
### W5 · Distribution + acceptance
|
||
|
||
#### T16 · Static-binary build `[ ]` — v0.9 (EXPLORE §6 distribution rank 2)
|
||
- **Owns**: `agent/src/dist/buildBinary.ts`, `agent/test/buildBinary.test.ts`
|
||
- **Depends**: T5 (CLI). **Parallel-safe**: with T17.
|
||
- **Contracts**: `export function buildBinaryConfig(target: BinaryTarget): BuildSpec` where
|
||
`BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'` (string-literal union);
|
||
produces a `bun --compile` spec (fallback `pkg`) for a one-`curl | sh` install. `npx web-terminal-agent`
|
||
remains the MVP path (EXPLORE §6 rank 1 — "customers already `npm install`").
|
||
- **TDD**: RED — build spec targets each supported triple; entry = `cli.ts`; the binary bundle **excludes**
|
||
any dev/test dep and any terminal parser (INV11 re-checked at package boundary). GREEN.
|
||
- **Test cases**: per-target spec; entrypoint correct; no ANSI-parser in the bundle graph; `npx` path
|
||
documented.
|
||
- **Security**: EXPLORE §4d — the agent is a **signed** artifact (a compromised update = same blast radius,
|
||
so the channel is signed/pinned; **signing itself is P5/CI-owned**, this task exposes the hook).
|
||
|
||
#### T17 · Service install + Origin touch-point `[ ]` — v0.9 (EXPLORE §6)
|
||
- **Owns**: `agent/src/service/install.ts`, `agent/src/service/launchd.ts`,
|
||
`agent/src/service/systemd.ts`, `agent/src/service/originConfig.ts`,
|
||
`agent/test/install.test.ts`, `agent/test/originConfig.test.ts`
|
||
- **Depends**: T2, T5. **Parallel-safe**: with T16.
|
||
- **Contracts**:
|
||
```ts
|
||
export type ServicePlatform = 'launchd' | 'systemd'
|
||
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null
|
||
export function installService(cfg: AgentConfig, platform: ServicePlatform): Promise<void> // writes+loads unit
|
||
export function uninstallService(platform: ServicePlatform): Promise<void>
|
||
// the ONE base-app touch-point (INDEX §0, EXPLORE §3): add the subdomain to ALLOWED_ORIGINS as CONFIG
|
||
export function ensureAllowedOrigin(baseAppEnvPath: string, subdomain: string, domain: string): void
|
||
```
|
||
`ensureAllowedOrigin` **appends** `https://<subdomain>.term.<domain>` to the base app's
|
||
`ALLOWED_ORIGINS` env (idempotent) — **no `src/` code edit** (EXPLORE §3 "Zero code change"). The service
|
||
runs the agent as the **logged-in user, not root** (EXPLORE §4d — least privilege).
|
||
- **TDD**: RED — launchd plist / systemd unit contain the right `ExecStart` (`web-terminal-agent run`),
|
||
restart-on-failure, and **run-as-user (never root)**; `ensureAllowedOrigin` is idempotent and never
|
||
removes an existing origin (does not weaken the Origin check — EXPLORE §3). GREEN with a mock FS.
|
||
- **Test cases**: plist/unit generation; run-as-user assertion (negative: refuses to install as root);
|
||
idempotent origin append; existing origins preserved; uninstall unloads cleanly.
|
||
- **Security**: least-privilege service (INV-adjacent, EXPLORE §4d); the Origin touch-point **augments,
|
||
never weakens**, CSWSH protection (EXPLORE §3, INV15/INV6 spirit); no secrets written to the unit file
|
||
(INV9 — key/cert stay in the keystore, referenced by path).
|
||
|
||
#### T18 · Acceptance / verification harness `[ ]` — spans phases
|
||
- **Owns**: `agent/test/acceptance/*.test.ts` (agent-local integration), `agent/test/security/*.test.ts`
|
||
- **Depends**: all above; **cross-plan** integration with P1/P3/P4 lives in their acceptance suites (this
|
||
is the agent-local slice — do not edit other packages).
|
||
- **Steps**:
|
||
- [ ] **INV tripwires** (one per owned INV, §1): INV2 marker-never-on-wire; INV4 no-private-key-egress;
|
||
INV5 keystore-0600 + code-not-at-rest; INV9 no-secret-in-logs; INV11 no-parser-in-bundle;
|
||
INV12 revoke→teardown+no-reconnect; INV14 mTLS-required + rotation.
|
||
- [ ] **INV1 defense-in-depth tripwire (T8)**: an OPEN whose `MuxOpen.subdomain !== cfg.subdomain` is RST
|
||
and **never dialed** — the agent's last-line cross-tenant boundary check.
|
||
- [ ] **INV12 GOAWAY-reason tripwire (T14)**: a `'revoked'` GOAWAY (decoded via the frozen
|
||
`decodeGoAwayReason`) tears down + suppresses reconnect; `'operatorDrain'` **and** `'shutdown'`
|
||
reconnect elsewhere; an **unknown reason code fails closed to revoked**; assert no bare integer reason
|
||
literal in `revocation.ts` (cross-plan-drift guard).
|
||
- [ ] **INV2 anti-MITM no-stub gate (T15)**: with P4's **forged-proof vector**, a failing `deviceAuthProof`
|
||
→ ABORT (no `DirectionalKeys`). The **verifier-mutation guard** — replacing the injected
|
||
`verifyDeviceProof` (P5-owned, wired via P4's `createHostHandshake`, FIX 6b) with a no-op makes this
|
||
test FAIL — must be present and passing (proves T15 cannot ship against a stub, and that no verifier is
|
||
imported directly from `relay-e2e`).
|
||
- [ ] **FIX 3 recoverable-replay tripwire (T19)**: a replay-bound output sealed with `sealReplayFrame`
|
||
(`K_content`) round-trips through P6's `openReplayCiphertext`, is **ciphertext to the relay** (INV2),
|
||
and is **distinct** from the live `h2c` frame for the same plaintext.
|
||
- [ ] **Café-demo agent slice**: pair (mock P3) → dial (mock P1) → OPEN → loopback splice against a
|
||
stub `ws://127.0.0.1:3000` echo server → bytes flow both ways (EXPLORE §5 demo, agent portion).
|
||
- [ ] Coverage ≥ 80% across `agent/src/**`.
|
||
- **Accept**: all INV tripwires green (incl. the T8 subdomain boundary, T14 GOAWAY-reason fail-closed, the
|
||
T15 no-stub anti-MITM guard, and the T19 recoverable-replay round-trip); café-demo slice green; coverage
|
||
gate met. **T15 must not be marked accepted while its injected-`verifyDeviceProof` gate (Q#2) is
|
||
unresolved** (T14's `GoAwayReason` contract is frozen in INDEX §4.1 — no longer a gate).
|
||
|
||
---
|
||
|
||
## 4. SECURITY (per-plan summary)
|
||
|
||
The host-agent is a **CIPHERTEXT-shuttle endpoint on the customer's own machine** (INDEX §0). Its security
|
||
posture:
|
||
|
||
1. **Private key never leaves the host (INV4)** — Ed25519 generated locally (T3); only the **public** key
|
||
+ a CSR are transmitted (T4); mTLS proves possession without sending the key (T12). A relay DB dump
|
||
cannot impersonate a host (EXPLORE §4a.1 "a per-host public key in your DB is useless to a thief").
|
||
2. **No shared secrets on the wire (INV4/INV14)** — the tunnel is authenticated by **mTLS**, not a bearer
|
||
token; certs are **short-lived and auto-rotated** (T13); revocation = stop renewing + tear down (T14,
|
||
INV12), where a **`revoked`** GOAWAY is distinguished from the graceful **`operatorDrain`/`shutdown`**
|
||
GOAWAY reasons only via the **frozen** 3-value `relay-contracts` `GoAwayReason`/`decodeGoAwayReason` (never
|
||
a locally-invented numeric mapping — a drift there would silently downgrade a revoke to a reconnect;
|
||
unknown reason ⇒ fail-closed to revoked). The
|
||
v0.8 shared `agentToken` (T6) is an explicit, retired stepping-stone.
|
||
3. **Relay sees only ciphertext (INV2)** — from v0.10, every byte entering the tunnel is a §4.4
|
||
`E2EEnvelope` sealed at the agent: **live** frames under the ephemeral `DirectionalKeys.h2c` (T15),
|
||
**replay-bound** frames under the recoverable `K_content` via `sealReplayFrame` (T19, FIX 3) — both
|
||
ciphertext to the relay. The handshake yields **`DirectionalKeys{c2h,h2c}`** (FIX 2, no single
|
||
`sessionKey`) and is **enrollment-key-bound AND device-auth-proof-gated** so a malicious relay cannot MITM
|
||
(browser pins `enroll_fpr`; the agent runs P4's `createHostHandshake` with the **injected, P5-owned**
|
||
`verifyDeviceProof` bound to `{clientEphPub, clientNonce}` (FIX 6b) and ABORTS on a forged `ClientHello`,
|
||
deriving no keys). **This guarantee is only real once that injected `verifyDeviceProof` is P5's real
|
||
verifier (wired via P4), never imported from `relay-e2e` — T15 is BLOCKED, not degraded, against any stub,
|
||
enforced by a no-stub CI mutation guard (§6 Q#2).** Anti-replay/injection via per-message nonce + monotonic
|
||
seq (INV13).
|
||
3b. **Agent-side cross-tenant boundary (INV1 defense-in-depth)** — although tenant authz is P1/P5's job and
|
||
there is one physical tunnel per host, `handleOpen` (T8) still refuses to dial any `OPEN` whose
|
||
`MuxOpen.subdomain` ≠ the agent's enrolled `subdomain`, RST-ing it and audit-logging the mismatch. Cheap,
|
||
load-bearing, and the last line against a cross-wired relay frame — consistent with "don't fully trust
|
||
the relay."
|
||
4. **No terminal parsing anywhere in the agent (INV11)** — even after E2E decrypt, bytes are handed to the
|
||
loopback WS opaque; the package has **no** ANSI/xterm dependency (enforced by a bundle-graph tripwire,
|
||
T1/T16).
|
||
5. **No plaintext/secret at rest (INV5)** — key/cert `0600`; raw pairing code never persisted or logged;
|
||
no shell bytes buffered (pure shuttle, per-connection allocation, **no global buffers** — closes the
|
||
EXPLORE §4b cross-tenant-buffer-bleed failure mode).
|
||
6. **Secrets validated at startup, never logged (INV9)** — Zod-validated config; fail-fast on missing key
|
||
material; a redacting logger; **no `console.log`**.
|
||
7. **Least privilege (EXPLORE §4d)** — the installed service runs as the **logged-in user, not root**
|
||
(T17); the loopback target is **loopback-only** (anti-SSRF, T2/T8).
|
||
8. **Origin check augmented, never weakened (EXPLORE §3)** — the agent replays the real browser `Origin`
|
||
(§4.1 `MuxOpen.originHeader`) to the base app and only **adds** the subdomain to `ALLOWED_ORIGINS` at
|
||
install (T17); CSWSH protection stays meaningful end-to-end.
|
||
|
||
**Out-of-scope security owned elsewhere (do not implement here):** capability-token verification, human
|
||
auth/step-up, the CI cross-tenant tripwire, the revocation *policy/DB*, the mTLS *CA* → **P5/P3**; the E2E
|
||
crypto *primitives* → **P4**; the relay-side isolation enforcement → **P1/P5**.
|
||
|
||
---
|
||
|
||
## 5. VERIFICATION
|
||
|
||
```bash
|
||
# Unit + security tripwires (agent-local)
|
||
npm --prefix agent run typecheck # strict TS; imports relay-contracts / relay-e2e read-only
|
||
npm --prefix agent test -- config logger # INV9 boundary + redaction
|
||
npm --prefix agent test -- identity keystore # INV4 (no private-key egress) + INV5 (0600)
|
||
npm --prefix agent test -- pair csr # §4.5 redemption; code-not-at-rest; single-use respect
|
||
npm --prefix agent test -- tunnel streamRouter loopback # §4.1 demux, RST-on-illegal, INV11 opaque, 2-stream isolation
|
||
npm --prefix agent test -- heartbeat backoff flowControl # 15s PING/PONG, 1/2/4…cap-30s, per-stream fairness
|
||
npm --prefix agent test -- dial rotation revocation # INV14 mTLS + rotation, INV12 revoke→teardown+no-reconnect
|
||
npm --prefix agent test -- hostEndpoint replaySeal # §4.4 handshake→DirectionalKeys vs P4 vector, INV2 envelope-only, INV13 replay/reorder/tamper; recoverable K_content seal (FIX 3)
|
||
npm --prefix agent test -- install originConfig # run-as-user (not root), idempotent ALLOWED_ORIGINS append
|
||
npm --prefix agent test -- acceptance security # all owned-INV tripwires + café-demo agent slice
|
||
npm --prefix agent run test:coverage # ≥ 80% across agent/src/**
|
||
|
||
# Manual café-demo (cross-plan, once P1+P3 exist): on a host behind NAT
|
||
npx web-terminal-agent pair ABCD-1234 # generates Ed25519 locally, redeems code, installs service
|
||
web-terminal-agent status # prints host_id/subdomain/online — NO key/cert material
|
||
# then open https://<subdomain>.term.<domain> on a phone → land in the host shell (P6 verifies the browser end)
|
||
```
|
||
|
||
**Key manual checks:** (1) after `pair`, the private key file is `0600` and the request that hit `/enroll`
|
||
carried **only** pubkey+CSR (INV4); (2) `status` leaks no secrets (INV9); (3) killing the relay node mid-
|
||
session → the agent backs off (1/2/4…) and reconnects, **PTY survives** on the host (EXPLORE §3, INV7 on
|
||
the relay side); (4) revoking the host → its tunnel drops within seconds and does not reconnect (INV12);
|
||
(5) with E2E (v0.10) a plaintext marker typed in the shell never appears in captured relay traffic (INV2).
|
||
|
||
---
|
||
|
||
## 6. Open questions (for the orchestrator — do NOT guess; return `[!] BLOCKED` if hit mid-task)
|
||
|
||
1. **CSR format for an Ed25519 SPIFFE-style cert** — §4.5 says the agent sends a `csr`, but the exact
|
||
profile (PKCS#10 vs a SPIFFE SVID request; SAN = the subdomain vs a SPIFFE URI) is set by **P3's CA**.
|
||
T4/T13 must consume P3's chosen shape; if P3 hasn't frozen it, T4 is BLOCKED on P3.
|
||
2. **[HARD PRE-W4 GATE — ownership RESOLVED by FIX 6b; the freeze/vector gate remains] `deviceAuthProof`
|
||
verifier (§4.4 `ClientHello`).** Ownership is now settled: **P5 issues+verifies** the proof
|
||
(`verifyDeviceProof(proof, { clientEphPub, clientNonce })`); **P4 wires it** into `createHostHandshake`;
|
||
**P2/T15 consumes it INJECTED through P4** — never `import { verifyDeviceAuthProof } from 'relay-e2e'` (the
|
||
old import was wrong, INDEX §6b). What remains a hard gate is timing: because the entire anti-MITM
|
||
guarantee of authenticated-ECDH-through-the-relay rests on this check, **T15 is BLOCKED (not degraded)
|
||
until P5's verifier is frozen, wired into P4's `createHostHandshake`, AND a cross-plan test vector (a valid
|
||
proof + ≥1 forged/failing proof) is published.** T15 must never ship against a stub/no-op/always-true
|
||
verifier (that would let a forged `ClientHello` complete a handshake and derive `DirectionalKeys` — the
|
||
exact threat). A no-stub CI guard (verifier-mutation test) enforces this at the build. If unmet at
|
||
dispatch, the builder returns `[!] BLOCKED`; the orchestrator lands the verifier+vector, then re-dispatches
|
||
T15.
|
||
3. **[RESOLVED — §4.1 addendum frozen] GOAWAY reason-code semantics.** INDEX §4.1 (line 225) now freezes the
|
||
mapping as `GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'` (string-literal union) +
|
||
`decodeGoAwayReason` (`1→operatorDrain · 2→revoked · 3→shutdown`, else throws) — the **same** codes P1
|
||
`drain.ts` `DRAIN_REASON` (`operatorDrain:1, revoked:2, shutdown:3`) and P5 revocation (FIX 4) emit. T14
|
||
imports the union + decoder **verbatim** from `relay-contracts/` (no local redefinition), maps
|
||
`operatorDrain`/`shutdown` → drain (reconnect elsewhere) and `revoked` → revoke (stop), and **fails closed
|
||
to `revoked` on any unknown numeric code** (`decodeGoAwayReason` throws). This closes the earlier drift
|
||
where P2 assumed a 2-value `'drain' | 'revoked'` union the frozen decoder never emits (a genuine revoke
|
||
misread as a drain would silently defeat INV12 with both plans' unit tests still green). **T14 is no longer
|
||
BLOCKED on this** — the contract is frozen in §4 per this INDEX's rule that shared semantics live there.
|
||
4. **v0.8 `agentToken` transport auth vs frp's own token** — T6 wraps `frpc`; whether the shared token is
|
||
frp's `auth.token` or a P3-issued value affects the P3 flat-table schema (§4.2 note). Confirm with P3
|
||
before wiring T6's `frpc.toml`.
|
||
5. **Cert-rotation renewal endpoint contract** — T13 posts a renewal CSR; the URL/auth (mTLS with the
|
||
*current* cert? a short renewal token?) is **P3**. Blocked on P3 freezing the renewal route.
|
||
6. **Multi-device key distribution channel (§4.4)** — "authenticated (account-derived) channel, not a
|
||
plaintext QR": the agent side (T15) derives per-device keys via per-stream handshakes; whether any
|
||
agent-side coordination is needed for the *same* session across devices is a **P4** design point —
|
||
confirm the agent has no extra responsibility beyond per-stream handshake.
|
||
```
|