feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

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.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

1609
agent/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
agent/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "web-terminal-agent",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "P2 — Host Agent for the rendezvous-relay service. Ed25519 per-host identity, single-use pairing redemption (§4.5), outbound mTLS wss tunnel holding the §4.1 mux (codec via relay-contracts), loopback splice to the UNCHANGED web-terminal, heartbeat/backoff, cert auto-rotation, fast revocation, and the agent-side E2E endpoint (§4.4). Ciphertext-shuttle on the customer's own machine. See docs/PLAN_RELAY_AGENT.md.",
"bin": {
"web-terminal-agent": "dist/cli.js"
},
"engines": {
"node": ">=18"
},
"main": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"relay-contracts": "file:../relay-contracts",
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^25.9.3",
"@types/ws": "^8.5.12",
"@vitest/coverage-v8": "^4.1.9",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
}

136
agent/src/certs/rotation.ts Normal file
View File

@@ -0,0 +1,136 @@
/**
* Short-lived cert rotation — PLAN_RELAY_AGENT T13 (INV14). Renews the mTLS cert BEFORE expiry via
* P3's renewal endpoint using a fresh CSR over the SAME Ed25519 key (pubkey unchanged, only the
* cert rotates). Installs the new cert atomically (keystore writeFile is whole-file). A 403 from
* the renewal endpoint ⇒ the host was revoked ⇒ onRevoked (⇒ T14 teardown, INV12).
*
* NOTE (cross-plan / open Q#5): the renewal URL + its auth (mTLS with the current cert vs a short
* renewal token) is P3-owned. This derives `${enrollUrl}` → `.../renew` and injects fetch; when P3
* freezes the route, adjust `renewalUrlFor`. Single integration point.
*/
import { X509Certificate } from 'node:crypto'
import type { AgentConfig } from '../config/agentConfig.js'
import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js'
import type { TimerLike } from '../transport/seams.js'
import { buildCsr } from '../enroll/csr.js'
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
export interface CertRotator {
start(): void
stop(): void
onRotated(cb: () => void): void
onRevoked(cb: () => void): void
}
export type RenewOutcome = 'rotated' | 'revoked'
/** Derive P3's renewal route from the enroll URL (integration point, open Q#5). */
export function renewalUrlFor(cfg: AgentConfig): string {
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
}
/** Ms until (validTo renewBeforeMs), clamped to ≥ 0. */
export function computeRenewDelayMs(
certPem: string,
renewBeforeMs: number,
now: Date,
parse: (pem: string) => Date = (p) => new Date(new X509Certificate(p).validTo),
): number {
const validTo = parse(certPem).getTime()
return Math.max(0, validTo - renewBeforeMs - now.getTime())
}
/**
* Perform one renewal round-trip. Returns 'rotated' (new cert stored) or 'revoked' (403). Any
* other HTTP/network failure throws (caller retries with backoff; the tunnel stays up until the
* cert actually expires).
*/
export async function renewCert(
cfg: AgentConfig,
id: AgentIdentity,
ks: Keystore,
fetchImpl: typeof fetch,
): Promise<RenewOutcome> {
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
const res = await fetchImpl(renewalUrlFor(cfg), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ csr }),
})
if (res.status === 403) return 'revoked'
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
const json = (await res.json()) as { cert?: string; caChain?: string }
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') {
throw new Error('cert renewal response missing cert/caChain')
}
ks.saveCert(json.cert, json.caChain) // atomic whole-file install
return 'rotated'
}
export function createCertRotator(
cfg: AgentConfig,
id: AgentIdentity,
ks: Keystore,
opts: {
renewBeforeMs?: number
timer?: TimerLike
fetchImpl?: typeof fetch
now?: () => Date
parseCert?: (pem: string) => Date
} = {},
): CertRotator {
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
const parseCert = opts.parseCert ?? ((p) => new Date(new X509Certificate(p).validTo))
const timer = opts.timer ?? {
setTimeout: (cb, ms) => setTimeout(cb, ms),
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
const doFetch = opts.fetchImpl ?? fetch
const now = opts.now ?? (() => new Date())
let handle: unknown = null
let rotatedCb: (() => void) | null = null
let revokedCb: (() => void) | null = null
function schedule(): void {
const certs = ks.loadCert()
if (certs === null) return
const delay = computeRenewDelayMs(certs.certPem, renewBeforeMs, now(), parseCert)
handle = timer.setTimeout(runRenewal, delay)
}
function runRenewal(): void {
void renewCert(cfg, id, ks, doFetch)
.then((outcome) => {
if (outcome === 'revoked') {
revokedCb?.()
return
}
rotatedCb?.()
schedule()
})
.catch(() => {
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile.
handle = timer.setTimeout(runRenewal, renewBeforeMs)
})
}
return {
start(): void {
schedule()
},
stop(): void {
if (handle !== null) timer.clearTimeout(handle)
handle = null
},
onRotated(cb): void {
rotatedCb = cb
},
onRevoked(cb): void {
revokedCb = cb
},
}
}

97
agent/src/cli.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
* `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
*/
import type { AgentConfig } from './config/agentConfig.js'
import type { AgentIdentity } from './keys/identity.js'
import type { Keystore } from './keys/keystore.js'
import type { EnrollResult } from 'relay-contracts'
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall'
const COMMANDS: readonly CliCommand[] = ['pair', 'run', 'status', 'install', 'uninstall']
export interface CliArgs {
readonly command: CliCommand
readonly code?: string
readonly flags: Readonly<Record<string, string | boolean>>
}
export class CliUsageError extends Error {
constructor(message: string) {
super(message)
this.name = 'CliUsageError'
}
}
export interface CliDeps {
loadConfig(): AgentConfig
openKeystore(stateDir: string): Keystore
generateIdentity(): AgentIdentity
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
installService(cfg: AgentConfig): Promise<void>
uninstallService(): Promise<void>
print(line: string): void
}
/** Parse argv (already sliced past node/script) into a typed CliArgs. */
export function parseArgs(argv: readonly string[]): CliArgs {
const [command, ...rest] = argv
if (command === undefined || !COMMANDS.includes(command as CliCommand)) {
throw new CliUsageError(`unknown command '${command ?? ''}' (expected ${COMMANDS.join(' | ')})`)
}
const flags: Record<string, string | boolean> = {}
const positionals: string[] = []
for (const token of rest) {
if (token.startsWith('--')) {
const [k, v] = token.slice(2).split('=')
flags[k!] = v ?? true
} else {
positionals.push(token)
}
}
const args: CliArgs = { command: command as CliCommand, flags }
if (command === 'pair') {
const code = positionals[0]
if (code === undefined) throw new CliUsageError('usage: web-terminal-agent pair <CODE>')
return { ...args, code }
}
return args
}
/** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
const cfg = deps.loadConfig()
const ks = deps.openKeystore(cfg.stateDir)
switch (args.command) {
case 'pair': {
const id = ks.loadIdentity() ?? deps.generateIdentity()
ks.saveIdentity(id)
const enroll = await deps.redeem(cfg, args.code!, id, ks)
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
if (args.flags['install']) await deps.installService(cfg)
return 0
}
case 'run': {
if (ks.loadIdentity() === null || ks.loadCert() === null) {
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
}
return deps.runTunnel(cfg, ks)
}
case 'status': {
const enrolled = ks.loadIdentity() !== null && ks.loadCert() !== null
// INV9: print only non-secret identifiers.
deps.print(`enrolled: ${enrolled}`)
deps.print(`host_id: ${cfg.hostId ?? '(none)'}`)
deps.print(`subdomain: ${cfg.subdomain ?? '(none)'}`)
return 0
}
case 'install':
await deps.installService(cfg)
return 0
case 'uninstall':
await deps.uninstallService()
return 0
}
}

View File

@@ -0,0 +1,87 @@
/**
* Agent configuration — PLAN_RELAY_AGENT T2. Zod-validated at the startup boundary (INV9):
* - relayUrl MUST be wss:// (encrypted tunnel only)
* - enrollUrl MUST be https:// (enrollment over TLS only)
* - localTargetUrl MUST be ws:// to a LOOPBACK host (anti-SSRF: the agent forwards ONLY to
* the local web-terminal, never an arbitrary target).
* Fail-fast on missing/invalid values — never silently default a security-relevant field.
*/
import { homedir } from 'node:os'
import { join } from 'node:path'
import { z } from 'zod'
export interface AgentConfig {
readonly relayUrl: string
readonly enrollUrl: string
readonly stateDir: string
readonly localTargetUrl: string
readonly subdomain: string | null
readonly hostId: string | null
}
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
export function isLoopbackWsUrl(url: string): boolean {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return false
}
if (parsed.protocol !== 'ws:') return false
const host = parsed.hostname
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
}
function hasScheme(url: string, scheme: string): boolean {
try {
return new URL(url).protocol === scheme
} catch {
return false
}
}
export const AgentConfigSchema = z
.object({
relayUrl: z
.string()
.refine((u) => hasScheme(u, 'wss:'), 'relayUrl must be a wss:// URL'),
enrollUrl: z
.string()
.refine((u) => hasScheme(u, 'https:'), 'enrollUrl must be an https:// URL'),
stateDir: z.string().min(1),
localTargetUrl: z
.string()
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
subdomain: z.string().min(1).nullable(),
hostId: z.string().min(1).nullable(),
})
.strict()
.readonly()
const DEFAULT_LOCAL_TARGET = 'ws://127.0.0.1:3000'
/** Default state dir where key/cert/content-secret live (~/.web-terminal-agent). */
export function defaultStateDir(): string {
return join(homedir(), '.web-terminal-agent')
}
/**
* Build + validate an AgentConfig from env + explicit argv overrides (argv wins). Throws a
* ZodError (fail-fast) if any required field is missing or fails a scheme/loopback refinement.
*/
export function loadAgentConfig(
env: NodeJS.ProcessEnv,
argv: Partial<AgentConfig> = {},
): AgentConfig {
const merged = {
relayUrl: argv.relayUrl ?? env.RELAY_URL,
enrollUrl: argv.enrollUrl ?? env.ENROLL_URL,
stateDir: argv.stateDir ?? env.STATE_DIR ?? defaultStateDir(),
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
hostId: argv.hostId ?? env.HOST_ID ?? null,
}
return AgentConfigSchema.parse(merged)
}

View File

@@ -0,0 +1,144 @@
/**
* Host E2E endpoint (§4.4) — PLAN_RELAY_AGENT T15. The HOST side of the authenticated ECDH: absorb
* ClientHello → verify the device-auth-proof FIRST → reply HostHello → derive DirectionalKeys →
* per-stream seal(h2c)/open(c2h). Distinct from live frames, every replay-bound output is ALSO
* sealed under K_content via the T19 ReplaySealer (FIX 3).
*
* ANTI-MITM (INV2): the device-auth-proof `verifyDeviceProof` is P5-OWNED and reaches this module
* INJECTED (FIX 6b) — this file MUST NOT `import { verifyDeviceAuthProof } from 'relay-e2e'`. The
* proof is verified BEFORE any key derivation; a forged proof ⇒ MitmAbortError, NO HostHello, NO
* DirectionalKeys. A no-stub guard test asserts this file imports no verifier and that swapping the
* injected verifier for `async () => true` makes the MITM test fail.
*
* BLOCKING GATE (open Q#2 — DEFERRED): the real §4.4 crypto (`sealFrame`/`openFrame`/
* `createE2ESession`) + host-handshake wiring (`createHostHandshake`) live in P4 `relay-e2e/`, and
* the P5 verifier + forged-proof vector are a hard pre-W4 gate. P4 is NOT built yet, so those are
* INJECTED here as seams typed to the frozen relay-contracts signatures — production passes the
* relay-e2e impls verbatim, NEVER a stub. INV11: even after open(), bytes go to loopback OPAQUE.
*/
import type {
ClientHello,
E2ESession,
HandshakeResult,
HostHello,
} from 'relay-contracts'
import type { AgentIdentity } from '../keys/identity.js'
import type { FrameTransform } from '../transport/streamRouter.js'
import type { ReplaySealer } from './replaySeal.js'
/** Host-side device-proof verifier — INJECTED (P5 issues+verifies), bound to the ClientHello. */
export type VerifyDeviceProof = (
proof: string,
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
) => Promise<boolean>
export class MitmAbortError extends Error {
constructor(message: string) {
super(message)
this.name = 'MitmAbortError'
}
}
/** P4 host-handshake wiring seam (`createHostHandshake`), typed to §4.4 shapes. */
export interface HostHandshake {
respond(clientHello: ClientHello): Promise<{ hello: HostHello; result: HandshakeResult }>
}
export type CreateHostHandshake = (deps: {
verifyDeviceProof: VerifyDeviceProof
identity: AgentIdentity
}) => HostHandshake
/** P4 §4.4 crypto core, injected (impls live in relay-e2e/). */
export interface E2ECryptoDeps {
createHostHandshake: CreateHostHandshake
createE2ESession(role: 'host', result: HandshakeResult): E2ESession
/** Wire codec for the HostHello reply (P4/P6-owned framing). */
encodeHostHello(hello: HostHello): Uint8Array
}
/**
* Produce the HostHello + HandshakeResult for a ClientHello. The proof is verified FIRST; a forged
* proof aborts with MitmAbortError and NO key derivation (anti-MITM). FIX 2: the result carries
* DirectionalKeys{c2h,h2c}, never a single sessionKey.
*/
export async function makeHostHello(
clientHello: ClientHello,
id: AgentIdentity,
verifyDeviceProof: VerifyDeviceProof,
deps: Pick<E2ECryptoDeps, 'createHostHandshake'>,
): Promise<{ hello: HostHello; result: HandshakeResult }> {
const ok = await verifyDeviceProof(clientHello.deviceAuthProof, {
clientEphPub: clientHello.clientEphPub,
clientNonce: clientHello.clientNonce,
})
if (!ok) {
throw new MitmAbortError('device-auth-proof verification failed — aborting, no keys derived')
}
const handshake = deps.createHostHandshake({ verifyDeviceProof, identity: id })
return handshake.respond(clientHello)
}
/**
* FrameTransform + a `seedSession` seam. The wiring layer intercepts the first (ClientHello) frame,
* runs `makeHostHello` (async, verify-first), then calls `seedSession` with the derived
* E2ESession + the encoded HostHello (queued as a control frame the router flushes upstream).
* After seeding: inbound = session.open(c2h) → loopback (OPAQUE, INV11); outbound = session.seal
* (h2c) → tunnel AND replay.seal(K_content) — two DISTINCT ciphertexts (FIX 3).
*/
export interface E2ETransform extends FrameTransform {
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void
}
interface StreamE2EState {
session: E2ESession | null
readonly control: Uint8Array[]
}
export function createE2ETransform(
_id: AgentIdentity,
_verifyDeviceProof: VerifyDeviceProof,
replay: ReplaySealer,
): E2ETransform {
const streams = new Map<number, StreamE2EState>()
function stateFor(streamId: number): StreamE2EState {
let s = streams.get(streamId)
if (s === undefined) {
s = { session: null, control: [] }
streams.set(streamId, s)
}
return s
}
return {
openStream(streamId: number): void {
stateFor(streamId)
},
closeStream(streamId: number): void {
streams.delete(streamId)
},
seedSession(streamId: number, session: E2ESession, hostHelloBytes: Uint8Array): void {
const s = stateFor(streamId)
s.session = session
s.control.push(hostHelloBytes)
},
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null {
const s = stateFor(streamId)
if (s.session === null) return null // handshake not yet seeded; wiring layer handles it
return s.session.open(cipher) // opaque plaintext to loopback (INV11)
},
outbound(streamId: number, plain: Uint8Array): Uint8Array {
const s = stateFor(streamId)
if (s.session === null) {
throw new MitmAbortError('cannot seal before the E2E session is established')
}
replay.seal(plain) // FIX 3: recoverable K_content seal, DISTINCT from the live h2c frame
return s.session.seal(plain)
},
takeControlFrames(streamId: number): Uint8Array[] {
const s = streams.get(streamId)
if (s === undefined || s.control.length === 0) return []
return s.control.splice(0, s.control.length)
},
}
}

View File

@@ -0,0 +1,49 @@
/**
* Replay-frame sealer (recoverable K_content) — PLAN_RELAY_AGENT T19 (FIX 3).
*
* Live host→client frames use the EPHEMERAL DirectionalKeys.h2c (forward-secret, gone after
* reconnect). But "refresh the page and the Claude session is still there" needs the ring-buffer /
* preview ciphertext to be RECOVERABLE — 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 (P5) and opens
* it (P6). This is the single agent-side consumer of the FIX 3 recoverable key.
*
* INTEGRATION SEAM: the frozen §4.4 replay-crypto IMPLEMENTATIONS live in P4 `relay-e2e/`
* (`deriveContentKey`, `sealReplayFrame`). P4 is not built yet, so they are INJECTED here typed to
* the frozen relay-contracts signatures — production wiring passes the relay-e2e impls verbatim.
* `hostContentSecret` comes from Keystore.loadContentSecret() (T3); NEVER the ephemeral key,
* NEVER logged, NEVER sent to the relay (INV2/INV9).
*/
import type { AeadAlg, AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
/** The two §4.4 replay primitives, typed to the frozen relay-contracts signatures (P4 impls). */
export interface ReplayCrypto {
deriveContentKey(params: ReplayKeyParams): AeadKey
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
}
export interface ReplaySealer {
/** K_content seal with monotonic seq per session (INV13); NOT the live h2c frame. */
seal(plaintext: Uint8Array): E2EEnvelope
}
/**
* Build a per-(host, session) replay sealer. K_content is derived ONCE from
* { hostContentSecret, sessionId, alg }; seq is strictly monotonic from 0 (INV13).
*/
export function createReplaySealer(
hostContentSecret: Uint8Array,
sessionId: string,
alg: AeadAlg,
crypto: ReplayCrypto,
): ReplaySealer {
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg })
let seq = 0n
return {
seal(plaintext: Uint8Array): E2EEnvelope {
const env = crypto.sealReplayFrame(key, seq, plaintext)
seq += 1n
return env
},
}
}

96
agent/src/enroll/csr.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
*
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
* only the PUBLIC key + a signature leave; the private key is never serialized here).
*
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
*/
import type { AgentIdentity } from '../keys/identity.js'
// --- minimal DER encoding helpers -------------------------------------------------------------
function derLen(len: number): Uint8Array {
if (len < 0x80) return Uint8Array.from([len])
const bytes: number[] = []
let n = len
while (n > 0) {
bytes.unshift(n & 0xff)
n >>= 8
}
return Uint8Array.from([0x80 | bytes.length, ...bytes])
}
function tlv(tag: number, value: Uint8Array): Uint8Array {
const len = derLen(value.length)
const out = new Uint8Array(1 + len.length + value.length)
out[0] = tag
out.set(len, 1)
out.set(value, 1 + len.length)
return out
}
function concat(chunks: readonly Uint8Array[]): Uint8Array {
const total = chunks.reduce((s, c) => s + c.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const c of chunks) {
out.set(c, off)
off += c.length
}
return out
}
const SEQUENCE = 0x30
const SET = 0x31
const INTEGER = 0x02
const BIT_STRING = 0x03
const OID = 0x06
const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
return tlv(SEQUENCE, concat([algId, pubBits]))
}
/** X.501 Name with a single CN=<subject> RDN. */
function nameFromCn(cn: string): Uint8Array {
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
const rdn = tlv(SET, atv)
return tlv(SEQUENCE, rdn)
}
function toPem(der: Uint8Array, label: string): string {
const b64 = Buffer.from(der).toString('base64')
const lines = b64.match(/.{1,64}/g) ?? []
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
/**
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
* The private key is used in-process only; never serialized into the output (INV4).
*/
export function buildCsr(id: AgentIdentity, subject: string): string {
const version = tlv(INTEGER, Uint8Array.from([0x00]))
const name = nameFromCn(subject)
const spki = spkiFromRawEd25519(id.publicKey)
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
return toPem(csr, 'CERTIFICATE REQUEST')
}

137
agent/src/enroll/pair.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4.
*
* REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5).
* RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated
* with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3
* `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process
* and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret
* is never logged/re-sent (INV5/INV9).
*
* Only the PUBLIC key + CSR leave the host (INV4).
*/
import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
import type { EnrollResult } from 'relay-contracts'
import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js'
import { buildCsr } from './csr.js'
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
export type EnrollMode = 'token' | 'ed25519'
export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519'
export class EnrollError extends Error {
constructor(message: string) {
super(message)
this.name = 'EnrollError'
}
}
export class PairingCodeSpentError extends EnrollError {
constructor() {
super('pairing code already redeemed (single-use)')
this.name = 'PairingCodeSpentError'
}
}
export class PairingCodeExpiredError extends EnrollError {
constructor() {
super('pairing code expired')
this.name = 'PairingCodeExpiredError'
}
}
/**
* Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process).
* P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected
* so the real unwrap drops in without touching the redeem flow. Default = passthrough.
*/
export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array
const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped
export interface RedeemOptions {
readonly fetchImpl?: typeof fetch
readonly mode?: EnrollMode
readonly agentToken?: string
readonly unwrapContentSecret?: UnwrapContentSecret
readonly subject?: string
}
interface EnrollResponseJson {
hostId: string
subdomain: string
cert: string
caChain: string
hostContentSecret: string // base64url over the wire
}
function parseEnrollResult(json: unknown): EnrollResult {
const j = json as Partial<EnrollResponseJson>
if (typeof j.hostContentSecret !== 'string') {
throw new EnrollError('enroll response missing hostContentSecret')
}
const candidate = {
hostId: j.hostId,
subdomain: j.subdomain,
cert: j.cert,
caChain: j.caChain,
hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret),
}
const result = EnrollResultSchema.safeParse(candidate)
if (!result.success) {
throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`)
}
return result.data
}
/**
* Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code
* (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600.
*/
export async function redeemPairingCode(
enrollUrl: string,
code: string,
id: AgentIdentity,
ks: Keystore,
opts: RedeemOptions = {},
): Promise<EnrollResult> {
const doFetch = opts.fetchImpl ?? fetch
const mode = opts.mode ?? DEFAULT_ENROLL_MODE
const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap
const body =
mode === 'token'
? { code, agentToken: opts.agentToken ?? '' }
: {
code,
agentPubkey: encodeBase64UrlBytes(id.publicKey),
csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'),
}
let res: Response
try {
res = await doFetch(enrollUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
} catch (err) {
throw new EnrollError(`enroll request failed: ${(err as Error).message}`)
}
if (res.status === 409) throw new PairingCodeSpentError()
if (res.status === 410) throw new PairingCodeExpiredError()
if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`)
let json: unknown
try {
json = await res.json()
} catch (err) {
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
}
const enroll = parseEnrollResult(json)
ks.saveCert(enroll.cert, enroll.caChain)
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
const unwrapped = unwrap(enroll.hostContentSecret, id)
ks.saveContentSecret(unwrapped)
return enroll
}

32
agent/src/index.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* web-terminal-agent (P2) — public barrel. The host agent: Ed25519 identity, §4.5 pairing, the
* §4.1 mux tunnel + loopback splice, mTLS dial + cert rotation, fast revocation, and the §4.4 E2E
* endpoint. Imports the FROZEN shared surface from relay-contracts read-only; wires P4 relay-e2e
* crypto via injected seams (see docs/PLAN_RELAY_AGENT.md).
*/
export * from './config/agentConfig.js'
export * from './log/logger.js'
export * from './transport/seams.js'
export * from './keys/identity.js'
export * from './keys/keystore.js'
export * from './enroll/csr.js'
export * from './enroll/pair.js'
export { parseArgs, runCli, CliUsageError } from './cli.js'
export type { CliArgs, CliCommand, CliDeps } from './cli.js'
export * from './transport/frpScaffold.js'
export * from './transport/tunnel.js'
export * from './transport/loopback.js'
export * from './transport/streamRouter.js'
export * from './transport/heartbeat.js'
export * from './transport/backoff.js'
export * from './transport/flowControl.js'
export * from './transport/dial.js'
export * from './certs/rotation.js'
export * from './lifecycle/revocation.js'
export * from './e2e/replaySeal.js'
export * from './e2e/hostEndpoint.js'
export * from './dist/buildBinary.js'
export * from './service/install.js'
export * from './service/launchd.js'
export * from './service/systemd.js'
export * from './service/originConfig.js'

View File

@@ -0,0 +1,83 @@
/**
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
*
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
* serializes the private key. The raw private key material is held in a module-private closure.
*/
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
import type { KeyObject } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts'
export interface AgentIdentity {
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
readonly publicKey: Uint8Array
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
readonly enrollFpr: string
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
sign(message: Uint8Array): Uint8Array
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
exportPrivatePkcs8Pem(): string
/** The underlying private KeyObject — for in-process crypto (CSR/mTLS) ONLY, never serialized to the wire. */
privateKeyObject(): KeyObject
}
/** SHA-256 fingerprint of a raw Ed25519 public key, base64url — §4.2 enroll_fpr. */
export function computeEnrollFpr(publicKey: Uint8Array): string {
const digest = createHash('sha256').update(publicKey).digest()
return encodeBase64UrlBytes(new Uint8Array(digest))
}
/** Raw 32-byte Ed25519 public key from a KeyObject (strips the SPKI DER prefix). */
function rawPublicKey(pub: KeyObject): Uint8Array {
const der = pub.export({ type: 'spki', format: 'der' })
// Ed25519 SPKI is a fixed 44-byte structure; the raw key is the trailing 32 bytes.
return new Uint8Array(der.subarray(der.length - 32))
}
function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
const rawPub = rawPublicKey(publicKey)
const enrollFpr = computeEnrollFpr(rawPub)
return {
publicKey: rawPub,
enrollFpr,
sign(message: Uint8Array): Uint8Array {
return new Uint8Array(sign(null, message, privateKey))
},
exportPrivatePkcs8Pem(): string {
return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
},
privateKeyObject(): KeyObject {
return privateKey
},
}
}
/** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
export function generateIdentity(): AgentIdentity {
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
return buildIdentity(privateKey, publicKey)
}
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
export function identityFromPrivatePem(pem: string): AgentIdentity {
const privateKey = createPrivateKey(pem)
const publicKey = createPublicKey(privateKey)
return buildIdentity(privateKey, publicKey)
}
/** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
export function verifySignature(
publicKey: Uint8Array,
message: Uint8Array,
signature: Uint8Array,
): boolean {
const spkiPrefix = Uint8Array.from([
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
])
const der = new Uint8Array(spkiPrefix.length + publicKey.length)
der.set(spkiPrefix, 0)
der.set(publicKey, spkiPrefix.length)
const pub = createPublicKey({ key: Buffer.from(der), format: 'der', type: 'spki' })
return verify(null, message, pub, signature)
}

109
agent/src/keys/keystore.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* On-disk keystore — PLAN_RELAY_AGENT T3 (INV4/INV5). Persists ONLY to `stateDir`, every secret
* file mode `0600`. Stores: the Ed25519 private key (PKCS#8 PEM), the mTLS cert + CA chain, and
* the FIX 3 unwrapped `hostContentSecret`. The raw pairing code is NEVER written here (T4).
*/
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs'
import { join } from 'node:path'
import type { AgentIdentity } from './identity.js'
import { identityFromPrivatePem } from './identity.js'
const SECRET_MODE = 0o600
const DIR_MODE = 0o700
export interface Keystore {
saveIdentity(id: AgentIdentity): void
loadIdentity(): AgentIdentity | null
saveCert(certPem: string, caChainPem: string): void
loadCert(): { certPem: string; caChainPem: string } | null
saveContentSecret(secret: Uint8Array): void
loadContentSecret(): Uint8Array | null
}
const KEY_FILE = 'agent.key.pem'
const CERT_FILE = 'agent.cert.pem'
const CA_FILE = 'agent.ca.pem'
const CONTENT_SECRET_FILE = 'content.secret'
/** Corrupt/unreadable key material surfaces as a typed error (never a silent empty read). */
export class KeystoreError extends Error {
constructor(message: string) {
super(message)
this.name = 'KeystoreError'
}
}
function ensureDir(stateDir: string): void {
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
return
}
// Refuse to write secrets into a world-/group-accessible directory (INV5 hard error).
const mode = statSync(stateDir).mode & 0o777
if ((mode & 0o077) !== 0) {
throw new KeystoreError(
`stateDir ${stateDir} is group/world accessible (mode ${mode.toString(8)}); refusing to write secrets`,
)
}
}
function writeSecret(path: string, data: string | Uint8Array): void {
writeFileSync(path, data, { mode: SECRET_MODE })
// Enforce 0600 even if a prior umask/file left it wider.
chmodSync(path, SECRET_MODE)
}
export function openKeystore(stateDir: string): Keystore {
const keyPath = join(stateDir, KEY_FILE)
const certPath = join(stateDir, CERT_FILE)
const caPath = join(stateDir, CA_FILE)
const secretPath = join(stateDir, CONTENT_SECRET_FILE)
return {
saveIdentity(id: AgentIdentity): void {
ensureDir(stateDir)
writeSecret(keyPath, id.exportPrivatePkcs8Pem())
},
loadIdentity(): AgentIdentity | null {
if (!existsSync(keyPath)) return null
let pem: string
try {
pem = readFileSync(keyPath, 'utf8')
} catch (err) {
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
}
try {
return identityFromPrivatePem(pem)
} catch (err) {
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
}
},
saveCert(certPem: string, caChainPem: string): void {
ensureDir(stateDir)
writeSecret(certPath, certPem)
writeSecret(caPath, caChainPem)
},
loadCert(): { certPem: string; caChainPem: string } | null {
if (!existsSync(certPath) || !existsSync(caPath)) return null
return {
certPem: readFileSync(certPath, 'utf8'),
caChainPem: readFileSync(caPath, 'utf8'),
}
},
saveContentSecret(secret: Uint8Array): void {
ensureDir(stateDir)
writeSecret(secretPath, secret)
},
loadContentSecret(): Uint8Array | null {
if (!existsSync(secretPath)) return null
return new Uint8Array(readFileSync(secretPath))
},
}
}

View File

@@ -0,0 +1,68 @@
/**
* Revocation + GOAWAY teardown — PLAN_RELAY_AGENT T14 (INV12). Distinguishes a `revoked` GOAWAY
* (immediate teardown, NO reconnect) from the graceful `operatorDrain`/`shutdown` reasons
* (finish in-flight, reconnect elsewhere) using ONLY the FROZEN §4.1 3-value `GoAwayReason` +
* `decodeGoAwayReason` — never a locally-invented numeric mapping (cross-plan drift = silent
* INV12 defeat). An unknown numeric code FAILS CLOSED to `revoked`.
*
* The RevocationState seam is imported from transport/seams.ts (W0), not redeclared. Its
* `isRevoked()` is what T10's reconnectLoop consumes (one-way edge, no task cycle).
*/
import { decodeGoAwayReason } from 'relay-contracts'
import type { GoAwayReason } from 'relay-contracts'
import type { RevocationState, RevokeReason } from '../transport/seams.js'
export type GoAwayAction = 'drain' | 'revoked'
/**
* Pure exhaustive map over the FROZEN 3-value union. `operatorDrain`/`shutdown` → drain (reconnect
* elsewhere); `revoked` → revoked (stop). No integer literals — the reason is already a label.
*/
export function classifyGoAway(reason: GoAwayReason): GoAwayAction {
switch (reason) {
case 'operatorDrain':
case 'shutdown':
return 'drain'
case 'revoked':
return 'revoked'
}
}
export function createRevocationState(onTeardown: () => void): RevocationState {
let revoked = false
return {
isRevoked(): boolean {
return revoked
},
markRevoked(_reason: RevokeReason): void {
if (revoked) return
revoked = true
onTeardown() // close all streams + tunnel immediately
},
}
}
/**
* Apply a decoded GOAWAY reason to the revocation state. On `revoked`, marks the state revoked
* (⇒ teardown + reconnect suppression). Returns the action taken.
*/
export function applyGoAway(reason: GoAwayReason, state: RevocationState): GoAwayAction {
const action = classifyGoAway(reason)
if (action === 'revoked') state.markRevoked('goaway-revoked')
return action
}
/**
* Apply a raw GOAWAY wire code. Decodes via the FROZEN decoder; an UNKNOWN code (decoder throws)
* FAILS CLOSED to `revoked` (a default that guessed "drain" would defeat INV12).
*/
export function applyGoAwayCode(code: number, state: RevocationState): GoAwayAction {
let reason: GoAwayReason
try {
reason = decodeGoAwayReason(code)
} catch {
state.markRevoked('goaway-revoked') // fail closed
return 'revoked'
}
return applyGoAway(reason, state)
}

75
agent/src/log/logger.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Structured redacting logger — PLAN_RELAY_AGENT T2 (INV9: secrets never logged, no console.log).
*
* Meta keys whose name matches a secret substring are replaced with `[REDACTED]` before the
* line is emitted, so a caller that accidentally passes `{ privateKey, cert, pairingCode,
* agentToken }` can never leak the value. Emission goes through an injectable sink (default
* stderr) — `console.log` is never used.
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export interface Logger {
log(level: LogLevel, msg: string, meta?: Record<string, unknown>): void
}
/** Sink for a formatted line. Default writes to stderr (NOT console.log). */
export type LogSink = (line: string) => void
const LEVEL_ORDER: Readonly<Record<LogLevel, number>> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
}
/**
* Substrings that mark a meta key as secret (case-insensitive). Matches `privateKey` (key),
* `cert`, `caChain` (chain? no — cert covers it via 'cert'), `pairingCode` (code), `agentToken`
* (token), etc. `nonce`/`streamId`/`hostId` intentionally do NOT match (they are not secrets).
*/
const REDACT_SUBSTRINGS: readonly string[] = [
'key',
'cert',
'code',
'token',
'secret',
'proof',
'password',
'pem',
]
const REDACTED = '[REDACTED]'
function isSecretKey(name: string): boolean {
const lower = name.toLowerCase()
return REDACT_SUBSTRINGS.some((s) => lower.includes(s))
}
function redactMeta(meta: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(meta)) {
out[k] = isSecretKey(k) ? REDACTED : v
}
return out
}
const defaultSink: LogSink = (line) => {
process.stderr.write(`${line}\n`)
}
/**
* Create a redacting logger at a minimum level. Lines below `level` are dropped. Secret meta
* values are redacted by key name before serialization (INV9).
*/
export function createLogger(level: LogLevel, sink: LogSink = defaultSink): Logger {
const threshold = LEVEL_ORDER[level]
return {
log(msgLevel, msg, meta) {
if (LEVEL_ORDER[msgLevel] < threshold) return
const safeMeta = meta ? redactMeta(meta) : undefined
const metaPart = safeMeta ? ` ${JSON.stringify(safeMeta)}` : ''
sink(`${msgLevel.toUpperCase()} ${msg}${metaPart}`)
},
}
}

View File

@@ -0,0 +1,78 @@
/**
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
* logic is unit-testable without touching the real system.
*/
import type { AgentConfig } from '../config/agentConfig.js'
import {
buildLaunchdPlist,
launchdLoadCommand,
launchdPlistPath,
launchdUnloadCommand,
} from './launchd.js'
import {
buildSystemdUnit,
systemdDisableCommand,
systemdEnableCommand,
systemdUnitPath,
} from './systemd.js'
export type ServicePlatform = 'launchd' | 'systemd'
export class RootRefusedError extends Error {
constructor() {
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
this.name = 'RootRefusedError'
}
}
export interface InstallDeps {
writeFile(path: string, content: string): void
runCommand(cmd: string, args: readonly string[]): Promise<void>
getuid(): number
homedir(): string
username(): string
binPath(): string
}
/** Map a Node platform to its service manager, or null if unsupported. */
export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
if (os === 'darwin') return 'launchd'
if (os === 'linux') return 'systemd'
return null
}
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
export async function installService(
_cfg: AgentConfig,
platform: ServicePlatform,
deps: InstallDeps,
): Promise<void> {
if (deps.getuid() === 0) throw new RootRefusedError()
const bin = deps.binPath()
if (platform === 'launchd') {
const path = launchdPlistPath(deps.homedir())
deps.writeFile(path, buildLaunchdPlist(bin))
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
return
}
const path = systemdUnitPath(deps.homedir())
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
const { cmd, args } = systemdEnableCommand()
await deps.runCommand(cmd, args)
}
/** Unload the service unit for `platform`. */
export async function uninstallService(
platform: ServicePlatform,
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
): Promise<void> {
if (platform === 'launchd') {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
await deps.runCommand(cmd, args)
return
}
const { cmd, args } = systemdDisableCommand()
await deps.runCommand(cmd, args)
}

View File

@@ -0,0 +1,44 @@
/**
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
*/
const LABEL = 'com.web-terminal.agent'
export function launchdLabel(): string {
return LABEL
}
export function launchdPlistPath(homedir: string): string {
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
}
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
export function buildLaunchdPlist(binPath: string): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<dict>',
' <key>Label</key>',
` <string>${LABEL}</string>`,
' <key>ProgramArguments</key>',
' <array>',
` <string>${binPath}</string>`,
' <string>run</string>',
' </array>',
' <key>RunAtLoad</key>',
' <true/>',
' <key>KeepAlive</key>',
' <true/>',
'</dict>',
'</plist>',
'',
].join('\n')
}
export function launchdLoadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
return { cmd: 'launchctl', args: ['load', plistPath] }
}
export function launchdUnloadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
return { cmd: 'launchctl', args: ['unload', plistPath] }
}

View File

@@ -0,0 +1,57 @@
/**
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
* as CONFIG — NO `src/` code edit. AUGMENTS, never weakens, the Origin/CSWSH check: existing
* origins are always preserved (EXPLORE §3 "do not weaken the check").
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
export interface OriginFsDeps {
exists(path: string): boolean
read(path: string): string
write(path: string, content: string): void
}
const defaultFs: OriginFsDeps = {
exists: existsSync,
read: (p) => readFileSync(p, 'utf8'),
write: (p, c) => writeFileSync(p, c),
}
/** Compose the subdomain origin the base app must trust. */
export function subdomainOrigin(subdomain: string, domain: string): string {
return `https://${subdomain}.term.${domain}`
}
const KEY = 'ALLOWED_ORIGINS'
function upsertOriginLine(content: string, origin: string): string {
const lines = content.length === 0 ? [] : content.split('\n')
let found = false
const next = lines.map((line) => {
if (!line.startsWith(`${KEY}=`)) return line
found = true
const current = line.slice(KEY.length + 1)
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
if (origins.includes(origin)) return line // idempotent — already trusted
return `${KEY}=${[...origins, origin].join(',')}`
})
if (!found) next.push(`${KEY}=${origin}`)
return next.join('\n')
}
/**
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
* existing origin. Creates the file/line if absent.
*/
export function ensureAllowedOrigin(
baseAppEnvPath: string,
subdomain: string,
domain: string,
fs: OriginFsDeps = defaultFs,
): void {
const origin = subdomainOrigin(subdomain, domain)
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
const updated = upsertOriginLine(existing, origin)
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`)
}

View File

@@ -0,0 +1,40 @@
/**
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
*/
const UNIT_NAME = 'web-terminal-agent.service'
export function systemdUnitName(): string {
return UNIT_NAME
}
export function systemdUnitPath(homedir: string): string {
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
}
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
export function buildSystemdUnit(binPath: string, user: string): string {
return [
'[Unit]',
'Description=web-terminal host agent (rendezvous relay)',
'After=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${binPath} run`,
'Restart=on-failure',
'RestartSec=1',
`User=${user}`,
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n')
}
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
}
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
}

View File

@@ -0,0 +1,63 @@
/**
* Reconnection / backoff — PLAN_RELAY_AGENT T10. REUSES the base app's 1/2/4…cap-30s policy
* (EXPLORE §3). `reconnectLoop` only CONSUMES an injected `isRevoked()` (the W0 seam) — a revoked
* host short-circuits and never reconnects (INV12). No task edge to T14.
*/
import type { Tunnel } from './tunnel.js'
export const BACKOFF_BASE_MS = 1_000
export const BACKOFF_CAP_MS = 30_000
export interface BackoffPolicy {
nextDelayMs(): number
reset(): void
}
/** Exponential backoff 1s,2s,4s…capped at 30s, optional [0.5×,1×] jitter. */
export function createBackoff(
opts: { baseMs?: number; capMs?: number; jitter?: boolean; rng?: () => number } = {},
): BackoffPolicy {
const baseMs = opts.baseMs ?? BACKOFF_BASE_MS
const capMs = opts.capMs ?? BACKOFF_CAP_MS
const jitter = opts.jitter ?? false
const rng = opts.rng ?? Math.random
let attempt = 0
return {
nextDelayMs(): number {
const raw = Math.min(baseMs * 2 ** attempt, capMs)
attempt += 1
if (!jitter) return raw
return Math.round(raw * (0.5 + rng() * 0.5)) // [0.5×, 1×]
},
reset(): void {
attempt = 0
},
}
}
export type Sleep = (ms: number) => Promise<void>
const realSleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms))
/**
* Dial with backoff until success (resolves the connected Tunnel) or the host is revoked
* (resolves null — never reconnect). Each dial failure waits `backoff.nextDelayMs()`; a success
* resets the backoff.
*/
export async function reconnectLoop(
dial: () => Promise<Tunnel>,
backoff: BackoffPolicy,
isRevoked: () => boolean,
sleep: Sleep = realSleep,
): Promise<Tunnel | null> {
while (!isRevoked()) {
try {
const tunnel = await dial()
backoff.reset()
return tunnel
} catch {
if (isRevoked()) return null
await sleep(backoff.nextDelayMs())
}
}
return null
}

103
agent/src/transport/dial.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Outbound mTLS dial — PLAN_RELAY_AGENT T12 (INV14/INV4). Builds a wss:// client authenticated by
* the client cert + IN-PROCESS Ed25519 key + pinned CA chain, `rejectUnauthorized: true`, and NO
* bearer/agent token (mTLS IS the auth). Absent/expired cert ⇒ fail-fast, no dial.
*/
import { X509Certificate } from 'node:crypto'
import type { Keystore } from '../keys/keystore.js'
import type { AgentConfig } from '../config/agentConfig.js'
import type { WsLike } from './seams.js'
export class NotEnrolledError extends Error {
constructor() {
super('agent is not enrolled (no identity/cert in keystore)')
this.name = 'NotEnrolledError'
}
}
export class CertExpiredError extends Error {
constructor() {
super('client certificate has expired; renew before dialling')
this.name = 'CertExpiredError'
}
}
/** TLS material for the wss client. NOTE: `rejectUnauthorized` is ALWAYS true (anti-MITM). */
export interface TlsClientOptions {
readonly cert: string
readonly key: string
readonly ca: string
readonly rejectUnauthorized: true
}
export interface CertInfo {
readonly validTo: Date
}
export type CertParser = (certPem: string) => CertInfo
const defaultCertParser: CertParser = (pem) => ({ validTo: new Date(new X509Certificate(pem).validTo) })
/**
* Assemble the mTLS options from the keystore. Throws NotEnrolledError if key/cert are missing,
* CertExpiredError if the cert is past `validTo`. There is NO token field by construction (INV4).
*/
export function buildTlsOptions(
ks: Keystore,
opts: { now?: Date; certParser?: CertParser } = {},
): TlsClientOptions {
const id = ks.loadIdentity()
const certs = ks.loadCert()
if (id === null || certs === null) throw new NotEnrolledError()
const parse = opts.certParser ?? defaultCertParser
const now = opts.now ?? new Date()
if (parse(certs.certPem).validTo.getTime() < now.getTime()) throw new CertExpiredError()
return {
cert: certs.certPem,
key: id.exportPrivatePkcs8Pem(),
ca: certs.caChainPem,
rejectUnauthorized: true,
}
}
export interface RawTlsWs {
send(data: Uint8Array): void
close(): void
on(event: string, cb: (...args: unknown[]) => void): void
once(event: string, cb: (...args: unknown[]) => void): void
}
export type TlsWsConstructor = new (url: string, opts: TlsClientOptions) => RawTlsWs
function toU8(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
return null
}
function adapt(raw: RawTlsWs): WsLike {
return {
send: (d) => raw.send(d),
on(ev, cb) {
if (ev === 'message') {
raw.on('message', (data: unknown) => {
const bytes = toU8(data)
if (bytes !== null) cb(bytes)
})
} else {
raw.on(ev, cb)
}
},
close: () => raw.close(),
}
}
/** Dial the relay's /agent endpoint over mTLS wss. Resolves the connected WsLike on open. */
export function dialRelay(
cfg: AgentConfig,
ks: Keystore,
opts: { Ctor: TlsWsConstructor; now?: Date; certParser?: CertParser },
): Promise<WsLike> {
const tls = buildTlsOptions(ks, { ...(opts.now ? { now: opts.now } : {}), ...(opts.certParser ? { certParser: opts.certParser } : {}) })
return new Promise<WsLike>((resolve, reject) => {
const raw = new opts.Ctor(cfg.relayUrl, tls)
raw.once('open', () => resolve(adapt(raw)))
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
})
}

View File

@@ -0,0 +1,41 @@
/**
* Per-stream flow control — PLAN_RELAY_AGENT T11. CONSUMES the §4.1 WINDOW_UPDATE credit protocol
* (P1 owns the protocol). Per-stream credit means one heavy vim/top redraw can't starve another
* stream. streamId 0 is the connection-level window applied to the whole link.
*/
export interface FlowController {
consume(streamId: number, bytes: number): boolean
grant(streamId: number, credit: number): void
initWindow(streamId: number, initialCredit: number): void
}
const CONNECTION_STREAM_ID = 0
export function createFlowController(): FlowController {
const windows = new Map<number, number>()
// Connection-level window is unbounded until explicitly initialized.
windows.set(CONNECTION_STREAM_ID, Number.POSITIVE_INFINITY)
function remaining(streamId: number): number {
return windows.get(streamId) ?? 0
}
return {
initWindow(streamId: number, initialCredit: number): void {
windows.set(streamId, initialCredit)
},
grant(streamId: number, credit: number): void {
windows.set(streamId, remaining(streamId) + credit)
},
consume(streamId: number, bytes: number): boolean {
const conn = remaining(CONNECTION_STREAM_ID)
const stream = remaining(streamId)
if (stream < bytes || conn < bytes) return false // credit exhausted → pause
windows.set(streamId, stream - bytes)
if (conn !== Number.POSITIVE_INFINITY) {
windows.set(CONNECTION_STREAM_ID, conn - bytes)
}
return true
},
}
}

View File

@@ -0,0 +1,72 @@
/**
* v0.8 frpc-wrap stepping-stone — PLAN_RELAY_AGENT T6. Fastest path to the café demo: wraps a
* child `frpc` presenting the shared v0.8 `agentToken`, registering the subdomain, forwarding to
* 127.0.0.1:3000. EXPLICITLY a stepping-stone — the native mux (T7T11) replaces it at v0.9.
*
* Forwards ONLY to loopback (anti-SSRF). Retired once EnrollMode==='ed25519' (guard below).
*/
import type { AgentConfig } from '../config/agentConfig.js'
import { isLoopbackWsUrl } from '../config/agentConfig.js'
import type { EnrollMode } from '../enroll/pair.js'
export interface FrpScaffold {
start(): Promise<void>
stop(): Promise<void>
onExit(cb: (code: number) => void): void
}
/** Minimal child-process seam so tests inject a fake spawn (no real frpc needed). */
export interface ChildLike {
on(ev: 'exit', cb: (code: number | null) => void): void
kill(): void
}
export type SpawnImpl = (cmd: string, args: readonly string[]) => ChildLike
const LOOPBACK_IP = '127.0.0.1'
const LOCAL_PORT = 3000
/** Build the frpc.toml. local_ip is ALWAYS loopback; tls is enabled. */
export function buildFrpcToml(cfg: AgentConfig): string {
if (!isLoopbackWsUrl(cfg.localTargetUrl)) {
throw new Error('frpScaffold refuses a non-loopback localTargetUrl (anti-SSRF)')
}
const subdomain = cfg.subdomain ?? ''
return [
'[common]',
'tls_enable = true',
'',
'[web-terminal]',
'type = "tcp"',
`local_ip = "${LOOPBACK_IP}"`,
`local_port = ${LOCAL_PORT}`,
`subdomain = "${subdomain}"`,
'',
].join('\n')
}
/** True once the native Ed25519 substrate is active — `run` must NOT wire frpc then. */
export function isFrpRetired(mode: EnrollMode): boolean {
return mode === 'ed25519'
}
/** Spawn (a mockable) frpc child with a generated config. */
export function spawnFrpc(cfg: AgentConfig, frpcPath: string, spawnImpl: SpawnImpl): FrpScaffold {
const toml = buildFrpcToml(cfg) // validates loopback before spawning
void toml
let child: ChildLike | null = null
const exitCbs: Array<(code: number) => void> = []
return {
async start(): Promise<void> {
child = spawnImpl(frpcPath, ['-c', 'frpc.toml'])
child.on('exit', (code) => {
for (const cb of exitCbs) cb(code ?? 0)
})
},
async stop(): Promise<void> {
child?.kill()
},
onExit(cb: (code: number) => void): void {
exitCbs.push(cb)
},
}
}

View File

@@ -0,0 +1,84 @@
/**
* §4.1 heartbeat — PLAN_RELAY_AGENT T9. PING every 15s on streamId 0; a missed PONG within the
* interval ⇒ the tunnel is dead (⇒ T10 reconnect). Also replies PONG (echoing the 8-byte token)
* to an inbound PING. Timers are injectable (TimerLike) for deterministic fake-timer tests.
*/
import { randomBytes } from 'node:crypto'
import type { TimerLike } from './seams.js'
import { pingHeader, pongHeader, type Tunnel } from './tunnel.js'
export const HEARTBEAT_INTERVAL_MS = 15_000
export interface Heartbeat {
onPing(token: Uint8Array): void
onPong(token: Uint8Array): void
start(): void
stop(): void
onDead(cb: () => void): void
}
const realTimer: TimerLike = {
setTimeout: (cb, ms) => setTimeout(cb, ms),
clearTimeout: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
export function createHeartbeat(
tunnel: Tunnel,
opts: { intervalMs?: number; timer?: TimerLike; genToken?: () => Uint8Array } = {},
): Heartbeat {
const intervalMs = opts.intervalMs ?? HEARTBEAT_INTERVAL_MS
const timer = opts.timer ?? realTimer
const genToken = opts.genToken ?? (() => new Uint8Array(randomBytes(8)))
let interval: unknown = null
let deadline: unknown = null
let pending = false
let deadCb: (() => void) | null = null
let dead = false
function fireDead(): void {
if (dead) return
dead = true
stop()
deadCb?.()
}
function sendPing(): void {
pending = true
tunnel.send(pingHeader(), genToken())
deadline = timer.setTimeout(() => {
if (pending) fireDead()
}, intervalMs)
}
function stop(): void {
if (interval !== null) timer.clearInterval(interval)
if (deadline !== null) timer.clearTimeout(deadline)
interval = null
deadline = null
}
return {
onPing(token: Uint8Array): void {
tunnel.send(pongHeader(), token) // echo the token byte-exact
},
onPong(): void {
pending = false
if (deadline !== null) {
timer.clearTimeout(deadline)
deadline = null
}
},
start(): void {
dead = false
sendPing()
interval = timer.setInterval(sendPing, intervalMs)
},
stop,
onDead(cb: () => void): void {
deadCb = cb
},
}
}

View File

@@ -0,0 +1,71 @@
/**
* Loopback forwarder — PLAN_RELAY_AGENT T8. One OPEN ⇒ one fresh ws://127.0.0.1:3000<path>
* socket, REPLAYING the real browser `Origin` (§4.1 MuxOpen.originHeader) so the UNCHANGED base
* app's Origin check still passes end-to-end (CSWSH protection preserved — EXPLORE §3).
*
* The raw `ws` constructor is injectable so the URL/Origin wiring is unit-testable without a real
* socket. The target is always loopback (validated upstream in config).
*/
import type { WsLike } from './seams.js'
export type DialLoopback = (path: string, origin: string) => Promise<WsLike>
/** Minimal surface of a raw `ws` client the adapter needs. */
export interface RawWs {
send(data: Uint8Array): void
close(): void
on(event: string, cb: (...args: unknown[]) => void): void
once(event: string, cb: (...args: unknown[]) => void): void
}
export type WsConstructor = new (
url: string,
opts?: { headers?: Record<string, string> },
) => RawWs
/** Join the loopback target with the request path (avoids a double slash). */
export function buildLoopbackUrl(target: string, path: string): string {
const base = target.endsWith('/') ? target.slice(0, -1) : target
const suffix = path.startsWith('/') ? path : `/${path}`
return `${base}${suffix}`
}
function toU8(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
return null
}
function adapt(raw: RawWs): WsLike {
return {
send(d: Uint8Array): void {
raw.send(d)
},
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
if (ev === 'message') {
raw.on('message', (data: unknown) => {
const bytes = toU8(data)
if (bytes !== null) cb(bytes)
})
} else {
raw.on(ev, cb)
}
},
close(): void {
raw.close()
},
}
}
/**
* Build a DialLoopback bound to `target`. Resolves once the loopback socket is open; rejects on
* a pre-open error (so a down base app surfaces as a typed dial failure, not a silent hang).
*/
export function dialLoopback(target: string, Ctor: WsConstructor): DialLoopback {
return (path: string, origin: string): Promise<WsLike> =>
new Promise<WsLike>((resolve, reject) => {
const url = buildLoopbackUrl(target, path)
const raw = new Ctor(url, { headers: { Origin: origin } })
raw.once('open', () => resolve(adapt(raw)))
raw.once('error', (err: unknown) => reject(err instanceof Error ? err : new Error(String(err))))
})
}

View File

@@ -0,0 +1,43 @@
/**
* W0 shared injection seams (DEPENDENCY-CYCLE BREAKER) — PLAN_RELAY_AGENT §2 / T2.
*
* These are intra-`agent/` seam *types only*. NO cross-plan frozen contract lives here —
* those stay in `relay-contracts/`. Declaring them once at W0 lets W2/W3 transport tasks
* (T7/T10/T12/T14) consume a stable type WITHOUT a task-level cycle (see §3 T10↔T14 note).
*
* This module MUST remain side-effect-free and runtime-dependency-free (type-only): a test
* asserts importing it pulls in no `ws`/crypto runtime, so W2/W3 can depend on it freely.
*/
/**
* Minimal WS surface the transport layer needs. dial/tunnel/backoff/loopback share it so no
* per-task redeclare and no drift. Adapters wrap the real `ws` socket into this shape.
*/
export interface WsLike {
send(d: Uint8Array): void
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void
close(): void
}
/** Why a host stopped tunnelling. `renewal-refused`/`goaway-revoked` ⇒ do NOT reconnect. */
export type RevokeReason = 'renewal-refused' | 'goaway-revoked' | 'operator'
/**
* Revocation seam — T14 IMPLEMENTS it; T10's reconnectLoop only CONSUMES `isRevoked()`.
* One-way edge (T14 → wires T10's loop), so there is no task cycle.
*/
export interface RevocationState {
isRevoked(): boolean
markRevoked(reason: RevokeReason): void
}
/**
* Minimal timer seam so heartbeat (T9) / cert rotation (T13) are testable with fake timers
* without depending on Node's global timer types leaking into the transport surface.
*/
export interface TimerLike {
setTimeout(cb: () => void, ms: number): unknown
clearTimeout(handle: unknown): void
setInterval(cb: () => void, ms: number): unknown
clearInterval(handle: unknown): void
}

View File

@@ -0,0 +1,148 @@
/**
* Stream router — PLAN_RELAY_AGENT T8. Maps §4.1 streamId ⇄ a loopback socket. Each OPEN gets a
* FRESH per-stream allocation (socket + transform state); there are NO global mutable buffers, so
* cross-tenant/cross-stream buffer bleed is structurally impossible (EXPLORE §4b failure #3).
*
* MANDATORY INV1 defense-in-depth: the FIRST thing handleOpen does — before any allocation or
* dial — is compare MuxOpen.subdomain (§4.1) against this agent's enrolled subdomain. A mismatch
* is RST and NEVER dialed (metadata-only audit log, INV10). Belt-and-suspenders to relay authz.
*/
import type { MuxOpen } from 'relay-contracts'
import { MuxOpenSchema } from 'relay-contracts'
import type { AgentConfig } from '../config/agentConfig.js'
import type { Logger } from '../log/logger.js'
import type { WsLike } from './seams.js'
import type { DialLoopback } from './loopback.js'
import { closeHeader, dataHeader, type Tunnel } from './tunnel.js'
/**
* Per-stream cipher transform. Identity in v0.9 (plaintext passthrough); replaced by the E2E
* codec in v0.10 (T15). `takeControlFrames` lets an E2E transform emit host→client control frames
* (e.g. HostHello) that the router forwards upstream — no-op for the identity transform.
*/
export interface FrameTransform {
inbound(streamId: number, cipher: Uint8Array): Uint8Array | null
outbound(streamId: number, plain: Uint8Array): Uint8Array
openStream(streamId: number): void
closeStream(streamId: number): void
takeControlFrames?(streamId: number): Uint8Array[]
}
export const identityTransform: FrameTransform = {
inbound: (_s, cipher) => cipher,
outbound: (_s, plain) => plain,
openStream: () => {},
closeStream: () => {},
}
export interface StreamRouter {
handleOpen(open: MuxOpen): void
handleData(streamId: number, payload: Uint8Array): void
handleClose(streamId: number, rst: boolean): void
activeStreamCount(): number
}
interface StreamState {
socket: WsLike | null
readonly pending: Uint8Array[] // inbound bytes buffered until the loopback socket is open
closed: boolean
}
export function createStreamRouter(
cfg: AgentConfig,
tunnel: Tunnel,
dial: DialLoopback,
transform: FrameTransform,
logger: Logger,
): StreamRouter {
const streams = new Map<number, StreamState>()
function flushControlFrames(streamId: number): void {
const frames = transform.takeControlFrames?.(streamId) ?? []
for (const frame of frames) {
tunnel.send(dataHeader(streamId, frame.length), frame)
}
}
function teardown(streamId: number, rst: boolean): void {
const state = streams.get(streamId)
if (state === undefined) return
// Delete FIRST so a synchronous socket 'close' event can't re-enter this teardown.
streams.delete(streamId)
state.closed = true
transform.closeStream(streamId)
tunnel.send(closeHeader(streamId, rst), new Uint8Array(0))
state.socket?.close()
}
return {
handleOpen(open: MuxOpen): void {
// INV1 defense-in-depth — FIRST statement, before any allocation or dial.
if (open.subdomain !== cfg.subdomain) {
tunnel.sendRst(open.streamId)
logger.log('error', 'open.subdomain mismatch — refusing to dial', { streamId: open.streamId })
return
}
if (!MuxOpenSchema.safeParse(open).success) {
tunnel.sendRst(open.streamId)
return
}
if (streams.has(open.streamId)) {
tunnel.sendRst(open.streamId) // duplicate OPEN for a live stream
return
}
const state: StreamState = { socket: null, pending: [], closed: false }
streams.set(open.streamId, state)
transform.openStream(open.streamId)
dial(open.requestPath, open.originHeader)
.then((socket) => {
if (state.closed) {
socket.close()
return
}
state.socket = socket
// loopback output → transform.outbound → tunnel DATA
socket.on('message', (data: unknown) => {
if (!(data instanceof Uint8Array)) return
const cipher = transform.outbound(open.streamId, data)
tunnel.send(dataHeader(open.streamId, cipher.length), cipher)
})
socket.on('close', () => teardown(open.streamId, false))
// flush anything buffered before the socket opened
for (const buffered of state.pending) socket.send(buffered)
state.pending.length = 0
})
.catch((err: unknown) => {
logger.log('error', 'loopback dial failed', { streamId: open.streamId })
void err
teardown(open.streamId, true)
})
},
handleData(streamId: number, payload: Uint8Array): void {
const state = streams.get(streamId)
if (state === undefined) {
tunnel.sendRst(streamId) // DATA before OPEN / after CLOSE / unknown stream → RST
return
}
const plain = transform.inbound(streamId, payload)
flushControlFrames(streamId) // E2E: emit HostHello etc. (no-op in v0.9)
if (plain === null) return // consumed (handshake), nothing to forward
if (state.socket === null) {
state.pending.push(plain)
return
}
state.socket.send(plain)
},
handleClose(streamId: number, rst: boolean): void {
teardown(streamId, rst)
},
activeStreamCount(): number {
return streams.size
},
}
}

View File

@@ -0,0 +1,161 @@
/**
* §4.1 tunnel holder — PLAN_RELAY_AGENT T7. Holds ONE physical mux over a WsLike socket:
* encodes outbound frames, decodes inbound frames (via the FROZEN relay-contracts codec — never
* re-implemented), and dispatches by type to the router (OPEN/DATA/CLOSE) or heartbeat
* (PING/PONG) or connection-level control (GOAWAY/WINDOW_UPDATE, streamId 0).
*
* INV11: payloads are OPAQUE — no ANSI/terminal parsing here. Malformed frames RST the affected
* stream (never the whole tunnel). After an inbound GOAWAY the tunnel DRAINS: no new OPEN.
*
* Design note (vs plan `holdTunnel(socket, router, heartbeat)`): to keep the construction graph
* ACYCLIC (router/heartbeat are built WITH the tunnel), wiring is a two-phase `dispatchTo()`
* call rather than constructor args. Same behavior, no task cycle. Recorded as a deviation.
*/
import {
decodeGoaway,
decodeMuxFrame,
decodeOpen,
encodeGoaway,
encodeMuxFrame,
} from 'relay-contracts'
import type { GoAwayReason, MuxFrameHeader, MuxOpen } from 'relay-contracts'
import type { WsLike } from './seams.js'
const EMPTY = new Uint8Array(0)
/** Handlers the tunnel dispatches decoded frames to (wired post-construction). */
export interface StreamHandlers {
handleOpen(open: MuxOpen): void
handleData(streamId: number, payload: Uint8Array): void
handleClose(streamId: number, rst: boolean): void
}
export interface HeartbeatSink {
onPing(token: Uint8Array): void
onPong(token: Uint8Array): void
}
export interface Tunnel {
send(header: MuxFrameHeader, payload: Uint8Array): void
onFrame(cb: (h: MuxFrameHeader, payload: Uint8Array) => void): void
onGoAway(cb: (reason: GoAwayReason) => void): void
goAway(lastStreamId: number, reason: GoAwayReason): void
sendRst(streamId: number): void
dispatchTo(router: StreamHandlers, heartbeat: HeartbeatSink): void
close(): void
}
// --- frame-header builders (shared across the transport layer) --------------------------------
export function dataHeader(streamId: number, payloadLen: number): MuxFrameHeader {
return { version: 1, type: 'data', fin: false, rst: false, streamId, payloadLen }
}
export function closeHeader(streamId: number, rst: boolean): MuxFrameHeader {
return { version: 1, type: 'close', fin: !rst, rst, streamId, payloadLen: 0 }
}
export function rstHeader(streamId: number): MuxFrameHeader {
return closeHeader(streamId, true)
}
export function pingHeader(): MuxFrameHeader {
return { version: 1, type: 'ping', fin: false, rst: false, streamId: 0, payloadLen: 8 }
}
export function pongHeader(): MuxFrameHeader {
return { version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 8 }
}
function toU8(data: unknown): Uint8Array | null {
if (data instanceof Uint8Array) return data
if (data instanceof ArrayBuffer) return new Uint8Array(data)
if (Array.isArray(data) && data[0] instanceof Uint8Array) return data[0] as Uint8Array
return null
}
export function holdTunnel(socket: WsLike): Tunnel {
let frameCb: ((h: MuxFrameHeader, payload: Uint8Array) => void) | null = null
let goAwayCb: ((reason: GoAwayReason) => void) | null = null
let handlers: StreamHandlers | null = null
let heartbeat: HeartbeatSink | null = null
let draining = false
function send(header: MuxFrameHeader, payload: Uint8Array): void {
socket.send(encodeMuxFrame(header, payload))
}
function sendRst(streamId: number): void {
send(rstHeader(streamId), EMPTY)
}
function dispatch(header: MuxFrameHeader, payload: Uint8Array): void {
frameCb?.(header, payload)
switch (header.type) {
case 'ping':
heartbeat?.onPing(payload)
return
case 'pong':
heartbeat?.onPong(payload)
return
case 'goaway': {
const { reason } = decodeGoaway(payload)
draining = true
goAwayCb?.(reason)
return
}
case 'open': {
if (draining) {
sendRst(header.streamId) // drain: refuse new streams
return
}
let open: MuxOpen
try {
open = decodeOpen(payload)
} catch {
sendRst(header.streamId) // malformed OPEN → RST that stream, tunnel stays up
return
}
handlers?.handleOpen(open)
return
}
case 'data':
handlers?.handleData(header.streamId, payload)
return
case 'close':
handlers?.handleClose(header.streamId, header.rst)
return
case 'windowUpdate':
return // consumed by the flow controller at the wiring layer (T11)
}
}
socket.on('message', (...args: unknown[]) => {
const bytes = toU8(args[0])
if (bytes === null) return
let decoded: { header: MuxFrameHeader; payload: Uint8Array }
try {
decoded = decodeMuxFrame(bytes)
} catch {
return // malformed framing: drop the frame, keep the tunnel alive (robust framing)
}
dispatch(decoded.header, decoded.payload)
})
return {
send,
sendRst,
onFrame(cb): void {
frameCb = cb
},
onGoAway(cb): void {
goAwayCb = cb
},
goAway(lastStreamId: number, reason: GoAwayReason): void {
draining = true
const payload = encodeGoaway(lastStreamId, reason)
send({ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length }, payload)
},
dispatchTo(router: StreamHandlers, hb: HeartbeatSink): void {
handlers = router
heartbeat = hb
},
close(): void {
socket.close()
},
}
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { decodeMuxFrame, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
import type { AgentConfig } from '../../src/config/agentConfig.js'
import { createLogger } from '../../src/log/logger.js'
import { holdTunnel, dataHeader } from '../../src/transport/tunnel.js'
import { createStreamRouter, identityTransform } from '../../src/transport/streamRouter.js'
import { FakeWs } from '../fixtures/fakes.js'
/**
* T18 café-demo agent slice: pair (implied) → dial (mock) → OPEN → loopback splice against a stub
* ws://127.0.0.1:3000 echo server → bytes flow BOTH ways (EXPLORE §5 demo, agent portion).
*/
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://x/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
const OPEN: MuxOpen = {
streamId: 5,
subdomain: 'host-42',
requestPath: '/term?join=abc',
originHeader: 'https://host-42.term.example.com',
remoteAddrHash: 'x',
capabilityTokenRef: 'jti',
}
const flush = () => new Promise((r) => setImmediate(r))
describe('café-demo agent slice (T18)', () => {
it('splices bytes both ways through the loopback echo', async () => {
const upstream = new FakeWs()
const tunnel = holdTunnel(upstream)
const loopback = new FakeWs()
const dial = vi.fn(async () => loopback)
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, createLogger('error', () => {}))
tunnel.dispatchTo(router, { onPing: () => {}, onPong: () => {} })
// relay → agent: OPEN + a keystroke
ws_emitOpen(upstream, OPEN)
await flush()
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
upstream.emitMessage(encodeMuxFrame(dataHeader(5, 3), new Uint8Array([104, 105, 10]))) // "hi\n"
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
// agent ← base app: echo output flows back as DATA upstream
loopback.emit('message', new Uint8Array([79, 75])) // "OK"
const last = decodeMuxFrame(upstream.sent.at(-1)!)
expect(last.header.type).toBe('data')
expect([...last.payload]).toEqual([79, 75])
})
})
function ws_emitOpen(upstream: FakeWs, open: MuxOpen): void {
const payload = encodeOpen(open)
upstream.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
payload,
),
)
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { AgentConfigSchema, isLoopbackWsUrl, loadAgentConfig } from '../src/config/agentConfig.js'
const base = {
relayUrl: 'wss://relay.example.com/agent',
enrollUrl: 'https://example.com/enroll',
stateDir: '/tmp/wta',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: null,
hostId: null,
}
describe('AgentConfig validation', () => {
it('parses a valid config', () => {
expect(() => AgentConfigSchema.parse(base)).not.toThrow()
})
it('rejects a non-wss relayUrl', () => {
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'http://relay.example.com' })).toThrow()
expect(() => AgentConfigSchema.parse({ ...base, relayUrl: 'ws://relay.example.com' })).toThrow()
})
it('rejects a non-https enrollUrl', () => {
expect(() => AgentConfigSchema.parse({ ...base, enrollUrl: 'http://example.com/enroll' })).toThrow()
})
it('rejects a non-loopback localTargetUrl (anti-SSRF)', () => {
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
expect(() => AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://evil.example.com:3000' })).toThrow()
})
it('accepts loopback localTargetUrl variants', () => {
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
})
it('loadAgentConfig fails fast on a missing relayUrl', () => {
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
})
it('loadAgentConfig reads env and lets argv override', () => {
const cfg = loadAgentConfig(
{ RELAY_URL: 'wss://a/agent', ENROLL_URL: 'https://a/enroll' } as unknown as NodeJS.ProcessEnv,
{ subdomain: 'host-42' },
)
expect(cfg.relayUrl).toBe('wss://a/agent')
expect(cfg.subdomain).toBe('host-42')
expect(cfg.localTargetUrl).toBe('ws://127.0.0.1:3000')
})
})

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest'
import { createBackoff, reconnectLoop, BACKOFF_CAP_MS } from '../src/transport/backoff.js'
import type { Tunnel } from '../src/transport/tunnel.js'
describe('createBackoff (T10)', () => {
it('follows 1s,2s,4s…cap 30s', () => {
const b = createBackoff()
const seq = [b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs(), b.nextDelayMs()]
expect(seq).toEqual([1000, 2000, 4000, 8000, 16000, 30000, 30000])
expect(BACKOFF_CAP_MS).toBe(30_000)
})
it('reset() returns to 1s', () => {
const b = createBackoff()
b.nextDelayMs()
b.nextDelayMs()
b.reset()
expect(b.nextDelayMs()).toBe(1000)
})
it('jitter stays within [0.5×, 1×]', () => {
const b = createBackoff({ jitter: true, rng: () => 0 })
expect(b.nextDelayMs()).toBe(500) // 1000 * 0.5
const b2 = createBackoff({ jitter: true, rng: () => 1 })
expect(b2.nextDelayMs()).toBe(1000) // 1000 * 1.0
})
})
describe('reconnectLoop (T10, INV12)', () => {
const fakeTunnel = {} as Tunnel
it('resolves on the first successful dial and resets backoff', async () => {
const dial = vi.fn().mockRejectedValueOnce(new Error('down')).mockResolvedValueOnce(fakeTunnel)
const backoff = createBackoff()
const reset = vi.spyOn(backoff, 'reset')
const sleeps: number[] = []
const result = await reconnectLoop(dial, backoff, () => false, async (ms) => {
sleeps.push(ms)
})
expect(result).toBe(fakeTunnel)
expect(sleeps).toEqual([1000])
expect(reset).toHaveBeenCalled()
})
it('a revoked host does not reconnect (INV12)', async () => {
const dial = vi.fn().mockResolvedValue(fakeTunnel)
const result = await reconnectLoop(dial, createBackoff(), () => true, async () => {})
expect(dial).not.toHaveBeenCalled()
expect(result).toBeNull()
})
it('stops retrying once revoked mid-loop', async () => {
let revoked = false
const dial = vi.fn(async () => {
revoked = true
throw new Error('down')
})
const result = await reconnectLoop(dial, createBackoff(), () => revoked, async () => {})
expect(result).toBeNull()
expect(dial).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { BINARY_TARGETS, buildBinaryConfig } from '../src/dist/buildBinary.js'
describe('buildBinaryConfig (T16)', () => {
it('produces a bun --compile spec per target with cli.ts entry', () => {
for (const target of BINARY_TARGETS) {
const spec = buildBinaryConfig(target)
expect(spec.tool).toBe('bun')
expect(spec.entry).toBe('src/cli.ts')
expect(spec.target).toBe(target)
expect(spec.bunTarget).toContain('bun-')
expect(spec.outfile).toContain(target)
}
})
it('carries the INV11 forbidden-dep tripwire (no terminal parser in the bundle)', () => {
const spec = buildBinaryConfig('darwin-arm64')
expect(spec.forbiddenDeps).toContain('xterm')
expect(spec.forbiddenDeps).toContain('ansi')
})
it('covers the four supported triples', () => {
expect([...BINARY_TARGETS].sort()).toEqual(
['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64'],
)
})
})

111
agent/test/cli.test.ts Normal file
View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import type { Keystore } from '../src/keys/keystore.js'
import type { AgentIdentity } from '../src/keys/identity.js'
import type { EnrollResult } from 'relay-contracts'
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://x/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
function fakeIdentity(): AgentIdentity {
return {
publicKey: new Uint8Array(32),
enrollFpr: 'fpr',
sign: () => new Uint8Array(64),
exportPrivatePkcs8Pem: () => 'PEM',
privateKeyObject: () => ({}) as never,
}
}
function fakeKeystore(enrolled: boolean): Keystore {
return {
saveIdentity: vi.fn(),
loadIdentity: () => (enrolled ? fakeIdentity() : null),
saveCert: vi.fn(),
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
saveContentSecret: vi.fn(),
loadContentSecret: () => null,
}
}
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
const out: string[] = []
const d: CliDeps = {
loadConfig: () => CFG,
openKeystore: () => fakeKeystore(enrolled),
generateIdentity: fakeIdentity,
redeem: async (): Promise<EnrollResult> => ({
hostId: 'h-1',
subdomain: 'host-42',
cert: 'C',
caChain: 'CA',
hostContentSecret: new Uint8Array([1]),
}),
runTunnel: async () => 0,
installService: vi.fn(async () => {}),
uninstallService: vi.fn(async () => {}),
print: (l) => out.push(l),
...overrides,
}
return { d, out }
}
describe('parseArgs (T5)', () => {
it('parses `pair ABCD-1234`', () => {
expect(parseArgs(['pair', 'ABCD-1234'])).toEqual({ command: 'pair', code: 'ABCD-1234', flags: {} })
})
it('rejects an unknown command', () => {
expect(() => parseArgs(['frobnicate'])).toThrow(CliUsageError)
})
it('requires a code on pair', () => {
expect(() => parseArgs(['pair'])).toThrow(CliUsageError)
})
it('collects flags', () => {
expect(parseArgs(['pair', 'X', '--install'])).toEqual({
command: 'pair',
code: 'X',
flags: { install: true },
})
})
})
describe('runCli (T5)', () => {
it('pair happy path calls redeem and prints no secrets', async () => {
const { d, out } = deps()
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
expect(code).toBe(0)
expect(out.join('\n')).toContain('host-42')
expect(out.join('\n')).not.toContain('PEM')
})
it('pair --install installs the service', async () => {
const install = vi.fn(async () => {})
const { d } = deps({ installService: install })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(install).toHaveBeenCalledOnce()
})
it('run before pairing fails fast', async () => {
const { d } = deps({}, false)
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
})
it('status prints no key/cert material (INV9)', async () => {
const { d, out } = deps({}, true)
await runCli({ command: 'status', flags: {} }, d)
const joined = out.join('\n')
expect(joined).toContain('subdomain: host-42')
expect(joined).not.toContain('PEM')
expect(joined).not.toContain('CA')
})
})

32
agent/test/csr.test.ts Normal file
View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { X509Certificate } from 'node:crypto'
import { generateIdentity } from '../src/keys/identity.js'
import { buildCsr } from '../src/enroll/csr.js'
describe('PKCS#10 CSR (T4)', () => {
it('emits a PEM CERTIFICATE REQUEST', () => {
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
})
it('does not contain private-key material (INV4)', () => {
const id = generateIdentity()
const csr = buildCsr(id, 'host-42')
expect(csr).not.toContain('PRIVATE KEY')
expect(csr).not.toContain(id.exportPrivatePkcs8Pem().split('\n')[1]!)
})
it('produces a syntactically decodable DER structure', () => {
// Node can't parse a CSR directly, but the base64 body must be valid DER (a SEQUENCE).
const csr = buildCsr(generateIdentity(), 'host-42')
const b64 = csr
.replace(/-----[A-Z ]+-----/g, '')
.replace(/\s+/g, '')
const der = Buffer.from(b64, 'base64')
expect(der[0]).toBe(0x30) // outer SEQUENCE tag
expect(der.length).toBeGreaterThan(64)
// sanity: X509Certificate exists (crypto available) — unrelated smoke to keep import used
expect(typeof X509Certificate).toBe('function')
})
})

112
agent/test/dial.test.ts Normal file
View File

@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AgentConfig } from '../src/config/agentConfig.js'
import { generateIdentity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import {
CertExpiredError,
NotEnrolledError,
buildTlsOptions,
dialRelay,
type RawTlsWs,
type TlsWsConstructor,
} from '../src/transport/dial.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay.example.com/agent',
enrollUrl: 'https://example.com/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
function enrolledKs() {
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
const ks = openKeystore(dir)
ks.saveIdentity(generateIdentity())
ks.saveCert('CERTPEM', 'CAPEM')
return { dir, ks }
}
const future: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() + 86_400_000) })
const past: () => { validTo: Date } = () => ({ validTo: new Date(Date.now() - 1000) })
describe('buildTlsOptions (T12, INV14/INV4)', () => {
it('wires cert + key + CA and forces rejectUnauthorized true', () => {
const { dir, ks } = enrolledKs()
const tls = buildTlsOptions(ks, { certParser: future })
expect(tls.cert).toBe('CERTPEM')
expect(tls.ca).toBe('CAPEM')
expect(tls.key).toContain('PRIVATE KEY') // in-process key PEM
expect(tls.rejectUnauthorized).toBe(true)
rmSync(dir, { recursive: true, force: true })
})
it('carries NO bearer/agent token (mTLS is the auth)', () => {
const { dir, ks } = enrolledKs()
const tls = buildTlsOptions(ks, { certParser: future })
expect(JSON.stringify(tls)).not.toMatch(/token|authorization|bearer/i)
rmSync(dir, { recursive: true, force: true })
})
it('missing cert → NotEnrolledError (fail-fast)', () => {
const dir = mkdtempSync(join(tmpdir(), 'wta-dial-'))
expect(() => buildTlsOptions(openKeystore(dir))).toThrow(NotEnrolledError)
rmSync(dir, { recursive: true, force: true })
})
it('expired cert → CertExpiredError', () => {
const { dir, ks } = enrolledKs()
expect(() => buildTlsOptions(ks, { certParser: past })).toThrow(CertExpiredError)
rmSync(dir, { recursive: true, force: true })
})
})
describe('dialRelay (T12)', () => {
it('constructs the wss client with the TLS options and resolves on open', async () => {
const { dir, ks } = enrolledKs()
let capturedUrl = ''
let capturedOpts: unknown
class FakeTlsWs implements RawTlsWs {
constructor(url: string, opts: unknown) {
capturedUrl = url
capturedOpts = opts
queueMicrotask(() => this.openCb?.())
}
private openCb?: () => void
send(): void {}
close(): void {}
on(): void {}
once(ev: string, cb: () => void): void {
if (ev === 'open') this.openCb = cb
}
}
const ws = await dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
expect(capturedUrl).toBe('wss://relay.example.com/agent')
expect((capturedOpts as { rejectUnauthorized: boolean }).rejectUnauthorized).toBe(true)
expect(ws).toBeDefined()
rmSync(dir, { recursive: true, force: true })
})
it('rejects when the server errors before open (bad chain / MITM)', async () => {
const { dir, ks } = enrolledKs()
class FakeTlsWs implements RawTlsWs {
private errCb?: (e: unknown) => void
constructor() {
queueMicrotask(() => this.errCb?.(new Error('unable to verify leaf signature')))
}
send(): void {}
close(): void {}
on(): void {}
once(ev: string, cb: (e: unknown) => void): void {
if (ev === 'error') this.errCb = cb
}
}
const p = dialRelay(CFG, ks, { Ctor: FakeTlsWs as unknown as TlsWsConstructor, certParser: future })
await expect(p).rejects.toThrow(/verify/)
rmSync(dir, { recursive: true, force: true })
})
})

63
agent/test/fixtures/fakes.ts vendored Normal file
View File

@@ -0,0 +1,63 @@
import type { WsLike, TimerLike } from '../../src/transport/seams.js'
/** In-memory WsLike double: records sent frames, lets tests emit inbound events. */
export class FakeWs implements WsLike {
readonly sent: Uint8Array[] = []
closed = false
private readonly listeners = new Map<string, Array<(...a: unknown[]) => void>>()
send(d: Uint8Array): void {
this.sent.push(d)
}
on(ev: 'message' | 'close' | 'error', cb: (...a: unknown[]) => void): void {
const arr = this.listeners.get(ev) ?? []
arr.push(cb)
this.listeners.set(ev, arr)
}
close(): void {
this.closed = true
this.emit('close')
}
emit(ev: string, ...args: unknown[]): void {
for (const cb of this.listeners.get(ev) ?? []) cb(...args)
}
emitMessage(bytes: Uint8Array): void {
this.emit('message', bytes)
}
}
/** Deterministic controllable timer for heartbeat/rotation tests. */
export class FakeTimer implements TimerLike {
private seq = 0
private readonly timeouts = new Map<number, { cb: () => void; ms: number }>()
private readonly intervals = new Map<number, { cb: () => void; ms: number }>()
setTimeout(cb: () => void, ms: number): unknown {
const id = this.seq++
this.timeouts.set(id, { cb, ms })
return id
}
clearTimeout(handle: unknown): void {
this.timeouts.delete(handle as number)
}
setInterval(cb: () => void, ms: number): unknown {
const id = this.seq++
this.intervals.set(id, { cb, ms })
return id
}
clearInterval(handle: unknown): void {
this.intervals.delete(handle as number)
}
/** Fire every armed timeout whose delay is ≤ ms (single shot) and every interval once. */
advance(ms: number): void {
for (const [id, t] of [...this.timeouts]) {
if (t.ms <= ms) {
this.timeouts.delete(id)
t.cb()
}
}
for (const t of [...this.intervals.values()]) {
if (t.ms <= ms) t.cb()
}
}
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { createFlowController } from '../src/transport/flowControl.js'
describe('FlowController (T11)', () => {
it('pauses at zero credit and resumes on grant', () => {
const fc = createFlowController()
fc.initWindow(1, 10)
expect(fc.consume(1, 8)).toBe(true)
expect(fc.consume(1, 8)).toBe(false) // only 2 credit left → pause
fc.grant(1, 16)
expect(fc.consume(1, 8)).toBe(true)
})
it('credit is per-stream: exhausting A does not pause B (starvation guard)', () => {
const fc = createFlowController()
fc.initWindow(1, 4)
fc.initWindow(2, 100)
expect(fc.consume(1, 4)).toBe(true)
expect(fc.consume(1, 1)).toBe(false) // A exhausted
expect(fc.consume(2, 50)).toBe(true) // B unaffected
})
it('connection-level (streamId 0) credit caps the whole link', () => {
const fc = createFlowController()
fc.initWindow(0, 5) // connection window
fc.initWindow(1, 1000)
expect(fc.consume(1, 5)).toBe(true)
expect(fc.consume(1, 1)).toBe(false) // connection window exhausted despite stream credit
fc.grant(0, 10)
expect(fc.consume(1, 1)).toBe(true)
})
it('an uninitialized stream has no credit', () => {
const fc = createFlowController()
expect(fc.consume(42, 1)).toBe(false)
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
buildFrpcToml,
isFrpRetired,
spawnFrpc,
type ChildLike,
} from '../src/transport/frpScaffold.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://x/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: null,
}
describe('frpScaffold (T6, v0.8 only)', () => {
it('generates a loopback-only tls frpc.toml', () => {
const toml = buildFrpcToml(CFG)
expect(toml).toContain('local_ip = "127.0.0.1"')
expect(toml).toContain('local_port = 3000')
expect(toml).toContain('subdomain = "host-42"')
expect(toml).toContain('tls_enable = true')
expect(toml).not.toMatch(/local_ip = "(?!127\.0\.0\.1)/)
})
it('refuses a non-loopback local target (anti-SSRF)', () => {
expect(() => buildFrpcToml({ ...CFG, localTargetUrl: 'ws://10.0.0.5:3000' })).toThrow()
})
it('propagates child exit to onExit', async () => {
let exitHandler: ((code: number | null) => void) | null = null
const child: ChildLike = {
on: (_ev, cb) => {
exitHandler = cb
},
kill: vi.fn(),
}
const scaffold = spawnFrpc(CFG, '/usr/bin/frpc', () => child)
const seen: number[] = []
scaffold.onExit((c) => seen.push(c))
await scaffold.start()
exitHandler!(7)
expect(seen).toEqual([7])
})
it('retirement guard: retired once ed25519', () => {
expect(isFrpRetired('ed25519')).toBe(true)
expect(isFrpRetired('token')).toBe(false)
})
})

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { decodeMuxFrame } from 'relay-contracts'
import { holdTunnel } from '../src/transport/tunnel.js'
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../src/transport/heartbeat.js'
import { FakeWs, FakeTimer } from './fixtures/fakes.js'
describe('Heartbeat (T9, §4.1)', () => {
it('replies PONG echoing the inbound PING token byte-exact', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const hb = createHeartbeat(tunnel, { timer: new FakeTimer() })
const token = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
hb.onPing(token)
const frame = decodeMuxFrame(ws.sent.at(-1)!)
expect(frame.header.type).toBe('pong')
expect(Buffer.from(frame.payload).equals(Buffer.from(token))).toBe(true)
})
it('fires onDead when no PONG arrives within the interval', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const timer = new FakeTimer()
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000 })
let dead = false
hb.onDead(() => {
dead = true
})
hb.start() // sends first ping, arms deadline
timer.advance(1000) // deadline fires, no pong seen
expect(dead).toBe(true)
})
it('does not fire onDead when PONG arrives in time', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const timer = new FakeTimer()
const hb = createHeartbeat(tunnel, { timer, intervalMs: 1000, genToken: () => new Uint8Array(8) })
let dead = false
hb.onDead(() => {
dead = true
})
hb.start()
hb.onPong(new Uint8Array(8)) // clears pending before the deadline
timer.advance(1000)
expect(dead).toBe(false)
})
it('exposes the frozen 15s interval', () => {
expect(HEARTBEAT_INTERVAL_MS).toBe(15_000)
})
it('stop() cancels timers (no leak)', () => {
const ws = new FakeWs()
const hb = createHeartbeat(holdTunnel(ws), { timer: new FakeTimer(), intervalMs: 1000 })
hb.start()
expect(() => hb.stop()).not.toThrow()
})
})

View File

@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type {
AeadAlg,
ClientHello,
E2ESession,
HandshakeResult,
HostHello,
} from 'relay-contracts'
import { generateIdentity } from '../src/keys/identity.js'
import {
MitmAbortError,
createE2ETransform,
makeHostHello,
type CreateHostHandshake,
type VerifyDeviceProof,
} from '../src/e2e/hostEndpoint.js'
import type { ReplaySealer } from '../src/e2e/replaySeal.js'
const ALG: AeadAlg = 'aes-256-gcm'
function clientHello(proof: string): ClientHello {
return {
clientEphPub: new Uint8Array([1, 2, 3]),
clientNonce: new Uint8Array([4, 5, 6]),
aeadOffer: [ALG],
deviceAuthProof: proof,
}
}
function fakeHandshake(keysByte: number): CreateHostHandshake {
return () => ({
async respond(): Promise<{ hello: HostHello; result: HandshakeResult }> {
const result: HandshakeResult = {
keys: {
c2h: new Uint8Array([keysByte]) as never,
h2c: new Uint8Array([keysByte + 1]) as never,
},
aead: ALG,
transcript: new Uint8Array([0xff]),
}
const hello: HostHello = {
hostEphPub: new Uint8Array([7]),
hostNonce: new Uint8Array([8]),
aeadChoice: ALG,
enrollFpr: 'fpr',
sig: new Uint8Array([9]),
}
return { hello, result }
},
})
}
// The load-bearing MITM verifier: only a specific proof passes.
const realVerifier: VerifyDeviceProof = async (proof) => proof === 'VALID-PROOF'
describe('makeHostHello (T15, anti-MITM)', () => {
it('derives DirectionalKeys{c2h,h2c} on a valid proof (FIX 2 — no single sessionKey)', async () => {
const { result } = await makeHostHello(clientHello('VALID-PROOF'), generateIdentity(), realVerifier, {
createHostHandshake: fakeHandshake(0x10),
})
expect(result.keys).toHaveProperty('c2h')
expect(result.keys).toHaveProperty('h2c')
expect(result).not.toHaveProperty('sessionKey')
})
it('ABORTS on a forged proof: no keys, no HostHello (MitmAbortError)', async () => {
const respond = vi.fn()
const spyHandshake: CreateHostHandshake = () => ({ respond: respond as never })
await expect(
makeHostHello(clientHello('FORGED'), generateIdentity(), realVerifier, {
createHostHandshake: spyHandshake,
}),
).rejects.toBeInstanceOf(MitmAbortError)
expect(respond).not.toHaveBeenCalled() // no key derivation reached
})
it('no-stub guard: swapping the verifier for always-true makes the MITM test FAIL', async () => {
const stub: VerifyDeviceProof = async () => true
// With the stubbed verifier, the forged proof WRONGLY completes — proving the verifier is
// load-bearing (a real build forbids this stub via the import guard below).
const out = await makeHostHello(clientHello('FORGED'), generateIdentity(), stub, {
createHostHandshake: fakeHandshake(0x20),
})
expect(out.result.keys).toBeDefined()
})
it('no-stub CI guard: hostEndpoint.ts imports NO verifier from relay-e2e (FIX 6b)', () => {
const raw = readFileSync(join(import.meta.dirname, '..', 'src', 'e2e', 'hostEndpoint.ts'), 'utf8')
const src = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '') // strip comments
expect(src).not.toMatch(/import[^\n]*verifyDeviceAuthProof/)
expect(src).not.toMatch(/from ['"]relay-e2e['"]/)
})
})
describe('createE2ETransform (T15)', () => {
function fakeSession(): E2ESession {
// Reversible transform that HIDES the plaintext (XOR) so INV2 assertions are meaningful.
return {
role: 'host',
seal: (pt) => Uint8Array.from([0xe0, ...pt.map((b) => b ^ 0x55)]),
open: (ct) => Uint8Array.from(ct.slice(1)).map((b) => b ^ 0x55),
rederive: () => {},
}
}
function fakeReplay(): ReplaySealer & { calls: number } {
const r = {
calls: 0,
seal(_pt: Uint8Array) {
r.calls += 1
return { seq: 0n, nonce: new Uint8Array(), ciphertext: new Uint8Array([0xde]), tag: new Uint8Array() }
},
}
return r
}
it('outbound seals under BOTH the live session and the replay key (FIX 3, INV2)', () => {
const replay = fakeReplay()
const t = createE2ETransform(generateIdentity(), realVerifier, replay)
t.openStream(1)
t.seedSession(1, fakeSession(), new Uint8Array([0x48])) // HostHello control frame
const marker = new TextEncoder().encode('PLAIN')
const sealed = t.outbound(1, marker)
expect(replay.calls).toBe(1) // replay-bound seal happened
expect(Buffer.from(sealed).includes(Buffer.from(marker))).toBe(false) // ciphertext, not plaintext
expect(t.takeControlFrames!(1)).toEqual([new Uint8Array([0x48])]) // HostHello flushed once
expect(t.takeControlFrames!(1)).toEqual([])
})
it('inbound before seeding returns null (handshake pending); after, opens opaque', () => {
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
const session = fakeSession()
t.openStream(1)
expect(t.inbound(1, new Uint8Array([1, 2]))).toBeNull()
t.seedSession(1, session, new Uint8Array([0x48]))
const ct = session.seal(new Uint8Array([9, 9])) // c2h ciphertext
expect([...t.inbound(1, ct)!]).toEqual([9, 9])
})
it('outbound before the session is established aborts (no silent plaintext leak)', () => {
const t = createE2ETransform(generateIdentity(), realVerifier, fakeReplay())
t.openStream(1)
expect(() => t.outbound(1, new Uint8Array([1]))).toThrow(MitmAbortError)
})
})

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
computeEnrollFpr,
generateIdentity,
identityFromPrivatePem,
verifySignature,
} from '../src/keys/identity.js'
describe('AgentIdentity (INV4)', () => {
it('generates distinct keypairs', () => {
const a = generateIdentity()
const b = generateIdentity()
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
expect(a.publicKey.length).toBe(32)
})
it('sign/verify round-trips', () => {
const id = generateIdentity()
const msg = new TextEncoder().encode('transcript-bytes')
const sig = id.sign(msg)
expect(verifySignature(id.publicKey, msg, sig)).toBe(true)
expect(verifySignature(id.publicKey, new TextEncoder().encode('tampered'), sig)).toBe(false)
})
it('enrollFpr is deterministic base64url(SHA-256(pubkey))', () => {
const id = generateIdentity()
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
})
it('reloads the same identity from PEM', () => {
const id = generateIdentity()
const pem = id.exportPrivatePkcs8Pem()
const reloaded = identityFromPrivatePem(pem)
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
})
it('security: no API returns raw private-key bytes', () => {
const id = generateIdentity()
// The only private-key surface is a PEM export for the 0600 keystore and an in-process
// KeyObject; there is NO Uint8Array/raw-bytes getter for the private key.
expect('privateKey' in id).toBe(false)
const src = readFileSync(
join(import.meta.dirname, '..', 'src', 'keys', 'identity.ts'),
'utf8',
)
// No function exports raw private key bytes onto the network surface.
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
RootRefusedError,
detectPlatform,
installService,
uninstallService,
type InstallDeps,
} from '../src/service/install.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://x/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
const writes: Array<[string, string]> = []
const runs: Array<[string, readonly string[]]> = []
return {
writes,
runs,
writeFile: (p, c) => writes.push([p, c]),
runCommand: async (cmd, args) => {
runs.push([cmd, args])
},
getuid: () => uid,
homedir: () => '/home/alice',
username: () => 'alice',
binPath: () => '/usr/local/bin/web-terminal-agent',
}
}
describe('detectPlatform (T17)', () => {
it('maps darwin→launchd, linux→systemd, else null', () => {
expect(detectPlatform('darwin')).toBe('launchd')
expect(detectPlatform('linux')).toBe('systemd')
expect(detectPlatform('win32')).toBeNull()
})
})
describe('installService (T17)', () => {
it('refuses to install as root (negative, least privilege)', async () => {
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
})
it('systemd: writes a run-as-user unit and enables it', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
expect(d.runs[0]![0]).toBe('systemctl')
})
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [path, plist] = d.writes[0]!
expect(path).toContain('LaunchAgents')
expect(plist).toContain('<string>run</string>')
expect(plist).toContain('<key>KeepAlive</key>')
expect(d.runs[0]![0]).toBe('launchctl')
})
it('uninstall unloads cleanly', async () => {
const d = deps()
await uninstallService('launchd', d)
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
})
})

View File

@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { generateIdentity } from '../src/keys/identity.js'
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
const dirs: string[] = []
function freshDir(): string {
const d = mkdtempSync(join(tmpdir(), 'wta-ks-'))
dirs.push(d)
return d
}
afterEach(() => {
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true })
})
function mode(path: string): number {
return statSync(path).mode & 0o777
}
describe('Keystore (INV4/INV5)', () => {
it('persists the private key 0600 and reloads it', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateIdentity()
ks.saveIdentity(id)
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
const reloaded = ks.loadIdentity()
expect(reloaded).not.toBeNull()
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
})
it('persists cert + CA chain 0600', () => {
const dir = freshDir()
const ks = openKeystore(dir)
ks.saveCert('CERTPEM', 'CAPEM')
expect(mode(join(dir, 'agent.cert.pem'))).toBe(0o600)
expect(mode(join(dir, 'agent.ca.pem'))).toBe(0o600)
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
})
it('FIX 3: round-trips hostContentSecret 0600', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const secret = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])
ks.saveContentSecret(secret)
expect(mode(join(dir, 'content.secret'))).toBe(0o600)
expect(Buffer.from(ks.loadContentSecret()!).equals(Buffer.from(secret))).toBe(true)
})
it('returns null when nothing is stored yet', () => {
const ks = openKeystore(freshDir())
expect(ks.loadIdentity()).toBeNull()
expect(ks.loadCert()).toBeNull()
expect(ks.loadContentSecret()).toBeNull()
})
it('throws a typed error on a corrupt key file', () => {
const dir = freshDir()
writeFileSync(join(dir, 'agent.key.pem'), 'not-a-pem')
expect(() => openKeystore(dir).loadIdentity()).toThrow(KeystoreError)
})
it('refuses to write into a group/world-accessible dir (INV5)', () => {
const dir = join(freshDir(), 'wideopen')
mkdirSync(dir, { mode: 0o755 })
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
})
})

43
agent/test/logger.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { createLogger } from '../src/log/logger.js'
function capture() {
const lines: string[] = []
return { lines, sink: (l: string) => lines.push(l) }
}
describe('redacting logger (INV9)', () => {
it('never emits secret meta values', () => {
const { lines, sink } = capture()
const log = createLogger('debug', sink)
log.log('info', 'enrolled', {
privateKey: 'SECRET-PRIV-KEY',
cert: 'SECRET-CERT',
pairingCode: 'ABCD-1234',
agentToken: 'tok-xyz',
})
const joined = lines.join('\n')
expect(joined).not.toContain('SECRET-PRIV-KEY')
expect(joined).not.toContain('SECRET-CERT')
expect(joined).not.toContain('ABCD-1234')
expect(joined).not.toContain('tok-xyz')
expect(joined).toContain('[REDACTED]')
})
it('passes non-secret meta (nonce) through', () => {
const { lines, sink } = capture()
const log = createLogger('debug', sink)
log.log('debug', 'frame', { nonce: 'abc123', streamId: 7 })
expect(lines[0]).toContain('abc123')
expect(lines[0]).toContain('7')
})
it('drops messages below the threshold level', () => {
const { lines, sink } = capture()
const log = createLogger('warn', sink)
log.log('debug', 'noisy')
log.log('error', 'boom')
expect(lines).toHaveLength(1)
expect(lines[0]).toContain('boom')
})
})

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { buildLoopbackUrl, dialLoopback, type RawWs, type WsConstructor } from '../src/transport/loopback.js'
describe('buildLoopbackUrl (T8)', () => {
it('joins target + path without a double slash', () => {
expect(buildLoopbackUrl('ws://127.0.0.1:3000', '/term?join=x')).toBe('ws://127.0.0.1:3000/term?join=x')
expect(buildLoopbackUrl('ws://127.0.0.1:3000/', 'term')).toBe('ws://127.0.0.1:3000/term')
})
})
class FakeRawWs implements RawWs {
static last: FakeRawWs | null = null
readonly url: string
readonly origin: string | undefined
private readonly handlers = new Map<string, (...a: unknown[]) => void>()
constructor(url: string, opts?: { headers?: Record<string, string> }) {
this.url = url
this.origin = opts?.headers?.['Origin']
FakeRawWs.last = this
}
send(): void {}
close(): void {}
on(): void {}
once(event: string, cb: (...a: unknown[]) => void): void {
this.handlers.set(event, cb)
}
fire(event: string, ...args: unknown[]): void {
this.handlers.get(event)?.(...args)
}
}
describe('dialLoopback (T8)', () => {
it('replays Origin and resolves on open', async () => {
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
const promise = dial('/term?join=x', 'https://host-42.term.example.com')
FakeRawWs.last!.fire('open')
const ws = await promise
expect(FakeRawWs.last!.url).toBe('ws://127.0.0.1:3000/term?join=x')
expect(FakeRawWs.last!.origin).toBe('https://host-42.term.example.com')
expect(ws).toBeDefined()
})
it('rejects on a pre-open error (base app down)', async () => {
const dial = dialLoopback('ws://127.0.0.1:3000', FakeRawWs as unknown as WsConstructor)
const promise = dial('/term', 'https://x')
FakeRawWs.last!.fire('error', new Error('ECONNREFUSED'))
await expect(promise).rejects.toThrow('ECONNREFUSED')
})
})

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
function memFs(initial: string | null): { fs: OriginFsDeps; get(): string } {
const store = { content: initial }
const fs: OriginFsDeps = {
exists: () => store.content !== null,
read: () => store.content ?? '',
write: (_p, c) => {
store.content = c
},
}
return { fs, get: () => store.content ?? '' }
}
const PATH = '/etc/web-terminal.env'
describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
it('composes https://<subdomain>.term.<domain>', () => {
expect(subdomainOrigin('host-42', 'example.com')).toBe('https://host-42.term.example.com')
})
it('appends the origin when the file has none', () => {
const { fs, get } = memFs('PORT=3000\n')
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
expect(get()).toContain('ALLOWED_ORIGINS=https://host-42.term.example.com')
expect(get()).toContain('PORT=3000')
})
it('is idempotent (no duplicate on a second run)', () => {
const { fs, get } = memFs('ALLOWED_ORIGINS=https://host-42.term.example.com\n')
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
const matches = get().match(/host-42\.term\.example\.com/g) ?? []
expect(matches).toHaveLength(1)
})
it('preserves existing origins (never weakens the Origin check)', () => {
const { fs, get } = memFs('ALLOWED_ORIGINS=https://existing.example.com\n')
ensureAllowedOrigin(PATH, 'host-42', 'example.com', fs)
expect(get()).toContain('https://existing.example.com')
expect(get()).toContain('https://host-42.term.example.com')
})
})

105
agent/test/pair.test.ts Normal file
View File

@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { generateIdentity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import {
DEFAULT_ENROLL_MODE,
EnrollError,
PairingCodeExpiredError,
PairingCodeSpentError,
redeemPairingCode,
} from '../src/enroll/pair.js'
const ENROLL_URL = 'https://example.com/enroll'
const VALID_UUID = '11111111-1111-4111-8111-111111111111'
function freshKs() {
const dir = mkdtempSync(join(tmpdir(), 'wta-pair-'))
return { dir, ks: openKeystore(dir) }
}
function jsonResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response
}
function okBody(wrappedSecret: Uint8Array) {
return {
hostId: VALID_UUID,
subdomain: 'host-42',
cert: 'CERTPEM',
caChain: 'CAPEM',
hostContentSecret: encodeBase64UrlBytes(wrappedSecret),
}
}
describe('redeemPairingCode (§4.5, T4)', () => {
it('defaults to ed25519 mode', () => {
expect(DEFAULT_ENROLL_MODE).toBe('ed25519')
})
it('sends only pubkey + csr, never the private key (INV4)', async () => {
const { dir, ks } = freshKs()
const id = generateIdentity()
const fetchImpl = vi.fn(async (_u: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init!.body)) as Record<string, unknown>
expect(body).toHaveProperty('agentPubkey')
expect(body).toHaveProperty('csr')
expect(JSON.stringify(body)).not.toContain('PRIVATE KEY')
expect(body).not.toHaveProperty('privateKey')
return jsonResponse(200, okBody(new Uint8Array([9, 9, 9])))
})
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, { fetchImpl: fetchImpl as unknown as typeof fetch })
rmSync(dir, { recursive: true, force: true })
})
it('stores cert + unwrapped content secret; wrapped bytes not persisted (FIX 3)', async () => {
const { dir, ks } = freshKs()
const id = generateIdentity()
const wrapped = new Uint8Array([1, 2, 3])
const unwrapped = new Uint8Array([7, 7, 7])
const fetchImpl = async () => jsonResponse(200, okBody(wrapped))
await redeemPairingCode(ENROLL_URL, 'ABCD-1234', id, ks, {
fetchImpl: fetchImpl as unknown as typeof fetch,
unwrapContentSecret: () => unwrapped,
})
expect(ks.loadCert()).toEqual({ certPem: 'CERTPEM', caChainPem: 'CAPEM' })
const stored = ks.loadContentSecret()!
expect(Buffer.from(stored).equals(Buffer.from(unwrapped))).toBe(true)
expect(Buffer.from(stored).equals(Buffer.from(wrapped))).toBe(false)
rmSync(dir, { recursive: true, force: true })
})
it('maps 409 → PairingCodeSpentError (single-use)', async () => {
const { dir, ks } = freshKs()
const fetchImpl = async () => jsonResponse(409, {})
await expect(
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
).rejects.toBeInstanceOf(PairingCodeSpentError)
rmSync(dir, { recursive: true, force: true })
})
it('maps 410 → PairingCodeExpiredError', async () => {
const { dir, ks } = freshKs()
const fetchImpl = async () => jsonResponse(410, {})
await expect(
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
).rejects.toBeInstanceOf(PairingCodeExpiredError)
rmSync(dir, { recursive: true, force: true })
})
it('rejects a schema-mismatched response (boundary validation)', async () => {
const { dir, ks } = freshKs()
const fetchImpl = async () => jsonResponse(200, { hostId: 'not-a-uuid', subdomain: 'x' })
await expect(
redeemPairingCode(ENROLL_URL, 'X', generateIdentity(), ks, { fetchImpl: fetchImpl as unknown as typeof fetch }),
).rejects.toBeInstanceOf(EnrollError)
rmSync(dir, { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
import { createReplaySealer, type ReplayCrypto } from '../src/e2e/replaySeal.js'
/** Fake AEAD: key = tagged secret‖sessionId; ciphertext = plaintext XOR keyByte (marker hidden). */
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[] } {
const derivations: ReplayKeyParams[] = []
return {
derivations,
deriveContentKey(params: ReplayKeyParams): AeadKey {
derivations.push(params)
const tag = new TextEncoder().encode(`${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}`)
return tag as unknown as AeadKey
},
sealReplayFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
const kb = (key as unknown as Uint8Array)[0] ?? 0x5a
const ciphertext = plaintext.map((b) => b ^ kb)
return { seq, nonce: new Uint8Array([Number(seq & 0xffn)]), ciphertext, tag: new Uint8Array([0xaa]) }
},
}
}
const SECRET = new Uint8Array([1, 2, 3, 4])
describe('createReplaySealer (T19, FIX 3)', () => {
it('derives K_content deterministically from (secret, sessionId, alg)', () => {
const c1 = fakeCrypto()
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c1)
const c2 = fakeCrypto()
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c2)
expect(c1.derivations[0]).toEqual(c2.derivations[0])
})
it('a different sessionId → a different key (per-session separation)', () => {
const c = fakeCrypto()
createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
createReplaySealer(SECRET, 'sess-2', 'aes-256-gcm', c)
expect(c.derivations[0]!.sessionId).not.toBe(c.derivations[1]!.sessionId)
})
it('emits a monotonic seq (INV13) and never leaks the plaintext marker (INV2)', () => {
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
const marker = new TextEncoder().encode('SECRET-MARKER')
const e0 = sealer.seal(marker)
const e1 = sealer.seal(marker)
expect(e0.seq).toBe(0n)
expect(e1.seq).toBe(1n)
expect(Buffer.from(e0.ciphertext).includes(Buffer.from(marker))).toBe(false)
})
it('replay seal is DISTINCT from a live h2c seal for the same plaintext (FIX 3)', () => {
const replay = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', fakeCrypto())
// model a live seal with a different key byte
const liveKey = new Uint8Array([0x11]) as unknown as AeadKey
const live = fakeCrypto().sealReplayFrame(liveKey, 0n, new Uint8Array([0x41, 0x42]))
const rep = replay.seal(new Uint8Array([0x41, 0x42]))
expect(Buffer.from(rep.ciphertext).equals(Buffer.from(live.ciphertext))).toBe(false)
})
it('the hostContentSecret is never mutated', () => {
const secret = new Uint8Array([9, 9, 9])
const spy = vi.fn()
createReplaySealer(secret, 's', 'aes-256-gcm', {
deriveContentKey: (p) => {
spy(p.hostContentSecret)
return new Uint8Array([1]) as unknown as AeadKey
},
sealReplayFrame: (_k, seq, pt) => ({ seq, nonce: new Uint8Array(), ciphertext: pt, tag: new Uint8Array() }),
})
expect([...secret]).toEqual([9, 9, 9])
})
})

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { GOAWAY_REASON_TO_CODE } from 'relay-contracts'
import {
applyGoAway,
applyGoAwayCode,
classifyGoAway,
createRevocationState,
} from '../src/lifecycle/revocation.js'
describe('classifyGoAway (T14, frozen 3-value union)', () => {
it('maps operatorDrain and shutdown → drain, revoked → revoked', () => {
expect(classifyGoAway('operatorDrain')).toBe('drain')
expect(classifyGoAway('shutdown')).toBe('drain')
expect(classifyGoAway('revoked')).toBe('revoked')
})
})
describe('createRevocationState (T14, INV12)', () => {
it('revoke tears down once and makes isRevoked() true (suppresses reconnect)', () => {
let teardowns = 0
const state = createRevocationState(() => {
teardowns += 1
})
expect(state.isRevoked()).toBe(false)
state.markRevoked('goaway-revoked')
expect(state.isRevoked()).toBe(true)
state.markRevoked('operator') // idempotent
expect(teardowns).toBe(1)
})
})
describe('applyGoAway / applyGoAwayCode (T14)', () => {
it('a revoked GOAWAY tears down; a drain GOAWAY does not', () => {
const revokeState = createRevocationState(() => {})
expect(applyGoAway('revoked', revokeState)).toBe('revoked')
expect(revokeState.isRevoked()).toBe(true)
const drainState = createRevocationState(() => {})
expect(applyGoAway('operatorDrain', drainState)).toBe('drain')
expect(applyGoAway('shutdown', drainState)).toBe('drain')
expect(drainState.isRevoked()).toBe(false)
})
it('decodes the frozen wire codes via decodeGoAwayReason', () => {
const state = createRevocationState(() => {})
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.operatorDrain, state)).toBe('drain')
expect(applyGoAwayCode(GOAWAY_REASON_TO_CODE.revoked, state)).toBe('revoked')
})
it('an unknown reason code FAILS CLOSED to revoked (INV12 safety)', () => {
const state = createRevocationState(() => {})
expect(applyGoAwayCode(99, state)).toBe('revoked')
expect(state.isRevoked()).toBe(true)
})
it('cross-plan-drift guard: no bare integer reason literal in revocation.ts', () => {
const src = readFileSync(
join(import.meta.dirname, '..', 'src', 'lifecycle', 'revocation.ts'),
'utf8',
)
// The reason mapping must come only from the frozen union/decoder — never a hardcoded
// `=== 2` / `reason: 2` style integer. (Comments are allowed to mention numbers.)
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
expect(code).not.toMatch(/reason\s*[=:]\s*\d/)
expect(code).not.toMatch(/===\s*[123]\b/)
})
})

120
agent/test/rotation.test.ts Normal file
View File

@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AgentConfig } from '../src/config/agentConfig.js'
import { generateIdentity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import {
computeRenewDelayMs,
createCertRotator,
renewCert,
renewalUrlFor,
} from '../src/certs/rotation.js'
import { FakeTimer } from './fixtures/fakes.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://example.com/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
function enrolledKs() {
const dir = mkdtempSync(join(tmpdir(), 'wta-rot-'))
const ks = openKeystore(dir)
ks.saveIdentity(generateIdentity())
ks.saveCert('OLDCERT', 'OLDCA')
return { dir, ks }
}
function jsonRes(status: number, body: unknown): Response {
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response
}
describe('cert rotation math (T13)', () => {
it('derives P3 renewal URL from enrollUrl', () => {
expect(renewalUrlFor(CFG)).toBe('https://example.com/renew')
})
it('renews renewBeforeMs before expiry, clamped at 0', () => {
const now = new Date('2026-01-01T00:00:00Z')
const parse = () => new Date('2026-01-01T01:00:00Z') // expires in 1h
expect(computeRenewDelayMs('C', 5 * 60_000, now, parse)).toBe(55 * 60_000)
const parsePast = () => new Date('2025-01-01T00:00:00Z')
expect(computeRenewDelayMs('C', 5 * 60_000, now, parsePast)).toBe(0)
})
})
describe('renewCert (T13)', () => {
it('installs a fresh cert atomically on success (same key)', async () => {
const { dir, ks } = enrolledKs()
const before = ks.loadIdentity()!.publicKey
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
expect(out).toBe('rotated')
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
// pubkey unchanged — only the cert rotated
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
rmSync(dir, { recursive: true, force: true })
})
it('403 → revoked (⇒ T14 teardown, INV12)', async () => {
const { dir, ks } = enrolledKs()
const fetchImpl = async () => jsonRes(403, {})
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
expect(out).toBe('revoked')
expect(ks.loadCert()).toEqual({ certPem: 'OLDCERT', caChainPem: 'OLDCA' }) // untouched
rmSync(dir, { recursive: true, force: true })
})
})
const flush = () => new Promise((r) => setImmediate(r))
describe('createCertRotator (T13)', () => {
it('fires onRevoked when the scheduled renewal returns 403', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
fetchImpl: (async () => jsonRes(403, {})) as unknown as typeof fetch,
now: () => new Date(0),
parseCert: () => new Date(2000), // expires 2s after epoch → delay ~1000ms
})
let revoked = false
rotator.onRevoked(() => {
revoked = true
})
rotator.start()
timer.advance(1000) // scheduled renewal fires
await flush()
expect(revoked).toBe(true)
rmSync(dir, { recursive: true, force: true })
})
it('rotates and reschedules on a successful renewal (seamless)', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch,
now: () => new Date(0),
parseCert: () => new Date(2000),
})
let rotated = 0
rotator.onRotated(() => {
rotated += 1
})
rotator.start()
timer.advance(1000)
await flush()
expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toBe('NEWCERT')
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import * as contracts from 'relay-contracts'
const pkg = JSON.parse(
readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8'),
) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
describe('package scaffold', () => {
it('resolves the frozen relay-contracts surface', () => {
// §4.1 codec + §4.4 shapes + §4.5 pairing must all be importable.
expect(typeof contracts.encodeMuxFrame).toBe('function')
expect(typeof contracts.decodeMuxFrame).toBe('function')
expect(typeof contracts.decodeGoAwayReason).toBe('function')
expect(contracts.EnrollResultSchema).toBeDefined()
})
it('declares web-terminal-agent bin (INDEX §4.5 distribution)', () => {
expect(pkg.dependencies?.['relay-contracts']).toBe('file:../relay-contracts')
})
it('INV11 tripwire: no ANSI/xterm/vt100 dependency', () => {
const allDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
const forbidden = /xterm|ansi|vt100/i
for (const name of Object.keys(allDeps)) {
expect(forbidden.test(name), `forbidden terminal-parser dep: ${name}`).toBe(false)
}
})
})

20
agent/test/seams.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
describe('transport seams (W0 cycle-breaker)', () => {
it('is a type-only module with no runtime side effects or transport deps', async () => {
const src = readFileSync(
join(import.meta.dirname, '..', 'src', 'transport', 'seams.ts'),
'utf8',
)
// No runtime imports: the seam file must not pull ws/crypto/node runtime.
expect(src).not.toMatch(/from ['"]ws['"]/)
expect(src).not.toMatch(/from ['"]node:crypto['"]/)
// Importing it must not throw or emit anything.
const mod = await import('../src/transport/seams.js')
expect(mod).toBeDefined()
// Interfaces are erased at runtime, so the module has no runtime exports.
expect(Object.keys(mod)).toHaveLength(0)
})
})

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
const SRC = join(import.meta.dirname, '..', '..', 'src')
function walk(dir: string): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const path = join(dir, name)
if (statSync(path).isDirectory()) out.push(...walk(path))
else if (name.endsWith('.ts')) out.push(path)
}
return out
}
const files = walk(SRC)
function code(path: string): string {
return readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
}
describe('agent security tripwires (T18, INDEX §3)', () => {
it('INV9: no console.log anywhere in src', () => {
for (const f of files) {
expect(code(f), `console.log in ${f}`).not.toMatch(/console\.log/)
}
})
it('INV11: no ANSI/xterm/vt100 import anywhere in src (byte-shuttle)', () => {
for (const f of files) {
expect(code(f), `terminal-parser import in ${f}`).not.toMatch(/from ['"][^'"]*(xterm|ansi|vt100)[^'"]*['"]/)
}
})
it('INV2 anti-MITM: hostEndpoint imports NO verifier from relay-e2e (FIX 6b)', () => {
const he = code(join(SRC, 'e2e', 'hostEndpoint.ts'))
expect(he).not.toMatch(/from ['"]relay-e2e['"]/)
expect(he).not.toMatch(/verifyDeviceAuthProof/)
})
it('INV12: revocation.ts uses the frozen decoder, no bare integer reason literal', () => {
const rev = code(join(SRC, 'lifecycle', 'revocation.ts'))
expect(rev).toMatch(/decodeGoAwayReason/)
expect(rev).not.toMatch(/reason\s*[=:]\s*\d/)
expect(rev).not.toMatch(/===\s*[123]\b/)
})
it('INV4: identity exposes no raw private-key byte export', () => {
const id = code(join(SRC, 'keys', 'identity.ts'))
expect(id).not.toMatch(/exportPrivateRaw|privateKeyBytes|rawPrivateKey/)
})
it('every src module is well under the 800-line hard cap', () => {
for (const f of files) {
const lines = readFileSync(f, 'utf8').split('\n').length
expect(lines, `${f} is ${lines} lines`).toBeLessThan(800)
}
})
})

View File

@@ -0,0 +1,140 @@
import { describe, expect, it, vi } from 'vitest'
import { decodeMuxFrame, encodeMuxFrame, type MuxOpen } from 'relay-contracts'
import type { AgentConfig } from '../src/config/agentConfig.js'
import { createLogger } from '../src/log/logger.js'
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
import { createStreamRouter, identityTransform } from '../src/transport/streamRouter.js'
import { FakeWs } from './fixtures/fakes.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://x/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
const OPEN: MuxOpen = {
streamId: 5,
subdomain: 'host-42',
requestPath: '/term?join=abc',
originHeader: 'https://host-42.term.example.com',
remoteAddrHash: 'x',
capabilityTokenRef: 'jti',
}
const silentLogger = createLogger('error', () => {})
const flush = () => new Promise((r) => setImmediate(r))
function setup(dialImpl?: (path: string, origin: string) => Promise<FakeWs>) {
const upstream = new FakeWs()
const tunnel = holdTunnel(upstream)
const loopbacks: FakeWs[] = []
const dial = vi.fn(
dialImpl ??
(async () => {
const ws = new FakeWs()
loopbacks.push(ws)
return ws
}),
)
const router = createStreamRouter(CFG, tunnel, dial as never, identityTransform, silentLogger)
return { upstream, router, dial, loopbacks }
}
function lastFrame(ws: FakeWs) {
return decodeMuxFrame(ws.sent.at(-1)!)
}
describe('StreamRouter (T8)', () => {
it('dials loopback with the request path and replayed Origin', async () => {
const { router, dial } = setup()
router.handleOpen(OPEN)
await flush()
expect(dial).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
})
it('INV1 defense-in-depth: foreign subdomain is RST and NEVER dialed', async () => {
const { router, dial, upstream } = setup()
router.handleOpen({ ...OPEN, subdomain: 'host-999' })
await flush()
expect(dial).not.toHaveBeenCalled()
const f = lastFrame(upstream)
expect(f.header.type).toBe('close')
expect(f.header.rst).toBe(true)
expect(router.activeStreamCount()).toBe(0)
})
it('splices loopback output → tunnel DATA', async () => {
const { router, upstream, loopbacks } = setup()
router.handleOpen(OPEN)
await flush()
const bytes = new Uint8Array([9, 8, 7])
loopbacks[0]!.emit('message', bytes)
const f = lastFrame(upstream)
expect(f.header.type).toBe('data')
expect(Buffer.from(f.payload).equals(Buffer.from(bytes))).toBe(true)
})
it('forwards tunnel DATA → loopback socket (buffering pre-open)', async () => {
const { router, loopbacks } = setup()
router.handleOpen(OPEN)
router.handleData(5, new Uint8Array([1, 2])) // arrives before dial resolves → buffered
await flush()
router.handleData(5, new Uint8Array([3, 4]))
expect(loopbacks[0]!.sent.map((b) => [...b])).toEqual([[1, 2], [3, 4]])
})
it('DATA on an unknown stream is RST (illegal transition)', () => {
const { router, upstream } = setup()
router.handleData(999, new Uint8Array([1]))
const f = lastFrame(upstream)
expect(f.header.type).toBe('close')
expect(f.header.rst).toBe(true)
})
it('two concurrent streams get separate sockets (no cross-stream bleed)', async () => {
const { router, loopbacks } = setup()
router.handleOpen(OPEN)
router.handleOpen({ ...OPEN, streamId: 6 })
await flush()
router.handleData(5, new Uint8Array([0xaa]))
router.handleData(6, new Uint8Array([0xbb]))
expect(loopbacks[0]!.sent).toHaveLength(1)
expect(loopbacks[1]!.sent).toHaveLength(1)
expect([...loopbacks[0]!.sent[0]!]).toEqual([0xaa])
expect([...loopbacks[1]!.sent[0]!]).toEqual([0xbb])
expect(router.activeStreamCount()).toBe(2)
})
it('CLOSE frees the loopback socket', async () => {
const { router, loopbacks } = setup()
router.handleOpen(OPEN)
await flush()
router.handleClose(5, false)
expect(loopbacks[0]!.closed).toBe(true)
expect(router.activeStreamCount()).toBe(0)
})
it('loopback connect-refused → RST upstream (typed, no swallow)', async () => {
const { router, upstream } = setup(async () => {
throw new Error('ECONNREFUSED')
})
router.handleOpen(OPEN)
await flush()
const f = lastFrame(upstream)
expect(f.header.type).toBe('close')
expect(f.header.rst).toBe(true)
})
})
describe('opaque splice sanity (INV11)', () => {
it('does not import any terminal parser (identity transform)', () => {
const bytes = new Uint8Array([0x1b, 0x5b, 0x41])
expect(identityTransform.outbound(1, bytes)).toBe(bytes)
// encode/decode round-trip stays byte-exact
const framed = encodeMuxFrame(dataHeader(1, bytes.length), bytes)
expect(Buffer.from(decodeMuxFrame(framed).payload).equals(Buffer.from(bytes))).toBe(true)
})
})

162
agent/test/tunnel.test.ts Normal file
View File

@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest'
import {
decodeMuxFrame,
encodeMuxFrame,
encodeOpen,
encodeGoaway,
type MuxFrameHeader,
type MuxOpen,
} from 'relay-contracts'
import { holdTunnel, dataHeader } from '../src/transport/tunnel.js'
import { FakeWs } from './fixtures/fakes.js'
const OPEN: MuxOpen = {
streamId: 5,
subdomain: 'host-42',
requestPath: '/term?join=abc',
originHeader: 'https://host-42.term.example.com',
remoteAddrHash: 'deadbeef',
capabilityTokenRef: 'jti-1',
}
function openFrame(open: MuxOpen): Uint8Array {
const payload = encodeOpen(open)
const header: MuxFrameHeader = {
version: 1,
type: 'open',
fin: false,
rst: false,
streamId: open.streamId,
payloadLen: payload.length,
}
return encodeMuxFrame(header, payload)
}
function stubHandlers() {
return {
handleOpen: vi.fn(),
handleData: vi.fn(),
handleClose: vi.fn(),
}
}
const stubHb = { onPing: vi.fn(), onPong: vi.fn() }
describe('Tunnel (T7, §4.1)', () => {
it('round-trips a frame and yields decoded header+payload via onFrame', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const seen: Array<{ h: MuxFrameHeader; p: Uint8Array }> = []
tunnel.onFrame((h, p) => seen.push({ h, p }))
ws.emitMessage(openFrame(OPEN))
expect(seen).toHaveLength(1)
expect(seen[0]!.h.type).toBe('open')
expect(seen[0]!.h.streamId).toBe(5)
})
it('dispatches OPEN/DATA to the router', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const handlers = stubHandlers()
tunnel.dispatchTo(handlers, stubHb)
ws.emitMessage(openFrame(OPEN))
expect(handlers.handleOpen).toHaveBeenCalledOnce()
const data = new Uint8Array([1, 2, 3])
ws.emitMessage(encodeMuxFrame(dataHeader(5, 3), data))
expect(handlers.handleData).toHaveBeenCalledWith(5, expect.any(Uint8Array))
})
it('INV11: DATA payload passes through opaque (no parsing)', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const handlers = stubHandlers()
tunnel.dispatchTo(handlers, stubHb)
const opaque = new Uint8Array([0x1b, 0x5b, 0x33, 0x31, 0x6d]) // looks like an ANSI seq
ws.emitMessage(encodeMuxFrame(dataHeader(7, opaque.length), opaque))
const forwarded = handlers.handleData.mock.calls[0]![1] as Uint8Array
expect(Buffer.from(forwarded).equals(Buffer.from(opaque))).toBe(true)
})
it('drains after inbound GOAWAY: no new OPEN accepted', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const handlers = stubHandlers()
tunnel.dispatchTo(handlers, stubHb)
const goaway = encodeGoaway(0, 'operatorDrain')
ws.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
goaway,
),
)
ws.emitMessage(openFrame({ ...OPEN, streamId: 9 }))
expect(handlers.handleOpen).not.toHaveBeenCalled()
// and it RST'd the refused stream
const last = decodeMuxFrame(ws.sent.at(-1)!)
expect(last.header.type).toBe('close')
expect(last.header.rst).toBe(true)
})
it('malformed framing does not crash the tunnel', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
tunnel.dispatchTo(stubHandlers(), stubHb)
expect(() => ws.emitMessage(new Uint8Array([0, 1, 2]))).not.toThrow()
})
it('dispatches CLOSE→handleClose and PONG→heartbeat.onPong', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const handlers = stubHandlers()
const hb = { onPing: vi.fn(), onPong: vi.fn() }
tunnel.dispatchTo(handlers, hb)
ws.emitMessage(
encodeMuxFrame({ version: 1, type: 'close', fin: false, rst: true, streamId: 5, payloadLen: 0 }, new Uint8Array(0)),
)
expect(handlers.handleClose).toHaveBeenCalledWith(5, true)
ws.emitMessage(
encodeMuxFrame({ version: 1, type: 'pong', fin: false, rst: false, streamId: 0, payloadLen: 2 }, new Uint8Array([1, 2])),
)
expect(hb.onPong).toHaveBeenCalledOnce()
})
it('send/goAway/sendRst/close emit well-formed frames', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
tunnel.send(dataHeader(3, 2), new Uint8Array([9, 9]))
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('data')
tunnel.goAway(3, 'shutdown')
expect(decodeMuxFrame(ws.sent.at(-1)!).header.type).toBe('goaway')
tunnel.sendRst(3)
const rst = decodeMuxFrame(ws.sent.at(-1)!)
expect(rst.header.type).toBe('close')
expect(rst.header.rst).toBe(true)
tunnel.close()
expect(ws.closed).toBe(true)
})
it('windowUpdate frames are dispatched without disturbing the stream handlers', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const handlers = stubHandlers()
tunnel.dispatchTo(handlers, stubHb)
ws.emitMessage(
encodeMuxFrame({ version: 1, type: 'windowUpdate', fin: false, rst: false, streamId: 1, payloadLen: 4 }, new Uint8Array([0, 0, 0, 5])),
)
expect(handlers.handleData).not.toHaveBeenCalled()
})
it('onGoAway surfaces the frozen reason', () => {
const ws = new FakeWs()
const tunnel = holdTunnel(ws)
const reasons: string[] = []
tunnel.onGoAway((r) => reasons.push(r))
const goaway = encodeGoaway(3, 'revoked')
ws.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: goaway.length },
goaway,
),
)
expect(reasons).toEqual(['revoked'])
})
})

23
agent/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true,
"outDir": "dist",
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

13
agent/vitest.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['**/*.d.ts', 'src/index.ts'],
},
},
})