/** * Tunnel frame protocol — agentproto/tunnel/v1. * * Bidirectional, event-shaped frames over a single duplex transport * (typically a WebSocket). Each frame is one JSON object per message. * The protocol is deliberately NOT JSON-RPC: process I/O is a * continuous event stream, not a request/response pattern, so we drop * the `id` / response correlation and let the underlying child-process * lifecycle drive everything. * * # Roles * * host — the orchestrator (e.g. Guilde API). Sends `spawn`, * `stdin`, `kill`, `resize`. Receives `stdout`, `stderr`, * `exit`, `error`. Holds the ACP client; sees the duplex * stream as a `ChildProcess`-shaped duck. * * daemon — the local executor (e.g. `agentproto serve` on a user's * laptop). Receives spawn-side frames; spawns real * subprocesses; relays I/O back. Owns the actual file * system, environment, network egress. * * # Identifiers * * execId — host-chosen UUID per spawned process. Lets one tunnel * multiplex many concurrent agent CLIs (one daemon serving * several conversations). * * # Encoding * * - Each WS message is one JSON object. Binary frames are not used; * stdout/stdin payloads are base64-encoded utf-safe strings. * - Why base64 and not raw text: claude-code & friends emit JSON-RPC * ACP traffic over stdio. A child can also emit non-utf8 bytes * (debug logs, terminal control codes when in TTY mode). Base64 * keeps the wire JSON-clean and bytewise-faithful. * - All frames carry `t` (type) and a per-type payload. Versioning * is per-tunnel via `hello.version`, not per-frame. * * # Lifecycle * * 1. Daemon connects to host's WS endpoint with bearer token. * 2. Daemon sends `hello { version, capabilities, label? }`. * 3. Host sends `spawn` for each child it wants on this daemon. * 4. Daemon spawns, replies with `spawned { execId, pid }` (or * `error { execId, message }`). * 5. stdout/stderr/exit notifications flow daemon→host; * stdin/resize/kill flow host→daemon. * 6. Either side can send `ping`; receiver MUST `pong` back. Used * to detect half-open connections. * 7. On disconnect, daemon SHOULD kill all children associated with * this tunnel (host can re-spawn after reconnect). */ declare const TUNNEL_VERSION: "agentproto/tunnel/v1"; /** * The most raw bytes one frame may carry in its base64 payload (`body`, * `data`). Anything larger is split across frames (see `http_response_head` / * `http_response_chunk`, `http_request.bodyChunked` / `http_request_chunk`, * and `ws_message.more`). * * Why: the rendezvous closes any WebSocket message above its * `maxMessageBytes` (1 MiB by default, including the hosted broker). Over a * pairing, a payload is base64'd into the frame, and that frame is sealed * and base64'd again into the `e2e` envelope, so wire size ≈ 16/9 × raw. At * 256 KiB that is ~466 KiB per message: under 1 MiB with room for headers. */ declare const MAX_FRAME_PAYLOAD_BYTES: number; /** Split `bytes` into pieces of at most `max` bytes (views, no copies). One * empty piece for an empty input. */ declare function splitPayload(bytes: Uint8Array, max?: number): Uint8Array[]; interface SpawnFrame { t: "spawn"; execId: string; command: string; args: readonly string[]; /** * Working directory on the daemon side. Daemon SHOULD reject * absolute paths that escape its declared `--root` (see * `agentproto serve --root`); host MUST pass paths that exist * on the daemon. */ cwd?: string; /** * Environment overrides. Daemon merges these atop its own * process.env. Daemon MAY redact values from logs but MUST forward * them verbatim into the child. */ env?: Readonly>; /** * When true, the daemon allocates a PTY instead of plain pipes. * Required for binaries that detect `isTTY` (REPLs, claude * interactive). When false (default), stdin/stdout/stderr are * separate pipes — the right choice for ACP wrappers that speak * line-delimited JSON-RPC. */ pty?: boolean; /** Initial PTY column width. Ignored when `pty` is false. Default 80. */ cols?: number; /** Initial PTY row height. Ignored when `pty` is false. Default 24. */ rows?: number; } interface StdinFrame { t: "stdin"; execId: string; /** Base64-encoded payload bytes. Empty string is a no-op. */ data: string; } interface KillFrame { t: "kill"; execId: string; /** Default "SIGTERM". POSIX names; Windows daemons translate. */ signal?: string; } interface ResizeFrame { t: "resize"; execId: string; cols: number; rows: number; } /** * Generic HTTP-over-tunnel request (HOST → DAEMON). Lets the host * proxy arbitrary HTTP traffic (MCP JSON-RPC today, anything tomorrow) * through the daemon's local network stack without exposing a public * URL. The daemon forwards to its configured "http upstream" (default: * its own gateway on `127.0.0.1:`) and replies with an * `HttpResponseFrame` carrying the same `reqId`. */ interface HttpRequestFrame { t: "http_request"; /** Correlates the response. Host-chosen, MUST be unique per inflight request. */ reqId: string; /** HTTP method (GET / POST / PUT / DELETE / PATCH / OPTIONS). */ method: string; /** Path + querystring, e.g. `/mcp` or `/mcp?session=abc`. * Daemon prepends its upstream base. Absolute URLs are rejected. */ path: string; /** Forwarded request headers. Hop-by-hop headers (Connection, * Keep-Alive, Transfer-Encoding, Upgrade, Proxy-*) MUST be stripped * before forwarding; daemon SHOULD also drop them defensively. */ headers?: Readonly>; /** Base64-encoded request body. Omitted when no body. */ body?: string; /** The body follows as `http_request_chunk` frames (the last with * `end: true`) instead of inline — for bodies above * `MAX_FRAME_PAYLOAD_BYTES`. `body` is then omitted. Only sent to a daemon * whose hello advertises `capabilities.httpRequestChunks`. */ bodyChunked?: boolean; /** Per-request timeout in ms. Daemon SHOULD enforce + reply with * `error: { code: "timeout" }` when exceeded. Default 30_000. */ timeoutMs?: number; } /** * One piece of a chunked request body (HOST → DAEMON), after an * `http_request` with `bodyChunked: true` for the same `reqId`. The daemon * buffers the pieces and forwards the request upstream at `end: true`. */ interface HttpRequestChunkFrame { t: "http_request_chunk"; reqId: string; /** Base64 bytes (at most `MAX_FRAME_PAYLOAD_BYTES` raw). */ data?: string; /** Set on the last piece. */ end?: boolean; } /** * Cancel an in-flight `http_request` (HOST → DAEMON) — the host's consumer * aborted (an `AbortSignal`, a cancelled response stream, a closed * EventSource). The daemon aborts its upstream call (and stops reading a * streamed body) and sends nothing further for `reqId`; the host has already * forgotten it, so any frame still in flight for it is dropped there. * * Backward-compatible: a daemon that predates it drops the unknown frame * (`parseFrame` → null), and the request simply runs to completion as before. */ interface HttpCancelFrame { t: "http_cancel"; reqId: string; } /** * Generic HTTP-over-tunnel response (DAEMON → HOST). Mirrors * `HttpRequestFrame` — see its docs. * * Two-frame protocol: * - SHORT RESPONSES (most HTTP): daemon emits a single `http_response` * with body inline. The buffered `forwardHttp` resolves directly * from this frame. * - STREAMED RESPONSES (text/event-stream, long-lived chunked * transfers, large downloads): daemon emits `http_response_head` * first with status+headers, then one or more `http_response_chunk` * frames, the last of which has `end: true`. The streaming * `forwardHttpStream` builds a `ReadableStream` from the chunks. * * Hosts that only support the legacy buffered API can detect the * stream protocol by looking for `http_response_head` and either * pull the chunks into a buffer (compat) or refuse to handle. */ interface HttpResponseFrame { t: "http_response"; reqId: string; /** HTTP status code (e.g. 200, 404, 500). Set even on transport * failure (then `error` is also set and status SHOULD be 502/504). */ status: number; headers?: Readonly>; /** Base64-encoded response body. */ body?: string; /** Set when the daemon failed to complete the upstream call. */ error?: Readonly<{ code: string; message: string; }>; } /** * Streaming HTTP response head (DAEMON → HOST). Sent ONCE per * streaming response, before any chunk frames. Carries status + * headers so the host can finalize the response shape (e.g. write * Content-Type) before any body bytes arrive. */ interface HttpResponseHeadFrame { t: "http_response_head"; reqId: string; status: number; headers?: Readonly>; } /** * Streaming HTTP response body chunk (DAEMON → HOST). Each chunk * carries some bytes of the response body. The terminal chunk has * `end: true` and MAY include final bytes. An immediate `end: true` * with no data signals empty-body close. * * `error` set on a chunk frame indicates the daemon's upstream * failed mid-stream; host SHOULD propagate it as a stream error. */ interface HttpResponseChunkFrame { t: "http_response_chunk"; reqId: string; /** Base64-encoded chunk bytes. Omitted on pure end-of-stream * markers (those carry `end: true` only). */ data?: string; /** Set on the last chunk. After this frame the host SHOULD ignore * any further frames for this reqId. */ end?: boolean; /** Set when the upstream failed mid-stream. Host surfaces this as * a stream error and closes the readable. */ error?: Readonly<{ code: string; message: string; }>; } interface HelloFrame { t: "hello"; version: typeof TUNNEL_VERSION; /** * Daemon-declared capabilities. Hosts MAY refuse to dispatch * features the daemon hasn't claimed. */ capabilities: Readonly<{ pty: boolean; /** Daemon can bridge browser-WS upgrades to its local upstream via * `ws_open` / `ws_message` / `ws_close`. Hosts MUST gate * `forwardWebSocket()` on this — older daemons ignore the frames. */ wsForward?: boolean; /** Daemon accepts `http_request.bodyChunked` + `http_request_chunk`. * Hosts send large bodies inline to daemons without it. */ httpRequestChunks?: boolean; /** Daemon reassembles fragmented host→daemon `ws_message`s (`more`). * Hosts send large WS messages whole to daemons without it. */ wsFragments?: boolean; /** * Identifiers of the capabilities the daemon serves — its registered * agent adapters / tool surfaces. Lets a host that fronts several * daemons enumerate and route by what each one can do, without a * round-trip. Omitted by older daemons (host treats as "unknown", * not "none"). Advisory: the authoritative surface is still the * daemon's live `tools/list` over the HTTP relay. */ tools?: ReadonlyArray; /** * Set when this daemon's tunnel channel is E2E-encrypted (design: * tunnel-e2e/v1). Informational confirmation only — this `hello` is * itself carried INSIDE the already-established encrypted channel (the * `wrapE2E` box), so a host that decrypts it has, by definition, already * completed the token-authenticated handshake. The negotiation that turns * a channel encrypted happens BEFORE this frame, over `e2e_handshake` * frames on the raw sink — not by advertising a capability here. A host * may surface this flag in its UI ("this daemon is end-to-end encrypted"). * Absent ⇒ plaintext tunnel (today's behaviour). See ./tunnel-e2e.ts. */ e2e?: boolean; }>; /** User-friendly daemon label, surfaced in host UIs. */ label?: string; /** Daemon process info — diagnostics only, not authoritative. */ daemon?: Readonly<{ name: string; version: string; platform: string; nodeVersion?: string; }>; } interface SpawnedFrame { t: "spawned"; execId: string; pid: number; } interface StdoutFrame { t: "stdout"; execId: string; /** Base64-encoded payload bytes. */ data: string; } interface StderrFrame { t: "stderr"; execId: string; /** Base64-encoded payload bytes. */ data: string; } interface ExitFrame { t: "exit"; execId: string; /** Null when killed by signal. */ code: number | null; /** POSIX signal name when killed by signal; null otherwise. */ signal: string | null; } interface ErrorFrame { t: "error"; /** Omitted for tunnel-level errors not tied to a specific exec. */ execId?: string; /** Stable machine-readable code, e.g. "spawn_failed", "unknown_exec". */ code: string; /** Human-readable diagnostic. */ message: string; } interface PingFrame { t: "ping"; /** Echoed verbatim in the matching pong. */ nonce: string; } interface PongFrame { t: "pong"; nonce: string; } /** * HOST → DAEMON. Graceful drain signal sent during host rollover (e.g. * Kubernetes preStop). On receipt the daemon SHOULD: * 1. Stop accepting new in-flight work where it can. * 2. Close the WS cleanly (does not need to wait — host will follow * up with a `close(1012, "service_restart")` ~2 s later as a * hard backstop). * 3. Reconnect IMMEDIATELY without the usual exponential backoff — * the host is mid-deploy, the new replica is already listening. * * Backward-compatible: daemons that don't know the frame just ignore * it (parseFrame returns null for unknown types). The host's close * then triggers the daemon's normal backoff loop — worst case ~30 s * reconnect gap instead of ~2 s with the handler. */ interface ReconnectSoonFrame { t: "reconnect_soon"; /** Hint of how long the host plans to wait before forcing close. * Optional — daemons MAY use it to decide whether to finish * in-flight work or close immediately. */ reasonMs?: number; } /** * HOST → DAEMON. Open a WS connection to the daemon's upstream. * Path semantics match `HttpRequestFrame` — absolute URLs rejected, * daemon prepends its `ws://` base. Headers forwarded as-is * (cookies are typically NOT forwarded since the daemon's upstream is * loopback; cookie passthrough is opt-in via daemon config). */ interface WsOpenFrame { t: "ws_open"; /** Correlates every WS frame in both directions for this connection. */ reqId: string; /** Path + querystring on the daemon-local upstream, e.g. * `/sessions/abc/pty?cols=80&rows=24`. */ path: string; /** Optional registered upstream to dial instead of the daemon's * default gateway — a NAME the daemon resolves to a base URL (never * a URL itself, so the host can't point the daemon at an arbitrary * origin). Used to reach an imported local service (e.g. a browser * capability server) by alias. Absent ⇒ the daemon's own gateway. */ upstream?: string; /** Headers to set on the upstream WS request. Hop-by-hop headers * (Connection, Upgrade, Sec-WebSocket-*) MUST be stripped — the * daemon's `ws` client manages those. */ headers?: Readonly>; /** Subprotocols requested by the browser; forwarded to upstream. */ protocols?: readonly string[]; /** The host reassembles fragmented `ws_message`s (`more`), so the daemon may * split large upstream messages for this connection. Older hosts omit it * and get every message whole. */ fragments?: boolean; } /** * DAEMON → HOST. Result of a `ws_open`. On success the upstream WS is * connected and frames can flow. On failure `error` is set; no further * frames for this `reqId` will arrive. */ interface WsOpenAckFrame { t: "ws_open_ack"; reqId: string; /** HTTP status from the upstream upgrade. 101 on success; 4xx/5xx * when the upstream refused the upgrade. */ status: number; /** Selected subprotocol, if any. */ protocol?: string; /** Set when the daemon couldn't open the upstream (DNS failure, * refused, timeout). Distinct from a clean upstream-side close, * which arrives as `ws_close`. */ error?: Readonly<{ code: string; message: string; }>; } /** * Either direction. One application-level WS frame's payload. Text * frames carry UTF-8 bytes (`binary: false`); binary frames carry * arbitrary bytes (`binary: true`). The `data` field is base64-encoded * either way so the transport stays JSON. */ interface WsMessageFrame { t: "ws_message"; reqId: string; /** Base64-encoded WS frame payload. */ data: string; /** True for binary frames; false (or omitted) for text. */ binary?: boolean; /** Not the last fragment of this WS message: the receiver buffers until a * frame without `more`, then delivers the whole message (with the last * fragment's `binary`). Only sent to a peer that negotiated fragments * (`ws_open.fragments` / `hello.capabilities.wsFragments`). */ more?: boolean; } /** * Either direction. Close the bridged WS connection. Includes the * close code (1000 normal, 1006 abnormal, etc.) and optional reason. * After sending or receiving this frame, neither side will send * further frames for `reqId`. */ interface WsCloseFrame { t: "ws_close"; reqId: string; /** WebSocket close code. */ code: number; reason?: string; } /** * Either direction. One AEAD-encrypted inner tunnel frame. `n` is the * sender's per-direction, strictly-monotonic frame counter — it is the basis * of the GCM nonce and lets the receiver detect replay (a repeated/older `n`), * reorder, or drops (a gap in `n`). `d` is base64(ciphertext ‖ 16-byte GCM * tag). The plaintext, once decrypted, is itself an `encodeFrame`'d * `TunnelFrame`. */ interface E2eFrame { t: "e2e"; n: number; d: string; } /** * Either direction. An opaque handshake message exchanged BEFORE the channel * is wrapped. `d` is base64 of a transport-agnostic handshake message (the * `pair/v1` hello or reply, produced by `@agentproto/secrets/pairing`). The * E2E layer here treats it as opaque bytes handed to an injected driver, so * this package never depends on the crypto package. */ interface E2eHandshakeFrame { t: "e2e_handshake"; d: string; } type HostToDaemonFrame = SpawnFrame | StdinFrame | KillFrame | ResizeFrame | HttpRequestFrame | HttpRequestChunkFrame | HttpCancelFrame | WsOpenFrame | WsMessageFrame | WsCloseFrame | PingFrame | PongFrame | ErrorFrame | ReconnectSoonFrame | E2eFrame | E2eHandshakeFrame; type DaemonToHostFrame = HelloFrame | SpawnedFrame | StdoutFrame | StderrFrame | ExitFrame | HttpResponseFrame | HttpResponseHeadFrame | HttpResponseChunkFrame | WsOpenAckFrame | WsMessageFrame | WsCloseFrame | PingFrame | PongFrame | ErrorFrame | E2eFrame | E2eHandshakeFrame; type TunnelFrame = HostToDaemonFrame | DaemonToHostFrame; /** * Parse a raw WS message into a `TunnelFrame`. Returns null when the * payload isn't a valid frame — callers MUST handle null defensively * (typically: send an `error` frame and ignore). We don't throw so * one bad frame can't tear down the tunnel. */ declare function parseFrame(raw: string): TunnelFrame | null; declare function encodeFrame(frame: TunnelFrame): string; /** * Encode raw bytes for an `stdin` / `stdout` / `stderr` frame's `data` * field. Centralised so both sides agree on the encoding. */ declare function encodeData(bytes: Uint8Array | string): string; /** * Decode a frame's base64 `data` field. Returns a `Uint8Array` (at runtime a * `Buffer` under Node); the `@agentproto/acp/tunnel` Node entry re-exports a * `Buffer`-typed wrapper for existing callers. */ declare function decodeData(data: string): Uint8Array; /** * Transport-agnostic frame sink. Wrap any duplex byte-stream (WebSocket, * TLS socket, ssh channel, in-process pair for tests) by adapting to * this shape. The tunnel client/server only ever talk to this — they * never know about `ws` directly. * * `send` MUST be best-effort non-blocking; the underlying transport * handles backpressure. `onFrame` fires for each successfully-parsed * frame; malformed frames are dropped silently (the transport-adapter * is the one that decides whether to log them). * * `close` is one-way — once called, no further sends are attempted and * `onClose` fires. `onClose` is also called when the remote end hangs * up. */ interface FrameSink { send(frame: TunnelFrame): void; close(reason?: string): void; onFrame(handler: (frame: TunnelFrame) => void): () => void; onClose(handler: (reason?: string) => void): () => void; /** True until `close` is called or the remote disconnects. */ readonly isOpen: boolean; } /** * The AEAD seam behind `wrapE2E`: AES-256-GCM with a 16-byte tag, and nothing * else. `@agentproto/acp` never touches key agreement — it only receives the two * symmetric session keys — so this is the whole crypto surface it needs. * * Structurally a subset of `@agentproto/secrets`' `CryptoProvider`, so either * package's provider objects can be passed where an `E2eAead` is expected. * * Two implementations: `nodeAead` (./aead-node.ts, `node:crypto`; the default * through the `@agentproto/acp/tunnel` Node entry) and `webCryptoAead` (here; * the default through `@agentproto/acp/tunnel/browser`). Both produce * `ciphertext ‖ tag` — AES-GCM is deterministic, so for the same key, nonce, * AAD and plaintext they are byte-identical. * * Browser-safe: no `node:` import. */ interface E2eAead { /** Returns `ciphertext ‖ 16-byte tag`. */ aesGcmEncrypt(key: Uint8Array, iv: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Promise; /** Opens `ciphertext ‖ tag`; rejects on any authentication failure. */ aesGcmDecrypt(key: Uint8Array, iv: Uint8Array, sealed: Uint8Array, aad?: Uint8Array): Promise; } /** * WebCrypto AES-GCM. Imported `CryptoKey`s are cached per key buffer, since * `wrapE2E` encrypts every frame under the same two keys. */ declare function createWebCryptoAead(): E2eAead; declare const webCryptoAead: E2eAead; /** * End-to-end AEAD wrapper for a `FrameSink` (design: DESIGN §5). * * `wrapE2E(inner, keys)` returns a `FrameSink` that is byte-for-byte * transparent to the tunnel client/server above it — they send and receive * ordinary `TunnelFrame`s — while everything crossing `inner` (the real * transport, typically a WebSocket spliced through an untrusted rendezvous) is * AES-256-GCM ciphertext wrapped in `e2e` envelope frames. The rendezvous sees * only opaque bytes, their sizes, and their timing. * * There is deliberately NO new application protocol: the existing * `agentproto/tunnel/v1` frames are reused verbatim, serialized with * `encodeFrame`, encrypted, and carried inside `e2e` envelopes. So * `createTunnelClient` / `createTunnelServer` work unchanged over a wrapped * sink. * * Nonce discipline (the security-critical part): * - Two independent AES-256-GCM keys, one per direction (`sendKey` / * `recvKey`), already assigned by the handshake — so a frame can never be * reflected back and decrypt. * - A per-direction, strictly-monotonic 64-bit counter `n` is the GCM nonce * (never randomly chosen — GCM nonce reuse is catastrophic, and a counter * under one key can never repeat). `n` is also bound as AEAD associated * data, so it can't be edited without failing the tag. * - The receiver requires `n` to be exactly the next expected value. * Anything else — an older/repeated `n` (replay), a higher `n` (a dropped * or reordered frame) — is a typed `E2eError` that closes the channel. * The GCM tag itself catches any bit-flip inside the ciphertext. * * Async crypto, ordered delivery: the AES-GCM calls go through an `E2eAead` * (node:crypto or WebCrypto — ./aead.ts), which is Promise-based because * WebCrypto is. `send` stays a synchronous, fire-and-forget call: the counter * is assigned at call time, encryption starts immediately, and ciphertexts are * written to `inner` strictly in counter order. Inbound frames are checked * (envelope, counter) synchronously on arrival and delivered, after * decryption, strictly in arrival order. A graceful close — local or remote — * lets frames already in flight drain first; a security failure drops them. * * This module is browser-safe: it depends only on the `E2eAead` seam, the frame * codec and portable byte helpers — never on `node:*` or on the * crypto/handshake package. It consumes opaque key material and, for the * handshake-over-sink helpers, an opaque driver callback. */ /** The two symmetric session keys, already role-adjusted by the handshake * (this side's `sendKey` is the peer's `recvKey`). Each is 32 bytes for * AES-256-GCM. */ interface E2eKeys { sendKey: Uint8Array; recvKey: Uint8Array; } /** Stable failure codes for the AEAD channel. */ type E2eErrorCode = "replay" | "reorder" | "auth" | "decode" | "not_e2e" | "overflow"; /** Raised for every E2E channel failure. Surfaced via `onSecurityError` and as * the `onClose` reason — the channel always closes on any of these, so no * tampered or out-of-order frame is ever delivered to the tunnel above. */ declare class E2eError extends Error { readonly code: E2eErrorCode; constructor(code: E2eErrorCode, message: string); } interface WrapE2EOptions { /** * Called once, with the typed error, the first time an inbound frame fails * an AEAD or counter check (or a send overflows). The channel is closed * immediately afterwards. Optional — the same reason is also delivered to * `onClose` listeners, so consumers that only care about closure can ignore * this. */ onSecurityError?: (err: E2eError) => void; /** * Frame count at which sends must stop and rekey. Defaults to 2^32 — the * point at which a 64-bit-counter/one-key regime should rotate keys. v1 has * no rekey, so it errors here instead of ever reusing a nonce. Unreachable * in practice; the guard exists so it can never be violated. */ maxFrames?: number; /** * The AES-256-GCM implementation. Defaults to WebCrypto here; the * `@agentproto/acp/tunnel` Node entry defaults it to `node:crypto`. Both * produce identical bytes. */ aead?: E2eAead; } /** A wrapped sink also exposes its direction counters, so a later phase can * trigger a rekey as they approach the limit. */ interface E2eFrameSink extends FrameSink { /** Number of frames this side has accepted for sending (each one has been * assigned its counter; encryption and the write to `inner` follow in * order). */ readonly sentCount: number; /** Number of frames this side has decrypted and delivered. */ readonly recvCount: number; } /** Default rekey ceiling: 2^32 frames per direction. */ declare const DEFAULT_E2E_MAX_FRAMES = 4294967296; /** * Wrap `inner` so the tunnel above it exchanges plaintext `TunnelFrame`s while * the wire carries AEAD ciphertext. Transparent: pass the result anywhere a * `FrameSink` is expected. */ declare function wrapE2E(inner: FrameSink, keys: E2eKeys, opts?: WrapE2EOptions): E2eFrameSink; interface HandshakeOverSinkOptions { /** How long to wait for the peer's handshake message before failing. * Default 10s. */ timeoutMs?: number; /** Forwarded to `wrapE2E`. */ wrap?: WrapE2EOptions; /** * Optional diagnostic sink for the POST-HANDSHAKE close (BOOTSTRAP P4 item * 2, field evidence 2026-09-30): the observed E2E flaps break exactly here * — the Noise handshake completes, the reply is sent, and THEN the remote * hangs up. Without this hook the close reason dies inside the returned * sink unless the caller remembers to subscribe, and a caller that races * (or the pre-subscription close gap) loses it entirely. Fired at most * once per handshake, with the remote's close reason when it gave one. * `daemonHandshakeOverSink` fires it for the daemon side — the field * evidence's exact hook (the reason was captured here and discarded). */ log?: (line: string) => void; } /** * A view of `inner` that holds frames arriving while it has no subscriber and * replays them, in order, to the next one. The handshake helpers read the * peer's handshake message, then spend a moment (async key derivation) before * `wrapE2E` subscribes; a peer that starts talking the instant its side is * wrapped must not lose those first frames to that gap. Transports like a raw * WebSocket don't buffer on their own. */ declare function holdFrames(inner: FrameSink): FrameSink; /** * Client side of the handshake-over-sink. Sends the `hello` bytes, awaits the * daemon's single reply, runs `deriveKeys` (which verifies the reply and yields * the session keys; it may be async), and returns a wrapped sink. Rejects — * closing the sink — if `deriveKeys` throws (e.g. a bad daemon signature). * Frames the daemon sends while the keys are being derived are held and * delivered once wrapped. */ declare function clientHandshakeOverSink(sink: FrameSink, hello: Uint8Array, deriveKeys: (reply: Uint8Array) => E2eKeys | Promise, opts?: HandshakeOverSinkOptions): Promise; /** * Daemon side of the handshake-over-sink. Awaits the client `hello`, runs * `respond` (which validates it and yields the reply bytes + session keys; it * may be async), sends the reply, and returns a wrapped sink. Rejects — * closing the sink — if `respond` throws (e.g. a rejected offer token or * tampered hello). */ declare function daemonHandshakeOverSink(sink: FrameSink, respond: (hello: Uint8Array) => { reply: Uint8Array; keys: E2eKeys; } | Promise<{ reply: Uint8Array; keys: E2eKeys; }>, opts?: HandshakeOverSinkOptions): Promise; /** * Opt-in E2E negotiation for the reverse `serve --connect` tunnel * (design: tunnel-e2e/v1). * * The plaintext reverse tunnel lets a host terminate the WS and read every * `agentproto/tunnel/v1` frame. These helpers upgrade that channel to the * `wrapE2E` AEAD box using a short, token-authenticated ephemeral handshake, so * even a trusted host loses plaintext visibility — while staying fully * backward-compatible: with an old peer (or the feature off) the channel stays * exactly as it is today. * * ## Negotiation, and why it lives before the tunnel `hello` * * `createTunnelServer` emits the tunnel `hello` as its very first frame, and we * want that `hello` — and everything after it — INSIDE the encrypted box. So the * negotiation cannot ride on the `hello`; it runs first, on the raw sink, over * `e2e_handshake` frames: * * ``` * daemon → host: e2e_handshake(offer) // daemon advertises e2e + its ephemeral * host → daemon: e2e_handshake(accept) // host advertises e2e + its ephemeral * … both wrapE2E(sink) and speak tunnel/v1 encrypted from here … * ``` * * The daemon (initiator) offers e2e ONLY when configured to; the host accepts * ONLY when it too supports it. If either doesn't, we fall back to plaintext: * - **Old host**: never sends an `accept`. `e2e_handshake` is a known frame * type with no handler on an unwrapped peer, so the daemon's offer is * ignored, and after `timeoutMs` the daemon falls back to plaintext on the * same still-open sink. One stray, ignored frame — behaviour is otherwise * identical to today. * - **Daemon with e2e off**: never sends an `offer`; it emits the plaintext * `hello` first. An e2e-capable host detects a non-handshake first frame and * falls back, re-injecting that frame into its plaintext client. * * ## Fail-closed vs. fall-back * * A silent peer (timeout) is a COMPATIBILITY signal → fall back to plaintext. A * peer that DID answer but whose handshake is invalid (a wrong `tunnel.token`, a * tampered message) is a SECURITY signal → the injected crypto throws and we * close the sink and propagate, never downgrading to plaintext. Distinguishing * these two is the whole point of returning `null` on timeout but throwing on a * bad handshake. * * This module is crypto-agnostic: all key material comes from injected callbacks * (backed by `@agentproto/secrets/pairing`'s `tunnel-handshake`). It depends only * on `wrapE2E` + the frame codec, so `@agentproto/acp` never imports the crypto * package — exactly as the pairing handshake-over-sink helpers do. */ /** Default negotiation timeout: how long the daemon waits for the host's * `accept` (or the host waits for the daemon's first frame) before concluding * the peer does not support e2e and falling back to plaintext. */ declare const DEFAULT_TUNNEL_E2E_TIMEOUT_MS = 8000; interface TunnelE2EOptions { /** Negotiation timeout in ms. Default `DEFAULT_TUNNEL_E2E_TIMEOUT_MS`. */ timeoutMs?: number; /** Forwarded to `wrapE2E`. */ wrap?: WrapE2EOptions; } /** * Daemon side of the tunnel-e2e negotiation. Sends the `offer` bytes as an * `e2e_handshake` frame, awaits the host's single reply, and: * * - **reply arrives** → runs `deriveKeys` (which verifies the reply and yields * the session keys; it may be async) and returns a `wrapE2E`-wrapped sink. If `deriveKeys` * throws — a wrong `tunnel.token`, a tampered accept — this CLOSES the sink * and rethrows: a security failure must NOT silently downgrade to plaintext. * - **timeout (peer silent)** → returns `null`. The peer is an old/plaintext * host; the sink is left open so the caller falls back to plaintext on it. * - **a non-handshake frame arrives first** → protocol violation (a compliant * old host stays silent until it receives the tunnel `hello`); closes the * sink and throws. */ declare function connectSinkE2E(sink: FrameSink, offer: Uint8Array, deriveKeys: (reply: Uint8Array) => E2eKeys | Promise, opts?: TunnelE2EOptions): Promise; /** Outcome of the host-side negotiation. On the plaintext branch, `sink` is a * view of the original transport that will replay any already-read frame to the * first subscriber — so the host can hand it straight to a plaintext tunnel * client without losing the daemon's `hello`. */ type AcceptSinkE2EResult = { e2e: true; sink: E2eFrameSink; } | { e2e: false; sink: FrameSink; }; /** * Host side of the tunnel-e2e negotiation. Awaits the daemon's first frame: * * - **`e2e_handshake` offer** → runs `respond` (which verifies the offer and * yields the reply bytes + session keys; it may be async), sends the reply, and returns a * `wrapE2E`-wrapped sink. A `respond` throw — wrong token, tampered offer — * closes the sink and rethrows (fail closed). * - **any other frame** (the daemon emitted a plaintext `hello` — it isn't * doing e2e) → returns `{ e2e: false }` with a sink that replays that frame, * so the caller's plaintext client still sees it. * - **timeout** → `{ e2e: false }` on the raw sink (nothing to replay). */ declare function acceptSinkE2E(sink: FrameSink, respond: (offer: Uint8Array) => { reply: Uint8Array; keys: E2eKeys; } | Promise<{ reply: Uint8Array; keys: E2eKeys; }>, opts?: TunnelE2EOptions): Promise; /** * Adapter from a WebSocket-shaped object to a `FrameSink`. Works with * both the browser-native WebSocket API and the `ws` library's server * sockets — anything with `send`, `close`, and `addEventListener`-style * `message`/`close` events. * * Why a duck-typed shape and not `import("ws")`: the cli, the host, * and (eventually) browser hosts all need to wrap WS instances they * obtained themselves. Importing `ws` in this package would force * every consumer to install it even when running in environments * (Bun, Deno, browser) that ship native WebSocket. */ interface WebSocketLike { send(data: string): void; close(code?: number, reason?: string): void; readyState: number; addEventListener(event: "message", handler: (ev: { data: string | ArrayBuffer | Uint8Array; }) => void): void; addEventListener(event: "close", handler: () => void): void; addEventListener(event: "error", handler: (ev: { message?: string; }) => void): void; removeEventListener(event: string, handler: (...args: never[]) => void): void; } declare function wrapWebSocket(ws: WebSocketLike): FrameSink; export { type AcceptSinkE2EResult, DEFAULT_E2E_MAX_FRAMES, DEFAULT_TUNNEL_E2E_TIMEOUT_MS, type DaemonToHostFrame, type E2eAead, E2eError, type E2eErrorCode, type E2eFrame, type E2eFrameSink, type E2eHandshakeFrame, type E2eKeys, type ErrorFrame, type ExitFrame, type FrameSink, type HandshakeOverSinkOptions, type HelloFrame, type HostToDaemonFrame, type HttpCancelFrame, type HttpRequestChunkFrame, type HttpRequestFrame, type HttpResponseChunkFrame, type HttpResponseFrame, type HttpResponseHeadFrame, type KillFrame, MAX_FRAME_PAYLOAD_BYTES, type PingFrame, type PongFrame, type ReconnectSoonFrame, type ResizeFrame, type SpawnFrame, type SpawnedFrame, type StderrFrame, type StdinFrame, type StdoutFrame, TUNNEL_VERSION, type TunnelE2EOptions, type TunnelFrame, type WebSocketLike, type WrapE2EOptions, type WsCloseFrame, type WsMessageFrame, type WsOpenAckFrame, type WsOpenFrame, acceptSinkE2E, clientHandshakeOverSink, connectSinkE2E, createWebCryptoAead, daemonHandshakeOverSink, decodeData, encodeData, encodeFrame, holdFrames, parseFrame, splitPayload, webCryptoAead, wrapE2E, wrapWebSocket };