Compare commits

...

84 Commits

Author SHA1 Message Date
Yaojia Wang
e254918b1c feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
2026-07-10 16:41:21 +02:00
Yaojia Wang
542fde9580 feat(push): FCM sender + /push/fcm-token route for the Android client (A33)
Mirror src/push/apns.ts behind the same NotifyService seam (combineNotifyServices),
so held-gate/done hook events fan out to Android via data-only high-priority FCM with
zero new event paths. OAuth2 bearer via google-auth-library (R7); payload minimized to
sessionId/cls/token (never cwd/command/bytes); intentionally-loose FCM-token validator;
Origin-guarded + rate-limited POST/DELETE /push/fcm-token; FCM_* env all-or-disabled.

Cross-review fix: dropped validateStatus:()=>true so google-auth-library's built-in
401 refresh-and-retry actually fires. 60 new tests; full server suite green; tsc clean.
2026-07-10 16:40:43 +02:00
Yaojia Wang
e7f3bd05f0 feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:11:13 +02:00
Yaojia Wang
31054450fc docs: zero-touch tunnel enrollment automation plan (research + 3-track design + cross-validation)
Multi-agent plan (11 agents, 70 web sources): fully automate cert enrollment so
customers never handle certs/keys. Chosen: authenticated-CSR REST (k8s TLS-bootstrap
model) + RFC 8628 device grant for host bootstrap; hardware keygen (Secure Enclave /
Android Keystore) + CSR for clients; Model-A→B via dNSName-SAN==Host binding at nginx
:8470 (njs) + per-tenant name-constrained intermediate CAs; short-lived certs + passive
revocation. Reuses control-plane CA, S2 install tooling, iOS ClientTLS.
2026-07-08 18:57:19 +02:00
Yaojia Wang
4fe1981997 chore(android): install Android SDK + wire AGP 9.2.1 (toolchain proven)
Android SDK installed on this machine (cmdline-tools via brew;
platform-tools + platforms;android-35 + build-tools;35.0.0), local.properties
points Gradle at it (gitignored). Wired AGP 9.2.1 into the version catalog +
google() repos; proved the full chain by building a throwaway android-library
module against SDK 35 (AAR produced), then removed the probe.

Groundwork for AW2+: AGP plugins in the catalog, google() in settings, and the
working recipe in README (incl. the AGP-9-has-built-in-Kotlin gotcha — Android
modules apply ONLY the android plugin, never kotlin.android). Pure JVM modules
still green (218 tests). Framework module stubs stay gated until implemented.
2026-07-08 17:37:54 +02:00
Yaojia Wang
7b3fe1b124 docs(progress): log Android client AW0+AW1 pure-Kotlin foundation (218 tests green) 2026-07-08 13:45:54 +02:00
Yaojia Wang
4ea8f7862a feat(android): pure-Kotlin foundation — AW0 scaffold + AW1 modules (218 tests green)
Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.

Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol  — frozen wire contract (sealed Client/ServerMessage, enums,
  HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
  byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core   — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
  GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
  KeyByteMap (byte-matches public/keybar.ts).
- :api-client     — all REST routes, tolerant decode, Origin-iff-guarded, prefs
  unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls     — pure half: PKCS#12 parse, X509KeyManager alias logic,
  CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support   — FakeTransport / FakeHttpTransport / virtual-clock fakes.

Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).

Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.

Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
2026-07-08 13:45:07 +02:00
Yaojia Wang
cf88e7c588 docs(android-plan): lock user decisions R1 (accept best-effort FCM) + R7 (use google-auth-library)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
R1: accept best-effort FCM background delivery as a documented limitation.
R7: server FCM sender uses google-auth-library (not hand-rolled RS256).
All three open questions (incl. R2 license) now decided; nothing blocks AW3/AW5.
2026-07-08 11:38:38 +02:00
Yaojia Wang
34e4a88059 docs: Android client implementation plan (multi-agent explore + review)
Phased build spec for an Android client at functional parity with the iOS
client. Produced by a multi-agent workflow: 10 iOS-subsystem explorers + 2
Android-tech researchers -> architect synthesis -> 4-lens adversarial review
-> finalize. Mirrors the iOS SPM package set as Gradle modules; 36 tasks
across waves AW0-AW6 with Owns/Depends/Verify, two de-risking spikes.

R2 (Termux license) independently re-verified and downgraded Critical->Low:
terminal-view/terminal-emulator are Apache-2.0 and on JitPack, not GPL-vendor.

Open questions for the user: R1 (FCM background-delivery acceptance),
R7 (FCM OAuth2 dependency).
2026-07-08 11:31:30 +02:00
Yaojia Wang
99cafdbdbb docs(readme): update test count (~1470) + document login-shell PATH behavior
Explains why hooks reliably find nvm/brew-managed node (shells spawn as
login shells) and how to recover an old session with a stale PATH.
2026-07-07 21:10:40 +02:00
Yaojia Wang
57725f7ef2 test: fix pre-existing failures — localStorage polyfill + prefs shape drift
- jsdom 29's localStorage methods are undefined under this vitest config, so
  every frontend test calling localStorage.clear()/setItem() threw. Add a
  shared in-memory Web Storage polyfill via setupFiles (no-op when a working
  Storage exists / in node env). Fixes quick-reply, push, projects-panel (135 tests).
- Update desktop prefs test: defaultPrefs gained remoteHosts/selectedHostId
  (remote-host mTLS work); sync EXPECTED_DEFAULTS and the round-trip fixture.

Full suite now green: 1473 passed (was 142 failing).
2026-07-07 21:09:49 +02:00
Yaojia Wang
fc3b849a08 fix(session): spawn PTY as login shell so hooks find nvm node
Spawn the shell with -l (POSIX only) so it loads the full profile chain
and rebuilds PATH. Without it, a shell spawned by a GUI-launched app or a
long-lived tmux keepalive server inherits a minimal PATH, so nvm-managed
node (only on the login PATH) is missing and Claude Code hooks fail with
'node: command not found'. PowerShell (Windows) has no -l; skipped there.

Applies to both the tmux new-session command and the direct-spawn path.
2026-07-07 21:03:35 +02:00
Yaojia Wang
a24465623e docs: LE wildcard cert live — native tunnel M2-ready (public trust verified, no -k) 2026-07-07 20:57:27 +02:00
Yaojia Wang
a25633a63b docs: native tunnel M1 achieved on VPS (frps + device-CA mTLS + nginx :8470 + SNI merge) 2026-07-07 20:28:27 +02:00
Yaojia Wang
5b9ca321d2 docs(deploy): master deployment guide — today's work + full VPS→client mTLS-tunnel runbook 2026-07-07 16:16:24 +02:00
Yaojia Wang
cb04516d52 docs: log native mTLS reverse-tunnel — four tracks landed + two HIGH fixups 2026-07-07 09:43:35 +02:00
Yaojia Wang
d0c249c739 feat(agent): inject S0 env into launchd/systemd via install CLI (S2)
- launchd writer emits <EnvironmentVariables>; systemd writer emits EnvironmentFile/Environment;
  originConfig zone parameterized (default 'term' preserved for relay callers).
- InstallOptions threaded CLI install -> createCliDeps -> installService seam -> writers, so generated
  units carry BIND_HOST (defaults 127.0.0.1 for tunnel hosts), ALLOWED_ORIGINS, PORT, SHELL_PATH,
  IDLE_TTL, USE_TMUX. systemd rejects control chars in env values.
Verified: agent typecheck+build clean; vitest 166/166 (154 baseline + 12 new).
2026-07-07 09:42:12 +02:00
Yaojia Wang
bb0949553c feat(desktop): remote-host mode + OS-store client-cert mTLS (D-1/D-2)
- remote-hosts.ts + DesktopPrefs (remoteHosts/selectedHostId, immutable, back-compat) + tray host-picker.
- will-navigate lock is now dynamic to the selected remote origin, fails closed on null (no open-redirect).
- select-client-certificate handler: event.preventDefault() first, pick by issuer CN, non-silent
  missing-cert dialog + cert-less callback -> clean nginx rejection (Chromium sources certs from OS store).
Verified: npm run typecheck exit 0; pure-helper assertions pass.
2026-07-07 09:42:12 +02:00
Yaojia Wang
e38e6d1689 feat(ios): device client-cert mTLS — ClientTLS package, transport wiring, install UX (C-iOS)
- ios/Packages/ClientTLS: SecIdentity wrapper, PKCS12 importer (typed errors), keychain store
  (AfterFirstUnlockThisDeviceOnly), pure MutualTLSChallengeResponder truth table, cross-platform
  X.509 DER summary. 14/14 tests.
- Both transports (SessionCore URLSessionTermTransport, App URLSessionHTTPTransport) + SessionThumbnail
  take a lazy @Sendable ()->ClientIdentity? provider: WS resolves per-connect, HTTP per client-cert
  challenge, so a freshly-imported cert applies without an app relaunch. AppEnvironment injects
  { store.loadedIdentityOrNil() }.
- ClientCertScreen (.fileImporter([.pkcs12]) + passphrase -> import -> keychain), reachable via a
  设备证书 entry in SessionListScreen.hostMenu. PairingViewModel gates tunnel-host probes on cert
  presence and re-maps mTLS-reject to a clientCertRejected message.
Verified: ClientTLS 14/14, SessionCore 93/93, xcodegen + xcodebuild BUILD SUCCEEDED.
2026-07-07 09:42:12 +02:00
Yaojia Wang
5337281e85 feat(deploy): device client-CA + issuance/revocation + frp/nginx mTLS templates (V2/V7)
Committable VPS artifacts for the native reverse-tunnel (no live-ops, no secrets):
- gen-device-ca.sh: EC P-256 self-signed device CA (CA:TRUE pathlen:0, keyCertSign+cRLSign),
  openssl ca db + empty CRL, idempotent, --kind frp-client scaffolds the control-channel CA.
- issue-device-cert.sh: P-256 leaf, EKU=clientAuth only, 825d, emits .p12 (-legacy) + .pem/.key/.fullchain,
  copy_extensions=none prevents CSR-injected serverAuth/CA:TRUE; CN traversal guard.
- revoke-device.sh: openssl ca revoke -> regenerate crl.pem (revocation enforced via -crl_check).
- frp/{frps.toml.example,frpc.example.toml}: control-channel mTLS+token, disableCustomTLSFirstByte=true.
- nginx/frp-mtls.conf: :8470 TLS-term, ssl_verify_client on + device-CA + ssl_crl, WS-upgrade proxy to :7080.
- nginx/stream-sni-additions.md: the two additive SNI map lines (merge-only, coexistence-safe).
Verified: gen->issue->openssl verify->revoke->CRL chain green in a tmp dir (OpenSSL 3.0.18).
2026-07-07 09:42:12 +02:00
Yaojia Wang
6b8269c1c1 docs: multi-tenant mTLS reverse-tunnel dev plan (VPS + server + native clients)
Generated via multi-agent workflow (3 planners → adversarial review → synthesis).
Covers frp tunnel on shared :443, mTLS device-cert auth, and client-cert
implementation for iOS/Android/desktop. Trust Model A (single-owner fleet) for v1.
2026-07-07 08:00:40 +02:00
Yaojia Wang
c1c837c54f fix(relay-run): buffer early ws frames in wsToWebSocketLike
The message listener was attached only when the mux consumer called onMessage — which
happens inside attach(), AFTER the async mTLS-registry lookup. On loopback the agent's FIRST
heartbeat ping arrived in that gap and ws dropped it (no listener), so the agent's heartbeat
died on the first miss at 15s and the tunnel flapped forever. Attach the listener at
construction and buffer early frames (and an early close) until the consumer wires up.
2026-07-07 04:59:53 +02:00
Yaojia Wang
89678c7949 fix(relay-run): agent-TLS ca must be full chain (intermediate+root), not intermediate-only
Node/OpenSSL rejects an agent client leaf whose only trust anchor is a non-self-signed
intermediate (no PARTIAL_CHAIN flag) → every agent was reset at the TLS layer before attach(),
surfacing as a silent 1006. Pass the full agent-ca bundle and stop swallowing tlsClientError.
2026-07-07 04:49:38 +02:00
Yaojia Wang
7af4a68ef5 fix(control-plane): encode enroll hostContentSecret as base64url (agent decodes base64url) 2026-07-06 21:50:08 +02:00
Yaojia Wang
6efed9772e feat(control-plane): real X.509 agent leaf issuance (closes enrollment↔mTLS gap)
- ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the
  intermediate; SAN built via relay-auth spiffeIdFor so it can't drift
- ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire
  accepts PEM or base64(DER)
- main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback
- interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests)
- deps: @peculiar/x509, reflect-metadata
2026-07-06 20:46:01 +02:00
Yaojia Wang
1a8984e851 feat(deploy): staging bootstrap script + manage-token minter for Phase-1 VPS deploy 2026-07-06 19:37:58 +02:00
Yaojia Wang
d77f1ff62c fix(relay-run): declare package deps for standalone deploy
relay-run imported bare specifiers (control-plane/relay-auth/relay-contracts/relay-e2e/
term-relay + ws/pg/ioredis) with an empty package.json, relying on a hand-populated
node_modules from local dev. Declare them (file: siblings + runtime deps + tsx) so
'npm install' resolves relay-run on a clean host.
2026-07-06 19:25:44 +02:00
Yaojia Wang
5e7e4b22f2 fix(ios): safe-area-inset clearance so tab-bar/key-bar don't obscure terminal
viewport-fit=cover spans the physical screen edges; add --safe-t/--safe-b from
env(safe-area-inset-*) threaded through #tabbar/#term/#keybar/#approvalbar/#searchbox/
#settingspanel. Falls back to 0px (zero regression on non-safe-area platforms).
2026-07-06 16:51:03 +02:00
Yaojia Wang
bfe1be1dfe feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint
- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers);
  header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect.
- F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst,
  before password compare).
- F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0).
- F5: scrub error logs to e.message/.code (no DSN leak, INV9).
relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
2026-07-06 16:26:05 +02:00
Yaojia Wang
aa1912b962 feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
2026-07-06 16:13:34 +02:00
Yaojia Wang
95b9cccf07 feat(relay): Phase1 foundation — P3 Postgres store + async capability verifier + compose
RELAY-PHASE1 Wave A (2/3) + E1 infra:
- A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations();
  writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov.
- A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async
  seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean.
- E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example.
- docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
2026-07-06 14:51:37 +02:00
Yaojia Wang
242a4e0dc1 docs: log Orbit icon rollout across iOS/desktop/tray/web + desktop repackage
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-06 11:54:48 +02:00
Yaojia Wang
86d100a9ad feat(icons): Orbit app icon + template tray icon across all clients
New brand icon (Orbit — a node circling a block-cursor core, amber-gold on
warm-dark, matching the desktop theme):
- iOS: Assets.xcassets/AppIcon.appiconset (opaque 1024, was no icon)
- desktop: resources/icon.png (rounded 1024 → .icns at package time)
- desktop tray: trayTemplate.png/@2x — a proper macOS TEMPLATE (black shapes on
  transparent, system-tinted) replacing the old white blob
- web: public/icon.svg (vector favicon/PWA)
Master SVGs rendered with rsvg-convert.
2026-07-06 11:49:06 +02:00
Yaojia Wang
3492f0cf1f fix(projects): drop parent folders; attribute sessions to the deepest project
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
The projects panel listed bare parent folders (~, ~/Documents) as projects and
a single session lit up every ancestor project's 'Active now'. dropParentFolders
filters non-git entries that merely contain other listed projects; assignSessions
attributes each live session to the DEEPEST containing project (detail page keeps
prefix matching). +3 tests; projects 29/29, tsc clean. Frontend unchanged.
2026-07-06 11:00:45 +02:00
Yaojia Wang
d36deb6922 fix(ios): dark-appearance-first + readable dirty badge (gold-on-light contrast)
Amber-gold accent is designed for the desktop's warm-dark background; on light
mode gold text/badges wash out. Match the desktop (dark theme by default):
.preferredColorScheme(.dark) at the root. DirtyBadge becomes a soft filled
capsule (amber on amber-18%) instead of amber-on-quaternary, readable in any mode.
2026-07-06 08:10:34 +02:00
Yaojia Wang
a887638557 docs: document native clients — full iOS/iPad feature README + root Clients section
ios/README: add a comprehensive Features section (P0 pocket cockpit, P1 walk-away
complete, iPad adaptive split, design system, verification status); note min iOS
17, both-idiom test commands, P1 server touch-points.
root README: add a Clients section covering the iOS/iPad app, desktop Electron
shell, and the pre-production relay stack (with Tailscale as the remote-access
path today).
2026-07-06 08:10:33 +02:00
Yaojia Wang
d01a69eb72 feat(ios): switch accent to desktop amber-gold theme (was indigo)
Match the desktop/web theme (public/style.css): accent #E3A64A gold (dark) /
#C9892F (light) replacing the indigo, +onAccent #1A1305 dark ink for gold-fill
buttons, +accentSoft. Status colors realigned to the web warm palette
(#46D07F/#F5B14C/#FF6B6B). Terminal canvas fixed to the desktop warm-dark
(#100F0D bg / #ECE9E3 fg) with gold caret/selection. DSButtonStyle primary now
uses onAccent (dark) instead of white for contrast on gold. All via the single
accent token + DS palette — no scattered edits.

Verified: iPhone 16 290 + iPad Pro 11 290 tests green (DesignSystemTests assert
the exact gold + web-status RGB in both schemes).
2026-07-06 07:25:32 +02:00
Yaojia Wang
c44725618c docs(ios): log comprehensive UX/UI polish (design system, 8.5/10, 290 tests both sims) 2026-07-05 22:01:27 +02:00
Yaojia Wang
660a40491a feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.

Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).

Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
2026-07-05 22:00:31 +02:00
Yaojia Wang
823432b1c8 feat(ipad): W1-W3 — adaptive split-view layout + finding fixes
T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
2026-07-05 19:58:30 +02:00
Yaojia Wang
77502ec4fe feat(ipad): T-iPad-1 — device family [1,2] + iPad orientations + CI leg
Opens WebTerm to iPhone + iPad: TARGETED_DEVICE_FAMILY 1→1,2 (project + all
targets), iPad gets all four orientations (~ipad key), ios.yml gains an
ipad-tests leg (iPad Pro 11 sim). Built plist verified UIDeviceFamily [1,2];
app launches natively full-screen on iPad Pro 11 sim (no letterbox). Adaptive
layout (NavigationSplitView) lands in T-iPad-2. Plan: docs/PLAN_IOS_IPAD.md.
2026-07-05 18:29:30 +02:00
Yaojia Wang
9c0097a305 docs(ios): P1 complete — T-iOS-30 acceptance + security re-audit both PASS (zero findings)
Part A: 261 pkg + 247 app + 10 integration re-run green; boot-crash fix re-verified (3 launches,
0 crashes, permission prompt reached); simctl push renders minimized WEBTERM_GATE banner; F-iOS-15
server-slice (distinct lastOutputAt) executed; old-server compat degradation verified against the
user's live 127.0.0.1:3000 (no unread dot, stuck red dot, correct meta)
Part B: APNs payload minimization holds; capability token clean end-to-end (validate→use→never
persisted→single-use→zero in logs); deep-link 50+ fuzz all safe; lock-screen .authenticationRequired
green; prefs empty-base guard intact; P1-B attack surface swept clean
P1 total: 518 automated checks + server 1470 green; server changed only at the 2 declared §0.3
touch-points. Real-device/APNs items DEFERRED (need device + paid account)
2026-07-05 16:42:50 +02:00
Yaojia Wang
f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00
Yaojia Wang
4871e8ac3d feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
2026-07-05 13:34:01 +02:00
Yaojia Wang
aa956fcbb4 feat(ios): W5 acceptance + finding fixes — XCUITest happy path, CI legs, privacy/device-family hardening
T-iOS-18 (F-iOS-1..13 walkthrough) + T-iOS-19 (security audit vs built artifacts): both
PASS_WITH_FINDINGS, 0 CRITICAL/HIGH; real-device items DEFERRED with manual checklists.
Fixes (1 MED + 3 LOW, all closed):
- WebTermUITests: the single scripted happy path (manual-entry pair → list → attach →
  KeyBar ^L → injected held gate via POST /hook/permission → tap Approve → server-side
  behavior==allow assertion). Green twice locally (fresh + already-paired branches).
- ios.yml: ui-test leg (server boot + TEST_RUNNER_ env as PROCESS env) + iOS17-floor leg
  (loud-skip if runtime absent, hard-fail if present and red); dropped CODE_SIGNING_ALLOWED=NO
  from app-tests leg (unsigned hosts get -34018 from the real keychain — measured)
- URLSessionHTTPTransport defaults to .ephemeral (no disk cache of preview terminal bytes)
- TARGETED_DEVICE_FAMILY moved to target level (built plist now UIDeviceFamily [1] only)
Final: 306 automated checks green (214 pkg + 76 app + 10 integration + XCUITest); server src/
untouched; P0 complete (19/19 tasks)
2026-07-05 02:16:52 +02:00
Yaojia Wang
cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00
Yaojia Wang
5098643355 feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
2026-07-05 00:13:14 +02:00
Yaojia Wang
5c8c87fb7a chore(ios): add WebTermTests unit-test bundle target (W3 coordination point)
project.yml gains a bundle.unit-test target hosted by the app, wired to all
5 local packages (TestSupport test-doubles included) so T-iOS-11..14 VM tests
run via xcodebuild test. Coverage gate still counts only the 4 packages (§9).
2026-07-04 23:00:11 +02:00
Yaojia Wang
a2b14ab6e7 feat(ios): W2 connection core — URLSessionTermTransport + SessionEngine actor
T-iOS-9: TermTransport impl (Origin single-source, 16MiB from Tunables, re-arm loop,
EMSGSIZE(40)/ENOBUFS(55)→typed replayTooLarge, delegate-driven state, RFC6455 scripted test server), 11 tests
T-iOS-10: SessionEngine actor (attach-first ordering, generation guard, terminal states never backoff,
exactly-once digest, gate canDecide second line, defensive input validation), 20 tests
Verified: 209 tests green across 5 packages; SessionCore own-src coverage 96.68%; 4/4 semantic invariants
audited in source; frozen contracts untouched
2026-07-04 22:55:56 +02:00
Yaojia Wang
95438cdc12 feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
2026-07-04 21:53:41 +02:00
Yaojia Wang
2ab93c9682 chore(ios): wire TestSupport test-dependency into SessionCore/APIClient manifests (W1 coordination point) 2026-07-04 21:21:09 +02:00
Yaojia Wang
cbaa08daba feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton
T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan
T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx
T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles
Verify: independent agent re-ran all acceptance — 6/6 PASS
2026-07-04 21:19:30 +02:00
Yaojia Wang
9b41ffa574 docs(ios): iOS client implementation plan (PLAN_IOS_CLIENT.md) + progress log entry 2026-07-04 20:23:25 +02:00
Yaojia Wang
ba85871227 feat(relay-run): Phase 0 single-host relay — real data-plane wiring + integration test
New relay-run/ package boots the REAL term-relay relay-node between a browser WSS
listener and an agent mTLS listener, delegating every authz verdict to the real
relay-auth onUpgrade (Origin/CSWSH + capability verify + DPoP PoP + single-use jti),
with the real P4 E2E crypto. No audited package modified.

- P1 (done): in-process integration test round-trips a sealed payload both ways
  through the real createRelayNode splice + real createMuxSession; INV2 asserted
  (relay sees only ciphertext); negative controls (foreign Origin / missing DPoP -> 401).
- P2 (boots): main.ts brings up self-signed TLS + 3 listeners + in-memory control-plane.
- P3 (not landed): no agent dial / node-pty / relay-web serve yet.
- 4 tests green, tsc clean. node_modules via symlinks (gitignored).

Two real seam drifts surfaced (documented in PLAN_RELAY_RUN_PHASE0, not hacked):
(1) term-relay authz-port DpopContext shape vs relay-auth — reconciled (server-derived htu/htm);
(2) control-plane CapabilityVerifier.verify is sync but relay-auth verify is async — blocks
    HTTP account/pairing seeding until a 1-line async widen.
2026-07-04 14:13:04 +02:00
Yaojia Wang
3d17bed322 docs(relay): Phase 0 runnable-relay build spec (PLAN_RELAY_RUN_PHASE0.md)
File-level plan for a single-host `npm start` relay: new relay-run/ package wiring
the real P4/P5 + in-memory control-plane + self-signed TLS + agent + relay-web.
Seam files to read, 9-step build order, verify steps. Executable spec for a fresh
build session; reuses the e2e/harness wiring.
2026-07-04 13:49:09 +02:00
Yaojia Wang
340ea48478 docs(relay): deployment build & ops plan (DEPLOY_RELAY.md)
Grounded assessment that the relay service is pre-production (every cross-package
seam is a fail-closed stub, no server/TLS/DB/orchestration). Documents the target
topology + trust boundaries, the 7 wiring gaps to close (P1 socket server, P5
injection into P1/P3, pg/redis stores, real KMS CA custody, agent/relay-web build,
F2 principal + F6 replay-epoch residuals), a phased build order (dev-run → staging
→ prod), an accurate per-service env reference, infra checklist, and an ops runbook.
2026-07-03 14:16:30 +02:00
Yaojia Wang
c0279fd6e2 Merge security/relay-auth-audit-fixes: relay-auth/relay-e2e security audit
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Two-round multi-agent security audit of the rendezvous-relay E2E/auth core.
Fixes 6 confirmed findings (F1–F6) with regression tests, adds a cross-package
e2e adversarial security harness (dynamic re-validation), and the audit report.
534 tests green across the affected packages; tsc clean. See docs/REVIEW_RELAY_SECURITY.md.
2026-07-02 16:41:38 +02:00
Yaojia Wang
0ad7c31549 docs(relay): security audit report + progress log for the relay findings
REVIEW_RELAY_SECURITY.md: two-round multi-agent audit — F1–F6 with fix plan/
resolution, refuted-but-recorded items, and the e2e harness coverage table.
PROGRESS_LOG.md: cross-session record of the audit + fixes + harness.
2026-07-02 16:41:19 +02:00
Yaojia Wang
ad5cf06207 test(relay): cross-package e2e adversarial security harness
New e2e/ package wires the REAL P5(auth)+P4(crypto)+P2(agent) exports through
in-memory seams and an untrusted-relay attacker vantage (RelaySpy). Dynamically
re-validates F1–F4/F6 plus MITM/reflection/replay/reorder/INV2/cross-tenant/
single-use — every assertion runs the production code path. 21 tests green, tsc
clean. node_modules via symlinks (gitignored); no npm install needed.
2026-07-02 16:41:19 +02:00
Yaojia Wang
3020184054 fix(relay): close F6 replay K_content nonce reuse via per-generation epoch-in-key
The recoverable replay key was stable per (hostContentSecret, sessionId) while the
agent-side sealer resets its deterministic-nonce seq to 0 on every restart/re-attach
→ two sealer generations sealed distinct plaintext under the SAME (key, nonce).

Add a required 'epoch' to ReplayKeyParams, fold it into the K_content HKDF salt
(sessionId U+001F epoch), mint a fresh epoch per createReplaySealer generation and
expose it, and thread it through ReplaySource so the browser re-derives the matching
key. Fresh epoch per generation ⇒ fresh key ⇒ seq=0 can never collide; recoverability
within a generation is preserved.

Touches relay-contracts/relay-e2e/agent/relay-web. Green: contracts 81, e2e 78,
agent 133, web 99; tsc clean. Regression proves same seq-0 nonce, different key.
2026-07-02 16:41:19 +02:00
Yaojia Wang
a09c131539 fix(relay-auth): close 5 pre-production security findings (F1–F5)
F1 (HIGH): bind step-up freshness to the required method (stepUpMethod) so a
  login-time or weaker (TOTP) factor can no longer satisfy a passkey requirement.
F2 (HIGH): make the step-up gate host-driven and fail-closed on connect+reattach
  (deny when policy.required and the principal is missing/stale/wrong-method).
F3 (MED): bind the WebAuthn assertion to the stored credential (credentialId
  cross-check + forward publicKey/credentialId to the verifier).
F4 (LOW): make verifyDpopProof totally fail-safe (no unhandled throw), wrap the
  authz boundary into a clean audited 401, and validate cnfJkt format at issue.
F5 (LOW): return newSignCount from finishAuthentication so the caller can persist
  the advanced counter (WebAuthn clone detection).

All fixes ship regression tests. relay-auth: 122 tests green, tsc clean.
2026-07-02 16:41:18 +02:00
Yaojia Wang
1529d2c94c Merge feat/desktop-electron: Electron all-in-one desktop shell (Mac/Windows)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-02 06:13:29 +02:00
Yaojia Wang
cf8cfccab4 feat(desktop): Electron all-in-one desktop shell (Mac/Windows) embedding the server
Add a `desktop/` Electron app that embeds the existing Node server + node-pty
(all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend
unchanged; other LAN devices can still connect. The server needs zero changes —
startServer(cfg)/loadConfig already support programmatic embedding.

- Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy,
  notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov)
- Electron glue: main/window/tray/menu/preload/embedded-server/logger
  (hardened: contextIsolation, sandbox, deny foreign-origin navigation)
- Native value: OS notifications driven by /live-sessions status, tray, deep links
- Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules
  on-disk under Resources so the server resolves its deps from /Applications;
  node-pty rebuilt for the Electron ABI
- Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated

Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97);
.dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
2026-07-02 06:13:17 +02:00
Yaojia Wang
2af57e6686 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.
2026-07-02 06:10:16 +02:00
Yaojia Wang
e4c327e25e feat: voice command mapping — context-gated approve/reject over voice
While a tool-permission gate is held on the active tab, spoken confirm
phrases resolve it via the existing approve/reject WS channel — no server
change (byte-shuttle intact). Everything else stays ordinary dictation.

Safety: whole-utterance-exact matching + leading-negation guard + reject
precedence + confidence gate (approve only) + a cancellable 1.5s confirm
window whose commit re-validates gate identity/epoch/connectivity (TOCTOU
guard) + stale-gate epoch bound at PTT-start.

New pure modules public/voice-commands.ts and public/voice-confirm.ts at
100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass,
typecheck clean, build:web OK. Independent code + security reviews flagged a
confirm-window TOCTOU and an unwired cancel path — both fixed and covered.

Plan: docs/PLAN_VOICE_COMMANDS.md.
2026-07-01 10:38:32 +02:00
Yaojia Wang
03612323c0 feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.

Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
  fall to "Other"), degrade-to-flat when repos share no prefix, running projects
  duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
  Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
  section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
  the legacy per-device proj-favs.

Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
  every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
  PREFS_STORE_PATH config.

Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
2026-07-01 04:25:57 +02:00
Yaojia Wang
7b3ba8491a docs: rewrite README to cover all features (v0.1–v0.7) 2026-06-30 21:26:24 +02:00
Yaojia Wang
7fd2ef3fc8 polish(v0.7): styled hover tooltips on timeline + push toggle (explain the icon)
Give the two tab-bar line-icon buttons the same styled data-tip hover tooltip as
the toolbar icons, so their meaning is clear on hover (native title was slow/ugly):
- timeline → "Activity timeline"
- push toggle → "Notifications off — tap to turn on" / "…on — tap to turn off"
Left-anchored (they sit on the left of the tab bar). 158 push+tabs tests green.
2026-06-30 19:28:15 +02:00
Yaojia Wang
b273b61795 polish(v0.7): restyle timeline + push toggle to match the .toolbtn line-icon look
The icons were swapped to lucide line icons, but the buttons kept a boxed/light
background that clashed with the minimal toolbar icons. CSS-only fix: render
.tab-timeline + .push-toggle-btn as transparent, monochrome 18px line-icon
buttons (text-dim, accent on hover/active) — identical to .toolbtn — overriding
the earlier boxed default. Markup unchanged (tests stay green: 158 push+tabs).
2026-06-30 19:18:38 +02:00
Yaojia Wang
e554c053ee polish(v0.7): replace timeline 📜 + push 🔔 emoji with themed line icons
Swap the two v0.7 emoji buttons for lucide line icons (stroke=currentColor) so
they take the Amber theme colour like the toolbar icons:
- timeline toggle (tabs.ts) → scroll-text icon
- push toggle + disabled bell (push.ts) → bell icon, keeping the On/Off label
- icons.ts: ICON_TIMELINE, ICON_BELL; style.css sizes the inline svgs (16px)

Frontend-only. web tsc + build:web clean, 102 push tests green, bundle emoji-free.
2026-06-30 18:54:11 +02:00
Yaojia Wang
d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00
Yaojia Wang
4f1d3ebc6b docs(v0.7): PRD + implementation plan for Walk-away Workbench (Band A + B)
Authored via multi-agent orchestration (PRD: Band A ∥ Band B → architect
review → integrate; Plan: frontend ∥ backend ∥ security → review → integrate).

- docs/FEATURE_WALKAWAY_WORKBENCH.md — PRD for 9 features:
  Band A (push+lock-screen approve, voice, quick-reply chips, activity
  timeline, stuck/idle alert) and Band B (read-only git diff, statusLine
  cost/context/PR gauges, UI worktree-create, plan-mode relay).
- docs/PLAN_WALKAWAY_WORKBENCH.md — 27 tasks (waves W-1..W4), disjoint Owns,
  contract resolutions, 30-item security checklist, dispatch schedule, AC map.

Planning only — no source changed.
2026-06-30 15:51:58 +02:00
Yaojia Wang
88b960bac5 feat(v0.6): show CLAUDE.md on the project detail page + /init generate/update button
The detail page now reads <repo>/CLAUDE.md and shows it in a scrollable
monospace box, with one smart button: "↻ Update" when it exists / " Generate"
when it doesn't. The button opens an interactive Claude session in the repo
running /init (claude "/init") so you review what it writes — reuses the project
launcher, no new backend orchestration, stays a byte-shuttle.

- ProjectDetail += hasClaudeMd / claudeMd; buildProjectDetail reads CLAUDE.md
  (truncated 64KB, best-effort, in the existing Promise.all)
- renderProjectDetail: CLAUDE.md section (content box or empty state) + button
- style.css: .proj-section-row / .proj-claudemd-btn / .proj-claudemd

Tests +5 (458): backend read present/absent; frontend content+Update,
empty+Generate, button opens `claude "/init"`. Verified: tsc clean, full suite
green, build OK, curl returns the content, in-browser shows the box + button.
2026-06-30 14:51:42 +02:00
Yaojia Wang
67b0a43b39 polish(v0.6): replace toolbar emoji with themed line icons + hover tooltips
The glossy emoji (🔍⚙▦🕘🔗📱) clashed with the Amber dark theme. Swap them
for clean lucide line icons (MIT, stroke=currentColor) so they take the theme
colour — muted by default, Amber on hover — and add a styled CSS hover tooltip
(data-tip, right-anchored so it never clips the edge) showing what each does.

- public/icons.ts: 7 line icons (search/settings/dashboard/history/keyboard/
  share/device), script-generated from lucide
- 7 toolbar modules: innerHTML = ICON_* + data-tip + aria-label
- style.css .toolbtn: 18px svg, --text-dim → --accent on hover, [data-tip] tooltip

Frontend-only. web typecheck + build clean; bundle has 0 toolbar emoji; verified
in-browser: line icons in theme colour, amber + tooltip on hover.
2026-06-30 14:36:26 +02:00
Yaojia Wang
59de784c8a polish(v0.6): make "New session" a grid tile instead of a heavy header button
The big solid-amber "+ New session" button clashed with the refined segmented
control. Replace it with a dashed "+" tile that leads the session grid (same
size as the session cards, re-prepended each refresh), so it reads as a natural
"create" cell. Header is now just the title + Sessions/Projects toggle; the
redundant empty-state message is gone (the tile is the CTA).

Frontend-only (launcher.ts + style.css). Verified: web typecheck + build clean,
preview-grid tests green, in-browser the grid leads with the New-session tile
followed by the session cards (Open/Kill).
2026-06-30 14:25:56 +02:00
Yaojia Wang
bf5241e87b feat(v0.6): remove standalone Manage page — manage sessions on the Sessions view
The Sessions chooser already shows every running session with a live thumbnail
and Open + New session, so fold delete into it and drop the separate page:

- add a Kill ✕ button to each session card (DELETE /live-sessions/:id + refresh),
  reusing makePreviewCard's extraActions and the existing .mg-kill style
- remove public/manage.html + public/manage.ts and the esbuild manage entry
- remove the 🗂 toolbar button (main.ts) and the 🗂 Manage header link (launcher.ts)

Backend DELETE/preview routes stay (used by the launcher kill, thumbnails, and
the project pages). Frontend + build-config only.

Verified: full suite 453 green, web typecheck clean, build:web emits only main
(no manage.* artifacts), and in-browser the Sessions cards have Open + Kill (2→1
on kill), no Manage entry, and GET /manage.html → 404.
2026-06-30 14:08:00 +02:00
Yaojia Wang
46698b8b5e feat(v0.6): kill a session directly from the Projects grid cards
The detail view could kill sessions, but the grid card's session rows were
enter-only. Add a ✕ kill button to each card session row (stopPropagation so it
doesn't also enter the session); makeSessionRow gains an optional onKill,
makeProjectCard an optional onKillSession, and mountProjects wires it to
DELETE /live-sessions/:id followed by a refresh.

Frontend-only. projects-panel tests +1 (51): ✕ present only with the callback,
click kills the right id without entering. Verified in-browser: a card with 2
sessions dropped to 1 after clicking ✕.
2026-06-30 13:35:53 +02:00
Yaojia Wang
99bc6fd9f6 feat(v0.6): per-project detail view — branch/worktrees + active sessions
Clicking a project's name opens an in-app detail view (back button returns to
the grid) with:
- header: name, path, branch, dirty
- Branch / Worktrees: each `git worktree list` entry (branch or detached@head,
  main/current/locked/prunable tags, path) — a single-worktree repo shows its
  branch; a multi-worktree repo shows them all
- Active sessions (N): detailed rows (status, devices watching, started-ago)
  with Open + Kill
- the Claude/Codex/VS Code launcher row

Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via
execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions)
in projects.ts (validates absolute existing dir, reuses branch/dirty/session
helpers, uncached — detail is on-demand and wants fresh state); read-only
GET /projects/detail?path= (no Origin guard, like /projects; 400 missing /
404 invalid). Types: WorktreeInfo, ProjectDetail.

Frontend: detail view + navigation in projects.ts (grid<->detail), project
name becomes a link, Kill via DELETE /live-sessions/:id.

Tests +21 (452 total): worktree parser, buildProjectDetail (validation,
non-git, session merge, real git-init worktree), renderProjectDetail.
worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a
Claude session, then the detail showed its branch+worktree and the live
session with Open/Kill.
2026-06-30 13:29:41 +02:00
Yaojia Wang
a390130205 feat(v0.6): project card launchers — Claude/Codex/VS Code logos + active highlight
Replace each card's "+ start claude here" with a row of three brand-logo
launcher buttons (logo + caption, touch-friendly, tooltips):

- Claude (coral burst)  → new terminal tab running `claude`
- Codex  (OpenAI mark)  → new terminal tab running `codex`
- VS Code (blue ribbon) → opens the repo in VS Code ON THE HOST

VS Code: new guarded POST /open-in-editor runs `<EDITOR_CMD> <path>` (default
`code`) on the host via execFile (no shell); path validated as an existing
absolute dir; Origin guard (CSRF) like the DELETE routes. EDITOR_CMD config added.

Active-session highlight (user request): when a project has a running Claude
session (Claude-hook status != unknown — plain shells/codex stay unknown), the
Claude logo gets a coral ring + tint, with a count badge when more than one.

New: public/icons.ts (simple-icons SVGs, fill=currentColor), src/http/editor.ts.
onOpenProject gains an optional cmd; makeProjectCard exported for tests.

Tests +16 (431 total): editor validation/launch, EDITOR_CMD config, launcher
row (3 buttons, claude/codex commands), and the active-Claude highlight + badge.
Verified: coverage >=80x4, build OK, both typechecks clean; in-browser the 3
logos render with brand colours, and POST /open-in-editor actually launched
VS Code on the host (403 no-Origin / 400 bad-path / 204 valid-dir all correct).
2026-06-30 12:46:12 +02:00
Yaojia Wang
3323bc81c0 polish(v0.6): put Sessions/Projects toggle on the filter row (kill whitespace)
The toggle sat on its own dedicated row, leaving a large empty band above the
project grid. On desktop (>=769px) float it top-right on the SAME row as the
filter box / launcher header: panels fill from the top (inset 0), launcher-head
reserves right padding for the pill. Mobile keeps the centred stacked toggle to
avoid overlapping the full-width filter on narrow screens. Also reclaim the
hidden key-bar's space on the home view (body.home-open #term { bottom: 0 }).

CSS-only (style.css is served directly). Verified in-browser: toggle and filter
share a row on Projects, no overlap with New/Manage on Sessions.
2026-06-30 12:03:30 +02:00
Yaojia Wang
32fb4b09b1 polish(v0.6): redesign Sessions/Projects toggle + hide keybar on home
- Segmented control: replace the two free-standing bordered boxes with a
  single rounded track holding two pill buttons (iOS-style), active segment
  filled Amber; centred via fit-content + margin auto. Panel top-offset 44→68px.
- Hide the bottom key-bar on the home chooser (it only targets an active
  terminal): updateHomeView toggles body.home-open; CSS hides #keybar.

Frontend-only. 415 tests green; both typechecks clean; verified in-browser
(toggle pill renders, keybar hidden on home, reappears when a tab opens).
2026-06-30 11:51:47 +02:00
Yaojia Wang
985ff8a178 feat(v0.6): add Home (⌂) button to reach Sessions/Projects from a session
Once a project/session tab was open, the home chooser (Sessions/Projects
segmented control) was hidden and only reachable by closing every tab — no
way back to the Projects view to open another project.

Add a ⌂ Home button to the tab bar that overlays the chooser on top of the
open tabs (homeForced flag; updateHomeView shows home when no tab OR forced,
hiding the terminal panes). Clicking ⌂ again, clicking a tab (activate resets
homeForced), or opening another session/project dismisses the overlay.
Closing the last tab resets the flag. The button highlights while active.

Tests: +3 (tabs.test.ts). Full suite 415 green; both typechecks clean;
verified in-browser (open tab → ⌂ → Projects panel reachable → ⌂ → terminal).
2026-06-30 11:39:38 +02:00
Yaojia Wang
7b4adf5072 feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring
Home screen gains a Sessions↔Projects segmented control. The Projects view
discovers the host's git repos and, on click, opens a tab named after the
repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project).

Backend (P2/P4):
- src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions):
  depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops
  descending at a repo), .git/HEAD branch parse, rate-limited `git status`
  dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of
  ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session
  merge (fresh each call), TTL discovery cache with in-flight dedup.
- src/server.ts — read-only GET /projects (no Origin guard, []-fallback).

Frontend (P5/P6):
- public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot,
  session rows, "+ start claude here"), live search, localStorage favourites;
  pure helpers extracted for unit tests.
- public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'),
  countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView).
- public/style.css — Projects panel + .home-seg control styles.

Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already
added; this commit adds fail-fast rejection of relative PROJECT_ROOTS.

Review hardening (4 confirmed HIGH + boundary validation):
- carriage-return fix so claude auto-executes (A3); seg-control z-order;
  parallelised history merge; concurrent-cache-miss dedup.
- normalizeProject guards the /projects response; getFavs filters non-strings.

Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%.
Verified: both typechecks clean, full suite green, build:web OK, coverage
≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
2026-06-30 11:04:49 +02:00
Yaojia Wang
dc5d073374 chore(v0.6): checkpoint foundation — Amber theme, P1 types, P3 config, design docs
Prior-session prep for the v0.6 Project Manager, checkpointed on the
v0.6-projects branch as a clean base for the parallel builders:

- P1 (src/types.ts): ProjectInfo + ProjectSessionRef contracts; Config
  gains projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck.
- P3 (src/config.ts): parseBool + parseProjectRoots helpers; parse the
  4 PROJECT_* env vars with defaults ([homeDir]/4/10000/true).
- UI: public/style.css indigo -> Amber (#e3a64a) accent theme.
- Docs: FEATURE_PROJECT_MANAGER.md (1:N session design), THEME.md,
  docs/mockups/ (final-amber.png is the locked visual).

Typecheck (backend + web) green. No behavior change to runtime code yet;
P2/P4/P5/P6 implement the feature on top of this.
2026-06-30 10:05:40 +02:00
1051 changed files with 156230 additions and 517 deletions

220
.github/workflows/ios.yml vendored Normal file
View File

@@ -0,0 +1,220 @@
# iOS CI (T-iOS-16; hardens the T-iOS-1 skeleton). Three layers, PLAN_IOS_CLIENT §9:
#
# 1. package-tests — per-package `swift test` + OWN-SOURCES coverage gate
# (>= 80%, plan §9). NOTE: the plan §9 raw llvm-cov command reads export
# TOTALS, which count statically-linked DEPENDENCY sources compiled into
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
# iPhone 16 simulator): ViewModels/components of the app glue layer.
# 3. integration-tests — Swift Testing against the REAL Node server. The
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
# whitelist, src/config.ts:187-226 — no ALLOWED_ORIGINS needed). This layer
# is the standing anti-drift gate between the Swift-replicated protocol
# and the server implementation — hence the src/** path triggers below.
name: ios
on:
push:
paths:
- "ios/**"
- ".github/workflows/ios.yml"
# integration layer guards client<->server protocol drift:
- "src/**"
- "package.json"
- "package-lock.json"
pull_request:
paths:
- "ios/**"
- ".github/workflows/ios.yml"
- "src/**"
- "package.json"
- "package-lock.json"
jobs:
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
# tests below without a gate (test doubles are excluded from the gate).
package-tests:
runs-on: macos-15
strategy:
fail-fast: false
matrix:
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test + own-sources coverage gate (>= 80%)
run: ios/IntegrationTests/scripts/coverage-gate.sh ${{ matrix.package }}
testsupport-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
run: swift test --package-path ios/Packages/TestSupport
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
app-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
# real data-protection keychain, which returns -34018 for UNSIGNED test
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
# handoff finding, verified locally in the ui-test leg runs.)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPhone 16' \
test
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
# exercised in CI, not only the compact iPhone path.
ipad-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
test
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
# npm ci compiles node-pty (needs the Xcode toolchain — present on the
# runner); the Swift ServerHarness then boots the server itself.
integration-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- uses: actions/setup-node@v4
with:
node-version: 22
- name: npm ci (builds node-pty native module)
run: npm ci
- name: swift test vs real server (ServerHarness boots tsx src/server.ts)
run: swift test --package-path ios/IntegrationTests
# Layer 4 (W5, plan §9): the ONE scripted XCUITest happy path — 配对(手输) →
# 列表 → attach → 输入 → gate approve — against a live loopback server. The
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
ui-test:
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- uses: actions/setup-node@v4
with:
node-version: 22
- name: npm ci (builds node-pty native module)
run: npm ci
- name: Start web-terminal server on loopback
run: |
PORT=3217 BIND_HOST=127.0.0.1 SHELL_PATH=/bin/bash USE_TMUX=0 \
nohup node_modules/.bin/tsx src/server.ts > uitest-server.log 2>&1 &
echo $! > uitest-server.pid
ready=0
for _ in $(seq 1 30); do
if curl -sf http://127.0.0.1:3217/live-sessions > /dev/null; then
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "server did not come up on 127.0.0.1:3217" >&2
cat uitest-server.log >&2
exit 1
fi
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
# The 输入 step taps the KeyBar, which is the terminal's
# inputAccessoryView — it only exists while the SOFT keyboard is up.
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
# strips the prefix and injects <VAR> into the test-runner process);
# passing it as a KEY=VALUE argument makes it a build setting, which
# never reaches the runner (verified empirically, 2026-07-05).
# NO CODE_SIGNING_ALLOWED=NO here: the app must be (ad-hoc) signed or
# the data-protection keychain rejects every SecItem call (-34018) and
# pairing dies at "保存到本机失败" (verified empirically, run 6).
# Simulator builds sign locally without any certificate.
env:
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
-destination 'platform=iOS Simulator,name=iPhone 16' test
- name: Server log + shutdown
if: always()
run: |
cat uitest-server.log || true
kill "$(cat uitest-server.pid)" 2>/dev/null || true
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
# Xcode 16.x can build against it. Local machines are NOT expected to
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
# fail the job (no silent pass).
ios17-floor-tests:
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3 (build toolchain)
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
id: sim17
run: |
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
if [ -z "$runtime" ]; then
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
echo "runtime=" >> "$GITHUB_OUTPUT"
exit 0
fi
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
echo "created $udid with $runtime"
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
echo "udid=$udid" >> "$GITHUB_OUTPUT"
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
if: steps.sim17.outputs.runtime != ''
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test

30
.github/workflows/relay-tripwire.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# PERMANENT cross-tenant isolation tripwire (INV1) — see docs/PLAN_RELAY_AUTH_ISOLATION.md T13.
#
# This job MUST be a REQUIRED status check. A green build is impossible if cross-tenant isolation
# regresses (device A reaching host B). DO NOT delete, skip, or make this non-required. If it ever
# goes red, BLOCK the merge (CRITICAL per code-review.md).
name: relay-tripwire
on:
push:
pull_request:
jobs:
cross-tenant-tripwire:
runs-on: ubuntu-latest
defaults:
run:
working-directory: relay-auth
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install relay-auth (+ local relay-contracts)
run: npm install
- name: Strict typecheck
run: npx tsc --noEmit
- name: THE cross-tenant tripwire (A -> B must be 403)
run: npx vitest run tripwire/cross-tenant
- name: Full relay-auth suite
run: npx vitest run

5
.gitignore vendored
View File

@@ -4,6 +4,8 @@ node_modules/
# build output
dist/
public/build/
desktop/build/
desktop/dist-app/
# local Claude Code settings (not shared)
.claude/settings.local.json
@@ -16,3 +18,6 @@ npm-debug.log*
# test coverage
coverage/
.gstack/
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
deploy/.env

242
README.md
View File

@@ -1,103 +1,205 @@
# Web Terminal
A browser-based terminal that exposes the **host machine's local shell** over WebSocket. Open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another computer) and you get a live, interactive terminal — primarily for **vibe coding**: hand Claude Code a task, walk away, reconnect from anywhere to check on it.
A self-hosted, browser-based terminal **and** Claude-Code session/project workbench. It exposes the **host machine's local shell** over WebSocket: open `http://<host-ip>:3000` from any device on your LAN (phone, tablet, another laptop) and you get a live, interactive shell. The core use case is **vibe coding** hand Claude Code a task, walk away, then reconnect from anywhere to check on it, approve a tool call from your phone's lock screen, or kick off the next one.
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the output replays.
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security](#security).
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
---
## Features
- **Multi-tab** — each tab is an independent shell session. Tabs auto-name to the current folder (double-click to rename), show a connection dot (🟢/🟡/🔴) and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
- **Claude Code cockpit** (with hooks, see below) — each tab shows Claude's status (⚙ working / ⏳ needs approval / ✓ idle), sends a browser notification when it needs you, and lets you **tap Approve / Reject** on a tool request with no typing.
- **tmux keepalive** (optional) — run the shell inside tmux so sessions survive a server/host restart, not just a disconnect.
- **Mobile + desktop shortcut bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / Tab / `/` (the Claude Code keys a phone keyboard can't produce).
- **Session keepalive + replay** — the PTY keeps running across disconnects; reconnect replays a ~2 MB scrollback ring buffer.
- **Toolbar** — 🔍 scrollback search · ⚙ themes & font size · ▦ all-sessions dashboard · 📱 QR connect (scan to open on another device). Clickable links. Installable as a PWA.
### Core terminal
- **WebSocket terminal** — xterm.js in the browser, node-pty on the host. Keystrokes shuttle to the shell; output streams back. No terminal parsing on the server.
- **Sessions survive disconnects** — the PTY lifecycle is decoupled from the WebSocket. Closing a tab *detaches* a client; the shell keeps running. Reconnect replays a ~2 MB scrollback ring buffer.
- **Auto-reconnect** — exponential backoff (1s/2s/4s… capped at 30s), carrying the `localStorage` session id, so a flaky network or a phone waking from sleep just resumes.
- **Mobile touch key-bar** — one-tap Esc / Esc·Esc / ⇧Tab / arrows / Enter / ^C / ^O / ^T / ^B / ^R / ^L / ^D / Tab / `/` — the high-frequency Claude Code keys a soft keyboard can't produce, sent as raw bytes (no soft-keyboard pop).
- **Multi-device session mirroring** — many devices can attach to the *same* session at once. Output, exit and status broadcast to all; any device can type (shared control). PTY sizing is **latest-writer-wins**: whichever device you're actively using drives the size and stays full-screen.
## Requirements
### Tabs & home
- **Multi-tab** — each tab is an independent shell. Tabs auto-name to the current folder (double-click to rename), show a connection dot and an unread-output dot, and can be drag-reordered. `+` opens a new tab in the active tab's directory.
- **Home Sessions chooser** — opening the app lands on a chooser, not a blank shell. It shows the host's running sessions as **live preview thumbnails** (read-only renders of each screen) so you can see what each one is doing, plus a dashed **+ New session** tile. Pick one to open (full scrollback replay) or start fresh.
- **Per-session Open / Kill** — manage sessions right from the chooser; no separate manage page.
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
- Node.js ≥ 18 (developed on v24)
- macOS/Linux. On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails.
### Claude Code cockpit
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
- **Plan-mode / permission-mode relay** — start a session in a chosen `--permission-mode` (default / acceptEdits / plan / auto). When Claude exits plan mode, the approval bar becomes a **three-way** gate (approve+auto / approve+review / keep planning). The high-risk `auto` mode is gated behind `ALLOW_AUTO_MODE`.
- **tmux keepalive** — run the shell inside tmux so sessions survive a **server/host restart**, not just a disconnect. Auto-detected, or forced with `USE_TMUX`.
- **Session history / resume** — browse past Claude Code sessions (from `~/.claude/projects`) and resume one.
## Install
### Projects (v0.6)
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and a **create-worktree** action.
### Walk-away workbench (v0.7)
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
- **Voice dictation** — push-to-talk mic on the input bar (Web Speech API); release to send the transcript as input (optional auto-Enter). Hidden where unsupported; audio never touches the server.
- **Quick-reply chips + saved-prompt palette** — tap-to-send chips (`yes`, `continue`, `1/2/3`, `Esc`) plus a persistent palette of your own named snippets.
- **Activity timeline** — a human-readable, timestamped stream of what happened while you were gone ("ran Bash · edited 3 files · waiting for approval · done"), built from discrete hook events in a bounded per-session ring.
- **Stuck / idle alert** — if a session goes silent past a threshold (no output, not idle, not exited) it fires a one-shot "possibly stuck" alert through the same notification channel. Runs on the existing reaper tick (no extra timer).
- **statusLine telemetry → per-tab gauges** — a statusLine script feeds back context-usage, cumulative cost, model, lines +/, PR state, and rate-limit (5h/7d) telemetry, rendered as **per-tab gauges** on tabs, thumbnails and project cards. Stale telemetry greys out.
### UX
- Themed UI (Amber dark theme + light / solarized, adjustable font size), scrollback **search**, an all-sessions **dashboard**, **share-session QR/link** (`?join=<id>`), **connect-device QR**, a keyboard-shortcut **cheat-sheet**, an installable **PWA**, clickable links, and a line-icon toolbar with hover tooltips.
---
## Clients
The browser is the reference client; two native clients and a remote-access
service are also in the repo (all consume the same wire protocol — the server
stays a byte-shuttle).
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop:
native terminal with scrollback replay, QR/manual pairing (Keychain), the
remote **Approve / Reject** gate + three-way plan gate, **APNs push with
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server
changes; P1 adds two declared additive touch-points. See
[`ios/README.md`](ios/README.md). *(Not yet merged.)*
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
embeds this Node server + node-pty** (all-in-one), for native notifications,
tray, deep links and launch-at-login. Design in
[`docs/DESKTOP_PLAN.md`](docs/DESKTOP_PLAN.md). *(Built; packaged & launch-
verified on Apple Silicon.)*
- **Rendezvous relay** (`term-relay/` · `agent/` · `relay-e2e/` · `relay-web/` ·
`relay-auth/` · `relay-contracts/`) — an "ngrok-for-Claude-Code" with
**end-to-end encryption** to reach a self-hosted host from anywhere, without a
VPN or port-forwarding. Security-audited but **pre-production / not deployed**;
see [`docs/PLAN_RELAY_INDEX.md`](docs/PLAN_RELAY_INDEX.md) and
[`docs/DEPLOY_RELAY.md`](docs/DEPLOY_RELAY.md). For remote access **today**, use
**Tailscale** (§ Security & deployment below).
---
## Quick start
### Prerequisites
- **Node.js ≥ 18** (developed on v24).
- **macOS / Linux.** On macOS, `node-pty` compiles a native addon — install **Xcode Command Line Tools** (`xcode-select --install`) if `npm install` fails. After a major Node version bump, run `npm rebuild`.
### Install & run
```bash
npm install # installs deps; postinstall makes node-pty's spawn-helper executable
```
## Run
```bash
npm run build:web # bundle the frontend (public/main.ts → public/build/main.js)
npm install # installs deps (incl. node-pty native build); postinstall fixes the spawn-helper bit
npm run build:web # bundle the frontend (public/main.ts → public/build/) with esbuild
npm start # serve on 0.0.0.0:3000
```
Then:
Then find your LAN IP and open it from any device on the same network:
```bash
# find your LAN IP (macOS):
ipconfig getifaddr en0
# open http://<that-ip>:3000 on any device on the same network
ipconfig getifaddr en0 # macOS
# open http://<that-ip>:3000
```
For frontend development, run `npm run dev:web` (esbuild --watch) alongside `npm start`.
## Claude Code cockpit (optional)
To see Claude's status per tab and approve/reject tool calls from your phone, install the hooks once:
For frontend development, run `npm run dev:web` (esbuild `--watch`) alongside `npm start`.
### Enable the Claude Code cockpit (optional but recommended)
```bash
npm run setup-hooks # adds http hooks to ~/.claude/settings.json (backs it up)
npm run setup-hooks # adds the hooks + statusLine to ~/.claude/settings.json (backs it up)
# npm run setup-hooks -- --remove # to uninstall
```
This wires Claude Code's hooks → **live per-tab status**, the **statusLine gauges**, and **push** notifications. The hooks are a no-op outside web-terminal (they only fire when `$WEBTERM_*` env vars are set in spawned shells), so they're safe to leave installed. Then run `claude` inside a tab.
Then run `claude` inside a tab. The hooks POST to the server (loopback only) when Claude starts/stops/needs permission; the tab badge updates live, and a `PermissionRequest` shows an **Approve / Reject** bar. The hooks are a no-op outside web-terminal (they curl `$WEBTERM_HOOK_URL`, which is only set in spawned shells), so they're safe to leave installed.
> **Login shells (why hooks can always find `node`)** — sessions spawn the shell as a **login shell** (`zsh -l`, POSIX only), so it loads your full profile (`~/.zprofile`, `~/.zshrc`, …) and rebuilds `PATH`. Without this, a GUI-launched app (desktop build) or a long-lived tmux keepalive can hand the shell a minimal `PATH`, and hooks that call an nvm-/brew-managed `node` fail with `node: command not found`. If you still hit that on an old session, start a fresh one so it picks up the login-shell `PATH`.
**tmux keepalive** — run with `USE_TMUX=1` (or just have `tmux` on PATH; it auto-detects) to keep sessions alive across a server restart:
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
### Tests
```bash
USE_TMUX=1 npm start
```
## Configuration
All via environment variables (no hardcoding):
| Var | Default | Meaning |
|-----|---------|---------|
| `PORT` | `3000` | listen port |
| `BIND_HOST` | `0.0.0.0` | listen address |
| `SHELL_PATH` | `$SHELL` or `/bin/zsh` | shell to spawn |
| `IDLE_TTL` | `86400` (s) | reclaim a detached session after this idle time |
| `SCROLLBACK_BYTES` | `2097152` | per-session replay ring buffer (bytes) |
| `MAX_PAYLOAD_BYTES` | `1048576` | max single WS frame |
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH |
| `ALLOWED_ORIGINS` | (derived) | extra allowed WS origins, comma-separated |
`allowedOrigins` is **derived from the host's network-interface IPs** (plus `localhost` and anything in `ALLOWED_ORIGINS`) — never from `BIND_HOST`, since `0.0.0.0` is never a real browser Origin.
## Security
This is a no-auth, LAN-only tool by design. The defenses that matter:
- **Origin check (cannot be skipped):** the WS handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`.
- **Path-scoped upgrades:** only `/term` is accepted for WS upgrade.
- **Frame size cap:** oversized frames are rejected (`MAX_PAYLOAD_BYTES`).
- **Never expose to the public internet.** `ws://` is **unencrypted** — on an untrusted network (café/office Wi-Fi) traffic (including keystrokes: passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also lets you use `wss://` (the frontend auto-selects `wss` on HTTPS).
## Development
```bash
npm test # vitest, all modules
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
npm run typecheck # tsc (backend + frontend)
npm run build # compile backend to dist/
```
Real-PTY end-to-end tests (`test/integration/`) auto-skip where `posix_spawn` is unavailable (e.g. sandboxes) and run everywhere else.
---
## How it works
## Configuration
The server is a **byte-shuttle, not a terminal**: `node-pty` provides a pseudo-terminal so the shell believes it has a real TTY, and `xterm.js` in the browser interprets the ANSI bytes and renders. The server never parses terminal semantics. PTY lifecycle is decoupled from the WebSocket — a disconnect *detaches* (the PTY keeps running) rather than killing it, which is what makes session survival work.
All config is via environment variables (no hardcoding). Invalid values fail fast at startup.
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (why) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (how).
### Core
| Var | Default | Purpose |
|-----|---------|---------|
| `PORT` | `3000` | Listen port. |
| `BIND_HOST` | `0.0.0.0` | Listen address. |
| `SHELL_PATH` | `$SHELL` or `/bin/zsh` | Shell to spawn. |
| `IDLE_TTL` | `86400` (s) | Reclaim a detached session after this idle time (no new output since detach). |
| `SCROLLBACK_BYTES` | `2097152` (2 MB) | Per-session replay ring buffer size. |
| `MAX_PAYLOAD_BYTES` | `1048576` (1 MB) | Max single WS frame; oversized frames are rejected. |
| `WS_PATH` | `/term` | The only path accepted for WS upgrade. |
| `MAX_SESSIONS` | `50` | Cap on concurrent sessions (DoS guard). |
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
### Projects (v0.6)
| Var | Default | Purpose |
|-----|---------|---------|
| `PROJECT_ROOTS` | `$HOME` | Comma-separated absolute roots to scan for git repos (`~`/`~/...` expanded; relative paths rejected). |
| `PROJECT_SCAN_DEPTH` | `4` | How deep to descend looking for `.git`. |
| `PROJECT_SCAN_TTL` | `10000` (ms) | Cache TTL for repo discovery. |
| `PROJECT_DIRTY_CHECK` | `1` (on) | Run `git status --porcelain` to flag dirty repos. |
| `EDITOR_CMD` | `code` | Command the host runs for the project "VS Code" button. |
### Walk-away workbench (v0.7)
| Var | Default | Purpose |
|-----|---------|---------|
| `VAPID_PUBLIC_KEY` | (unset → push off) | Web Push public key, exposed to the service worker. |
| `VAPID_PRIVATE_KEY` | (unset, **secret**) | Web Push private key; required to enable push. Never logged. |
| `VAPID_SUBJECT` | `mailto:admin@localhost` | VAPID `sub`. |
| `PUSH_STORE_PATH` | `~/.web-terminal-push-subs.json` | Persisted push-subscription store. |
| `PUSH_MAX_SUBS` | `50` | Max stored push subscriptions (DoS guard). |
| `NOTIFY_DONE` | `1` (on) | Send the low-priority DONE push on Stop/SessionEnd. |
| `NOTIFY_DND` | `0` (off) | Global do-not-disturb default. |
| `DECISION_TOKEN_TTL_MS` | = `PERM_TIMEOUT_MS` | Lifetime of a lock-screen decision capability token. |
| `TIMELINE_MAX` | `200` | Per-session activity-timeline event ring cap. |
| `TIMELINE_ENABLED` | `1` (on) | Capture/serve the activity timeline. |
| `STUCK_TTL` | `600` (s) | Silence window before a "possibly stuck" alert; `0` disables. |
| `STUCK_ALERT` | `1` (on) | Master switch for stuck alerts. |
| `DIFF_TIMEOUT_MS` | `2000` | Timeout for a single `git diff`. |
| `DIFF_MAX_BYTES` | `2097152` (2 MB) | Patch truncation cap. |
| `DIFF_MAX_FILES` | `300` | Max files returned before the diff is marked `truncated`. |
| `STATUSLINE_TTL_MS` | `30000` | After this with no update, per-tab telemetry greys out as stale. |
| `WORKTREE_ENABLED` | `1` (on) | Master switch for the create-worktree feature (the only write-to-disk action). |
| `WORKTREE_ROOT` | (unset → `<repo>-worktrees`) | Root that new worktrees must land inside. |
| `WORKTREE_TIMEOUT_MS` | `10000` | Timeout for `git worktree add`. |
| `DEFAULT_PERMISSION_MODE` | `default` | `--permission-mode` used when a session is started without an explicit choice (`default`/`acceptEdits`/`plan`/`auto`). |
| `ALLOW_AUTO_MODE` | `0` (off) | Whether the high-risk `auto` (bypass-permissions) mode is offered/honored. |
### Spawn-injected env (set by the server / read by hook scripts — not user config)
`WEBTERM_SESSION`, `WEBTERM_HOOK_URL`, `WEBTERM_STATUSLINE_URL` are injected into each spawned shell so the hooks/statusLine know which session they belong to and where to POST. The optional ntfy/Pushover bridge reads `WEBTERM_NTFY_URL` / `WEBTERM_NTFY_TOPIC` / `WEBTERM_NTFY_TOKEN` and `WEBTERM_PUSHOVER_TOKEN` / `WEBTERM_PUSHOVER_USER` — secrets stay in env, never written into `settings.json` or command argv.
---
## Security & deployment
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter:
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
- **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
---
## Architecture
The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a real pseudo-terminal, the server shuttles raw bytes over the WebSocket, and `xterm.js` in the browser interprets ANSI and renders. The server never parses terminal semantics — every "smart" feature (status, timeline, telemetry, diff, push) rides an **out-of-band side-channel** (loopback HTTP/JSON from Claude Code hooks, or `git` subprocesses), keeping the terminal stream a pure pipe.
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).

1609
agent/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
agent/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"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",
"build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/cli.js --banner:js='#!/usr/bin/env node\nimport{createRequire as __cjs}from\"node:module\";const require=__cjs(import.meta.url);'",
"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",
"esbuild": "^0.28.1",
"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
},
}
}

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

@@ -0,0 +1,171 @@
/**
* CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
* `pair | run | status | install | uninstall`. All side effects (network/FS/tunnel/provision) are
* injected via `CliDeps` so `runCli` stays pure and offline-testable.
*
* `pair <CODE> --install` is the NATIVE zero-touch onboard: P-256 keygen (FIX H-host-2) → CSR →
* POST /enroll → provision the pinned frpc binary → write frpc.toml → install BOTH units (base-app
* + agent, base-app env routed to the base-app unit; the agent unit supervises frpc — NOT the old
* relay `runTunnel` rendezvous) → print `https://<sub>.terminal.<domain>`.
*
* `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 { assertNativeZone, type InstallOptions } from './service/install.js'
import { subdomainOrigin } from './service/originConfig.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'
}
}
/** The non-secret enrollment result the native onboard needs (host id + assigned subdomain). */
export interface NativeEnrollResult {
readonly hostId: string
readonly subdomain: string
}
export interface CliDeps {
loadConfig(): AgentConfig
openKeystore(stateDir: string): Keystore
/** Ed25519 identity for the legacy relay path. */
generateIdentity(): AgentIdentity
/** P-256 identity for the native frp-client key (FIX H-host-2); private key never leaves the host. */
generateP256Identity(): AgentIdentity
/** Legacy relay redemption (Ed25519, E2E rendezvous). */
redeem(cfg: AgentConfig, code: string, id: AgentIdentity, ks: Keystore): Promise<EnrollResult>
/** Native enroll: build the P-256 CSR, POST /enroll, store the returned cert; return ids only. */
enrollNative(
cfg: AgentConfig,
code: string,
id: AgentIdentity,
ks: Keystore,
): Promise<NativeEnrollResult>
/** Download + verify + place the pinned frpc binary (B3); returns its path. */
provisionFrpc(cfg: AgentConfig): Promise<string>
/** Write the native `frpc.toml` for `subdomain` (base-app env is routed via installService). */
writeFrpcConfig(cfg: AgentConfig, subdomain: string): void
/** True iff a written native `frpc.toml` exists in `cfg.stateDir` (native-onboard signal). */
nativeConfigExists(cfg: AgentConfig): boolean
/** Legacy relay run-loop (Ed25519 WS rendezvous, T10 backoff + T9 heartbeat). */
runTunnel(cfg: AgentConfig, ks: Keystore): Promise<number>
/** Native run-loop: supervise the pinned frpc child (restart-on-exit backoff + health probe). */
superviseFrpc(cfg: AgentConfig, ks: Keystore): Promise<number>
/** Resolve per-host install inputs (S0 env incl. loopback BIND_HOST, tunnel origin) from env/flags. */
resolveInstallOptions(): InstallOptions
installService(cfg: AgentConfig, options: InstallOptions): 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
}
/**
* Native zero-touch onboard for `pair <CODE> --install` (B5). Order is load-bearing: keygen(P-256)
* → enroll (CSR + POST /enroll) → provision frpc (so the binary exists before the agent unit
* starts) → write frpc.toml → install BOTH units (which start them) → print the tunnel URL.
*/
async function pairInstallNative(
code: string,
cfg: AgentConfig,
ks: Keystore,
deps: CliDeps,
): Promise<number> {
const options = deps.resolveInstallOptions()
if (!options.domain) {
throw new CliUsageError(
'native install requires TUNNEL_DOMAIN — the origin is https://<sub>.terminal.<domain>',
)
}
assertNativeZone(options.zone) // FIX L-host-zone: native ⇒ `terminal`
const id = deps.generateP256Identity() // keygen (P-256, FIX H-host-2)
ks.saveIdentity(id)
const enroll = await deps.enrollNative(cfg, code, id, ks) // CSR → POST /enroll → store cert
await deps.provisionFrpc(cfg) // pinned frpc binary on disk before the service starts (B3)
deps.writeFrpcConfig(cfg, enroll.subdomain) // frpc.toml
await deps.installService(cfg, options) // base-app + agent units; base-app env routed → started
deps.print(subdomainOrigin(enroll.subdomain, options.domain, options.zone))
return 0
}
/** 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': {
if (args.flags['install']) return pairInstallNative(args.code!, cfg, ks, deps)
// Legacy relay pair (Ed25519 rendezvous, no install).
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}`)
return 0
}
case 'run': {
const id = ks.loadIdentity()
if (id === null || ks.loadCert() === null) {
throw new CliUsageError('not enrolled — run `web-terminal-agent pair <CODE>` first')
}
// Native onboard = a P-256 frp-client identity + a written frpc.toml. Supervise frpc as a
// child (restart-on-exit backoff + health probe) instead of the legacy Ed25519 relay tunnel.
if (id.alg === 'p256' && deps.nativeConfigExists(cfg)) {
return deps.superviseFrpc(cfg, ks)
}
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, deps.resolveInstallOptions())
return 0
case 'uninstall':
await deps.uninstallService()
return 0
}
}

232
agent/src/cli/deps.ts Normal file
View File

@@ -0,0 +1,232 @@
/**
* CliDeps factory — PLAN_RELAY_PHASE1 C2, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
* Wires the abstract `CliDeps` seams (consumed by `runCli`) to their real implementations: env-driven
* config, the on-disk keystore, identity generation (Ed25519 + P-256), §4.5 pairing redemption, the
* native enroll + frpc provisioning + frpc.toml writer, the supervised tunnel, and the two-unit OS
* service install. All side effects live here so `cli.ts`/`runCli` stay pure and unit-testable.
*/
import { execFile } from 'node:child_process'
import { X509Certificate } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir, userInfo } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CliDeps, NativeEnrollResult } from '../cli.js'
import type { AgentConfig } from '../config/agentConfig.js'
import { loadAgentConfig } from '../config/agentConfig.js'
import { openKeystore } from '../keys/keystore.js'
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js'
import { redeemPairingCode } from '../enroll/pair.js'
import { runTunnel } from '../transport/runTunnel.js'
import { buildNativeFrpcToml } from '../transport/frpcToml.js'
import { superviseFrpc } from '../transport/frpSupervise.js'
import { provisionFrpc } from '../provision/frpcBinary.js'
import {
probeLoopbackBaseApp,
renderHealthStatus,
runHealthProbe,
startHealthMonitor,
} from '../health/probe.js'
import { createLogger } from '../log/logger.js'
import { ensureAllowedOrigin } from '../service/originConfig.js'
import {
buildInstallOptions,
detectPlatform,
NATIVE_ORIGIN_ZONE,
installService as installServiceUnit,
uninstallService as uninstallServiceUnit,
type InstallDeps,
type ServicePlatform,
} from '../service/install.js'
/** Keystore file names in `stateDir` (kept in lockstep with `keys/keystore.ts`). */
const KEYSTORE_CERT = 'agent.cert.pem'
const KEYSTORE_KEY = 'agent.key.pem'
const KEYSTORE_CA = 'agent.ca.pem'
const FRPC_TOML = 'frpc.toml'
const FRPC_LOG = 'frpc.log'
const BASE_APP_ENV_FILE = 'base-app.env'
const DEFAULT_LOCAL_PORT = 3000
/** Resolve this process's own executable path (the bundled `dist/cli.js`) for the service unit. */
function selfBinPath(): string {
return fileURLToPath(import.meta.url)
}
function runCommand(cmd: string, args: readonly string[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
execFile(cmd, [...args], (err) => (err ? reject(err) : resolve()))
})
}
function realInstallDeps(): InstallDeps {
return {
writeFile: (path, content) => {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
},
runCommand,
getuid: () => (typeof process.getuid === 'function' ? process.getuid() : 0),
homedir,
username: () => userInfo().username,
binPath: selfBinPath,
}
}
/** Map the current OS to its service manager, or fail fast with a clear message. */
function requirePlatform(): ServicePlatform {
const platform = detectPlatform(process.platform)
if (platform === null) {
throw new Error(`service install/uninstall is unsupported on platform '${process.platform}'`)
}
return platform
}
/** Parse a positive-integer PORT from the env (falls back to the base-app default 3000). */
function resolveLocalPort(): number {
const raw = process.env.PORT
const port = raw ? Number.parseInt(raw, 10) : NaN
return Number.isInteger(port) && port > 0 ? port : DEFAULT_LOCAL_PORT
}
/** Native enroll: build the P-256 CSR + POST /enroll (via the frozen redeem flow), store the cert. */
async function enrollNative(
cfg: AgentConfig,
code: string,
id: AgentIdentity,
ks: Keystore,
): Promise<NativeEnrollResult> {
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
}
/**
* Write the native `frpc.toml` into `stateDir`, pointing frpc at this host's keystore cert/key/CA and
* the loopback base app. Also materializes the base-app ALLOWED_ORIGINS env file. The frps shared
* token comes from `FRP_AUTH_TOKEN` (deploy secret; never logged). NOTE: the frpc binary run/e2e is
* pending the B3 tar.gz extraction; this seam only emits the config.
*/
function writeFrpcConfig(cfg: AgentConfig, subdomain: string): void {
const domain = process.env.TUNNEL_DOMAIN
const toml = buildNativeFrpcToml({
subdomain,
localPort: resolveLocalPort(),
authToken: process.env.FRP_AUTH_TOKEN ?? '',
certFile: join(cfg.stateDir, KEYSTORE_CERT),
keyFile: join(cfg.stateDir, KEYSTORE_KEY),
trustedCaFile: join(cfg.stateDir, KEYSTORE_CA),
})
mkdirSync(cfg.stateDir, { recursive: true })
writeFileSync(join(cfg.stateDir, FRPC_TOML), toml, { mode: 0o600 })
if (domain) {
ensureAllowedOrigin(join(cfg.stateDir, BASE_APP_ENV_FILE), subdomain, domain, undefined, NATIVE_ORIGIN_ZONE)
}
}
/** The stored frp-client leaf's `notAfter`, or null if no cert/parse failure (non-secret metadata). */
function certNotAfter(ks: Keystore): Date | null {
const cert = ks.loadCert()
if (cert === null) return null
try {
return new X509Certificate(cert.certPem).validToDate
} catch {
return null
}
}
/**
* The single frpc log path in `stateDir`. Used by BOTH the supervisor's file-logging spawn (writer)
* and `readFrpcLog` (reader) so the health probe can never scan a different file than frpc writes.
*/
export function frpcLogPath(stateDir: string): string {
return join(stateDir, FRPC_LOG)
}
/** Read the accumulated frpc log (empty string if not yet written) for the proxy-started scan. */
export function readFrpcLog(stateDir: string): string {
const path = frpcLogPath(stateDir)
if (!existsSync(path)) return ''
try {
return readFileSync(path, 'utf8')
} catch {
return ''
}
}
/**
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a
* periodic health probe (frpc alive, base-app loopback reachable, proxy-started, cert-not-expiring)
* logs NON-SECRET status only (INV9). Resolves when the supervisor stops (SIGTERM/SIGINT).
*/
function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
const logger = createLogger('info')
const binPath = join(cfg.stateDir, 'bin', 'frpc')
const tomlPath = join(cfg.stateDir, FRPC_TOML)
// Tee the frpc child's stdout/stderr into `<stateDir>/frpc.log` (the SAME path `readFrpcLog`
// scans below) so the proxy-started health sub-check has real content — without this wiring the
// log stays empty and `HealthReport.healthy` can never be true (B4/H4 goal).
const handle = superviseFrpc(binPath, tomlPath, { logger, logFile: frpcLogPath(cfg.stateDir) })
const port = resolveLocalPort()
const monitor = startHealthMonitor(
() =>
runHealthProbe({
isFrpcAlive: () => handle.isChildAlive(),
probeBaseApp: () => probeLoopbackBaseApp(port, (url) => fetch(url)),
readFrpcLog: () => readFrpcLog(cfg.stateDir),
certNotAfter: () => certNotAfter(ks),
now: () => new Date(),
}),
(report) => {
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
},
)
const onSignal = (): void => {
void handle.stop()
}
process.once('SIGTERM', onSignal)
process.once('SIGINT', onSignal)
return handle.done.finally(() => monitor.stop())
}
/** Build the concrete CliDeps used by the real CLI entrypoint. */
export function createCliDeps(): CliDeps {
return {
loadConfig: () => loadAgentConfig(process.env),
openKeystore: (stateDir) => openKeystore(stateDir),
generateIdentity: () => generateIdentity(),
generateP256Identity: () => generateP256Identity(),
redeem: (cfg: AgentConfig, code, id, ks) => redeemPairingCode(cfg.enrollUrl, code, id, ks),
enrollNative: (cfg, code, id, ks) => enrollNative(cfg, code, id, ks),
provisionFrpc: async (cfg) => {
const result = await provisionFrpc({
platform: process.platform,
arch: process.arch,
binDir: join(cfg.stateDir, 'bin'),
})
return result.binPath
},
writeFrpcConfig: (cfg, subdomain) => writeFrpcConfig(cfg, subdomain),
nativeConfigExists: (cfg) => existsSync(join(cfg.stateDir, FRPC_TOML)),
superviseFrpc: (cfg, ks) => superviseNative(cfg, ks),
runTunnel: async (cfg, ks) => {
const handle = await runTunnel(cfg, ks)
const onSignal = (): void => {
void handle.stop()
}
process.once('SIGTERM', onSignal)
process.once('SIGINT', onSignal)
return handle.done
},
resolveInstallOptions: () => buildInstallOptions(process.env),
installService: (cfg, options) =>
installServiceUnit(cfg, requirePlatform(), realInstallDeps(), options),
uninstallService: () => uninstallServiceUnit(requirePlatform(), { runCommand, homedir }),
print: (line) => {
process.stdout.write(`${line}\n`)
},
}
}

View File

@@ -0,0 +1,90 @@
/**
* 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'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export interface AgentConfig {
readonly relayUrl: string
readonly enrollUrl: string
readonly stateDir: string
readonly localTargetUrl: string
readonly subdomain: string | null
readonly hostId: string | null
}
/**
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
* ::1). Uses the shared strict check so a crafted suffixed hostname such as
* `ws://127.0.0.1.attacker.example.com:3000` — which the outbound dial would DNS-resolve and connect
* to wherever it points — is REJECTED, closing the anti-SSRF bypass (not merely `startsWith('127.')`).
*/
export function isLoopbackWsUrl(url: string): boolean {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return false
}
if (parsed.protocol !== 'ws:') return false
return isLoopbackHostLiteral(parsed.hostname)
}
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,70 @@
/**
* 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).
*
* PER-GENERATION EPOCH (F6 fix): K_content = HKDF(hostContentSecret, salt=sessionId, info=const) is
* byte-identical for a given (host, sessionId) and RECOVERABLE by design — but the deterministic seal
* nonce is seq, which resets to 0 on every sealer reconstruction (restart / re-attach). Two sealer
* GENERATIONS would therefore seal DISTINCT plaintext under the SAME (key, nonce) → catastrophic AEAD
* reuse. Each `createReplaySealer` call mints a FRESH, NON-SECRET `epoch` (randomUUID) that is folded
* into K_content derivation, so a restart / new generation yields a FRESH key even for the same
* (hostContentSecret, sessionId); seq=0 can never collide across generations. The epoch is exposed on
* the sealer so the wiring can persist it with the ring buffer / replay stream and serve it to the
* browser, which re-derives the matching key. Recoverability WITHIN one generation (same epoch ⇒ same
* key) is preserved.
*/
import { randomUUID } from 'node:crypto'
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 {
/**
* The fresh, NON-SECRET per-generation epoch folded into K_content (F6). The wiring persists it
* with the ring buffer / replay stream so the browser re-derives the matching key.
*/
readonly epoch: string
/** 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 for ONE generation. A FRESH `epoch` is minted per call
* and folded into K_content, which is derived ONCE from { hostContentSecret, sessionId, alg, epoch };
* seq is strictly monotonic from 0 (INV13). A restart / re-attach constructs a NEW generation with a
* NEW epoch ⇒ a FRESH key, so seq=0 never collides across generations (F6).
*/
export function createReplaySealer(
hostContentSecret: Uint8Array,
sessionId: string,
alg: AeadAlg,
crypto: ReplayCrypto,
): ReplaySealer {
const epoch = randomUUID()
const key = crypto.deriveContentKey({ hostContentSecret, sessionId, alg, epoch })
let seq = 0n
return {
epoch,
seal(plaintext: Uint8Array): E2EEnvelope {
const env = crypto.sealReplayFrame(key, seq, plaintext)
seq += 1n
return env
},
}
}

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

@@ -0,0 +1,118 @@
/**
* 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), 1.3.101.112 (Ed25519), 1.2.840.10045.4.3.2 (ecdsa-with-SHA256) as
// pre-encoded DER value bytes.
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
const OID_ECDSA_WITH_SHA256 = Uint8Array.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
/** 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]))
}
/**
* SubjectPublicKeyInfo bytes for the CSR. For P-256, `id.publicKey` IS already the full EC SPKI DER
* (built by `keys/identity.ts`), so it is embedded verbatim; for Ed25519 the raw 32-byte key is
* wrapped into the fixed SPKI structure.
*/
function spkiFor(id: AgentIdentity): Uint8Array {
return id.alg === 'p256' ? id.publicKey : spkiFromRawEd25519(id.publicKey)
}
/**
* The signatureAlgorithm AlgorithmIdentifier: `SEQUENCE { OID }` (no parameters — RFC 5758 §3.2 for
* ecdsa-with-SHA256, and Ed25519 likewise omits parameters).
*/
function sigAlgFor(id: AgentIdentity): Uint8Array {
const oid = id.alg === 'p256' ? OID_ECDSA_WITH_SHA256 : OID_ED25519
return tlv(SEQUENCE, tlv(OID, oid))
}
/** 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 identity's key. The
* Ed25519 and P-256 (ecdsa-with-SHA256, FIX H-host-2) paths share this one encoder — only the SPKI
* and the signatureAlgorithm differ. 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 = spkiFor(id)
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) // over CertificationRequestInfo, per id.alg
const sigAlg = sigAlgFor(id)
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
}

182
agent/src/health/probe.ts Normal file
View File

@@ -0,0 +1,182 @@
/**
* Native-tunnel health probe — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2 / §5, INV9).
*
* Reports four independent sub-checks that together say whether this host's native frp tunnel is
* actually serving:
* (a) frpcAlive — the supervised frpc child process is running;
* (b) baseAppReachable — the loopback base app answers `GET http://127.0.0.1:PORT` (loopback ONLY);
* (c) proxyStarted — frpc logged a "start proxy success" line (control channel + proxy up);
* (d) certFresh — the frp-client leaf's `notAfter` is beyond the renewal window (not expiring).
*
* Every side effect is an INJECTABLE seam (process-liveness checker, loopback HTTP probe, log
* scanner, clock) so the logic is pure and offline-testable. The status renderer emits ONLY
* non-secret identifiers (subdomain, host id, cert expiry date, boolean flags) — NEVER keys, certs,
* tokens, or CSRs (INV9). No `console.log`.
*/
/** Default renewal window: 8h — one third of the 24h host frp-client TTL (renew at ~2/3 TTL). */
export const DEFAULT_CERT_RENEW_WINDOW_MS = 8 * 60 * 60 * 1000
/** frpc emits this line on the control channel once a proxy is registered and forwarding. */
export const FRPC_START_SUCCESS_RE = /start proxy success/i
/** Loopback host the base-app probe targets — hardcoded so the probe can never reach off-host. */
export const LOOPBACK_PROBE_HOST = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
/** The four independent sub-checks plus the derived overall verdict. */
export interface HealthReport {
/** The supervised frpc child is running. */
readonly frpcAlive: boolean
/** `GET http://127.0.0.1:PORT` returned a response (base app is up on loopback). */
readonly baseAppReachable: boolean
/** frpc logged "start proxy success" (the tunnel proxy is registered). */
readonly proxyStarted: boolean
/** The frp-client cert's `notAfter` is beyond the renewal window (not near expiry). */
readonly certFresh: boolean
/** True iff all four sub-checks pass. */
readonly healthy: boolean
}
/** Injectable side effects the probe consumes; each is independently faked in tests. */
export interface HealthProbeSeams {
/** Process-liveness checker (the supervisor exposes whether its frpc child is alive). */
readonly isFrpcAlive: () => boolean
/** Loopback HTTP probe of the base app; resolves true iff it answered ok. */
readonly probeBaseApp: () => Promise<boolean>
/** Returns the accumulated frpc stdout/log text to scan for the success line. */
readonly readFrpcLog: () => string
/** The stored frp-client leaf's `notAfter`, or null if no cert is available. */
readonly certNotAfter: () => Date | null
/** Current time (injected for deterministic expiry tests). */
readonly now: () => Date
}
export interface HealthProbeConfig {
/** Certs within this many ms of `notAfter` are "near expiry" (default 8h). */
readonly renewWindowMs?: number
}
/** Pure: true iff the frpc log contains a "start proxy success" line. */
export function frpcProxyStarted(logText: string): boolean {
return FRPC_START_SUCCESS_RE.test(logText)
}
/** Pure: true iff `notAfter` is strictly beyond the renewal window from `now` (not near expiry). */
export function certIsFresh(notAfter: Date | null, now: Date, renewWindowMs: number): boolean {
if (notAfter === null) return false
return notAfter.getTime() - now.getTime() > renewWindowMs
}
/** Minimal shape of a fetch response the loopback probe cares about. */
export interface LoopbackResponse {
readonly ok: boolean
}
/** Injectable loopback HTTP fetch (real wiring passes global `fetch`). */
export type LoopbackFetch = (url: string) => Promise<LoopbackResponse>
/**
* Probe the loopback base app with `GET http://127.0.0.1:PORT/`. The host is hardcoded loopback, so
* the probe can never reach an off-host target (anti-SSRF). Any thrown/rejected fetch — or a
* non-integer/out-of-range port — resolves to `false` rather than propagating (a probe never throws).
*/
export async function probeLoopbackBaseApp(port: number, fetchImpl: LoopbackFetch): Promise<boolean> {
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) return false
const url = `http://${LOOPBACK_PROBE_HOST}:${port}/`
try {
const res = await fetchImpl(url)
return res.ok
} catch {
return false
}
}
/** Run all four sub-checks and derive the overall verdict. Never throws (each seam is guarded). */
export async function runHealthProbe(
seams: HealthProbeSeams,
config: HealthProbeConfig = {},
): Promise<HealthReport> {
const renewWindowMs = config.renewWindowMs ?? DEFAULT_CERT_RENEW_WINDOW_MS
const frpcAlive = seams.isFrpcAlive()
const baseAppReachable = await seams.probeBaseApp()
const proxyStarted = frpcProxyStarted(seams.readFrpcLog())
const certFresh = certIsFresh(seams.certNotAfter(), seams.now(), renewWindowMs)
const healthy = frpcAlive && baseAppReachable && proxyStarted && certFresh
return { frpcAlive, baseAppReachable, proxyStarted, certFresh, healthy }
}
/** Non-secret identifiers safe to print in `status` (INV9): NO key/cert/token/CSR material. */
export interface StatusIdentifiers {
readonly subdomain: string | null
readonly hostId: string | null
readonly certNotAfter: Date | null
}
/**
* Render `status` lines from non-secret identifiers + a health report (INV9). Emits the subdomain,
* host id, cert EXPIRY DATE (never the cert bytes), and the boolean sub-check flags — never any key,
* cert, token, or CSR material.
*/
export function renderHealthStatus(
ids: StatusIdentifiers,
report: HealthReport,
): readonly string[] {
return [
`subdomain: ${ids.subdomain ?? '(none)'}`,
`host_id: ${ids.hostId ?? '(none)'}`,
`cert_expiry: ${ids.certNotAfter ? ids.certNotAfter.toISOString() : '(unknown)'}`,
`frpc_alive: ${report.frpcAlive}`,
`base_app_reachable: ${report.baseAppReachable}`,
`proxy_started: ${report.proxyStarted}`,
`cert_fresh: ${report.certFresh}`,
`healthy: ${report.healthy}`,
]
}
/** Default periodic health-monitor interval (30s). */
export const DEFAULT_HEALTH_INTERVAL_MS = 30_000
/** Minimal injectable interval timer (fake-timer-testable). */
export interface IntervalTimer {
setInterval(cb: () => void, ms: number): unknown
clearInterval(handle: unknown): void
}
const realIntervalTimer: IntervalTimer = {
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
/** Handle to a running health monitor. */
export interface HealthMonitor {
stop(): void
}
/**
* Start a periodic health monitor: every `intervalMs`, run `probe()` and hand the report to
* `onReport` (the run-loop wires this to a redacting logger — non-secret lines only). A rejected
* probe is swallowed (a monitor must never crash the supervisor). `stop()` clears the interval.
*/
export function startHealthMonitor(
probe: () => Promise<HealthReport>,
onReport: (report: HealthReport) => void,
opts: { intervalMs?: number; timer?: IntervalTimer } = {},
): HealthMonitor {
const intervalMs = opts.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS
const timer = opts.timer ?? realIntervalTimer
const handle = timer.setInterval(() => {
void probe()
.then(onReport)
.catch(() => {
/* a probe failure is itself an unhealthy signal; never let it crash the monitor */
})
}, intervalMs)
return {
stop(): void {
timer.clearInterval(handle)
},
}
}

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'

143
agent/src/keys/identity.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
*
* Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
* - `ed25519` — the relay E2E rendezvous path (unchanged).
* - `p256` — the native-tunnel HOST frp-client key (FIX H-host-2): the `frp-client-CA` is P-256,
* so the host key, its CSR (ECDSA-with-SHA256), and its leaf are all P-256.
* `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 and, for P-256, NEVER leaves the host (only the pubkey + CSR do).
*/
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
import type { KeyObject } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts'
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
export type KeyAlg = 'ed25519' | 'p256'
export interface AgentIdentity {
/** Which key algorithm this identity uses (`ed25519` = relay path, `p256` = native frp-client). */
readonly alg: KeyAlg
/**
* The registry-stored public key bytes (§4.2 agent_pubkey):
* - `ed25519`: the raw 32-byte public key;
* - `p256`: the EC SubjectPublicKeyInfo DER (what the control-plane P-256 gate compares).
*/
readonly publicKey: Uint8Array
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
readonly enrollFpr: string
/**
* Signature over `message` using the in-process private key (never returns the key):
* - `ed25519`: a raw 64-byte Ed25519 signature;
* - `p256`: a DER `ECDSA-Sig-Value` (ecdsa-with-SHA256) — the PKCS#10 signatureValue shape.
*/
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 {
alg: 'ed25519',
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
},
}
}
/**
* P-256 identity (FIX H-host-2). `publicKey` is the EC SubjectPublicKeyInfo DER (the exact bytes the
* control-plane frp-client gate compares against the registry); `sign` produces a DER `ECDSA-Sig-Value`
* over the SHA-256 digest — the PKCS#10 / X.509 signatureValue shape. The private key never leaves
* this closure (INV4); only the SPKI + CSR are emitted off-host.
*/
function buildP256Identity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity {
const spkiDer = new Uint8Array(publicKey.export({ type: 'spki', format: 'der' }))
const enrollFpr = computeEnrollFpr(spkiDer)
return {
alg: 'p256',
publicKey: spkiDer,
enrollFpr,
sign(message: Uint8Array): Uint8Array {
// ecdsa-with-SHA256 → DER ECDSA-Sig-Value (node's default dsaEncoding is 'der').
return new Uint8Array(sign('sha256', 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)
}
/**
* Generate a fresh EC P-256 identity for the native-tunnel host frp-client key (FIX H-host-2). The
* private key is held in-memory only (INV4) and NEVER serialized off-host; only the pubkey + CSR leave.
*/
export function generateP256Identity(): AgentIdentity {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
return buildP256Identity(privateKey, publicKey)
}
/** Reconstruct an Ed25519 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)
}
/** Reconstruct a P-256 identity from a stored PKCS#8 PEM private key (keystore load path). */
export function p256IdentityFromPrivatePem(pem: string): AgentIdentity {
const privateKey = createPrivateKey(pem)
const publicKey = createPublicKey(privateKey)
return buildP256Identity(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)
}

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

@@ -0,0 +1,136 @@
/**
* 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 { createPrivateKey } from 'node:crypto'
import type { AgentIdentity } from './identity.js'
import { identityFromPrivatePem, p256IdentityFromPrivatePem } 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`,
)
}
}
/**
* Reconstruct an `AgentIdentity` from a stored PKCS#8 PEM, branching on the key's algorithm
* discriminant (the PKCS#8 AlgorithmIdentifier OID, surfaced as `asymmetricKeyType`): an Ed25519
* key → the relay-path builder; an EC P-256 key → the native frp-client builder (EC SPKI publicKey).
* A saved P-256 identity therefore round-trips as `alg: 'p256'` with a usable signing key, while
* Ed25519 stays byte-identical. Any other algorithm is a hard error (never silently mislabeled).
*/
function identityFromStoredPem(pem: string): AgentIdentity {
const key = createPrivateKey(pem)
const alg = key.asymmetricKeyType
if (alg === 'ed25519') return identityFromPrivatePem(pem)
if (alg === 'ec') {
// An `ec` key alone is not proof of P-256 — a P-384/secp256k1 key also reports `ec`. The native
// frp-client path is P-256 ONLY, so assert the named curve before treating it as such; any other
// curve is a hard error (never silently mislabeled as a usable P-256 identity).
const curve = key.asymmetricKeyDetails?.namedCurve
if (curve !== 'prime256v1') {
throw new Error(
`unsupported stored EC identity curve: ${curve ?? 'unknown'} (only prime256v1/P-256 is supported)`,
)
}
return p256IdentityFromPrivatePem(pem)
}
throw new Error(`unsupported stored identity key algorithm: ${alg ?? 'unknown'}`)
}
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 identityFromStoredPem(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}`)
},
}
}

30
agent/src/main.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* CLI bootstrap — PLAN_RELAY_PHASE1 C2. The `dist/cli.js` entrypoint (the `#!/usr/bin/env node`
* shebang is prepended at build time via esbuild `--banner`, NOT here). Reads argv, builds the
* real CliDeps, dispatches through `runCli`, and maps any error to a clean stderr line + exit code
* (usage errors ⇒ 2, everything else ⇒ 1) so no invocation ever crashes with a raw stack trace.
*/
import { parseArgs, runCli, CliUsageError } from './cli.js'
import { createCliDeps } from './cli/deps.js'
async function main(): Promise<number> {
const argv = process.argv.slice(2)
const deps = createCliDeps()
try {
return await runCli(parseArgs(argv), deps)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
process.stderr.write(`web-terminal-agent: ${message}\n`)
return err instanceof CliUsageError ? 2 : 1
}
}
main()
.then((code) => {
process.exitCode = code
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
process.stderr.write(`web-terminal-agent: fatal ${message}\n`)
process.exitCode = 1
})

View File

@@ -0,0 +1,29 @@
/**
* Strict loopback-literal check — the single source of truth for both S-GATE-style guards:
* - `service/install.ts` isLoopbackBindHost (BIND_HOST S-GATE, FIX C-host-1, CRITICAL)
* - `config/agentConfig.ts` isLoopbackWsUrl (localTargetUrl anti-SSRF)
*
* Both previously used `value.startsWith('127.')`, which treats an arbitrary suffixed HOSTNAME
* such as `127.0.0.1.attacker.example.com` or `127.evil.net` as loopback. Because Node resolves
* non-literal hosts via DNS before bind()/dial, that prefix match let a crafted value defeat the
* exact invariant the guard exists to enforce. This module fails closed: it accepts a value ONLY
* when it is an EXACT loopback literal — `localhost`, `::1`/`[::1]`, or a fully-parsed IPv4 address
* in 127.0.0.0/8 with NO trailing characters. Anything else (a hostname, a partial IP, `0.0.0.0`,
* `::`) is rejected.
*/
import { isIPv4 } from 'node:net'
/** Non-IPv4 loopback literals accepted verbatim (localhost + the IPv6 loopback, with/without brackets). */
const LOOPBACK_LITERALS: ReadonlySet<string> = new Set(['localhost', '::1', '[::1]'])
/**
* True iff `host` is an EXACT loopback literal: `localhost`, `::1`, `[::1]`, or a well-formed IPv4
* address in 127.0.0.0/8 (the whole string must parse as a dotted-quad — no trailing label). A
* suffixed hostname like `127.0.0.1.attacker.example.com` is NOT loopback and returns false.
*/
export function isLoopbackHostLiteral(host: string): boolean {
if (LOOPBACK_LITERALS.has(host)) return true
// isIPv4 requires the FULL string to be a dotted-quad, so no trailing hostname label can slip
// through; the first-octet check then confines it to the 127.0.0.0/8 loopback block.
return isIPv4(host) && host.split('.')[0] === '127'
}

View File

@@ -0,0 +1,259 @@
/**
* Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
*
* Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED
* release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its
* SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically
* (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed.
*
* Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`)
* — the default fetch enforces `https:` and there is no insecure escape hatch.
*
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
* a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes
* are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner
* `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar,
* unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`.
*
* The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise
* URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures,
* and unsupported-platform errors with no network access.
*/
import { createHash, randomBytes } from 'node:crypto'
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { extractFrpcBinary } from './untar.js'
export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64'
export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [
'darwin-arm64',
'darwin-amd64',
'linux-arm64',
'linux-amd64',
]
/** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */
export interface FrpcReleaseRef {
readonly version: string
readonly url: string
readonly sha256: string
}
const FRPC_VERSION = '0.61.1'
const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}`
/**
* The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the
* REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded
* darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests
* from that release's checksum file. Tests inject their own release map.
*/
export const FRPC_RELEASES: Readonly<Record<FrpcPlatform, FrpcReleaseRef>> = {
'darwin-arm64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`,
sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d',
},
'darwin-amd64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`,
sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491',
},
'linux-arm64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`,
sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8',
},
'linux-amd64': {
version: FRPC_VERSION,
url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`,
sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40',
},
}
/** Node `os.arch()` values mapped to frp's arch tokens. */
const ARCH_MAP: Readonly<Record<string, 'arm64' | 'amd64'>> = {
arm64: 'arm64',
x64: 'amd64',
}
const SUPPORTED_OS: ReadonlySet<string> = new Set(['darwin', 'linux'])
const BIN_NAME = 'frpc'
const TMP_NAME = 'frpc.download.tmp'
const EXEC_MODE = 0o755
const DIR_MODE = 0o700
/** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */
export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null {
const mappedArch = ARCH_MAP[arch]
if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null
const key = `${platform}-${mappedArch}` as FrpcPlatform
return FRPC_PLATFORMS.includes(key) ? key : null
}
/** Injectable network fetch: returns the artifact bytes for `url`. */
export type FrpcFetch = (url: string) => Promise<Uint8Array>
/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
export interface FrpcFsDeps {
mkdir(dir: string): Promise<void>
writeFile(path: string, data: Uint8Array): Promise<void>
rename(from: string, to: string): Promise<void>
chmod(path: string, mode: number): Promise<void>
rm(path: string): Promise<void>
}
export interface ProvisionFrpcDeps {
readonly fetch: FrpcFetch
readonly fs: FrpcFsDeps
/** Override the digest function (default: node:crypto SHA-256, lowercase hex). */
readonly computeSha256?: (data: Uint8Array) => string
}
export interface ProvisionFrpcOptions {
/** `os.platform()` (e.g. `'darwin'`, `'linux'`). */
readonly platform: string
/** `os.arch()` (e.g. `'arm64'`, `'x64'`). */
readonly arch: string
/** Directory the verified `frpc` binary is placed into. */
readonly binDir: string
/** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */
readonly releases?: Readonly<Record<FrpcPlatform, FrpcReleaseRef>>
}
export interface ProvisionResult {
readonly binPath: string
readonly version: string
readonly platform: FrpcPlatform
}
/** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */
export class FrpcProvisionError extends Error {
constructor(message: string) {
super(message)
this.name = 'FrpcProvisionError'
}
}
function defaultSha256(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
/** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */
async function defaultFetch(url: string): Promise<Uint8Array> {
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new FrpcProvisionError(`invalid frpc download URL: ${url}`)
}
if (parsed.protocol !== 'https:') {
throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`)
}
let res: Response
try {
res = await fetch(url)
} catch (err: unknown) {
// Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other
// failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly.
throw new FrpcProvisionError(
`frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`,
)
}
if (!res.ok) {
throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`)
}
return new Uint8Array(await res.arrayBuffer())
}
const defaultFsDeps: FrpcFsDeps = {
mkdir: async (dir) => {
await mkdir(dir, { recursive: true, mode: DIR_MODE })
},
writeFile: async (path, data) => {
await writeFile(path, data, { mode: 0o600 })
},
rename: async (from, to) => {
await rename(from, to)
},
chmod: async (path, mode) => {
await chmod(path, mode)
},
rm: async (path) => {
await rm(path, { force: true })
},
}
function defaultDeps(): ProvisionFrpcDeps {
return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 }
}
/**
* Download + verify + extract + place the pinned frpc binary for the current platform.
*
* Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive ->
* write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On
* mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to
* pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary,
* chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws.
* Nothing is ever placed or made executable before the digest matches the pin.
*/
export async function provisionFrpc(
opts: ProvisionFrpcOptions,
deps: ProvisionFrpcDeps = defaultDeps(),
): Promise<ProvisionResult> {
const platform = detectFrpcPlatform(opts.platform, opts.arch)
if (platform === null) {
throw new FrpcProvisionError(
`unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`,
)
}
const releases = opts.releases ?? FRPC_RELEASES
const release = releases[platform]
if (release === undefined) {
throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`)
}
const computeSha256 = deps.computeSha256 ?? defaultSha256
// Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls)
// must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename.
const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`)
const binPath = join(opts.binDir, BIN_NAME)
const bytes = await deps.fetch(release.url)
await deps.fs.mkdir(opts.binDir)
await deps.fs.writeFile(tmpPath, bytes)
const digest = computeSha256(bytes).toLowerCase()
const expected = release.sha256.toLowerCase()
if (digest !== expected) {
await deps.fs.rm(tmpPath)
throw new FrpcProvisionError(
`frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`,
)
}
// Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and
// rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any
// extraction failure the (still-archive) temp is removed and nothing is placed.
let frpcBytes: Uint8Array
try {
frpcBytes = extractFrpcBinary(bytes)
} catch (err: unknown) {
await deps.fs.rm(tmpPath)
const detail = err instanceof Error ? err.message : 'unknown error'
throw new FrpcProvisionError(
`frpc extraction failed for ${platform}: ${detail} — nothing placed`,
)
}
// Overwrite the temp with the verified extracted binary, grant exec, then promote atomically.
await deps.fs.writeFile(tmpPath, frpcBytes)
await deps.fs.chmod(tmpPath, EXEC_MODE)
await deps.fs.rename(tmpPath, binPath)
return { binPath, version: release.version, platform }
}

View File

@@ -0,0 +1,159 @@
/**
* Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction
* step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
*
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
* the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256
* against its pin it hands the bytes here to pull out ONLY the inner `frpc`.
*
* SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes
* the returned bytes to a FIXED destination it owns; entries are selected purely by matching the
* (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or
* has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used
* to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir.
*
* Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU
* long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs:
* name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL.
*/
import { gunzipSync } from 'node:zlib'
const TAR_BLOCK = 512
const NAME_OFFSET = 0
const NAME_LEN = 100
const SIZE_OFFSET = 124
const SIZE_LEN = 12
const TYPEFLAG_OFFSET = 156
// USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5',
// symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching.
const TYPE_REGULAR = 0x30
const TYPE_LEGACY = 0x00
// Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile
// size field before it can drive a huge allocation or an out-of-bounds read.
const MAX_ENTRY_BYTES = 256 * 1024 * 1024
const FRPC_BASENAME = 'frpc'
/** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */
export class TarExtractError extends Error {
constructor(message: string) {
super(message)
this.name = 'TarExtractError'
}
}
/**
* True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has
* any `..` path segment. Both `/` and `\` are treated as separators (defense in depth).
*/
function isUnsafeEntryName(name: string): boolean {
if (name.length === 0) return true
if (name.includes('\0')) return true
if (name.startsWith('/') || name.startsWith('\\')) return true
return name.split(/[/\\]/).some((segment) => segment === '..')
}
/** Last `/`- or `\`-separated segment of a tar entry name. */
function basename(name: string): string {
const parts = name.split(/[/\\]/)
return parts[parts.length - 1] ?? name
}
/** Decode the NUL-terminated USTAR name field of the header block at `off`. */
function readName(tar: Uint8Array, off: number): string {
const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN)
const nul = raw.indexOf(0)
return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul))
}
/**
* Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them);
* any non-octal digit yields `NaN` so the caller can reject a corrupt header.
*/
function readOctal(tar: Uint8Array, start: number, len: number): number {
let value = 0
let seenDigit = false
for (let i = start; i < start + len; i++) {
const c = tar[i]
if (c === undefined || c === 0x00 || c === 0x20) {
if (seenDigit) break
continue
}
if (c < 0x30 || c > 0x37) return Number.NaN
value = value * 8 + (c - 0x30)
seenDigit = true
}
return seenDigit ? value : 0
}
/** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */
function isZeroBlock(tar: Uint8Array, off: number): boolean {
for (let i = off; i < off + TAR_BLOCK; i++) {
if (tar[i] !== 0) return false
}
return true
}
/**
* Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`.
*
* Selection is by basename only — the archive's directory structure is never mapped to a path. A
* candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing
* entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing).
*/
export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array {
let off = 0
while (off + TAR_BLOCK <= tar.length) {
if (isZeroBlock(tar, off)) break // end-of-archive
const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN)
if (!Number.isInteger(size) || size < 0) {
throw new TarExtractError('corrupt tar: invalid entry size field')
}
if (size > MAX_ENTRY_BYTES) {
throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`)
}
const contentStart = off + TAR_BLOCK
const contentEnd = contentStart + size
if (contentEnd > tar.length) {
throw new TarExtractError('corrupt tar: entry content is truncated')
}
const typeflag = tar[off + TYPEFLAG_OFFSET]
const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY
if (isRegular) {
const name = readName(tar, off)
if (basename(name) === targetBasename) {
if (isUnsafeEntryName(name)) {
throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`)
}
return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer
}
}
// Advance past this entry's content, padded up to the next 512-byte boundary.
off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
}
throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`)
}
/**
* Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used
* on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled
* decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`.
*/
export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array {
return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME)
}
function gunzip(archiveGz: Uint8Array): Uint8Array {
try {
return new Uint8Array(gunzipSync(archiveGz))
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : 'not a gzip stream'
throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`)
}
}

View File

@@ -0,0 +1,254 @@
/**
* Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
* (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
* the agent — then loads/enables both. REFUSES to install as root (EXPLORE §4d least privilege).
* All IO is injected so the logic is unit-testable without touching the real system.
*
* Two safety controls are load-bearing here:
* - FIX C-host-1 (S-GATE, CRITICAL): the base-app unit MUST bind loopback. A non-loopback
* `BIND_HOST` (e.g. `0.0.0.0`) is REJECTED — the throw happens before any file is written, so a
* rejected install emits nothing. An absent value is normalized to `127.0.0.1`.
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
*/
import type { AgentConfig } from '../config/agentConfig.js'
import {
agentLabel,
baseAppLabel,
buildLaunchdPlist,
launchdLoadCommand,
launchdPlistPath,
launchdUnloadCommand,
type ServiceEnv,
} from './launchd.js'
import {
agentUnitName,
baseAppUnitName,
buildSystemdUnit,
systemdDisableCommand,
systemdEnableCommand,
systemdUnitPath,
type SystemdUnitOptions,
} from './systemd.js'
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export type ServicePlatform = 'launchd' | 'systemd'
/**
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
* existing callers (and relay callers) are unaffected. `env` is the BASE-APP env (routed to the
* base-app unit only); `baseAppExec` overrides the base-app `ExecStart` argv.
*/
export interface InstallOptions {
/** Base-app env injected into the base-app unit (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
readonly env?: ServiceEnv
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
readonly envFile?: string
/** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */
readonly zone?: string
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
readonly domain?: string
/** Base-app process argv (default `['node','dist/server.js']`). */
readonly baseAppExec?: readonly string[]
}
/** S0 base-app env vars the install CLI bakes into the base-app unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = [
'ALLOWED_ORIGINS',
'PORT',
'SHELL_PATH',
'IDLE_TTL',
'USE_TMUX',
'SCROLLBACK_BYTES',
'MAX_PAYLOAD_BYTES',
] as const
/**
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
* entirely (PLAN_NATIVE_TUNNEL S0/R2, FIX C-host-1). This is the normalized loopback default.
*/
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
export const TUNNEL_ORIGIN_ZONE = 'terminal'
/** Native-tunnel origin zone label; native installs MUST use this (FIX L-host-zone). */
export const NATIVE_ORIGIN_ZONE = 'terminal'
/** Base-app process argv when the caller does not override it. */
const DEFAULT_BASE_APP_EXEC: readonly string[] = ['node', 'dist/server.js']
/** A base-app BIND_HOST that is not loopback — the S-GATE fail-closed error (FIX C-host-1). */
export class BindHostError extends Error {
constructor(value: string) {
super(
`refusing to install: BIND_HOST="${value}" is not loopback. A tunnel host MUST bind ` +
'127.0.0.1/::1/localhost — the device-cert mTLS at the relay is the only auth gate, so a ' +
'0.0.0.0 (or LAN-IP) bind would serve an unauth\'d shell on the LAN. [FIX C-host-1 S-GATE]',
)
this.name = 'BindHostError'
}
}
/**
* True iff `value` is a loopback bind address (a well-formed 127.0.0.0/8 IPv4 literal, ::1, or
* localhost). Delegates to the shared strict check so a suffixed hostname such as
* `127.0.0.1.attacker.example.com` — which Node would DNS-resolve before bind() — is REJECTED,
* not treated as loopback (FIX C-host-1 S-GATE).
*/
function isLoopbackBindHost(value: string): boolean {
return isLoopbackHostLiteral(value)
}
/**
* S-GATE (FIX C-host-1): normalize an absent/empty BIND_HOST to loopback; REJECT any non-loopback
* value (throws `BindHostError`). The emitted base-app unit can therefore never bind `0.0.0.0`.
*/
export function normalizeBindHost(value: string | undefined): string {
if (value === undefined || value.length === 0) return TUNNEL_DEFAULT_BIND_HOST
if (!isLoopbackBindHost(value)) throw new BindHostError(value)
return value
}
/** Assert a native-tunnel install uses the `terminal` zone (FIX L-host-zone). Throws otherwise. */
export function assertNativeZone(zone: string | undefined): void {
if (zone !== NATIVE_ORIGIN_ZONE) {
throw new Error(
`native tunnel install requires zone="${NATIVE_ORIGIN_ZONE}" (got "${zone ?? '(default term)'}")` +
' — the base-app origin must be https://<sub>.terminal.<domain> [FIX L-host-zone]',
)
}
}
/**
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
* Sources the S0 base-app env — normalizing/gating `BIND_HOST` to loopback (S-GATE: throws on a
* non-loopback value so no install can ever emit a LAN-exposed unit) — plus the tunnel-origin
* `domain`/`zone` used to derive ALLOWED_ORIGINS. Pure/immutable (env is a parameter).
*/
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
const passthrough = Object.fromEntries(
BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
),
)
// S-GATE at env-read time: a non-loopback BIND_HOST fails closed here (before any install).
const serviceEnv: ServiceEnv = { BIND_HOST: normalizeBindHost(env.BIND_HOST), ...passthrough }
const domain = env.TUNNEL_DOMAIN
const envFile = env.AGENT_ENV_FILE
const baseAppEntry = env.BASE_APP_ENTRY
return {
env: serviceEnv,
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
...(envFile ? { envFile } : {}),
...(baseAppEntry ? { baseAppExec: ['node', baseAppEntry] } : {}),
}
}
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
}
/**
* Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
* `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
* is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
* never weakening an origin the caller already provided. Immutable.
*/
function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
const base = options.env ?? {}
const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
if (!options.domain || !cfg.subdomain) return withBind
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
}
/**
* Write + load BOTH service units for `platform`: the base-app (`node dist/server.js` + base-app
* env) and the agent (`<bin> run`, supervises frpc — no base-app env). Throws RootRefusedError if
* running as root, or BindHostError (S-GATE) BEFORE any write if the base-app BIND_HOST is not
* loopback (so a rejected install emits nothing).
*/
export async function installService(
cfg: AgentConfig,
platform: ServicePlatform,
deps: InstallDeps,
options: InstallOptions = {},
): Promise<void> {
if (deps.getuid() === 0) throw new RootRefusedError()
// Resolve (and S-GATE) the base-app env BEFORE any IO — a non-loopback BIND_HOST throws here,
// so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath()
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
if (platform === 'launchd') {
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel()))
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel()))
for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
}
return
}
const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile }
: { env: baseAppEnv }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile(
baseAppPath,
buildSystemdUnit(baseAppExec.join(' '), deps.username(), baseAppOptions, 'web-terminal base app (loopback)'),
)
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile(
agentPath,
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
)
for (const unit of [baseAppUnitName(), agentUnitName()]) {
const { cmd, args } = systemdEnableCommand(unit)
await deps.runCommand(cmd, args)
}
}
/** Unload/disable BOTH service units for `platform` (agent first, then base-app). */
export async function uninstallService(
platform: ServicePlatform,
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
): Promise<void> {
if (platform === 'launchd') {
for (const label of [agentLabel(), baseAppLabel()]) {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
await deps.runCommand(cmd, args)
}
return
}
for (const unit of [agentUnitName(), baseAppUnitName()]) {
const { cmd, args } = systemdDisableCommand(unit)
await deps.runCommand(cmd, args)
}
}

View File

@@ -0,0 +1,106 @@
/**
* 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).
*
* PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
* sorted for deterministic, immutable output.
*
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` +
* `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app
* (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which supervises
* frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`).
*/
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
export type ServiceEnv = Readonly<Record<string, string>>
/** The native-tunnel agent unit (supervises frpc). */
const AGENT_LABEL = 'com.web-terminal.agent'
/** The base-app unit (`node dist/server.js`, loopback-bound). */
const BASE_APP_LABEL = 'com.web-terminal.base-app'
export function agentLabel(): string {
return AGENT_LABEL
}
export function baseAppLabel(): string {
return BASE_APP_LABEL
}
/** Back-compat alias for the agent label. */
export function launchdLabel(): string {
return AGENT_LABEL
}
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
return `${homedir}/Library/LaunchAgents/${label}.plist`
}
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
function escapeXml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/** Build the `<key>EnvironmentVariables</key><dict>…</dict>` lines (empty array when env is empty). */
function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
if (entries.length === 0) return []
const lines = [' <key>EnvironmentVariables</key>', ' <dict>']
for (const [key, value] of entries) {
lines.push(` <key>${escapeXml(key)}</key>`)
lines.push(` <string>${escapeXml(value)}</string>`)
}
lines.push(' </dict>')
return lines
}
/** Build the `<key>ProgramArguments</key><array>…</array>` lines. */
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
const lines = [' <key>ProgramArguments</key>', ' <array>']
for (const arg of programArguments) {
lines.push(` <string>${escapeXml(arg)}</string>`)
}
lines.push(' </array>')
return lines
}
/**
* Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`);
* RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd
* `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string.
*/
export function buildLaunchdPlist(
programArguments: readonly string[],
env: ServiceEnv = {},
label: string = AGENT_LABEL,
): 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>${escapeXml(label)}</string>`,
...programArgumentsBlock(programArguments),
' <key>RunAtLoad</key>',
' <true/>',
' <key>KeepAlive</key>',
' <true/>',
...environmentVariablesBlock(env),
'</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,81 @@
/**
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
* APPENDS `https://<subdomain>.<zone>.<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").
*
* PLAN_NATIVE_TUNNEL S2: the DNS zone label is PARAMETERIZED. Relay callers keep the historical
* `term` zone (default), while native-tunnel hosts pass `terminal` so the base app trusts
* `https://<name>.terminal.<domain>`. The default is preserved so existing callers/tests are
* unaffected — the zone is opt-in per call, never hard-flipped.
*/
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),
}
/** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
export const DEFAULT_ORIGIN_ZONE = 'term'
const KEY = 'ALLOWED_ORIGINS'
/** Compose the subdomain origin the base app must trust: `https://<subdomain>.<zone>.<domain>`. */
export function subdomainOrigin(
subdomain: string,
domain: string,
zone: string = DEFAULT_ORIGIN_ZONE,
): string {
return `https://${subdomain}.${zone}.${domain}`
}
/**
* Merge `origin` into a comma-separated ALLOWED_ORIGINS value, preserving every existing origin and
* de-duplicating. Returns the merged CSV; never removes an origin. Pure/immutable.
*/
export function mergeOrigins(current: string | undefined, origin: string): string {
const origins = (current ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
if (origins.includes(origin)) return origins.join(',')
return [...origins, origin].join(',')
}
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
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
})
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. `zone` selects the DNS zone label (default
* preserves relay callers).
*/
export function ensureAllowedOrigin(
baseAppEnvPath: string,
subdomain: string,
domain: string,
fs: OriginFsDeps = defaultFs,
zone: string = DEFAULT_ORIGIN_ZONE,
): void {
const origin = subdomainOrigin(subdomain, domain, zone)
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,143 @@
/**
* 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).
*
* PLAN_NATIVE_TUNNEL S2: the unit can inject the per-host tunnel env via an `EnvironmentFile=`
* line (preferred — keeps values out of the world-readable unit) and/or inline `Environment=`
* lines from a caller-supplied env map. Inline `Environment=` is emitted after `EnvironmentFile=`
* so an explicit value overrides the file on conflict.
*
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on the full
* `ExecStart` command + `Description`, so a native-tunnel install emits TWO distinct units — the
* base-app (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which
* supervises frpc). Base-app env is routed to the base-app unit ONLY by the caller (`install.ts`).
*/
import type { ServiceEnv } from './launchd.js'
/** The native-tunnel agent unit (supervises frpc). */
const AGENT_UNIT = 'web-terminal-agent.service'
/** The base-app unit (`node dist/server.js`, loopback-bound). */
const BASE_APP_UNIT = 'web-terminal-base-app.service'
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
const FIRST_PRINTABLE_ASCII = 0x20
const DEL_CODE = 0x7f
export interface SystemdUnitOptions {
/** Inline env map → one `Environment="KEY=value"` line each (keys sorted, values escaped). */
readonly env?: ServiceEnv
/** Path referenced by a single `EnvironmentFile=` line (preferred over inline for secrets). */
readonly envFile?: string
}
export function agentUnitName(): string {
return AGENT_UNIT
}
export function baseAppUnitName(): string {
return BASE_APP_UNIT
}
/** Back-compat alias for the agent unit name. */
export function systemdUnitName(): string {
return AGENT_UNIT
}
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
return `${homedir}/.config/systemd/user/${unitName}`
}
/**
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
* newline/CR would terminate the current line and let the remainder inject arbitrary directives into
* the `[Service]` section — so such values are rejected rather than escaped.
*/
function hasControlChar(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0)
if (code === undefined) continue
if (code < FIRST_PRINTABLE_ASCII || code === DEL_CODE) return true
}
return false
}
/**
* Reject ANY field interpolated into the unit that carries a control character. EVERY value written
* into the unit (ExecStart, User, Description, EnvironmentFile path, and each Environment key+value)
* flows through this one guard, so no field can smuggle a newline that starts a new `[Service]`
* directive. Fail loud rather than escape — these fields are never legitimately multi-line.
*/
function assertNoControlChar(fieldName: string, value: string): void {
if (hasControlChar(value)) {
throw new Error(
`refusing to write systemd unit: ${fieldName} contains a control character ` +
'(newline/CR/etc.) that could corrupt the [Service] section',
)
}
}
/** Quote a systemd `Environment=` value: reject control chars (key AND value), then escape `\` and `"`. */
function quoteEnvAssignment(key: string, value: string): string {
assertNoControlChar(`Environment key '${key}'`, key)
assertNoControlChar(`Environment value for '${key}'`, value)
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return `"${key}=${escaped}"`
}
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
function environmentLines(options: SystemdUnitOptions): readonly string[] {
const lines: string[] = []
if (options.envFile) {
assertNoControlChar('EnvironmentFile path', options.envFile)
lines.push(`EnvironmentFile=${options.envFile}`)
}
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
for (const [key, value] of entries) {
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
}
return lines
}
/**
* Build a unit whose `ExecStart` is `execStart` (a full command line, e.g. `<bin> run` or
* `node dist/server.js`); Restart=on-failure; User=<user> (never root). When `options.env`/
* `options.envFile` are supplied, the per-host tunnel env is injected. Pure/immutable.
*/
export function buildSystemdUnit(
execStart: string,
user: string,
options: SystemdUnitOptions = {},
description: string = 'web-terminal host agent',
): string {
// Guard every non-env field the same way env values are guarded — a newline in ExecStart/User/
// Description would otherwise inject a `[Service]` directive on the next line.
assertNoControlChar('ExecStart', execStart)
assertNoControlChar('User', user)
assertNoControlChar('Description', description)
return [
'[Unit]',
`Description=${description}`,
'After=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${execStart}`,
'Restart=on-failure',
'RestartSec=1',
`User=${user}`,
...environmentLines(options),
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n')
}
export function systemdEnableCommand(
unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
}
export function systemdDisableCommand(
unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
}

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,200 @@
/**
* Native-tunnel frpc supervisor — TASK B4/H4 (PLAN_TUNNEL_AUTOMATION §3.2).
*
* Spawns the pinned `frpc` child with the written `frpc.toml` and keeps it running: on every exit
* it restarts the child after an exponential backoff (REUSES `transport/backoff.ts` — the same
* 1s/2s/4s…cap-30s policy as the relay reconnect). A child that ran longer than a stability window
* resets the backoff, so a healthy long-lived tunnel that finally dies restarts fast, while a
* crash-loop stays capped at 30s. All IO — spawn, sleep, clock — is injected so the loop is
* fully offline-testable (no real frpc / no real timers). No `console.log` (INV9 redacting logger).
*
* SCOPE (deferral #11): the real `frpc` binary run is pending B3 tar.gz extraction; the default
* `spawn` shells out to the provisioned binary, but tests inject a fake child so nothing executes.
*
* LOG CAPTURE (B4/H4 wiring fix): when a `logFile` is supplied, the default spawn tees the frpc
* child's stdout+stderr into that file (truncated per (re)spawn) so the health probe's log scanner
* (`readFrpcLog` → `frpcProxyStarted`) has real content to match "start proxy success" against.
* Truncate-per-spawn keeps the file bounded to the CURRENT child and free of a stale success line
* from a dead one — a freshly connected frpc always re-logs the success line.
*/
import { spawn as nodeSpawn } from 'node:child_process'
import { createWriteStream, mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { createBackoff, type BackoffPolicy, type Sleep } from './backoff.js'
import { createLogger, type Logger } from '../log/logger.js'
/** A child process seam the supervisor drives (a fake child in tests; a real frpc in prod). */
export interface FrpcChild {
/** Register a one-shot exit handler (`null` code ⇒ killed by signal or spawn error). */
onExit(cb: (code: number | null) => void): void
/** Whether the child is still running (feeds the health probe's `isFrpcAlive`). */
isAlive(): boolean
/** Request termination. */
kill(): void
}
/** Spawn a frpc child running `frpc -c <tomlPath>`. */
export type SpawnFrpc = (binPath: string, tomlPath: string) => FrpcChild
/** Injectable seams for the supervisor; unset fields default to real spawn/sleep/clock/logger. */
export interface FrpSuperviseDeps {
spawn: SpawnFrpc
backoff: BackoffPolicy
sleep: Sleep
logger: Logger
now: () => number
}
/**
* Supervisor overrides: any `FrpSuperviseDeps` seam plus an optional `logFile`. When `logFile` is
* set and no explicit `spawn` is given, the default spawn tees frpc's stdout/stderr into that file
* so the health probe can scan it. An explicit `spawn` (tests) always wins over `logFile`.
*/
export interface FrpSuperviseOptions extends Partial<FrpSuperviseDeps> {
readonly logFile?: string
}
/** Handle returned by `superviseFrpc`: stop the loop, or await its terminal exit code. */
export interface FrpSuperviseHandle {
/** Request graceful shutdown (kills the child); resolves once the loop has fully stopped. */
stop(): Promise<void>
/** Resolves with an exit code (always 0 — a stopped supervisor is a clean shutdown). */
readonly done: Promise<number>
/** True while a frpc child is currently running (for the health probe). */
isChildAlive(): boolean
}
/** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
export const STABLE_RUN_MS = 60_000
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
/**
* Default spawn (no log capture): launch the provisioned frpc binary, discarding its stdout/stderr.
* `stdio: 'ignore'` (not `'pipe'`) is deliberate — an unconsumed pipe fills its OS buffer (~64KB) and
* then BLOCKS the child. Log capture is opt-in via `createFileLoggingSpawn` (see `logFile`).
*/
const realSpawn: SpawnFrpc = (binPath, tomlPath) => {
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'ignore', 'ignore'] })
let alive = true
return {
onExit(cb: (code: number | null) => void): void {
cp.once('exit', (code) => {
alive = false
cb(code)
})
cp.once('error', () => {
alive = false
cb(null)
})
},
isAlive: () => alive,
kill: () => {
cp.kill()
},
}
}
/**
* Spawn frpc and TEE its stdout+stderr into `logFile` (truncated per spawn) so the health probe's
* `readFrpcLog` scanner has real content. A log-write failure is swallowed — persisting the log for
* the probe must never crash the supervised tunnel.
*/
export function createFileLoggingSpawn(logFile: string): SpawnFrpc {
return (binPath, tomlPath) => {
mkdirSync(dirname(logFile), { recursive: true })
const cp = nodeSpawn(binPath, ['-c', tomlPath], { stdio: ['ignore', 'pipe', 'pipe'] })
// flags:'w' truncates so the file reflects only the current child (no stale success line).
const log = createWriteStream(logFile, { flags: 'w' })
log.on('error', () => {
/* a log-write failure is non-fatal to the tunnel; the probe just sees no success line */
})
cp.stdout?.pipe(log, { end: false })
cp.stderr?.pipe(log, { end: false })
let alive = true
const closeLog = (): void => {
log.end()
}
return {
onExit(cb: (code: number | null) => void): void {
cp.once('exit', (code) => {
alive = false
closeLog()
cb(code)
})
cp.once('error', () => {
alive = false
closeLog()
cb(null)
})
},
isAlive: () => alive,
kill: () => {
cp.kill()
},
}
}
}
function resolveDeps(o?: FrpSuperviseOptions): FrpSuperviseDeps {
const defaultSpawn = o?.logFile !== undefined ? createFileLoggingSpawn(o.logFile) : realSpawn
return {
spawn: o?.spawn ?? defaultSpawn,
backoff: o?.backoff ?? createBackoff({ jitter: true }),
sleep: o?.sleep ?? realSleep,
logger: o?.logger ?? createLogger('info'),
now: o?.now ?? Date.now,
}
}
/**
* Start supervising frpc. Returns immediately with a handle; the restart loop runs in the
* background. `stop()` sets the stop flag, kills the live child, and awaits loop termination.
*/
export function superviseFrpc(
binPath: string,
tomlPath: string,
overrides?: FrpSuperviseOptions,
): FrpSuperviseHandle {
const { spawn, backoff, sleep, logger, now } = resolveDeps(overrides)
let stopped = false
let child: FrpcChild | null = null
/** Spawn one frpc child and resolve when it exits. */
function runOnce(): Promise<number | null> {
return new Promise<number | null>((resolve) => {
const c = spawn(binPath, tomlPath)
child = c
c.onExit((code) => {
child = null
resolve(code)
})
})
}
async function loop(): Promise<number> {
while (!stopped) {
const startedAt = now()
const exitCode = await runOnce()
if (stopped) break
if (now() - startedAt >= STABLE_RUN_MS) backoff.reset()
const delayMs = backoff.nextDelayMs()
logger.log('warn', 'frpc exited — restarting after backoff', { exitCode, delayMs })
await sleep(delayMs)
}
return 0
}
const done = loop()
return {
async stop(): Promise<void> {
stopped = true
child?.kill()
await done
},
done,
isChildAlive: () => child?.isAlive() ?? false,
}
}

View File

@@ -0,0 +1,155 @@
/**
* Native-tunnel frpc.toml writer — TASK B2h (PLAN_TUNNEL_AUTOMATION §3.2 / PLAN_NATIVE_TUNNEL §4).
*
* Emits the frp v0.61 TOML that a host's `frpc` presents to the VPS:
* - dials `serverAddr:443`, SNI-routed by nginx `ssl_preread` to frps :7000;
* - control-channel mTLS (frp-client cert/key + trusted CA) + shared token;
* - one `[[proxies]]` of `type = "http"` exposing the loopback base app as `<sub>.terminal...`.
*
* SUPERSEDES the retired v0.8 `[common]/tls_enable` shape in `frpScaffold.ts` (do not reuse that
* grammar — this is the native-tunnel writer).
*
* ANTI-SSRF (hard invariant): `localIP` MUST be loopback. frpc forwards ONLY to the local base app,
* never an arbitrary target — a non-loopback `localIP` would turn the tunnel into an open proxy.
* All inputs are validated at this boundary (fail-fast, clear messages); no `console.log`.
*/
const DEFAULT_SERVER_ADDR = '8.138.1.192'
const SERVER_PORT = 443
const TLS_SERVER_NAME = 'frp.terminal.yaojia.wang'
const DEFAULT_LOCAL_IP = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
const MAX_LABEL_LEN = 63
const DEL_CHAR_CODE = 0x7f
const FIRST_PRINTABLE_CODE = 0x20
/** Loopback forms accepted for `localIP` (anti-SSRF allowlist). */
const LOOPBACK_IPS: readonly string[] = ['127.0.0.1', '::1', 'localhost']
/** RFC 1035 DNS label: 1-63 chars, alnum, internal hyphens only (no leading/trailing hyphen). */
const LABEL_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
export interface NativeFrpcOptions {
/** Subdomain label -> reachable at `https://<subdomain>.terminal.yaojia.wang`. Label-safe. */
readonly subdomain: string
/** Loopback port of the local base app frpc forwards to (1-65535). */
readonly localPort: number
/** Shared frps auth token (kept out of logs; the file itself is chmod 600 by the caller). */
readonly authToken: string
/** Keystore path to this host's frp-client leaf cert (control-channel mTLS). */
readonly certFile: string
/** Keystore path to the matching private key. */
readonly keyFile: string
/** Keystore path to the CA that signed the frps control cert (server verification). */
readonly trustedCaFile: string
/** VPS address; defaults to the deployed relay `8.138.1.192`. */
readonly serverAddr?: string
/** Local forward target IP; MUST be loopback. Defaults to `127.0.0.1`. */
readonly localIP?: string
}
/** A validation failure at the frpc.toml boundary. */
export class FrpcTomlError extends Error {
constructor(message: string) {
super(message)
this.name = 'FrpcTomlError'
}
}
/** True iff `value` contains an ASCII control character (C0 range or DEL). */
function hasControlChar(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i)
if (code < FIRST_PRINTABLE_CODE || code === DEL_CHAR_CODE) return true
}
return false
}
/** Non-empty, control-char-free path/secret at the boundary. */
function assertPresent(field: string, value: string): void {
if (typeof value !== 'string' || value.length === 0) {
throw new FrpcTomlError(`${field} is required`)
}
if (hasControlChar(value)) {
throw new FrpcTomlError(`${field} contains control characters`)
}
}
/** Escape a value for a TOML basic (double-quoted) string: backslash + quote (Windows paths). */
function tomlBasicString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function assertLoopback(localIP: string): void {
if (!LOOPBACK_IPS.includes(localIP)) {
throw new FrpcTomlError(
`localIP "${localIP}" is not loopback — frpc must forward only to the local base app (anti-SSRF)`,
)
}
}
function assertSubdomain(subdomain: string): void {
if (typeof subdomain !== 'string' || subdomain.length === 0) {
throw new FrpcTomlError('subdomain is required')
}
if (subdomain.length > MAX_LABEL_LEN || !LABEL_RE.test(subdomain)) {
throw new FrpcTomlError(
`subdomain "${subdomain}" is not a valid DNS label (alnum + internal hyphens, 1-63 chars)`,
)
}
}
function assertLocalPort(localPort: number): void {
if (!Number.isInteger(localPort) || localPort < MIN_PORT || localPort > MAX_PORT) {
throw new FrpcTomlError(
`localPort must be an integer in ${MIN_PORT}-${MAX_PORT}, got ${localPort}`,
)
}
}
/**
* Build a validated frp v0.61 `frpc.toml` for the native mTLS tunnel. Throws `FrpcTomlError` on any
* invalid input (fail-fast). The returned string is the full config file contents.
*/
export function buildNativeFrpcToml(opts: NativeFrpcOptions): string {
assertSubdomain(opts.subdomain)
assertLocalPort(opts.localPort)
assertPresent('authToken', opts.authToken)
assertPresent('certFile', opts.certFile)
assertPresent('keyFile', opts.keyFile)
assertPresent('trustedCaFile', opts.trustedCaFile)
const serverAddr = opts.serverAddr ?? DEFAULT_SERVER_ADDR
assertPresent('serverAddr', serverAddr)
const localIP = opts.localIP ?? DEFAULT_LOCAL_IP
assertLoopback(localIP)
const lines: readonly string[] = [
`serverAddr = "${tomlBasicString(serverAddr)}"`,
`serverPort = ${SERVER_PORT}`,
'',
'auth.method = "token"',
`auth.token = "${tomlBasicString(opts.authToken)}"`,
'',
'# control-channel mTLS: present this host frp-client cert; verify the frps control cert',
'transport.tls.enable = true',
`transport.tls.serverName = "${TLS_SERVER_NAME}"`,
'transport.tls.disableCustomTLSFirstByte = true',
`transport.tls.certFile = "${tomlBasicString(opts.certFile)}"`,
`transport.tls.keyFile = "${tomlBasicString(opts.keyFile)}"`,
`transport.tls.trustedCaFile = "${tomlBasicString(opts.trustedCaFile)}"`,
'',
'loginFailExit = false',
'',
'[[proxies]]',
`name = "${opts.subdomain}"`,
'type = "http"',
`localIP = "${localIP}"`,
`localPort = ${opts.localPort}`,
`subdomain = "${opts.subdomain}"`,
'',
]
return lines.join('\n')
}

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,152 @@
/**
* Long-running tunnel supervisor — PLAN_RELAY_PHASE1 C2. Ports the PROVEN cafeDemo assembly
* (dialRelay → holdTunnel → createStreamRouter → dialLoopback, plus heartbeat) into a supervised
* loop that survives disconnects: exponential backoff reconnect (T10 policy), heartbeat liveness
* (T9), and GOAWAY/revocation-aware teardown (T14, INV12 — a revoked host NEVER reconnects).
*
* All IO is injectable via `RunTunnelDeps` so the loop is unit-testable with fakes (see cafeDemo);
* the two-arg `runTunnel(cfg, ks)` default path wires the real `ws` sockets. INV2 is preserved: the
* router splices OPAQUE bytes (identityTransform) — no terminal parsing happens here.
*/
import { WebSocket } from 'ws'
import type { AgentConfig } from '../config/agentConfig.js'
import type { Keystore } from '../keys/keystore.js'
import { createLogger, type Logger } from '../log/logger.js'
import { createRevocationState, applyGoAway } from '../lifecycle/revocation.js'
import { dialRelay, type TlsWsConstructor } from './dial.js'
import { dialLoopback, type DialLoopback, type WsConstructor } from './loopback.js'
import { holdTunnel, type Tunnel } from './tunnel.js'
import { createStreamRouter, identityTransform } from './streamRouter.js'
import { createHeartbeat } from './heartbeat.js'
import { createBackoff, reconnectLoop, type BackoffPolicy, type Sleep } from './backoff.js'
import type { TimerLike, WsLike } from './seams.js'
/** Handle returned by `runTunnel`: stop the supervisor, or await its terminal exit code. */
export interface TunnelHandle {
/** Request graceful shutdown; resolves once the supervisor loop has fully stopped. */
stop(): Promise<void>
/** Resolves with a process exit code when the loop ends (stopped or host revoked ⇒ 0). */
readonly done: Promise<number>
}
/** Injectable seams for the supervisor. All optional; unset fields default to real `ws` IO. */
export interface RunTunnelDeps {
connectRelay(): Promise<WsLike>
dialLoopback: DialLoopback
logger: Logger
timer: TimerLike
sleep: Sleep
backoff: BackoffPolicy
}
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>),
}
const realSleep: Sleep = (ms) => new Promise<void>((r) => setTimeout(r, ms))
function resolveDeps(cfg: AgentConfig, ks: Keystore, o?: Partial<RunTunnelDeps>): RunTunnelDeps {
return {
connectRelay:
o?.connectRelay ?? (() => dialRelay(cfg, ks, { Ctor: WebSocket as unknown as TlsWsConstructor })),
dialLoopback: o?.dialLoopback ?? dialLoopback(cfg.localTargetUrl, WebSocket as unknown as WsConstructor),
logger: o?.logger ?? createLogger('info'),
timer: o?.timer ?? realTimer,
sleep: o?.sleep ?? realSleep,
backoff: o?.backoff ?? createBackoff({ jitter: true }),
}
}
/**
* Start the supervised tunnel. Returns immediately with a handle; the reconnect loop runs in the
* background. Never awaits the first connection (an unreachable relay would otherwise hang the
* caller with no way to `stop()`).
*/
export async function runTunnel(
cfg: AgentConfig,
ks: Keystore,
overrides?: Partial<RunTunnelDeps>,
): Promise<TunnelHandle> {
const { connectRelay, dialLoopback: dialLb, logger, timer, sleep, backoff } = resolveDeps(cfg, ks, overrides)
let stopped = false
let currentTunnel: Tunnel | null = null
let currentSocket: WsLike | null = null
// INV12: revocation tears the live tunnel down immediately and suppresses all reconnects.
const revocation = createRevocationState(() => currentTunnel?.close())
const shouldStop = (): boolean => stopped || revocation.isRevoked()
async function dialTunnel(): Promise<Tunnel> {
const socket = await connectRelay()
currentSocket = socket
return holdTunnel(socket)
}
/** Run ONE tunnel session; resolves when this session ends (dead heartbeat / close / GOAWAY). */
function runSession(tunnel: Tunnel, socket: WsLike): Promise<void> {
return new Promise<void>((resolve) => {
const router = createStreamRouter(cfg, tunnel, dialLb, identityTransform, logger)
const heartbeat = createHeartbeat(tunnel, { timer })
tunnel.dispatchTo(router, heartbeat)
let settled = false
const endSession = (): void => {
if (settled) return
settled = true
heartbeat.stop()
resolve()
}
heartbeat.onDead(() => {
logger.log('warn', 'heartbeat missed — tunnel presumed down, will reconnect')
tunnel.close()
endSession()
})
socket.on('close', () => endSession())
socket.on('error', () => {
tunnel.close()
endSession()
})
tunnel.onGoAway((reason) => {
const action = applyGoAway(reason, revocation) // 'revoked' ⇒ no reconnect (INV12)
logger.log('info', 'received GOAWAY', { action })
tunnel.close()
endSession()
})
heartbeat.start()
})
}
async function supervise(): Promise<number> {
while (!shouldStop()) {
const tunnel = await reconnectLoop(dialTunnel, backoff, shouldStop, sleep)
if (tunnel === null || currentSocket === null) break
if (shouldStop()) {
// stop()/revoke raced with the in-flight dial — discard the fresh tunnel.
tunnel.close()
break
}
currentTunnel = tunnel
logger.log('info', 'relay tunnel established')
await runSession(tunnel, currentSocket)
currentTunnel = null
currentSocket = null
}
return 0
}
const done = supervise()
return {
async stop(): Promise<void> {
stopped = true
currentTunnel?.close()
await done
},
done,
}
}

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,63 @@
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://[::1]:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
})
it('REGRESSION: rejects a crafted suffixed-hostname target (anti-SSRF bypass)', () => {
// Hostname, not a loopback literal — the outbound dial would DNS-resolve and connect out.
expect(isLoopbackWsUrl('ws://127.0.0.1.attacker.example.com:3000/x')).toBe(false)
expect(isLoopbackWsUrl('ws://127.evil.net:3000')).toBe(false)
expect(() =>
AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://127.0.0.1.attacker.example.com:3000' }),
).toThrow()
})
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'],
)
})
})

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

@@ -0,0 +1,253 @@
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'
import type { InstallOptions } 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 fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
return {
alg,
publicKey: new Uint8Array(32),
enrollFpr: 'fpr',
sign: () => new Uint8Array(64),
exportPrivatePkcs8Pem: () => 'PEM',
privateKeyObject: () => ({}) as never,
}
}
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
return {
saveIdentity: vi.fn(),
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
saveCert: vi.fn(),
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
saveContentSecret: vi.fn(),
loadContentSecret: () => null,
}
}
const NATIVE_OPTIONS: InstallOptions = {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
domain: 'yaojia.wang',
zone: 'terminal',
}
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
const out: string[] = []
const d: CliDeps = {
loadConfig: () => CFG,
openKeystore: () => fakeKeystore(enrolled),
generateIdentity: () => fakeIdentity('ed25519'),
generateP256Identity: () => fakeIdentity('p256'),
redeem: async (): Promise<EnrollResult> => ({
hostId: 'h-1',
subdomain: 'host-42',
cert: 'C',
caChain: 'CA',
hostContentSecret: new Uint8Array([1]),
}),
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
provisionFrpc: async () => '/opt/frpc',
writeFrpcConfig: vi.fn(),
nativeConfigExists: () => false,
runTunnel: async () => 0,
superviseFrpc: async () => 0,
resolveInstallOptions: () => NATIVE_OPTIONS,
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 — legacy relay pair (no --install)', () => {
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')
})
})
describe('runCli — native pair --install onboard (B5)', () => {
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
const calls: string[] = []
const enrolledIds: AgentIdentity[] = []
const generateP256Identity = vi.fn(() => {
calls.push('keygen')
return fakeIdentity('p256')
})
const enrollNative = vi.fn(async (_cfg: AgentConfig, _code: string, id: AgentIdentity) => {
calls.push('enroll')
enrolledIds.push(id)
return { hostId: 'h-1', subdomain: 'host-42' }
})
const provisionFrpc = vi.fn(async () => {
calls.push('provision')
return '/opt/frpc'
})
const writeFrpcConfig = vi.fn(() => {
calls.push('writeConfig')
})
const installService = vi.fn(async () => {
calls.push('install')
})
const { d, out } = deps({
generateP256Identity,
enrollNative,
provisionFrpc,
writeFrpcConfig,
installService,
})
const code = await runCli(parseArgs(['pair', 'ABCD-1234', '--install']), d)
expect(code).toBe(0)
expect(calls).toEqual(['keygen', 'enroll', 'provision', 'writeConfig', 'install'])
// CSR is built from a P-256 identity (FIX H-host-2)
expect(enrolledIds[0]!.alg).toBe('p256')
// enroll seam received the pairing code
expect(enrollNative).toHaveBeenCalledWith(CFG, 'ABCD-1234', expect.anything(), expect.anything())
// both units installed with the resolved options (env routed to base-app by installService)
expect(installService).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
// frpc.toml written for the returned subdomain
expect(writeFrpcConfig).toHaveBeenCalledWith(CFG, 'host-42')
// prints the final tunnel URL
expect(out.join('\n')).toContain('https://host-42.terminal.yaojia.wang')
})
it('does NOT use the legacy relay redeem path on --install', async () => {
const redeem = vi.fn()
const { d } = deps({ redeem: redeem as unknown as CliDeps['redeem'] })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(redeem).not.toHaveBeenCalled()
})
it('saves the freshly generated P-256 identity before enrolling', async () => {
const ks = fakeKeystore(false)
const { d } = deps({ openKeystore: () => ks })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(ks.saveIdentity).toHaveBeenCalled()
})
it('rejects a native install whose zone is not `terminal` (FIX L-host-zone)', async () => {
const { d } = deps({
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'term' }),
})
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toThrow(/terminal/)
})
it('rejects a native install with no TUNNEL_DOMAIN (cannot form the origin)', async () => {
const { d } = deps({ resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }) })
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toBeInstanceOf(CliUsageError)
})
it('prints no key/cert material during --install (INV9)', async () => {
const { d, out } = deps()
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
const joined = out.join('\n')
expect(joined).not.toContain('PEM')
expect(joined).not.toContain('CA')
})
})
describe('runCli — install / run / status', () => {
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => NATIVE_OPTIONS, installService: install })
const code = await runCli(parseArgs(['install']), d)
expect(code).toBe(0)
expect(install).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
})
it('run before pairing fails fast', async () => {
const { d } = deps({}, false)
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
})
it('legacy run (Ed25519 identity, no frpc.toml) drives runTunnel — not frpc supervision', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps(
{ runTunnel, superviseFrpc, nativeConfigExists: () => false },
true, // enrolled with the default Ed25519 identity
)
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(runTunnel).toHaveBeenCalledWith(CFG, expect.anything())
expect(superviseFrpc).not.toHaveBeenCalled()
})
it('native run (P-256 identity + frpc.toml) supervises frpc — not the legacy relay', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => true,
runTunnel,
superviseFrpc,
})
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(superviseFrpc).toHaveBeenCalledWith(CFG, expect.anything())
expect(runTunnel).not.toHaveBeenCalled()
})
it('native identity but missing frpc.toml falls back to the legacy relay path', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => false,
runTunnel,
superviseFrpc,
})
await runCli({ command: 'run', flags: {} }, d)
expect(runTunnel).toHaveBeenCalled()
expect(superviseFrpc).not.toHaveBeenCalled()
})
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')
})
})

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

@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { buildCsr } from '../src/enroll/csr.js'
// --- minimal DER reader (test-only) — walks the PKCS#10 outer SEQUENCE into its 3 children -------
interface Tlv {
readonly tag: number
/** the full tag+length+value bytes (what was signed, for the CertificationRequestInfo). */
readonly tlv: Uint8Array
readonly content: Uint8Array
}
function readTlv(buf: Uint8Array, off: number): { node: Tlv; next: number } {
const tag = buf[off]!
let i = off + 1
const first = buf[i]!
let len: number
if (first < 0x80) {
len = first
i += 1
} else {
const n = first & 0x7f
len = 0
for (let k = 0; k < n; k++) len = (len << 8) | buf[i + 1 + k]!
i += 1 + n
}
return { node: { tag, tlv: buf.subarray(off, i + len), content: buf.subarray(i, i + len) }, next: i + len }
}
function pemToDer(pem: string): Uint8Array {
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
return new Uint8Array(Buffer.from(b64, 'base64'))
}
/** The three children of the PKCS#10 outer SEQUENCE: [certificationRequestInfo, sigAlg, signature]. */
function csrChildren(pem: string): readonly Tlv[] {
const der = pemToDer(pem)
const outer = readTlv(der, 0).node
const children: Tlv[] = []
let p = 0
while (p < outer.content.length) {
const { node, next } = readTlv(outer.content, p)
children.push(node)
p = next
}
return children
}
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')
})
})
describe('P-256 PKCS#10 CSR (FIX H-host-2)', () => {
it('emits a PEM CERTIFICATE REQUEST for a P-256 identity', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
expect(csr).not.toContain('PRIVATE KEY')
})
it('round-trips as a valid PKCS#10: signatureAlgorithm is ecdsa-with-SHA256', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
const [, sigAlg] = csrChildren(csr)
// sigAlg = SEQUENCE { OID 1.2.840.10045.4.3.2 } (no parameters, RFC 5758 §3.2)
const oidTlv = readTlv(sigAlg!.content, 0).node
expect(Array.from(oidTlv.content)).toEqual([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
// no parameters: the sigAlg SEQUENCE holds ONLY the OID.
expect(oidTlv.tlv.length).toBe(sigAlg!.content.length)
})
it('self-signature verifies over the CertificationRequestInfo (verifyCsrPoPEc semantics)', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo, , sigVal] = csrChildren(csr)
// signatureValue BIT STRING content = 0x00 (unused bits) || DER ECDSA-Sig-Value.
expect(sigVal!.tag).toBe(0x03)
const signature = sigVal!.content.subarray(1)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
// Verify over the EXACT CertificationRequestInfo bytes that buildCsr signed.
expect(verify('sha256', reqInfo!.tlv, pub, signature)).toBe(true)
// Tampering with the signed body breaks PoP.
const tampered = Uint8Array.from(reqInfo!.tlv)
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff
expect(verify('sha256', tampered, pub, signature)).toBe(false)
})
it('embeds the identity EC SPKI verbatim as the CSR subjectPublicKeyInfo', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo] = csrChildren(csr)
// requestInfo = SEQUENCE { version, name, spki, [0] attributes }; the spki is the 3rd child.
const inner = reqInfo!.content
const version = readTlv(inner, 0)
const name = readTlv(inner, version.next)
const spki = readTlv(inner, name.next).node
expect(Buffer.from(spki.tlv).equals(Buffer.from(id.publicKey))).toBe(true)
})
})

103
agent/test/deps.test.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* B4/H4 wiring test — closes the frpc-log capture loop that `HealthReport.healthy` depends on.
*
* Regression guarded: nothing used to WRITE `<stateDir>/frpc.log`, so `readFrpcLog` always returned
* '' and `frpcProxyStarted` was permanently false — health could never be true. This exercises the
* real production path end-to-end: `createFileLoggingSpawn` (the spawn `superviseNative` injects)
* tees a child's stdout into the exact file `readFrpcLog` scans, and `frpcProxyStarted` detects the
* "start proxy success" line. Writer path and reader path share `frpcLogPath`, so they can't diverge.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createFileLoggingSpawn, type FrpcChild } from '../src/transport/frpSupervise.js'
import { frpcLogPath, readFrpcLog } from '../src/cli/deps.js'
import { frpcProxyStarted } from '../src/health/probe.js'
const SUCCESS_LINE = '[web-terminal] start proxy success'
/** Poll `predicate` until true or `timeoutMs` elapses (real IO flush is async). */
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return true
await new Promise((r) => setTimeout(r, 25))
}
return predicate()
}
/** Write an executable fake `frpc` (node shebang) that repeatedly prints the success line to stdout. */
function writeFakeFrpc(dir: string): string {
const bin = join(dir, 'fake-frpc.mjs')
const script =
`#!${process.execPath}\n` +
`process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)})\n` +
`setInterval(() => process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)}), 100)\n`
writeFileSync(bin, script, { mode: 0o755 })
return bin
}
describe('B4/H4 frpc-log capture wiring (createFileLoggingSpawn ↔ readFrpcLog)', () => {
const dirs: string[] = []
let child: FrpcChild | null = null
afterEach(() => {
child?.kill()
child = null
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
it('tees the frpc child stdout into the file readFrpcLog scans → proxyStarted becomes true', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Before any child runs, the log is empty and the proxy is not started.
expect(readFrpcLog(dir)).toBe('')
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
// Spawn via the SAME factory superviseNative injects, pointed at the SAME path it reads.
const bin = writeFakeFrpc(dir)
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
const detected = await waitFor(() => frpcProxyStarted(readFrpcLog(dir)))
expect(detected).toBe(true)
expect(readFrpcLog(dir)).toContain('start proxy success')
})
it('createFileLoggingSpawn truncates a stale log so a dead childs success line is not reused', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Simulate a leftover log from a previous (now dead) frpc that had succeeded.
writeFileSync(frpcLogPath(dir), `${SUCCESS_LINE}\n`)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(true)
// A fresh spawn whose child never prints the line must NOT keep reporting the stale success.
const bin = join(dir, 'silent-frpc.mjs')
writeFileSync(bin, `#!${process.execPath}\nsetInterval(() => {}, 100)\n`, { mode: 0o755 })
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
// Truncate-on-spawn clears the stale line; the silent child adds none.
const cleared = await waitFor(() => readFrpcLog(dir) === '')
expect(cleared).toBe(true)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
})
})
describe('frpcLogPath / readFrpcLog (deterministic)', () => {
it('frpcLogPath joins frpc.log under the state dir', () => {
expect(frpcLogPath('/state/x')).toBe(join('/state/x', 'frpc.log'))
})
it('readFrpcLog returns "" when the log file does not exist', () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
try {
expect(readFrpcLog(dir)).toBe('')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

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,148 @@
import { describe, expect, it, vi } from 'vitest'
import { createLogger } from '../src/log/logger.js'
import { createBackoff } from '../src/transport/backoff.js'
import {
STABLE_RUN_MS,
superviseFrpc,
type FrpcChild,
type SpawnFrpc,
} from '../src/transport/frpSupervise.js'
const silentLogger = createLogger('error', () => {})
/**
* A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies,
* firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock).
* exit/kill fire the handler at most once.
*/
function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } {
let onExit: ((code: number | null) => void) | null = null
let alive = true
const state = { killed: false }
const fire = (code: number | null): void => {
if (!alive) return
alive = false
onExit?.(code)
}
return {
child: {
onExit: (cb) => {
onExit = cb
},
isAlive: () => alive,
kill: () => {
state.killed = true
fire(null)
},
},
exit: (code) => fire(code),
get killed() {
return state.killed
},
}
}
describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
it('spawns the frpc binary with -c <toml> via the child seam', async () => {
const spawn: SpawnFrpc = vi.fn(() => makeChild().child)
superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml')
})
it('restarts the child on exit, backing off 1s → 2s → 4s', async () => {
const children = [makeChild(), makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
// now() fixed so no run counts as "stable" ⇒ backoff monotonically increases.
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => 1000,
})
// Crash three times; each crash schedules the next spawn after the growing backoff.
for (let i = 0; i < 3; i += 1) {
children[i]!.exit(1)
await Promise.resolve()
await Promise.resolve()
}
expect(sleeps).toEqual([1000, 2000, 4000])
await handle.stop()
})
it('resets the backoff after a run that stayed up past the stability window', async () => {
const children = [makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
let clock = 0
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => clock,
})
// First run crashes instantly ⇒ backoff 1s.
children[0]!.exit(1)
await Promise.resolve()
await Promise.resolve()
// Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s).
clock += STABLE_RUN_MS + 1
children[1]!.exit(1)
await Promise.resolve()
await Promise.resolve()
expect(sleeps).toEqual([1000, 1000])
await handle.stop()
})
it('stop() halts the loop and kills the live child; done resolves 0', async () => {
const c = makeChild()
const spawn: SpawnFrpc = () => c.child
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(handle.isChildAlive()).toBe(true)
// stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks.
const stopping = handle.stop()
c.exit(null)
await expect(stopping).resolves.toBeUndefined()
await expect(handle.done).resolves.toBe(0)
expect(c.killed).toBe(true)
})
it('does not restart after stop (no spawn past shutdown)', async () => {
const first = makeChild()
let n = 0
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
const stopping = handle.stop()
first.exit(0)
await stopping
expect(spawn).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,416 @@
import { describe, expect, it } from 'vitest'
import { createHash } from 'node:crypto'
import { gzipSync } from 'node:zlib'
import {
detectFrpcPlatform,
FRPC_PLATFORMS,
FRPC_RELEASES,
provisionFrpc,
type FrpcPlatform,
type FrpcReleaseRef,
type ProvisionFrpcDeps,
} from '../src/provision/frpcBinary.js'
import {
extractTarFileByBasename,
extractFrpcBinary,
TarExtractError,
} from '../src/provision/untar.js'
function sha256Hex(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
// ---------------------------------------------------------------------------
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
// + regular files), with zero network access.
// ---------------------------------------------------------------------------
const TAR_BLOCK = 512
const TYPE_FILE = '0'
const TYPE_DIR = '5'
interface TarEntrySpec {
readonly name: string
readonly content: Uint8Array
readonly typeflag?: string
}
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
const s = value.toString(8).padStart(len - 1, '0')
block.set(new TextEncoder().encode(s), offset)
block[offset + len - 1] = 0 // NUL terminator
}
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
const block = new Uint8Array(TAR_BLOCK)
const enc = new TextEncoder()
block.set(enc.encode(name).subarray(0, 100), 0)
writeOctalField(block, 100, 8, 0o755) // mode
writeOctalField(block, 108, 8, 0) // uid
writeOctalField(block, 116, 8, 0) // gid
writeOctalField(block, 124, 12, size) // size
writeOctalField(block, 136, 12, 0) // mtime
block[156] = typeflag.charCodeAt(0)
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
block[263] = 0x30 // version "00"
block[264] = 0x30
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
for (let i = 148; i < 156; i++) block[i] = 0x20
let sum = 0
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
block[154] = 0
block[155] = 0x20
return block
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const total = parts.reduce((n, p) => n + p.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const p of parts) {
out.set(p, off)
off += p.length
}
return out
}
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
const parts: Uint8Array[] = []
for (const e of entries) {
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
parts.push(e.content)
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
if (pad > 0) parts.push(new Uint8Array(pad))
}
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
return concatBytes(parts)
}
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
return new Uint8Array(gzipSync(makeTar(entries)))
}
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
return makeTarGz([
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: `${prefix}/frps`, content: FRPS_BYTES },
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
])
}
interface FakeFsState {
writes: Map<string, Uint8Array>
renames: Array<{ from: string; to: string }>
removed: string[]
chmods: Array<{ path: string; mode: number }>
mkdirs: string[]
}
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
deps: ProvisionFrpcDeps
state: FakeFsState
fetchedUrls: string[]
} {
const state: FakeFsState = {
writes: new Map(),
renames: [],
removed: [],
chmods: [],
mkdirs: [],
}
const fetchedUrls: string[] = []
const deps: ProvisionFrpcDeps = {
fetch: async (url) => {
fetchedUrls.push(url)
const bytes = bytesByUrl[url]
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
return bytes
},
fs: {
mkdir: async (dir) => {
state.mkdirs.push(dir)
},
writeFile: async (path, data) => {
state.writes.set(path, data)
},
rename: async (from, to) => {
state.renames.push({ from, to })
const data = state.writes.get(from)
if (data) {
state.writes.delete(from)
state.writes.set(to, data)
}
},
chmod: async (path, mode) => {
state.chmods.push({ path, mode })
},
rm: async (path) => {
state.removed.push(path)
state.writes.delete(path)
},
},
}
return { deps, state, fetchedUrls }
}
function releaseOverride(
platform: FrpcPlatform,
ref: FrpcReleaseRef,
): Record<FrpcPlatform, FrpcReleaseRef> {
return { ...FRPC_RELEASES, [platform]: ref }
}
const BIN_DIR = '/opt/wt/bin'
const BIN_PATH = '/opt/wt/bin/frpc'
describe('detectFrpcPlatform (B3)', () => {
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
})
it('returns null for unsupported platform/arch', () => {
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
})
})
describe('FRPC_RELEASES pinning', () => {
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
expect([...FRPC_PLATFORMS].sort()).toEqual([
'darwin-amd64',
'darwin-arm64',
'linux-amd64',
'linux-arm64',
])
for (const platform of FRPC_PLATFORMS) {
const ref = FRPC_RELEASES[platform]
expect(ref.url.startsWith('https://')).toBe(true)
expect(ref.url.endsWith('.tar.gz')).toBe(true)
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
}
})
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
for (const platform of FRPC_PLATFORMS) {
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
}
})
})
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
it('returns the bytes of the first regular file whose basename matches', () => {
const tar = makeTar([
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
})
it('does NOT match a directory entry that shares the basename', () => {
const tar = makeTar([
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
})
it('throws when no matching file entry exists', () => {
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
})
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
})
it('REJECTS a matching entry with an absolute path name', () => {
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
})
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
})
})
describe('extractFrpcBinary (gunzip + extract)', () => {
it('gunzips then extracts the inner frpc bytes', () => {
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
})
it('throws on non-gzip input', () => {
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
})
})
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
const result = await provisionFrpc(
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
deps,
)
expect(fetchedUrls).toEqual([url])
expect(result.binPath).toBe(BIN_PATH)
expect(result.version).toBe('0.61.1')
expect(result.platform).toBe('darwin-arm64')
// atomic place: renamed into the final path, made executable
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
const placed = state.writes.get(BIN_PATH)
expect(placed).toEqual(FRPC_BYTES)
expect(placed).not.toEqual(archive)
})
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/frp.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
})
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-linux-amd64.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: 'f'.repeat(64), // deliberately wrong
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
})
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/bad.tar.gz'
const releases = releaseOverride('linux-arm64', {
version: '0.61.1',
url,
sha256: '0'.repeat(64),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
})
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
])
const url = 'https://example.test/no-frpc.tar.gz'
const releases = releaseOverride('darwin-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|frpc|tar/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
})
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
const archive = makeTarGz([
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/evil.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
// nothing placed, and every write that ever happened stayed inside binDir
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.renames).toEqual([])
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
})
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
const url = 'https://example.test/corrupt.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(garbage),
})
const { deps, state } = makeFakeDeps({ [url]: garbage })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|gzip|tar/i)
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0)
})
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
const { deps, fetchedUrls } = makeFakeDeps({})
await expect(
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
).rejects.toThrow(/unsupported|platform/i)
expect(fetchedUrls).toEqual([])
})
})

100
agent/test/frpcToml.test.ts Normal file
View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
const BASE: NativeFrpcOptions = {
subdomain: 'alice',
localPort: 3000,
authToken: 'super-secret-token',
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
}
describe('buildNativeFrpcToml (B2h)', () => {
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('serverAddr = "8.138.1.192"')
expect(toml).toContain('serverPort = 443')
expect(toml).toContain('transport.tls.enable = true')
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
expect(toml).toContain('auth.method = "token"')
expect(toml).toContain('auth.token = "super-secret-token"')
})
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('[[proxies]]')
expect(toml).toContain('type = "http"')
expect(toml).toContain('subdomain = "alice"')
expect(toml).toContain('localIP = "127.0.0.1"')
expect(toml).toContain('localPort = 3000')
// the proxy block comes after the server/tls preamble
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
})
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).not.toContain('[common]')
expect(toml).not.toContain('tls_enable')
})
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
'serverAddr = "10.9.8.7"',
)
})
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
})
it('accepts the three explicit loopback localIP forms', () => {
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
}
})
it('rejects an empty or non-label-safe subdomain', () => {
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
})
it('rejects an out-of-range or non-integer localPort', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
})
it('rejects missing keystore paths and an empty auth token', () => {
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
})
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
expect(toml).toContain(
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
)
})
it('rejects control characters in paths/token (TOML-injection guard)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
})
})

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,147 @@
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,
epoch: 'test-epoch',
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)
})
})

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

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createPublicKey, verify } from 'node:crypto'
import {
computeEnrollFpr,
generateIdentity,
generateP256Identity,
identityFromPrivatePem,
p256IdentityFromPrivatePem,
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/)
})
it('keeps the Ed25519 alg tag (no P-256 regression)', () => {
expect(generateIdentity().alg).toBe('ed25519')
})
})
describe('P-256 AgentIdentity (FIX H-host-2 — native frp-client key)', () => {
it('generates distinct EC P-256 keypairs tagged alg=p256', () => {
const a = generateP256Identity()
const b = generateP256Identity()
expect(a.alg).toBe('p256')
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
// publicKey is the EC SubjectPublicKeyInfo DER (outer SEQUENCE), importable as a public key.
expect(a.publicKey[0]).toBe(0x30)
const pub = createPublicKey({ key: Buffer.from(a.publicKey), format: 'der', type: 'spki' })
expect(pub.asymmetricKeyType).toBe('ec')
expect(pub.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
})
it('sign produces a DER ECDSA signature that verifies under SHA-256', () => {
const id = generateP256Identity()
const msg = new TextEncoder().encode('certificationRequestInfo-bytes')
const sig = id.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
expect(verify('sha256', new TextEncoder().encode('tampered'), pub, sig)).toBe(false)
})
it('enrollFpr is deterministic base64url(SHA-256(spki))', () => {
const id = generateP256Identity()
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
})
it('reloads the same P-256 identity from PEM (keystore load path)', () => {
const id = generateP256Identity()
const pem = id.exportPrivatePkcs8Pem()
const reloaded = p256IdentityFromPrivatePem(pem)
expect(reloaded.alg).toBe('p256')
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
})
it('security: P-256 identity exposes no raw private-key getter', () => {
const id = generateP256Identity()
expect('privateKey' in id).toBe(false)
// the PEM export is the only serialization surface; it is a PRIVATE key PEM (0600 keystore only).
expect(id.exportPrivatePkcs8Pem()).toContain('PRIVATE KEY')
})
})

420
agent/test/install.test.ts Normal file
View File

@@ -0,0 +1,420 @@
import { describe, expect, it } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
BindHostError,
RootRefusedError,
assertNativeZone,
buildInstallOptions,
detectPlatform,
installService,
normalizeBindHost,
uninstallService,
type InstallDeps,
} from '../src/service/install.js'
import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js'
import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.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',
}
type FakeDeps = InstallDeps & {
writes: Array<[string, string]>
runs: Array<[string, readonly string[]]>
}
function deps(uid = 501): FakeDeps {
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',
}
}
/** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */
function unitWith(d: FakeDeps, needle: string): string {
const hit = d.writes.find(([path]) => path.includes(needle))
if (!hit) throw new Error(`no unit written whose path contains '${needle}'`)
return hit[1]
}
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 — least privilege (T17)', () => {
it('refuses to install as root (negative, least privilege)', async () => {
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
})
it('root refusal emits nothing', async () => {
const d = deps(0)
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
})
describe('installService — two distinct units (FIX M-host-2service)', () => {
it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
// exactly two units
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
expect(baseApp).toContain('ExecStart=')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('web-terminal-agent run')
// both least-privilege + restart-on-failure
for (const unit of [baseApp, agent]) {
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
}
// both enabled
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppLabel())
const agent = unitWith(d, agentLabel())
expect(agent).toContain('<string>run</string>')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('<string>run</string>')
for (const plist of [baseApp, agent]) {
expect(plist).toContain('<key>KeepAlive</key>')
}
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="PORT=3000"')
// the agent unit must NOT carry the base-app env
expect(agent).not.toContain('BIND_HOST')
expect(agent).not.toContain('PORT=3000')
})
it('uninstall tears down BOTH units (launchd unload)', async () => {
const d = deps()
await uninstallService('launchd', d)
const targets = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
})
it('uninstall tears down BOTH units (systemd disable)', async () => {
const d = deps()
await uninstallService('systemd', d)
const units = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(units).toContain(baseAppUnitName())
expect(units).toContain(agentUnitName())
})
})
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
it('normalizeBindHost defaults an absent value to loopback', () => {
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
expect(normalizeBindHost('')).toBe('127.0.0.1')
})
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
expect(normalizeBindHost('::1')).toBe('::1')
expect(normalizeBindHost('localhost')).toBe('localhost')
})
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
})
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
BindHostError,
)
})
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
})
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
const d = deps()
await expect(
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
const d = deps()
await expect(
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
})
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).not.toContain('0.0.0.0')
})
})
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
const options = buildInstallOptions({})
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
})
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
const options = buildInstallOptions({
BIND_HOST: '127.0.0.2',
PORT: '3000',
SHELL_PATH: '/bin/zsh',
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
expect(options.env).toEqual({
BIND_HOST: '127.0.0.2',
PORT: '3000',
SHELL_PATH: '/bin/zsh',
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
expect(options.env).toEqual({
BIND_HOST: '127.0.0.1',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
it('omits unset/empty passthrough vars', () => {
const options = buildInstallOptions({ PORT: '', SHELL_PATH: '/bin/bash' })
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SHELL_PATH: '/bin/bash' })
})
it('derives domain + default `terminal` zone from TUNNEL_DOMAIN', () => {
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang' })
expect(options.domain).toBe('yaojia.wang')
expect(options.zone).toBe('terminal')
})
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
const options = buildInstallOptions({
TUNNEL_DOMAIN: 'yaojia.wang',
TUNNEL_ZONE: 'term',
AGENT_ENV_FILE: '/etc/wt.env',
})
expect(options.zone).toBe('term')
expect(options.envFile).toBe('/etc/wt.env')
})
it('omits domain/zone when no TUNNEL_DOMAIN is set', () => {
const options = buildInstallOptions({})
expect(options.domain).toBeUndefined()
expect(options.zone).toBeUndefined()
})
})
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
expect(() => assertNativeZone('terminal')).not.toThrow()
expect(() => assertNativeZone('term')).toThrow(/terminal/)
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
})
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
})
})
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>BIND_HOST</key>')
expect(baseApp).toContain('<string>127.0.0.1</string>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(baseApp).toContain('<key>PORT</key>')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(baseApp).toContain('Environment="PORT=3000"')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
envFile: '/etc/web-terminal.env',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
})
})
describe('unit writers — escaping & control-char hardening', () => {
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://a',
PORT: '3000',
})
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
})
it('launchd: no env → no EnvironmentVariables block', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
expect(plist).not.toContain('EnvironmentVariables')
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
})
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
).toThrow(/control character/)
})
it('systemd: rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
/control character/,
)
})
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
).toThrow(/control character/)
})
it('AG2: rejects a newline in the User field', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
/control character/,
)
})
it('AG2: rejects a newline in the Description field', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
).toThrow(/control character/)
})
it('AG2: rejects a newline in the EnvironmentFile path', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
).toThrow(/control character/)
})
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
).toThrow(/control character/)
})
it('systemd: default (no env) omits Environment lines', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice')
expect(unit).not.toContain('Environment')
})
})

117
agent/test/keystore.test.ts Normal file
View File

@@ -0,0 +1,117 @@
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 { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } 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)
})
it('keeps the Ed25519 identity byte-identical across save/load (no P-256 regression)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateIdentity()
ks.saveIdentity(id)
const reloaded = ks.loadIdentity()
expect(reloaded!.alg).toBe('ed25519')
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
})
it('FIX H-host-2: round-trips a P-256 frp-client identity (alg + usable signing key)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateP256Identity()
ks.saveIdentity(id)
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
const reloaded = ks.loadIdentity()
expect(reloaded).not.toBeNull()
// loadIdentity() branched on the stored key's alg discriminant → P-256, not Ed25519.
expect(reloaded!.alg).toBe('p256')
// EC SubjectPublicKeyInfo DER (outer SEQUENCE) preserved exactly.
expect(reloaded!.publicKey[0]).toBe(0x30)
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
// The reloaded key still signs: a DER ECDSA signature that verifies under the original pubkey.
const msg = new TextEncoder().encode('csr-bytes')
const sig = reloaded!.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
})
it('AG1: rejects a stored EC key on a non-P256 curve (e.g. secp384r1) with a clear error', () => {
const dir = freshDir()
const ks = openKeystore(dir)
// Plant a valid PKCS#8 EC key on the WRONG curve directly at the key path — `ec` alone must not
// be mistaken for P-256; loadIdentity must assert the named curve and fail closed.
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'secp384r1' })
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
writeFileSync(join(dir, 'agent.key.pem'), pem, { mode: 0o600 })
expect(() => ks.loadIdentity()).toThrow(KeystoreError)
expect(() => ks.loadIdentity()).toThrow(/prime256v1|P-256|curve/)
})
})

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,39 @@
import { describe, expect, it } from 'vitest'
import { isLoopbackHostLiteral } from '../src/net/loopbackLiteral.js'
describe('isLoopbackHostLiteral — strict loopback-literal check (FIX C-host-1 / anti-SSRF)', () => {
it('accepts exact loopback literals', () => {
expect(isLoopbackHostLiteral('localhost')).toBe(true)
expect(isLoopbackHostLiteral('::1')).toBe(true)
expect(isLoopbackHostLiteral('[::1]')).toBe(true)
})
it('accepts any well-formed IPv4 in 127.0.0.0/8', () => {
expect(isLoopbackHostLiteral('127.0.0.1')).toBe(true)
expect(isLoopbackHostLiteral('127.0.0.2')).toBe(true)
expect(isLoopbackHostLiteral('127.5.5.5')).toBe(true)
expect(isLoopbackHostLiteral('127.255.255.255')).toBe(true)
})
it('rejects non-loopback IPs and wildcards', () => {
expect(isLoopbackHostLiteral('0.0.0.0')).toBe(false)
expect(isLoopbackHostLiteral('192.168.1.10')).toBe(false)
expect(isLoopbackHostLiteral('10.0.0.5')).toBe(false)
expect(isLoopbackHostLiteral('::')).toBe(false)
})
it('REJECTS suffixed hostnames that merely start with 127. (the S-GATE bypass)', () => {
expect(isLoopbackHostLiteral('127.0.0.1.attacker.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.evil.net')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1.evil.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1x')).toBe(false)
})
it('rejects malformed / non-dotted-quad partials and out-of-range octets', () => {
expect(isLoopbackHostLiteral('127.1')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.256')).toBe(false)
expect(isLoopbackHostLiteral('0127.0.0.1')).toBe(false)
expect(isLoopbackHostLiteral('')).toBe(false)
expect(isLoopbackHostLiteral('127')).toBe(false)
})
})

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_ORIGIN_ZONE,
ensureAllowedOrigin,
mergeOrigins,
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')
})
})
describe('zone parameterization (PLAN_NATIVE_TUNNEL S2)', () => {
it('defaults to the historical `term` zone', () => {
expect(DEFAULT_ORIGIN_ZONE).toBe('term')
expect(subdomainOrigin('t1', 'yaojia.wang')).toBe('https://t1.term.yaojia.wang')
})
it('composes the `terminal` zone for native-tunnel hosts', () => {
expect(subdomainOrigin('t1', 'yaojia.wang', 'terminal')).toBe('https://t1.terminal.yaojia.wang')
})
it('ensureAllowedOrigin writes the caller-selected zone', () => {
const { fs, get } = memFs('PORT=3000\n')
ensureAllowedOrigin(PATH, 't1', 'yaojia.wang', fs, 'terminal')
expect(get()).toContain('ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang')
expect(get()).not.toContain('.term.yaojia.wang')
})
})
describe('mergeOrigins (PLAN_NATIVE_TUNNEL S2)', () => {
it('appends to an empty/undefined value', () => {
expect(mergeOrigins(undefined, 'https://a.example.com')).toBe('https://a.example.com')
expect(mergeOrigins('', 'https://a.example.com')).toBe('https://a.example.com')
})
it('de-duplicates an origin already present', () => {
expect(mergeOrigins('https://a.example.com', 'https://a.example.com')).toBe('https://a.example.com')
})
it('appends a new origin, trimming whitespace, preserving existing ones', () => {
expect(mergeOrigins(' https://a.example.com , https://b.example.com ', 'https://c.example.com')).toBe(
'https://a.example.com,https://b.example.com,https://c.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 })
})
})

216
agent/test/probe.test.ts Normal file
View File

@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_CERT_RENEW_WINDOW_MS,
certIsFresh,
frpcProxyStarted,
probeLoopbackBaseApp,
renderHealthStatus,
runHealthProbe,
startHealthMonitor,
type HealthProbeSeams,
type HealthReport,
type IntervalTimer,
} from '../src/health/probe.js'
/** All-passing seams; each test overrides exactly one to prove sub-check independence. */
function healthySeams(over: Partial<HealthProbeSeams> = {}): HealthProbeSeams {
return {
isFrpcAlive: () => true,
probeBaseApp: async () => true,
readFrpcLog: () => 'proxy [web-terminal] start proxy success',
certNotAfter: () => new Date('2026-08-01T00:00:00Z'),
now: () => new Date('2026-07-08T00:00:00Z'),
...over,
}
}
describe('frpcProxyStarted (log scan)', () => {
it('detects the frpc start-proxy-success line', () => {
expect(frpcProxyStarted('2026/07/08 [I] [proxy_manager] start proxy success')).toBe(true)
})
it('is false before frpc reports success', () => {
expect(frpcProxyStarted('login to server success\nstart proxy ...')).toBe(false)
expect(frpcProxyStarted('')).toBe(false)
})
})
describe('certIsFresh (near-expiry check)', () => {
const now = new Date('2026-07-08T00:00:00Z')
it('is fresh when notAfter is beyond the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS + 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(true)
})
it('is NOT fresh when notAfter is inside the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS - 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
it('treats a missing cert (null notAfter) as not fresh', () => {
expect(certIsFresh(null, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
})
describe('probeLoopbackBaseApp (loopback-only)', () => {
it('targets 127.0.0.1:PORT and returns true on an ok response', async () => {
const fetchImpl = vi.fn(async (url: string) => ({ ok: url.includes('127.0.0.1:3000') }))
await expect(probeLoopbackBaseApp(3000, fetchImpl)).resolves.toBe(true)
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3000/')
})
it('returns false on a non-ok response', async () => {
await expect(probeLoopbackBaseApp(3000, async () => ({ ok: false }))).resolves.toBe(false)
})
it('swallows a rejected fetch (a probe never throws)', async () => {
await expect(
probeLoopbackBaseApp(3000, async () => {
throw new Error('ECONNREFUSED')
}),
).resolves.toBe(false)
})
it('rejects an out-of-range port without fetching', async () => {
const fetchImpl = vi.fn(async () => ({ ok: true }))
await expect(probeLoopbackBaseApp(0, fetchImpl)).resolves.toBe(false)
await expect(probeLoopbackBaseApp(70000, fetchImpl)).resolves.toBe(false)
expect(fetchImpl).not.toHaveBeenCalled()
})
})
describe('runHealthProbe (aggregate verdict)', () => {
it('is healthy when all four sub-checks pass', async () => {
const report = await runHealthProbe(healthySeams())
expect(report).toEqual<HealthReport>({
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
})
})
it('is unhealthy if frpc is dead', async () => {
const report = await runHealthProbe(healthySeams({ isFrpcAlive: () => false }))
expect(report.frpcAlive).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the base app is unreachable', async () => {
const report = await runHealthProbe(healthySeams({ probeBaseApp: async () => false }))
expect(report.baseAppReachable).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the proxy never started', async () => {
const report = await runHealthProbe(healthySeams({ readFrpcLog: () => 'connecting...' }))
expect(report.proxyStarted).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the cert is near expiry', async () => {
const report = await runHealthProbe(
healthySeams({
certNotAfter: () => new Date('2026-07-08T01:00:00Z'), // 1h out, inside 8h window
}),
)
expect(report.certFresh).toBe(false)
expect(report.healthy).toBe(false)
})
})
describe('renderHealthStatus (INV9 — non-secret only)', () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
it('prints subdomain, host id, expiry date, and flags', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: alice')
expect(joined).toContain('host_id: h-1')
expect(joined).toContain('cert_expiry: 2026-08-01T00:00:00.000Z')
expect(joined).toContain('healthy: true')
})
it('leaks NO key/cert/token/CSR material', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).not.toMatch(/PRIVATE KEY|BEGIN CERTIFICATE|BEGIN CERTIFICATE REQUEST/)
expect(joined.toLowerCase()).not.toMatch(/token|secret|csr|pem/)
})
it('renders (none)/(unknown) placeholders when identifiers are absent', () => {
const lines = renderHealthStatus({ subdomain: null, hostId: null, certNotAfter: null }, report)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: (none)')
expect(joined).toContain('cert_expiry: (unknown)')
})
})
describe('startHealthMonitor (periodic)', () => {
function fakeTimer(): { timer: IntervalTimer; fire: () => void; cleared: boolean } {
let cb: (() => void) | null = null
const state = { cleared: false }
return {
timer: {
setInterval: (fn) => {
cb = fn
return 1
},
clearInterval: () => {
state.cleared = true
},
},
fire: () => cb?.(),
get cleared() {
return state.cleared
},
}
}
it('runs the probe on each tick and reports it', async () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
const probe = vi.fn(async () => report)
const seen: HealthReport[] = []
const ft = fakeTimer()
const monitor = startHealthMonitor(probe, (r) => seen.push(r), { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(probe).toHaveBeenCalledTimes(1)
expect(seen).toEqual([report])
monitor.stop()
expect(ft.cleared).toBe(true)
})
it('swallows a rejected probe (monitor never crashes)', async () => {
const ft = fakeTimer()
const onReport = vi.fn()
startHealthMonitor(async () => Promise.reject(new Error('boom')), onReport, { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(onReport).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,113 @@
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. The derived key is a tag that DEPENDS on every ReplayKeyParams field — crucially on
* `epoch` (F6), so two sealer generations for the same (secret, sessionId, alg) yield DIFFERENT keys.
* The tag leads with `epoch` so key[0] also varies per generation; ciphertext = plaintext XOR key[0]
* (a UUID's first char is a hex digit 0x300x66 → always nonzero, so the marker is always hidden).
*/
function fakeCrypto(): ReplayCrypto & { derivations: ReplayKeyParams[]; keys: Uint8Array[] } {
const derivations: ReplayKeyParams[] = []
const keys: Uint8Array[] = []
return {
derivations,
keys,
deriveContentKey(params: ReplayKeyParams): AeadKey {
derivations.push(params)
const tag = new TextEncoder().encode(
`${params.epoch}:${Buffer.from(params.hostContentSecret).toString('hex')}:${params.sessionId}:${params.alg}`,
)
keys.push(tag)
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('folds the exposed per-generation epoch into K_content, deriving it exactly once', () => {
const c = fakeCrypto()
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
expect(c.derivations).toHaveLength(1)
expect(c.derivations[0]!.epoch).toBe(sealer.epoch)
expect(sealer.epoch).toMatch(/^[0-9a-f-]{36}$/) // randomUUID shape
})
it('recoverable WITHIN one generation: re-deriving with the exposed epoch yields the same key', () => {
const c = fakeCrypto()
const sealer = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// The browser re-derives from the SAME (secret, sessionId, alg, epoch) carried with the ring buffer.
const browser = fakeCrypto()
const browserKey = browser.deriveContentKey({
hostContentSecret: SECRET,
sessionId: 'sess-1',
alg: 'aes-256-gcm',
epoch: sealer.epoch,
}) as unknown as Uint8Array
expect(Buffer.from(browserKey).equals(Buffer.from(c.keys[0]!))).toBe(true)
})
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('F6 regression: two generations for the SAME (secret, sessionId, alg) get DIFFERENT epochs → DIFFERENT keys, so seq=0 never collides', () => {
const c = fakeCrypto()
const gen1 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
const gen2 = createReplaySealer(SECRET, 'sess-1', 'aes-256-gcm', c)
// Fresh epoch per generation…
expect(gen2.epoch).not.toBe(gen1.epoch)
// …therefore distinct K_content even though (secret, sessionId, alg) are identical…
expect(Buffer.from(c.keys[1]!).equals(Buffer.from(c.keys[0]!))).toBe(false)
// …so the seq=0 seal of generation 2 uses a DIFFERENT key than the seq=0 seal of generation 1
// (this is exactly the (key, nonce) reuse F6 prevents — same nonce, but a fresh key).
const s1 = gen1.seal(new Uint8Array([0x41, 0x42, 0x43]))
const s2 = gen2.seal(new Uint8Array([0x41, 0x42, 0x43]))
expect(s1.seq).toBe(0n)
expect(s2.seq).toBe(0n)
expect(s1.nonce).toEqual(s2.nonce) // same deterministic nonce (seq=0)…
})
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 (0x11 is below the 0x300x66 hex-digit epoch prefix)
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,128 @@
import { describe, expect, it, vi } from 'vitest'
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
import type { AgentConfig } from '../src/config/agentConfig.js'
import type { Keystore } from '../src/keys/keystore.js'
import { createBackoff } from '../src/transport/backoff.js'
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
/**
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
*/
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 KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
function 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,
),
)
}
function emitGoAwayRevoked(upstream: FakeWs): void {
const payload = encodeGoaway(0, 'revoked')
upstream.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
payload,
),
)
}
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
return {
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
sleep: async () => {},
backoff: createBackoff(),
...over,
}
}
describe('runTunnel supervisor (C2)', () => {
it('splices bytes both ways through the loopback', async () => {
const upstream = new FakeWs()
const loopback = new FakeWs()
const connectRelay = vi.fn(async () => upstream)
const dialLoopback = vi.fn(async () => loopback)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
await flush()
emitOpen(upstream, OPEN)
await flush()
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
const last = decodeMuxFrame(upstream.sent.at(-1)!)
expect(last.header.type).toBe('data')
expect([...last.payload]).toEqual([79, 75])
await handle.stop()
expect(await handle.done).toBe(0)
})
it('reconnects after the tunnel dies', async () => {
const sockets = [new FakeWs(), new FakeWs()]
let i = 0
const connectRelay = vi.fn(async () => sockets[i++]!)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
expect(connectRelay).toHaveBeenCalledTimes(1)
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
await flush()
expect(connectRelay).toHaveBeenCalledTimes(2)
await handle.stop()
})
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
let socket: FakeWs | undefined
const wrapped = vi.fn(async () => {
socket = new FakeWs()
return socket
})
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
await flush()
expect(wrapped).toHaveBeenCalledTimes(1)
emitGoAwayRevoked(socket!)
await flush()
expect(await handle.done).toBe(0)
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
expect(connectRelay).not.toHaveBeenCalled()
})
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
await handle.stop()
expect(await handle.done).toBe(0)
expect(connectRelay).toHaveBeenCalledTimes(1)
})
})

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'],
},
},
})

42
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# Gradle
.gradle/
build/
**/build/
!gradle/wrapper/gradle-wrapper.jar
!src/**/build/
# Gradle caches / config-cache
.gradle/configuration-cache/
# IDE — IntelliJ IDEA / Android Studio
.idea/
*.iml
*.ipr
*.iws
captures/
.navigation/
# Local machine config (never commit)
local.properties
# Android build outputs (relevant once the SDK-gated modules are enabled)
*.apk
*.aab
*.ap_
*.dex
release/
proguard/
# Secrets — never commit
google-services.json
**/google-services.json
*.keystore
*.jks
*.p12
service-account*.json
# OS cruft
.DS_Store
# Kover / test reports
**/kover/

View File

@@ -0,0 +1,76 @@
# Android client — device-QA checklist (A36 / plan §7)
Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator,
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests,
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping.
## Deploy artifacts to provide first (not built here)
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
absence with `runCatching`, so the app runs without it; push just won't register).
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
omitted it — applying it without the json fails the build).
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the
`FCM_*` env group (service-account key path + project id).
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
- [ ] `attach → attached → output` round-trip timing.
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
- [ ] kill via `DELETE /live-sessions/:id` (swipe-to-kill) removes the row (404 = already-gone = success).
- [ ] `POST /hook/decision` resolves a held gate (from a push Allow/Deny).
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
## A35 — one macrobenchmark / Espresso happy path
- [ ] pair → attach → type → approve, on a real device.
## Terminal (A16/A17/A21)
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
WebView fallback ONLY if fidelity diverges — not expected).
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap.
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs
web/iOS on the same device sizes (R5).
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
generation bump → fresh emulator replays.
- [ ] key-bar above the IME (soft keyboard never pops on a key-bar tap); hardware chords; DECCKM arrows
emit `ESC O A` under app-cursor mode (vim/htop).
- [ ] `FLAG_SECURE` + privacy cover blanks the recents thumbnail on `ON_STOP`.
- [ ] off-main chunked append does not ANR on a multi-MB replay.
## Push (A30/A31 · R1/S2)
- [ ] **S2 spike:** data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one
Xiaomi/Huawei/Samsung) under Doze / OEM battery managers / force-stop — quantify loss, drive the
"delivery not guaranteed while backgrounded" user-facing copy.
- [ ] **Deny** from the lock screen (BroadcastReceiver, no app open, expedited POST).
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
success; cancel/error/no-enrolled-auth → no POST (fail-safe).
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals
on rotation / new host / removal.
## Pairing / cert / storage (A19/A27/A11/A12)
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry.
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
bypass); public host explicit-ack.
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
## Nav / deep links / adaptive (A32/A26/A29)
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
right destination; invalid UUID ignored.
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens.
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md)
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't
carry the sessionId; the gate is still visible in the terminal). MEDIUM.
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView`
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to
`:app` and delete the shim.

416
android/PROGRESS_ANDROID.md Normal file
View File

@@ -0,0 +1,416 @@
# Android Client — Progress (this-session working log)
> **Why this file exists (temporary):** `docs/PROGRESS_LOG.md` (the canonical cross-session
> memory) was being **concurrently edited by another live session** (the tunnel-automation
> workstream on branch `feat/tunnel-automation`) while this Android work ran on the SAME working
> tree. To avoid a read-modify-write clobber of that session's log, the Android orchestrator
> records progress here instead. **FOLD THESE ENTRIES INTO `docs/PROGRESS_LOG.md` once the
> concurrent session is done** (they belong under the `🤖 ANDROID CLIENT` section).
>
> **Git status note:** nothing below is committed. The Android lane (`android/**`,
> `src/push/fcm.ts`, `test/push-fcm.test.ts`, `src/server.ts` FCM wiring, `package.json`
> google-auth-library) is file-disjoint from the tunnel work, but the shared `.git`/index means
> **no `git add -A`** — commit the Android lane by explicit path once branch strategy is settled.
Plan: [`../docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md). 36 tasks, waves AW0AW6.
Prior state (commit `4ea8f78`/`4fe1981`): AW0 + AW1 pure-Kotlin foundation (218 tests) + Android
SDK/AGP 9.2.1 toolchain proven.
---
## [x] Wave R1 — A7 + A14 + A33 (DONE, orchestrator-verified 2026-07-08)
One implementation Workflow (3 TDD builders in parallel) → 6-agent adversarial cross-review (2
independent lenses/task) → fix Workflow (must-fixes + regression tests) → adversarial re-verify →
**orchestrator independent unified gate** (not just agent self-report).
**Unified verification (measured):**
- `cd android && ./gradlew test`**BUILD SUCCESSFUL, 250 tests, 0 failures** (wire-protocol 47 /
session-core 75 / api-client 69 / client-tls 27 / test-support 15 / **transport-okhttp 17**).
- `npx vitest run test/`**54 files, 1533 tests, 0 failures** (incl. new `test/push-fcm.test.ts`
60 tests; no regression of the existing server suite).
- `npm run typecheck` (tsc main + web) → clean.
### [x] A7 `:transport-okhttp` — OkHttp WS + REST transport (pure JVM; MockWebServer-tested)
- Files: `transport-okhttp/src/main/.../transport/{OkHttpClientFactory, OkHttpTermTransport,
OkHttpWebSocketConnection, OkHttpHttpTransport}.kt` + 3 test files. Implements frozen
`TermTransport`/`PingableTermTransport`/`HttpTransport` verbatim (contracts unmodified).
- WS: `connect()` stamps `Origin = endpoint.originHeader` on the upgrade (CSWSH; asserted
byte-equal); `frames` = `channelFlow` draining an unlimited listener mailbox — clean close →
normal completion, `onFailure` → error (distinguishable); `send`→`webSocket.send`,
`close`→`close(1000)` (**detach, never kill**). REST: non-2xx RETURNED, transport-failure THROWN,
headers verbatim (no self-added Origin). Shared `OkHttpClient` `.cache(null)` (§8) + default
system trust; mTLS via a LOCAL `ClientIdentityProvider` fun-interface seam (module depends only
on `:wire-protocol`, no `:client-tls` edge).
- **Cross-review fixes (regression-tested):** (1) HIGH — handshake socket leak on mid-dial cancel:
`openConnection` now tears down the just-created WS on any throwable (incl. `CancellationException`)
via an idempotent `cancel()` (`webSocket.cancel()`+`finish(null)`) before rethrowing — proven
red→green by revert. (2) HIGH — the redundant transport-level 16 MiB frame cap
(`TransportFrameTooLargeException`) was **deleted**: it was unreachable from `:session-core` (which
may depend only on `:wire-protocol`), and `SessionEngine` already self-measures frames and emits
`REPLAY_TOO_LARGE` — the single authoritative classifier. (3) MEDIUM — pump rethrows
`CancellationException` (defensive; kept as a contract lock).
### [x] A14 `SessionEngine` — pure lifecycle state machine (`:session-core`, runTest virtual time)
- Files: `session-core/src/main/.../session/SessionEngine.kt` + `SessionEngineTest.kt` (15 tests).
Composes ReconnectMachine/PingScheduler/GateTracker/AwayDigest over an injected `TermTransport` +
dispatcher/TimeSource; NO OkHttp/Android imports.
- Verified: attach-first ordering, adopt-server-id, connect-now-on-foreground, **close≠kill**,
oversized-replay→terminal `REPLAY_TOO_LARGE`, gate-decision epoch drop, backoff ladder
1→2→4→8→16→30, ping 25s/2-miss — all under virtual time via the fakes.
- **Cross-review fixes (regression-tested):** (1) HIGH (found independently by BOTH reviewers) —
`close()` during the dial/attach window failed to detach the freshly-opened connection: engine now
re-checks the `closed` flag after dial and after attach, closing + returning terminal with no
further emits (2 new tests: close-during-dial, close-during-attach). (4) MEDIUM — gate double-send:
`decideGate()` now locally retires the held gate after a successful send so a second same-epoch tap
is dropped (new test). (2) MEDIUM — `started` flip moved onto the confined dispatcher (invariant
#4). (3) MEDIUM — suspend-call `runCatching` replaced with cancel-rethrowing `ignoringNonCancel`.
- **KNOWN follow-up routed to A15 (AW2):** the engine does not close its live WS when its *scope* is
cancelled (only on explicit `close()`). Intended teardown is explicit `engine.close()` (per §6.6),
so **the `RetainedSessionHolder` MUST call `engine.close()` before cancelling the engine scope**;
additionally consider a `finally`-close in the engine for defense-in-depth. Non-blocking for R1
(server PTY survives either way; only affects prompt-vs-TCP-timeout detach).
### [x] A33 server FCM — the ONE additive server change (§4.5)
- Files: NEW `src/push/fcm.ts` (`NotifyService` impl behind a seam) + `POST/DELETE /push/fcm-token`
route + `initFcm`/`normalizeFcmToken` wiring in `src/server.ts` (combined via
`combineNotifyServices` beside web-push/APNs — zero new event paths) + `FCM_*` env
(all-or-disabled) + `test/push-fcm.test.ts` (60 tests). Added `google-auth-library@^10` to
`package.json` deps (R7 decision).
- Data-only high-priority messages; payload minimized to `sessionId`/`cls`/`token` (no
notification block, no cwd/command); loose FCM-token validator; token/key never logged; disabled
cleanly when `FCM_*` incomplete.
- **Cross-review fix (regression-tested):** HIGH — removed `validateStatus:()=>true`, which had
silently defeated google-auth-library's built-in 401/403 refresh-and-retry (the whole reason R7
chose the lib). Now wraps `client.request` in try/catch, reading `{status,body}` from
`GaxiosError.response`; a 401 goes through the lib's refresh before surfacing; a no-response
transport error is a send failure (token never falsely pruned). 3 new tests (401→refresh→200,
404/UNREGISTERED→prune, no-response→keep).
**Not run here (deferred per plan §7):** instrumented/device tests (no emulator installed) and the
S2 FCM real-handset delivery spike (needs ≥2 physical devices under Doze/OEM battery managers).
---
## [~] Wave R2 — A13 (done) · A11 + A12 (building) · S1 → moved to head of AW3
### [x] A13 `:app` Compose baseline (DONE, orchestrator-verified 2026-07-08)
First real full Android app module — establishes the UI-stack version matrix every later Android
task reuses. Workflow: TDD builder → kotlin + design cross-review.
- Files: `app/build.gradle.kts`, `AndroidManifest.xml`, `WebTermApp.kt` (@HiltAndroidApp),
`MainActivity.kt` (@AndroidEntryPoint), `di/AppModule.kt` (minimal Hilt), `designsystem/{DesignSpec,
Tokens,Typography,Theme,Primitives}.kt`, `res/{values,xml}/*`, `DesignSpecTest.kt` (5 JVM tests).
- **Version matrix PROVEN** (`:app:assembleDebug` → 32.7 MB APK, `:app:testDebugUnitTest` green):
AGP 9.2.1 · Kotlin 2.3.21 · compose-compiler plugin 2.3.21 · Compose BOM 2025.11.01 (material3
1.4.0, ui 1.9.5, material3.adaptive 1.2.0) · Hilt 2.60.1 via KSP 2.3.9 · **compileSdk 36**
(installed `platforms;android-36`; SDK-37 unavailable — cmdline-tools too old), minSdk 29,
targetSdk 35. Design system mirrors iOS DS 1:1 (amber-gold #E3A64A/#C9892F, semantic status
colors, 8pt scale, tabular mono numerals, dark-first, fixed terminal-canvas #100F0D/#ECE9E3/gold).
- **Cross-review fix (orchestrator applied + re-verified assemble green):** both reviewers flagged
the primitives (StatusBadge/TelemetryChip/WebTermCard) hardcoding `WebTermColors.dark.*` → would
render wrong in light theme. Routed through `MaterialTheme.colorScheme.{onSurfaceVariant,
surfaceVariant,outline}` (Theme.kt already maps those). Also fixed preview `.dp` literals →
`Spacing.*`. Docs synced: `android/README.md` + `settings.gradle.kts` recipe + `[[android-build-env]]`
memory now say compileSdk 36 / android-36.
- Deferred (per §7): instrumented/Compose-UI tests (no emulator). Follow-up for AW3: add a
`Motion.gated()` helper (reduce-motion) to Tokens.kt when the first animation lands (LocalReduceMotion
+ DesignSpec.MOTION_* already present); register a ContentObserver on ANIMATOR_DURATION_SCALE then.
### [x] A11 `:client-tls-android` — ClientTLS framework half (DONE, orchestrator-verified)
Catalog pre-staged (tink 1.15.0, datastore-preferences 1.1.1, androidx-test); enabled in settings.
Build workflow → security+kotlin cross-review → fix workflow → adversarial security re-verify →
orchestrator applied 3 more fixes the re-verify caught + independent compile gate.
- Files: `client-tls-android/src/main/.../tlsandroid/{AndroidKeyStoreImporter, TinkCertStore(+CertStore
iface), ReReadingX509KeyManager, IdentityRepository(+ClientSslMaterial), StoredIdentityMetadata}.kt`
+ androidTest `{Fixtures, AndroidKeyStoreImporterTest, IdentityRepositoryTest}.kt`.
- ONE key home = AndroidKeyStore (non-exportable, per-handshake re-reading `X509KeyManager`); Tink AEAD
encrypts ONLY the cert-chain+metadata blob; NO `.p12`/passphrase persisted; validate-before-persist;
`connectionPool.evictAll()` on rotate/remove. Exposes `IdentityRepository`/`ClientSslMaterial` as the
seam A15 bridges to `:transport-okhttp`'s `ClientIdentityProvider` (NO `:transport-okhttp` dep).
- **Security must-fix (rotation safety) — resolved via single-commit + adversarial follow-through:**
the re-verify caught the fixer's first attempt still lost the prior identity. Final design:
**ping-pong staging slot → one atomic durable `SharedPreferences.commit()` live-pointer flip**
(`StoredIdentityMetadata.keyStoreAlias`). Any failure before the flip → prior identity fully live;
after → new identity fully live; stores never diverge. Orchestrator then fixed 3 residual bugs the
security re-verify empirically proved: **(1 HIGH)** `currentLive()` used `liveOverride?.value ?:
initialIdentity` which collapsed `Box(null)` (explicitly removed) into the stale cached identity →
a *removed* cert stayed "live" and could be presented with a dangling key; now resolves via Box
presence. **(3)** `TinkCertStore.save()/clear()` switched `apply()`→`commit()` (durable + throws on
failure) so a crash-window can't leave the pointer naming a just-deleted key (both-identities-lost).
**(2)** widened staging-key cleanup to cover the key-readback/encode steps. Added instrumented
regression `remove_afterStartupTouch_reportsNoIdentity_notStaleCached`.
- Verified (orchestrator): `./gradlew :client-tls-android:assembleDebug :client-tls-android:compileDebugAndroidTestKotlin`
→ BUILD SUCCESSFUL (main + instrumented compile). All AndroidKeyStore/Tink tests run on device (§7).
### [x] A12 `:host-registry` — Host/HostStore + DataStore + LastSessionStore (DONE, both reviewers approved)
- Files: `host-registry/src/main/.../hostregistry/{Host, HostStore(+immutable transforms),
InMemoryHostStore, DataStoreHostStore, LastSessionStore, DataStoreLastSessionStore, HostCodec}.kt`
+ tests (JVM unit: HostStoreTransforms/InMemoryHostStore/HostCodec/InMemoryLastSessionStore) + androidTest.
- Immutable HostStore (upsert/remove return new lists, position-preserved, unknown-id no-op);
DataStore read-modify-write; HostEndpoint re-validated on load; LastSessionStore set-on-adopted /
clear-on-exited. **Orchestrator fix:** `DataStoreLastSessionStore` now guards the persisted id with
the frozen v4 `Validation.isValidSessionId` (was `UUID.fromString` format-only).
- Verified (orchestrator): `./gradlew :host-registry:assembleDebug :host-registry:testDebugUnitTest`
→ BUILD SUCCESSFUL, **17 JVM unit tests green**; DataStore-backed tests are androidTest (device QA).
### S1 renderer spike — relocated to the head of AW3 (it gates A16, the XL renderer task).
**Wave R2 complete.** Android modules now: 6 pure-JVM (250 tests) + `:app` + `:host-registry` +
`:client-tls-android` (framework, assemble+unit-verified here; instrumented deferred to device QA).
---
## [x] Wave AW2 — A15 `:app` wiring + DI FREEZE (DONE, orchestrator-verified 2026-07-09)
The convergence/contract task that gates all of AW4. Build → arch+coroutine cross-review (BOTH blocked)
→ fix (6 must-fixes) → arch+coroutine re-verify (caught 1 more real bug) → residual fix → orchestrator
added the final test lock + independent gate. `./gradlew :app:assembleDebug :app:testDebugUnitTest
:session-core:test` all green (EventBus 6 tests, RetainedSessionHolder 4, DesignSpec 5, SessionEngine 15).
- Files: `app/src/main/.../wiring/{EventBus, TerminalSessionController, RetainedSessionHolder,
AppEnvironment, ColdStartPolicy, ApiClientFactory, SessionEngineFactory}.kt` + `di/{NetworkModule,
TlsModule, StorageModule, SessionModule}.kt` (replaced A13's placeholder AppModule) + tests.
- **Frozen contracts (AW4 depends on these):** per-session `EventBus` (NOT @Singleton) with two typed
sub-streams `outputBytes(): Flow<ByteArray>` (Output-only, UTF-8 decode off-Main via flowOn) +
`controlEvents(): Flow<SessionEvent>` (non-Output); `TerminalSessionController` (start() decoupled
from bind(), eager mailbox registration); `RetainedSessionHolder` (@HiltViewModel, config-survival,
single-session-for-life BoundKey guard, close-join-before-cancel teardown); `AppEnvironment.warmUp()`
(off-Main first identity touch); `ColdStartPolicy`; factories for per-host ApiClient + per-session
SessionEngine. mTLS bridge: A11 `IdentityRepository` → A7 `ClientIdentityProvider` → ONE shared
`OkHttpClient` (.cache(null), WS+REST) — construction cycle broken via a shared `ConnectionPool`.
- **Coordination change (authorized):** `SessionEngine.close()` now returns its `Job` so teardown can
`join()` the clean detach BEFORE cancelling the confinement scope (structural close-before-cancel).
- **Cross-review caught + fixed:** (HIGH) EventBus was @Singleton → cross-session event bleed → now
per-session; (HIGH) bind()-started-engine-before-UI-subscribe → lost `attached`+replay → decoupled
start() + eager registration; (HIGH) eager DI did AndroidKeyStore/Tink I/O on Main → dagger.Lazy +
warmUp() on IO; (HIGH, re-verify) `register()`'s finally-close made the reused controller's output/
events flows DEAD after one config-change (blank terminal on rotation) → mailbox now lives for the
session (receiveAsFlow consume=false), released only by `EventBus.close()` at teardown; + typed
sub-streams, structural close, bind mis-route guard, @Volatile started. R10 (per-consumer UNLIMITED
channels: slow consumer neither stalls nor drops) unit-tested; confinement invariant #4 intact.
## [~] Wave AW3 — A16 `:terminal-view` DONE · A17 KeyBar + A18 ThumbnailPipeline building
### [x] S1 + A16 `:terminal-view` (XL) — the renderer, DONE + orchestrator-verified 2026-07-09
**S1 gate PASSED headless** (no device): a Termux `TerminalEmulator` (no JNI/subprocess) fed canned WS
bytes renders into a readable `TerminalBuffer` ("hello", cursorCol 5); DSR reply routes out via
`TerminalOutput.write`→engineSend; DECCKM `ESC[?1h` flips to `ESC OA`. **Termux resolved via JitPack**
`com.github.termux.termux-app:{terminal-view,terminal-emulator}:v0.118.0` (only transitive dep
androidx.annotation; no guava → R2 shim unneeded; Apache-2.0 scope held — never termux-shared/app).
JitPack repo + coordinate added to settings/catalog; `:terminal-view` enabled.
- Files: `terminal-view/src/main/.../terminalview/{RemoteTerminalSession, RemoteTerminalView,
TerminalGridMath, NoOpTerminalSessionClient, LinkPolicy}.kt` + 5 test suites (17 unit tests).
- `RemoteTerminalSession` = the FORK (no subprocess): a single confined-writer via an ordered
`Channel<TerminalCommand>` (Feed/Resize/Bind/Unbind); append chunked 4 KiB + yield(); `onScreenUpdated`
posted to an injected main dispatcher. pendingOutput queued-then-flushed in order (ESC[0m preserved).
Resize math extracted as pure `TerminalGridMath` (R5), JVM-tested. Titles pass RAW (no :session-core
edge — :app wires TitleSanitizer); OSC-52 declined; http/https LinkPolicy.
- **Sound deviation (§6.1):** at v0.118.0 Termux `TerminalView`/`TerminalSession` are `final` →
`RemoteTerminalView` is a COMPOSITION wrapper binding the forked emulator via the public `mEmulator`
field (rendering stays 100% stock). Documented.
- **Cross-review (correctness approved; threading blocked→fixed):** HIGH — bind published `mEmulator`
to the renderer SYNC before the pendingOutput flush → bind-time UI-read-vs-confined-append race;
fixed by routing the publish through the Bind command (flush on confined thread, THEN post
publish+onScreenUpdated to Main together; test captures buffer state AT publish). MEDIUM — confined
command loop now try/catches (rethrows Cancellation) so a hostile escape byte can't freeze output.
LOW — added `kotlinx-coroutines-android` (else `Dispatchers.Main` crashes on-device). Verified:
`:terminal-view:assembleDebug` + `testDebugUnitTest` 17/17 green.
- **Accepted (not a defect):** steady-state append runs concurrently with the UI-thread `onDraw` — this
is the intended §6.2 single-WRITER model (matches upstream Termux; torn read self-corrects next frame).
- Deferred to device QA (§7): glyph/true-color rendering, IME/CJK, selection→clipboard, link-tap,
rotation-rebind, real font-metric→grid (TerminalRenderer metrics are package-private → measured
on-device, fed to the tested TerminalGridMath).
### [x] A17 KeyBar (DONE, green) — `app/.../components/KeyBar.kt` + test (12 tests)
Compose mobile key-bar overlay porting `public/keybar.ts` (17 buttons, order/glyphs 1:1). Each tap →
`KeyByteMap.bytes(key)` sent VERBATIM via an injected `onSend` (A21 wires `controller::sendInput`),
bypassing IME/BasicTextField (soft keyboard never pops). Pinned above IME via
`windowInsetsPadding(WindowInsets.ime.union(navigationBars))`, horizontally scrollable, hidden when
`screenWidthDp>768` OR a hardware keyboard is present. **DECCKM split** = pure
`HardwareKeyRouter.resolve()`: ⇧Tab→ESC[Z, Esc→ESC, Ctrl+{C,R,O,L,T,B,D}→control byte; everything else
(arrows/Enter/Tab/unmapped) → `DeferToTerminal` so Termux `KeyHandler` emits DECCKM-correct `ESC OA`
(never hardcoded `ESC[A`). Verified `:app` 27 tests green (KeyBar 12). Cross-reviewed: reviewers
stalled (infra), but the byte contract is unit-locked. Device-QA: layout/IME-inset/real key delivery.
A21 install: `remote.onKeyCommand = { kc,ev -> HardwareKeyRouter.handle(kc,ev,controller::sendInput) }`
+ `KeyBar(onSend = controller::sendInput)`.
### [x] A18 ThumbnailPipeline (DONE, green — review deferred) — `app/.../wiring/ThumbnailPipeline.kt` + test (5 tests)
Off-screen preview rasterisation (§6.7): `LruCache` keyed `(sessionId, lastOutputAt)` (unchanged ⇒
cache hit, no re-render); fair `Semaphore(2)` FIFO fetch+render cap; in-flight dedup via
`Map<Key,Deferred<Bitmap>>` under a `Mutex` (2nd same-key request awaits the 1st); failure caches a
PLACEHOLDER (no retry storm); preview fetched over the shared **mTLS** `ApiClient` (`GET
/live-sessions/:id/preview`, 256 KiB cap); off-screen `TerminalEmulator` (no view) → `Canvas` cell
painter behind a `Rasterizer` seam (bitmap draw device-QA'd; concurrency/cache logic JVM-tested).
**NOTE:** A18's build agent + all 4 AW3 reviewers stalled ~3.5h (infra degradation); the agent had
written the file first. Orchestrator fixed a 1-char test-name error (illegal `;` in a backtick name),
re-verified `:app:assembleDebug + testDebugUnitTest` → **32 tests green**. A18's formal cross-review was
NOT run — flag it for the AW6 acceptance pass (concurrency code warrants a second look).
**Wave AW3 complete.** `:terminal-view` (17) + `:app` (32) + 6 pure-JVM (250) all green. The terminal
render path — the plan's dominant risk — is proven and hardened.
---
## [~] Wave AW4 — 11 UI screens (A19A29)
### [x] A21 Terminal screen + reconnect/EXIT banner + new-session-in-cwd (built, green — integration pass in flight)
Files: `components/ReconnectBanner.kt` (pure `bannerModel` reducer: exited/failed OUTRANK
connecting/reconnecting; exit -1 spawn-failure; REPLAY_TOO_LARGE no-spinner+new-session), `wiring/
TerminalSessionControllerImpl.kt` (wraps the holder controller `by base`, wires output→feedRemote
before start, OSC title→TitleSanitizer), `screens/TerminalScreen.kt` (generation-keyed AndroidView,
KeyBar/HardwareKeyRouter, FLAG_SECURE privacy shade, new-session-in-cwd → attach(null,cwd)). `:app` 64
tests (ReconnectBanner 8 + NewSessionInCwd 3).
### [x] A22 Gate/cockpit surfaces (built, green, reviewer approved)
Files: `viewmodels/GateViewModel.kt` (two-line epoch stale-guard, decide via controller.decideGate,
approve.mode top-level) + `components/{GateBanner (tool 2-btn), PlanGateSheet (plan 3-way sheet),
TelemetryChips, AwayDigestView}.kt`. Built but not yet composed into the terminal screen (→ integration).
### [x] Terminal-path integration + A15 seam refinement (DONE, orchestrator-verified — `:app` 66 tests)
Both re-verifiers confirmed (arch 7/0 broken cosmetic; kotlin 7/0). Frozen-contract changes:
`TerminalSessionController.events` (single mailbox) → `controlEvents()` (per-consumer mailbox, no
split); `RetainedSessionHolder.generation` → `mutableIntStateOf`; holder now owns
`TerminalSessionControllerImpl` + `RemoteTerminalSession` (config-survival §6.6 — emulator+scrollback
content survive rotation, no replay round-trip); GateViewModel + gate surfaces composed into
TerminalScreen (haptic reduce-motion gated); warmUp error/retry pane. Confinement invariant #4 intact.
Residual (cosmetic, tracked for AW6 doc pass): dangling `events` KDoc refs; scroll *offset* (not
content) resets on rotation. Tests: TerminalSessionControllerTest (2, multi-consumer no-split),
RetainedSessionHolder (4), GateViewModel (10).
### [x] AW4b — A19 Pairing + A20 Session list + A24 Diff + A25 Quick-reply (DONE, `:app` 111 tests green)
Pre-staged CameraX (1.4.1) + ML Kit barcode (17.3.0) for A19 QR. All 4 reviewers approve/warn, 0 must-fix.
- **A19 Pairing** — QR (CameraX+MLKit) / manual, both through the ONE `HostEndpoint.fromBaseUrl`
validator; confirm-before-network; §5.4 tiers (public explicit-ack; tunnel cert-gated choke point
retry can't bypass; fail-safe unknown→PUBLIC; tunnel-TLS→client-cert copy); no cert import; inert.
- **A20 Session list** — STARTED-scoped poll, status/telemetry/thumbnail/cols×rows/sanitized-title/
unread-dot rows, swipe-kill (optimistic, 404=success), multi-host switch, host menu (配对新主机/设备证书).
SessionListViewModelTest 11.
- **A24 Diff** — staged flag as STRING "1"/"0", files→hunks→lines flatten, lossy decode, inert read-only.
A24's builder died mid-run (API drop) leaving a missing `LaunchedEffect` import (blocked :app compile)
+ no test; orchestrator added the import + wrote DiffViewModelTest (8 tests: fromWire, flatten order,
lossy decode, VM phase transitions + staged re-fetch).
- **A25 Quick-reply** — DataStore CRUD palette (immutable), verbatim send, float-while-gate-held.
### [x] AW4c — A23 Projects + A27 CertScreen + A28 Timeline + A29 Cold-start (DONE, `:app` 164 tests green)
Reviews: A27 approve; A23/A29 warn; all 0 must-fix except A28's "wired to away-digest expand" (a
composition/nav wiring → tracked for the app-assembly pass below, not a code defect).
- **A23 Projects** — ProjectGrouping BYTE-IDENTICAL group keys to web/iOS (" active"/" other" sentinels,
first-seen-cased namespace keys, MIN_GROUP_SIZE=2); favourites/collapse via `/prefs` unknown-key-
preserving (R11: never PUT if never loaded); detail page; open-Claude-here → attach(null,cwd,"claude\r");
grid column seam (A26 owns full adaptive). ProjectsViewModelTest 12.
- **A27 ClientCertScreen** — import(SAF .p12)/rotate/remove over `:client-tls-android` IdentityRepository;
validate-before-persist keeps prior cert on bad passphrase; summary (issuer/subject-CN/expiry/EXPIRED);
passphrase scrubbed, never logged; confirm-gated remove; inert. ClientCertViewModelTest 14.
- **A28 Timeline** — TimelineSheet(ModalBottomSheet) + TimelineViewModel over `/events`, lossy decode,
tokenized event colors. TimelineViewModelTest 16. (away-digest→sheet wiring → app-assembly.)
- **A29 Cold-start** — SessionActivityBridge (set LastSessionStore on adopted / clear on exited),
ContinueLastBanner (stack+sidebar), ColdStartPolicy route (no host→pairing). Tests: bridge 6 + policy 2.
### [x] A26 Adaptive large-screen + nav shell (DONE, review warn 0 must-fix)
Pure `LayoutPolicy.mode(WindowWidth)` (compact→STACK, medium/expanded→LIST_DETAIL) + `PointerMenuPolicy.
enabled(mode, sw>=600)` — both JVM-tested (LayoutPolicyTest 8). `AdaptiveHome` = NavigationSuiteScaffold +
ListDetailPaneScaffold keyed on `currentWindowAdaptiveInfo()`. `PointerContextMenu` = `pointerInput`
secondary-click → `DropdownMenu` (NOT ContextMenuArea), gated. Full NavGraph wiring → A32/app-assembly.
**Wave AW4 COMPLETE — all 11 screens (A19A29) built + verified; `:app` green.**
---
## [~] Wave AW5 — push + deep-links (built; wiring → app-assembly)
### [x] A30 FCM client (green — `:app` 222 tests, PushDecisionTest 14)
`push/{FcmService, NotificationBuilder, DenyBroadcastReceiver, AllowTrampolineActivity, PushPayload,
PushDecisionSubmitter, PushTokenSink}.kt`. Data-only payload → notification built LOCALLY; **Deny** =
BroadcastReceiver (goAsync + expedited POST, no UI, auth-free); **Allow** = translucent
excludeFromRecents FragmentActivity hosting `BiometricPrompt` → POST (R1). Token single-use, only in
FLAG_IMMUTABLE extras → ApiClient, never persisted/logged (test-asserted); payload minimized (no
cwd/command/bytes). Multi-host: tries each paired host until 204 (foreign host 403s harmlessly).
Biometric = BIOMETRIC_STRONG+negativeButton (minSdk-29 valid combo). Manifest (service/receiver/activity)
+ `Theme.WebTerm.Translucent` **applied by orchestrator; `:app` assembles**.
### [x] A31 PushRegistrar (green — PushRegistrarTest 7)
`push/PushRegistrar.kt` — POST /push/fcm-token to each paired host, self-heal (token rotation / new host
/ removal→DELETE), token not logged. Review HIGH: not yet invoked in the app → **app-assembly** (DI:
@Binds PushTokenSink + on-start/host-change invocation).
### [x] A32 DeepLinkRouter + NavGraph (green, review approve — DeepLinkRouterTest)
`nav/{DeepLinkRouter, NavGraph}.kt` — pure `webterminal://open?host=&join=` + verified App-Links parser,
v4-UUID whitelist on host+join (invalid ignored+counted, never partial), one router for cold/warm/push.
Manifest intent-filters (custom scheme + `autoVerify` https) **applied by orchestrator; `:app` assembles**.
### [x] APP-ASSEMBLY — the app is a RUNNABLE whole (DONE, `:app` 227 tests, APK builds)
`MainActivity` (@AndroidEntryPoint) → `WebTermNavHost` composing all screens; start destination from
`ColdStartPolicy`; inter-screen callbacks wired (row/project/continue → terminal, host-menu →
pairing/cert, away-digest → timeline); `AdaptiveHome` for large screens; PushRegistrar DI (@Binds
PushTokenSink + on-start/host-change invocation); deep links via DeepLinkRouter (onCreate+onNewIntent);
warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid+acyclic. Manifest
(push components + deep-link intent-filters) applied by orchestrator.
- **Review HIGH fixed by orchestrator:** cold-start deep links were dropped by a composition race
(navigate before the NavHost graph was set) → moved `DeepLinkEffect` inside the `route != null`
branch so it runs only after `WebTermNavHost` composed (graph set). Re-verified `:app` 227 green.
- Minor gaps tracked (see DEVICE_QA_CHECKLIST): push-tap→specific-gate, diff inbound link, host-remove
UI, the A21 Termux-view reflection shim.
**Wave AW5 COMPLETE.**
---
## [x] Wave AW6 — acceptance (DONE, orchestrator-verified 2026-07-09)
- **[x] A36 coverage gate:** Kover 0.9.1 applied to the 4 pure modules with an enforced `koverVerify`
`minBound(80)` rule. Measured line coverage: **wire-protocol 91.0%** (added a HostClassifier/TimelineEvent
test — the type lives here but was only tested cross-module, so its own report read 77% → 91%),
**session-core 95.2%, api-client 89.2%, client-tls 96.4%**. `./gradlew koverVerify` GREEN.
- **[x] Device-QA checklist:** `android/DEVICE_QA_CHECKLIST.md` — captures **A34** (instrumented E2E vs
real `npm start`: attach→attached→output, reconnect replay, kill, hook/decision, **bad-Origin reject
F9**) + **A35** (pair→attach→type→approve macrobenchmark) + the **S2** FCM real-handset spike + every
per-task device-deferred item + the deploy artifacts (google-services.json, assetlinks.json, FCM_* env).
A34/A35 are device/instrumented by definition (plan §7) — deferred to real hardware, not run here.
- **[x] FINAL UNIFIED GATE (orchestrator, measured):** `./gradlew test :app:assembleDebug
:app:testDebugUnitTest koverVerify` → **BUILD SUCCESSFUL**. ~**484 JVM tests** (257 pure/transport +
227 `:app`) + Kover ≥80% + the full `:app` APK + `:terminal-view`/`:host-registry`/`:client-tls-android`
all assemble. Server side (A33 FCM) unchanged since R1 (1533 server tests green).
---
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1A36 + S1) landed
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure
JVM) + `:app :terminal-view :host-registry :client-tls-android` (framework). The Android app **builds to
an APK** with functional parity to the iOS P0+P1 scope; the Termux renderer seam (the plan's dominant
risk) is proven working headless. S2 (FCM real-device delivery) is the one spike inherently requiring
≥2 physical handsets — documented in the checklist. Everything device-observable is deferred to
`DEVICE_QA_CHECKLIST.md` per plan §7 (no emulator/Firebase/host in this env).
**Method:** every wave ran a Workflow (TDD builders → 2-lens adversarial cross-review → fix workflow →
adversarial re-verify → orchestrator-independent gate). Cross-validation caught + fixed ~20 real defects
before they reached a screen (transport socket leaks, engine close-during-dial + gate double-send, an
FCM token-refresh regression, a security bug where a REMOVED client cert stayed live in TLS, cross-session
event bleed, blank-terminal-on-rotation, terminal-freeze-on-hostile-bytes, cold-start deep-link drop, …).
**GIT / not committed:** all Android work is on-disk + test-verified but UNCOMMITTED — a concurrent
session was running the tunnel-automation workstream on this same `feat/tunnel-automation` working tree,
so the orchestrator held ALL git ops and never touched `docs/PROGRESS_LOG.md`. **TODO for the user:**
reconcile the two sessions, then commit the Android lane by explicit path and fold this file into
`docs/PROGRESS_LOG.md`.
### Tracked APP-ASSEMBLY items (a final wiring pass composes screens into the NavGraph + connects
### callbacks): away-digest expand → TimelineSheet (A28); host menu → pairing/cert (A20→A19/A27);
### project open → terminal (A23→A21); continue-last → terminal (A29); session row → terminal (A20→A21);
### and the A21 reflection shim → `libs.termux.terminal.view` on `:app` (dep now available to add).
Cross-review of A21/A22 surfaced interlocking frozen-seam gaps being fixed together: (1 HIGH)
`controller.events` was a single mailbox but banner+gate both collect → event split → made
multi-consumer (`controlEvents()` per consumer); (2 HIGH) `holder.generation` made Compose-observable
(mutableIntStateOf) so a real-background bump re-keys the AndroidView; (3 HIGH) RemoteTerminalSession/
controller moved into the config-surviving RetainedSessionHolder so §6.6 rotation keeps the emulator+
scrollback; (4) A22 gate surfaces composed into TerminalScreen; (5) warmUp error handling. A21 also
flagged for later: the reflection shim to reach the impl-scoped Termux `TerminalView` from `:app`
(clean fix = add `libs.termux.terminal.view` to `:app`); adopted-session-id survival across
real-background → A29's LastSessionStore.
### Remaining AW4 screens (independent of the terminal path, next batches):
A19 Pairing (QR/confirm/tiers) · A20 Session list+dashboard+host menu · A23 Projects+grouping+detail ·
A24 Diff viewer · A25 Quick-reply chips+palette · A27 ClientCertScreen · A28 Timeline sheet (wires to
A22 away-digest expand) · A29 Cold-start UX (ColdStartPolicy + LastSessionStore lifecycle) · A26
Adaptive large-screen (LAST — deps A20+A21).
- **[ ] AW2** A15 wiring/DI freeze (carry the A14 scope-close follow-up above).
- **[ ] AW3** A16/A17/A18 · **[ ] AW4** A19A29 · **[ ] AW5** A30A32 · **[ ] AW6** A34A36.

133
android/README.md Normal file
View File

@@ -0,0 +1,133 @@
# WebTerm — Android client
A native Android client for the WebTerm browser-terminal server, targeting functional
parity with the shipped **iOS** client. See the full design in
[`docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md) (stack §2, module
architecture §3, server contract §4, task waves §5).
This directory is a **Gradle multi-module** project. The module set mirrors the iOS
SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1).
## ⚠️ No-SDK constraint (why only 5 modules build here)
The current build environment has **no Android SDK**. Everything that can be pure
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
To bring the Android modules online later: install an SDK, add
`local.properties``sdk.dir`, add the Android Gradle Plugin + `google()` to
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
each stub.
## Module map (mirror of the iOS SPM packages — plan §3)
| iOS SPM package | Android module | Kind | Status |
|------------------------|------------------------|-------------------------------|--------|
| WireProtocol | `:wire-protocol` | pure Kotlin/JVM | ✅ built |
| SessionCore (reducers) | `:session-core` | pure Kotlin/JVM | ✅ built |
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS
> `URLSession*Transport`s consolidate into it (plan §3 framing note).
### Dependency graph (arrows = "depends on")
```
:app (SDK-gated)
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated) │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
└──────── :test-support → test source sets only
```
`:wire-protocol` is the **frozen shared contract** (Android analogue of
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
interfaces. New wire types are added **only** here (a coordination point).
## Toolchain
- **Gradle** 9.6.1 (via the committed wrapper — always use `./gradlew`).
- **Kotlin** 2.3.21 (matches the Kotlin embedded in Gradle 9.6.1).
- **JVM toolchain** 17 (`jvmToolchain(17)` in every module).
- Versions are pinned in the version catalog
[`gradle/libs.versions.toml`](gradle/libs.versions.toml): kotlinx-serialization-json,
kotlinx-coroutines-core/-test, JUnit5 (Jupiter), Turbine, MockK.
Pure modules apply `kotlin("jvm")` + `kotlin("plugin.serialization")`, wire the
`libs.bundles.unit-test` bundle into `testImplementation`, and run tests on the
JUnit Platform (`tasks.test { useJUnitPlatform() }`).
## Build & test
```bash
# Use the committed wrapper for everything.
./gradlew help # sanity: the build configures
./gradlew projects # lists the 5 pure modules
./gradlew build # compile all pure modules
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
```
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
> small focused files — same discipline as the rest of the repo.
## Android SDK setup (proven working)
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules
(`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android` — plan AW2+)
need the Android SDK. This machine is set up and the toolchain is **proven** (an
AGP library module compiled against SDK 35 and produced an AAR):
- **SDK location:** `/usr/local/share/android-commandlinetools`
(installed via `brew install --cask android-commandlinetools`).
- **Installed packages:** `platform-tools`, `platforms;android-35`, `platforms;android-36`,
`build-tools;35.0.0`, `build-tools;36.0.0`. (`:app` compiles against SDK **36** — the
Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; `platforms;android-37`
is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.)
- **`android/local.properties`** (gitignored) points Gradle at it:
`sdk.dir=/usr/local/share/android-commandlinetools`.
- **Shell env** (for `sdkmanager`/`adb`): `export ANDROID_HOME=/usr/local/share/android-commandlinetools`.
### Wiring an Android module (the working recipe)
- Repos: `google()` is in both `pluginManagement` and `dependencyResolutionManagement`
in `settings.gradle.kts` (needed to resolve AGP + androidx).
- Plugin: **AGP 9.2.1** (`libs.plugins.android.library` / `.android.application`),
compatible with Gradle 9.6.1.
- **Gotcha:** AGP 9 has **built-in Kotlin** — apply ONLY the android plugin. Adding
`org.jetbrains.kotlin.android` errors with "no longer required since AGP 9.0".
- Module block: `android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }`.
(Framework modules target `compileSdk = 36`; `targetSdk` stays `35` per plan §2.)
- **`:app` UI-stack version matrix** (A13, proven `:app:assembleDebug` green): AGP 9.2.1 ·
Kotlin 2.3.21 · Compose-compiler plugin `org.jetbrains.kotlin.plugin.compose` = 2.3.21 ·
Compose BOM `2025.11.01` (→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0,
material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP `2.3.9` ·
androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins:
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
To add more SDK pieces later (e.g. an emulator image for instrumented tests):
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.

View File

@@ -0,0 +1,39 @@
// :api-client — pure REST client logic (12 routes, tolerant decode, Origin-iff-
// guarded, strict query encoding, prefs unknown-key preservation, pairing probe /
// PairingError / HostClassifier tiers). Consumes HttpTransport by interface only.
// Depends only on :wire-protocol.
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kover)
}
kotlin {
jvmToolchain(17)
}
dependencies {
api(project(":wire-protocol"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(project(":test-support"))
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}
tasks.test {
useJUnitPlatform()
}
// A36 acceptance gate: >=80% line coverage on this pure module (plan §7).
kover {
reports {
verify {
rule {
minBound(80)
}
}
}
}

View File

@@ -0,0 +1,10 @@
package wang.yaojia.webterm.api.models
/**
* The two verdicts `POST /hook/decision` accepts — anything else is a 400 server-side. The [wire]
* value is what goes into the request body (`{ sessionId, decision, token }`).
*/
public enum class HookDecision(public val wire: String) {
ALLOW("allow"),
DENY("deny"),
}

View File

@@ -0,0 +1,45 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.StatusTelemetry
import java.util.UUID
/**
* One running (or just-exited) server session from `GET /live-sessions` (read-only discovery — NO
* Origin header). Mirrors `src/types.ts` `LiveSessionInfo` and iOS `APIClient.LiveSessionInfo`
* field-for-field.
*
* Tolerant decode at the untrusted boundary (plan §4):
* - identity/geometry ([id]/[createdAt]/[clientCount]/[exited]/[cols]/[rows]) are REQUIRED — an
* entry missing them is dropped by `LossyDecode.listOrNull`;
* - an unrecognized `status` string maps to [ClaudeStatus.UNKNOWN] (a future server status must
* not make a running session invisible);
* - absent `cwd`/`telemetry`/`lastOutputAt` degrade to `null`.
*
* NOTE: ms timestamps ([createdAt]/[lastOutputAt]) are `Long` — epoch-ms overflows a 32-bit `Int`
* (Swift's `Int` is 64-bit, so iOS used `Int`).
*/
@Serializable
public data class LiveSessionInfo(
@Serializable(with = UuidSerializer::class) val id: UUID,
/** Server `Date.now()` at spawn (ms since epoch). */
val createdAt: Long,
/** Devices currently attached (JOIN/mirror semantics). */
val clientCount: Int,
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
val exited: Boolean,
val cwd: String? = null,
/** Tab title / derived label (e.g. 'claude', 'shell'), OSC-title-derived server-side (`src/types.ts`
* `title?`). HOST/ATTACKER-influenced — run through `TitleSanitizer` before any UI/list use (§8).
* Additive optional field; absent → `null`. */
val title: String? = null,
/** Current PTY size (latest-writer-wins on the server). */
val cols: Int,
val rows: Int,
/** Latest statusLine telemetry, if any (B2). */
val telemetry: StatusTelemetry? = null,
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */
val lastOutputAt: Long? = null,
)

View File

@@ -0,0 +1,48 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
/**
* Tolerant decode config for the UNTRUSTED server boundary (plan §4 / A8). The server is an
* untrusted input source here: unknown keys are ignored, malformed list elements are dropped one
* by one, and nothing crashes on bad input. The Android analogue of iOS's per-field `try?`
* tolerance in `APIClient/Models.swift`.
*
* ENCODE is deterministic (`ignoreUnknownKeys`/`isLenient` only affect parsing) so the same
* instance also encodes request bodies (`/hook/decision`, `/push/fcm-token`, `PUT /prefs`).
*/
internal val ModelJson: Json = Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Per-element / per-body tolerant decoders (mirror iOS `LiveSessionInfo.decodeList` /
* `LossyBox` / `LossyList`).
*
* - [listOrNull]: a non-array top level yields `null` (the caller raises
* `ApiClientError.InvalidResponseBody` — the pairing "port speaks HTTP but isn't web-terminal"
* signal); malformed elements are dropped, good ones kept.
* - [listOrEmpty]: a non-array top level yields `[]` (matches iOS `TimelineEvent.decodeList`, which
* the server itself returns when timeline capture is disabled).
* - [objectOrNull]: a single object that fails to decode yields `null` (caller raises
* `InvalidResponseBody`).
*/
internal object LossyDecode {
fun <T> listOrNull(bytes: ByteArray, element: KSerializer<T>): List<T>? {
val array = parseArray(bytes) ?: return null
return array.mapNotNull { el -> runCatching { ModelJson.decodeFromJsonElement(element, el) }.getOrNull() }
}
fun <T> listOrEmpty(bytes: ByteArray, element: KSerializer<T>): List<T> =
listOrNull(bytes, element) ?: emptyList()
fun <T> objectOrNull(bytes: ByteArray, deserializer: KSerializer<T>): T? =
runCatching { ModelJson.decodeFromString(deserializer, bytes.decodeToString()) }.getOrNull()
private fun parseArray(bytes: ByteArray): JsonArray? =
runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) as? JsonArray }.getOrNull()
}

View File

@@ -0,0 +1,69 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* One running session belonging to a project (`src/types.ts` `ProjectSessionRef`). The server
* mirrors [LiveSessionInfo] fields into this ref. Tolerant: unknown status → [ClaudeStatus.UNKNOWN],
* absent title → `null`; missing id/clientCount/createdAt/exited drops the ref (nested lossy list).
*/
@Serializable
public data class ProjectSessionRef(
@Serializable(with = UuidSerializer::class) val id: UUID,
val title: String? = null,
@Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN,
val clientCount: Int,
val createdAt: Long,
val exited: Boolean,
)
/**
* A discovered project (git repo or recently-used cwd) — `GET /projects` (`src/types.ts`
* `ProjectInfo`). There is NO `namespace` field on the wire; namespace grouping is a client concept
* surfaced only via `UiPrefs.collapsed` group-keys.
*/
@Serializable
public data class ProjectInfo(
val name: String,
val path: String,
val isGit: Boolean,
val branch: String? = null,
/** Uncommitted changes; only present when the server runs the dirty check. */
val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null,
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
)
/** One entry from `git worktree list --porcelain` (`src/types.ts` `WorktreeInfo`). */
@Serializable
public data class WorktreeInfo(
val path: String,
/** Branch name; `null` on detached HEAD. */
val branch: String? = null,
val head: String? = null,
val isMain: Boolean = false,
val isCurrent: Boolean = false,
val locked: Boolean? = null,
val prunable: Boolean? = null,
)
/** Detailed view of one project — `GET /projects/detail?path=` (`src/types.ts` `ProjectDetail`). */
@Serializable
public data class ProjectDetail(
val name: String,
val path: String,
val isGit: Boolean,
val branch: String? = null,
val dirty: Boolean? = null,
@Serializable(with = WorktreeInfoListSerializer::class)
val worktrees: List<WorktreeInfo> = emptyList(),
@Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(),
val hasClaudeMd: Boolean = false,
/** CLAUDE.md content (server-truncated for display) when present. */
val claudeMd: String? = null,
)

View File

@@ -0,0 +1,59 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.decodeFromJsonElement
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* Decode a server session id as [UUID]. Server ids are lowercase `crypto.randomUUID()` strings;
* a non-UUID string throws, so `LossyDecode` drops that list element (matches iOS decoding
* `UUID.self` — a malformed id must not surface). Serializes back lowercase (`UUID.toString()`).
*/
internal object UuidSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: UUID) = encoder.encodeString(value.toString())
}
/**
* Decode [ClaudeStatus] by its `wire` value; an unknown/future value maps to
* [ClaudeStatus.UNKNOWN] rather than dropping the entry — a new server status must never make a
* running session invisible (iOS `rawStatus.flatMap(...) ?? .unknown`).
*/
internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ClaudeStatus", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ClaudeStatus =
ClaudeStatus.fromWire(decoder.decodeString()) ?: ClaudeStatus.UNKNOWN
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
}
/**
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,
* `ProjectDetail.worktrees`). A bad nested element must not fail the whole parent object.
*/
internal class LossyListSerializer<T>(private val element: KSerializer<T>) : KSerializer<List<T>> {
private val delegate = ListSerializer(element)
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: List<T>) = delegate.serialize(encoder, value)
override fun deserialize(decoder: Decoder): List<T> {
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
val array = json.decodeJsonElement() as? JsonArray ?: return emptyList()
return array.mapNotNull { runCatching { json.json.decodeFromJsonElement(element, it) }.getOrNull() }
}
}
internal object ProjectSessionRefListSerializer :
KSerializer<List<ProjectSessionRef>> by LossyListSerializer(ProjectSessionRef.serializer())
internal object WorktreeInfoListSerializer :
KSerializer<List<WorktreeInfo>> by LossyListSerializer(WorktreeInfo.serializer())

View File

@@ -0,0 +1,17 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
import java.util.UUID
/**
* `GET /live-sessions/:id/preview` response: the tail of the session's ring buffer for a
* read-only thumbnail (no attach, no client registered). [data] is opaque ANSI/UTF-8 — feed it to
* a terminal, never parse it. All fields required (a malformed body → `InvalidResponseBody`).
*/
@Serializable
public data class SessionPreview(
@Serializable(with = UuidSerializer::class) val id: UUID,
val cols: Int,
val rows: Int,
val data: String,
)

View File

@@ -0,0 +1,12 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* `GET /config/ui` response (`{ allowAutoMode }`). Reserved for a future permission-mode switcher
* (filters the high-risk raw `auto` mode); the plan-gate three-way UI does not consume it.
*/
@Serializable
public data class UiConfig(
val allowAutoMode: Boolean,
)

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
/**
* The cross-device Projects-UI prefs blob (`GET /prefs` RO · `PUT /prefs` G) — an
* OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web client
* (`favourites: string[]`, `collapsed: { group-key: true }`); ALL OTHER top-level keys are
* preserved verbatim across decode → mutate → encode, so an Android `PUT` can never clobber prefs
* written by the web/iOS client or a future server (`PUT` replaces the WHOLE blob server-side, so
* key preservation is correctness, not politeness).
*
* Immutable snapshot: [withFavourites]/[withCollapsed] return NEW copies that replace exactly one
* known key and carry every other key through untouched. Numbers round-trip verbatim because a
* parsed [JsonPrimitive] keeps its source token (`42` never re-encodes as `42.0`).
*/
public class UiPrefs private constructor(private val storage: JsonObject) {
/**
* Favourited project paths (★): non-empty JSON strings, de-duplicated, original order kept.
* Wrong-typed entries are dropped, not fatal (mirrors `public/prefs.ts sanitizePrefs`).
*/
public val favourites: List<String>
get() {
val array = storage[KEY_FAVOURITES] as? JsonArray ?: return emptyList()
val out = LinkedHashSet<String>()
for (element in array) {
val primitive = element as? JsonPrimitive ?: continue
if (!primitive.isString) continue
val path = primitive.content
if (path.isNotEmpty()) out.add(path)
}
return out.toList()
}
/**
* Namespace group-key → collapsed. Only literal `true` values count (expanded is the default;
* both web and server sanitizers agree). A JSON string `"true"` does NOT count.
*/
public val collapsed: Map<String, Boolean>
get() {
val obj = storage[KEY_COLLAPSED] as? JsonObject ?: return emptyMap()
val out = LinkedHashMap<String, Boolean>()
for ((key, value) in obj) {
if (key.isEmpty()) continue
if (value is JsonPrimitive && !value.isString && value.booleanOrNull == true) {
out[key] = true
}
}
return out
}
/** New snapshot with `favourites` replaced; every other key untouched (position preserved). */
public fun withFavourites(favourites: List<String>): UiPrefs {
val next = LinkedHashMap<String, JsonElement>(storage)
next[KEY_FAVOURITES] = JsonArray(favourites.map { JsonPrimitive(it) })
return UiPrefs(JsonObject(next))
}
/** New snapshot with `collapsed` replaced; every other key untouched (position preserved). */
public fun withCollapsed(collapsed: Map<String, Boolean>): UiPrefs {
val next = LinkedHashMap<String, JsonElement>(storage)
next[KEY_COLLAPSED] = JsonObject(collapsed.mapValues { JsonPrimitive(it.value) })
return UiPrefs(JsonObject(next))
}
/** Encode the FULL blob (known + unknown keys) for `PUT /prefs`. */
public fun encodeBody(): ByteArray =
ModelJson.encodeToString(JsonObject.serializer(), storage).encodeToByteArray()
override fun equals(other: Any?): Boolean = other is UiPrefs && other.storage == storage
override fun hashCode(): Int = storage.hashCode()
override fun toString(): String = "UiPrefs(storage=$storage)"
public companion object {
private const val KEY_FAVOURITES = "favourites"
private const val KEY_COLLAPSED = "collapsed"
/** Fresh prefs (e.g. first write from a device that never fetched). */
public fun create(
favourites: List<String> = emptyList(),
collapsed: Map<String, Boolean> = emptyMap(),
): UiPrefs = UiPrefs(
JsonObject(
mapOf(
KEY_FAVOURITES to JsonArray(favourites.map { JsonPrimitive(it) }),
KEY_COLLAPSED to JsonObject(collapsed.mapValues { JsonPrimitive(it.value) }),
),
),
)
/**
* Decode a `/prefs` body. `null` = top level is not a JSON object — callers surface
* `InvalidResponseBody` LOUDLY instead of degrading to empty prefs (an empty-based `PUT`
* would wipe the server blob).
*/
public fun decode(bytes: ByteArray): UiPrefs? {
val element = runCatching { ModelJson.parseToJsonElement(bytes.decodeToString()) }.getOrNull()
return (element as? JsonObject)?.let { UiPrefs(it) }
}
}
}

View File

@@ -0,0 +1,131 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.PortUnreachableException
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertPathBuilderException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
/**
* Error taxonomy for the pairing probe (Android port of iOS `APIClient.PairingError`). Each case
* maps one probe failure mode to actionable UI copy (`PairingViewModel`, A19, renders these).
*
* DEVIATION from iOS (plan R12): the iOS `localNetworkDenied` case is DROPPED — Android has no
* iOS-style Local-Network permission prompt (a plain WS to a LAN IP needs no runtime permission),
* so the ENETDOWN→localNetworkDenied diagnosis is dead weight here. iOS `atsBlocked` is kept as
* [CleartextBlocked]: the genuine Android analogue is a `network_security_config` cleartext block
* surfaced as [java.net.UnknownServiceException] (plan §6.9/§8 cleartext posture).
*/
public sealed interface PairingError {
/** Nothing answered — connection refused / no route / DNS failure (`ConnectException`, `UnknownHostException`, …). */
public data class HostUnreachable(val underlying: String) : PairingError
/**
* The port speaks HTTP but `GET /live-sessions` did not return the web-terminal shape (a JSON
* array) — "端口对吗?". Also used when probe ② connects but the socket never speaks our protocol.
*/
public data object HttpOkButNotWebTerminal : PairingError
/**
* The WS upgrade (or the guarded kill round-trip) was rejected — the host's Origin whitelist
* does not contain our dialed origin. [hint] carries the exact `ALLOWED_ORIGINS=<origin>` line,
* always derived from [HostEndpoint.originHeader] (never hand-assembled; default ports omitted).
*/
public data class OriginRejected(val hint: String) : PairingError
/**
* Cleartext (`ws://`/`http://`) to a host outside the app's `network_security_config` allowlist
* was blocked by the platform ([java.net.UnknownServiceException]). Android analogue of iOS
* `atsBlocked`. [host] is the dialed host for the actionable copy.
*/
public data class CleartextBlocked(val host: String) : PairingError
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError
public companion object {
/**
* Actionable copy for [OriginRejected] — always derived from the SINGLE origin source
* ([HostEndpoint.originHeader]), never hand-assembled. Ported verbatim from iOS.
*/
public fun originRejectedHint(endpoint: HostEndpoint): String =
"服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=${endpoint.originHeader}" +
"(与 App 连接的 URL 完全一致)后重启 web-terminal再重试配对。"
/**
* Classify a transport-level [Throwable] thrown by [wang.yaojia.webterm.wire.HttpTransport]
* or [wang.yaojia.webterm.wire.TermTransport] into the probe taxonomy. Walks the bounded
* `cause` chain (OkHttp wraps causes; never trust an error graph not to cycle) so wrapped
* roots (e.g. an `IOException` wrapping a `ConnectException`) are seen.
*
* @param unrecognizedFallback used by probe step ② — after step ① proved the host reachable
* AND web-terminal-shaped, an upgrade failure with no recognizable network cause is, by
* elimination, the server's Origin 401 (its only upgrade-reject path).
*/
public fun classify(
error: Throwable,
endpoint: HostEndpoint,
unrecognizedFallback: PairingError? = null,
): PairingError {
val chain = causeChain(error)
if (chain.any { it is UnknownServiceException }) {
return CleartextBlocked(host = HostClassifier.hostOf(endpoint))
}
if (chain.any { isTimeout(it) }) return Timeout
if (chain.any { isTls(it) }) return TlsFailure
if (chain.any { isConnectivity(it) }) {
return HostUnreachable(underlying = describe(error))
}
return unrecognizedFallback ?: HostUnreachable(underlying = describe(error))
}
private const val MAX_CAUSE_DEPTH = 8
/** Bounded walk of [Throwable.cause] with an identity cycle-guard (defensive). */
private fun causeChain(error: Throwable): List<Throwable> {
val chain = mutableListOf<Throwable>()
var current: Throwable? = error
while (current != null && chain.size < MAX_CAUSE_DEPTH) {
if (chain.any { it === current }) break
chain.add(current)
current = current.cause
}
return chain
}
// `SocketTimeoutException` extends `InterruptedIOException`; OkHttp's overall call-timeout
// also surfaces as a bare `InterruptedIOException` — both mean "timed out".
private fun isTimeout(e: Throwable): Boolean = e is InterruptedIOException
private fun isTls(e: Throwable): Boolean =
e is SSLException ||
e is CertificateException ||
e is CertPathValidatorException ||
e is CertPathBuilderException
// `ConnectException` / `NoRouteToHostException` / `PortUnreachableException` all extend
// `SocketException`; listed explicitly for readability. `UnknownHostException` is a DNS
// failure (extends `IOException`, not `SocketException`).
private fun isConnectivity(e: Throwable): Boolean =
e is ConnectException ||
e is NoRouteToHostException ||
e is PortUnreachableException ||
e is SocketException ||
e is UnknownHostException
private fun describe(error: Throwable): String =
error.message ?: error::class.simpleName ?: "unknown"
}
}

View File

@@ -0,0 +1,227 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.MessageCodec
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.Tunables
import java.net.URI
import kotlin.time.Duration
/**
* Result of a pairing probe — the validated [HostEndpoint] on success, a [PairingError] on failure.
* Android analogue of iOS `Result<HostEndpoint, PairingError>`. The pairing UI (A19) constructs the
* persisted `Host{id,name}` from the returned endpoint (id/name are not the probe's to know).
*/
public sealed interface PairingProbeResult {
public data class Success(val endpoint: HostEndpoint) : PairingProbeResult
public data class Failure(val error: PairingError) : PairingProbeResult
}
/**
* Public probe entry (Android port of iOS `runPairingProbe`). Two-step probe:
* 1. `GET /live-sessions` (NO Origin) — reachability + web-terminal shape.
* 2. WS `attach(null)` → adopt the server-issued `attached` id → close → **immediately
* `DELETE /live-sessions/:id` WITH Origin** (verifies the Origin guard on both the upgrade and
* the guarded-HTTP side, and never leaks the probe's orphan session).
*
* **Confirm-before-network contract:** callers MUST run this only after the user confirmed a
* scanned/typed host (A19). Step ① already talks to the network and step ② spawns a PTY on the
* target machine — nothing here is speculative, so the UI gate is the caller's responsibility.
*
* The wall-clock deadline is [Tunables.PAIRING_PROBE_TIMEOUT]; tests drive [runPairingProbeCore]
* with an explicit (or `null`) timeout for deterministic virtual-time coverage.
*/
public suspend fun runPairingProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT)
/**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
* still apply — fast, race-free tests); otherwise the whole probe is cancelled past the deadline and
* resolves [PairingError.Timeout]. Cancellation is the coroutine analogue of iOS's `group.cancelAll`.
*/
internal suspend fun runPairingProbeCore(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
timeout: Duration?,
): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) }
?: PairingProbeResult.Failure(PairingError.Timeout)
}
// ── Probe body ────────────────────────────────────────────────────────────────────────────────
private suspend fun performProbe(
endpoint: HostEndpoint,
http: HttpTransport,
ws: TermTransport,
): PairingProbeResult {
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?").
probeReachability(endpoint, http)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected.
val connection: TransportConnection = try {
ws.connect(endpoint)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(
PairingError.classify(
error,
endpoint,
unrecognizedFallback = PairingError.OriginRejected(
PairingError.originRejectedHint(endpoint),
),
),
)
}
// Once connected, the connection MUST be closed on every exit path — including the timeout/cancel
// path, where withTimeoutOrNull cancels us mid-`firstOrNull`. Without this finally, a cancel skips
// close() and leaks the WS + its orphan PTY session on the host. NonCancellable so the close still
// runs while we are already being cancelled.
return try {
when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http)
}
} finally {
withContext(NonCancellable) { runCatching { connection.close() } }
}
}
/**
* Probe step ①. Returns a [PairingProbeResult.Failure] to short-circuit, or `null` to proceed.
* Reachable + web-terminal-shaped = HTTP 200 with a JSON-array body (an HTML admin page, a 404, or
* a non-array body are all "not web-terminal").
*/
private suspend fun probeReachability(
endpoint: HostEndpoint,
http: HttpTransport,
): PairingProbeResult.Failure? {
val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint)))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
}
return null
}
/**
* Send `attach(null)` (explicit JSON `"sessionId":null` via [MessageCodec]) and wait for the
* server-issued `attached` id, skipping any other or undecodable frame (untrusted server; only
* `attached` matters here). A stream that ends or errors before speaking our protocol is NOT an
* Origin problem — the upgrade already succeeded — so it maps to [PairingError.HttpOkButNotWebTerminal].
*/
private suspend fun adoptAttachedSession(connection: TransportConnection): Adoption =
try {
connection.send(MessageCodec.encode(ClientMessage.Attach(sessionId = null)))
val sessionId = connection.frames
.mapNotNull { frame -> (MessageCodec.decodeServer(frame) as? ServerMessage.Attached)?.sessionId }
.firstOrNull()
if (sessionId != null) Adoption.Success(sessionId) else Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
Adoption.Failure(PairingError.HttpOkButNotWebTerminal)
}
/**
* The guarded kill round-trip is part of pairing verification itself (`DELETE` exercises the
* HTTP-side Origin guard the later `hookDecision` will need) AND guarantees the probe leaves no
* orphan session. 204 = killed, 404 = already gone (both success), 403 = Origin guard rejected us.
*/
private suspend fun killProbeSession(
sessionId: String,
endpoint: HostEndpoint,
http: HttpTransport,
): PairingProbeResult {
val response = try {
http.send(
HttpRequest(
method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader),
),
)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
}
return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
)
else -> PairingProbeResult.Failure(
PairingError.HostUnreachable(underlying = "HTTP ${response.status}"),
)
}
}
private sealed interface Adoption {
data class Success(val sessionId: String) : Adoption
data class Failure(val error: PairingError) : Adoption
}
// ── URL derivation + shape check ────────────────────────────────────────────────────────────────
private val PROBE_JSON = Json { ignoreUnknownKeys = true; isLenient = true }
/** Web-terminal shape = the body parses as a JSON array (the server replies `[]` when idle). */
private fun isJsonArray(body: ByteArray): Boolean =
runCatching { PROBE_JSON.parseToJsonElement(body.decodeToString()) is JsonArray }.getOrDefault(false)
private fun liveSessionsUrl(endpoint: HostEndpoint): String = httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH
private fun killUrl(endpoint: HostEndpoint, sessionId: String): String =
httpBaseUrl(endpoint) + LIVE_SESSIONS_PATH + "/" + sessionId
/**
* `scheme://host[:port]` from the endpoint's dialed URL — path/query/fragment/credentials dropped
* (same derivation philosophy as [HostEndpoint.wsUrl]). The dialed port is kept verbatim; the host
* is left as [java.net.URI] returns it (IPv6 literals are already bracketed).
*/
private fun httpBaseUrl(endpoint: HostEndpoint): String {
val uri = URI(endpoint.baseUrl)
val scheme = (uri.scheme ?: "http").lowercase()
val host = uri.host ?: ""
val portPart = if (uri.port != -1) ":${uri.port}" else ""
return "$scheme://$host$portPart"
}
private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404

View File

@@ -0,0 +1,173 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TimelineEvent
import java.util.UUID
/**
* Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport]
* by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the
* `:test-support` fake queues canned responses.
*
* **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp
* `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place
* ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint].
*
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*/
public class ApiClient(
public val endpoint: HostEndpoint,
private val http: HttpTransport,
) {
// ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
/** `GET /live-sessions` — the discovery list every device polls. */
public suspend fun liveSessions(): List<LiveSessionInfo> {
val response = perform(Endpoints.liveSessions())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */
public suspend fun preview(id: UUID): SessionPreview {
val response = perform(Endpoints.preview(id))
requireOk(response)
return LossyDecode.objectOrNull(response.body, SessionPreview.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture
* disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */
public suspend fun events(id: UUID): List<TimelineEvent> {
val response = perform(Endpoints.events(id))
requireOk(response)
return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer())
}
/** `GET /config/ui` — `{ allowAutoMode }`. */
public suspend fun uiConfig(): UiConfig {
val response = perform(Endpoints.uiConfig())
requireOk(response)
return LossyDecode.objectOrNull(response.body, UiConfig.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /projects` — discovered projects with their running sessions merged in. */
public suspend fun projects(): List<ProjectInfo> {
val response = perform(Endpoints.projects())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return LossyDecode.listOrNull(response.body, ProjectInfo.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is
* rejected client-side (mirror of the server's 400) before any network I/O. */
public suspend fun projectDetail(path: String): ProjectDetail {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectDetail(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
* `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs {
val response = perform(Endpoints.getPrefs())
if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
}
// ── G (state-changing — Origin required, byte-equal) ───────────────────────────────────
/** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server,
* but iOS surfaces it as `SessionNotFound` — matched here). */
public suspend fun killSession(id: UUID) {
val response = perform(Endpoints.killSession(id))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push
* payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */
public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) {
val response = perform(Endpoints.hookDecision(sessionId, decision, token))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the
* new source of truth, not the input. 403 = Origin guard. */
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
val response = perform(Endpoints.putPrefs(prefs))
return when (response.status) {
HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
* tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) {
sendFcmToken(token, Endpoints::registerFcmToken)
}
/** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */
public suspend fun unregisterFcmToken(token: String) {
sendFcmToken(token, Endpoints::unregisterFcmToken)
}
private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) {
val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken
val response = perform(build(normalized))
when (response.status) {
HttpStatus.NO_CONTENT -> Unit
HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken
HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
// ── Internals ──────────────────────────────────────────────────────────────────────────
private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
return http.send(request)
}
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
private fun requireOk(response: HttpResponse) {
when (response.status) {
HttpStatus.OK -> Unit
HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
}

View File

@@ -0,0 +1,49 @@
package wang.yaojia.webterm.api.routes
/**
* Typed failures for [ApiClient] calls (explicit error handling, plan §4). Transport-level errors
* thrown by `HttpTransport.send` propagate UNWRAPPED (the pairing classifier reads their shape).
*
* [userMessage] is UI-ready copy (matches the iOS `APIClientError.message` 话术). The no-arg cases
* are singletons (`object`) so tests can assert by identity/equality; [UnexpectedStatus] carries the
* offending code.
*/
public sealed class ApiClientError(public val userMessage: String) : Exception(userMessage) {
/** The request could not be built from the endpoint (malformed base URL — should not happen for
* a validated `HostEndpoint`; surfaced instead of crashing). */
public data object InvalidRequest : ApiClientError("无法构造请求(主机地址异常)。")
/** A 2xx arrived but the body is not the endpoint's shape. For `/live-sessions` this is the
* pairing probe's "the port speaks HTTP but is not web-terminal" signal. */
public data object InvalidResponseBody : ApiClientError("服务器响应不是预期格式——端口对吗?")
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")
/** 403 from `POST /hook/decision`: the capability token is missing / mismatched / STALE —
* tokens are single-use and expiring by design. */
public data object DecisionRejected : ApiClientError("审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。")
/** 429: the endpoint is rate-limited per IP by fixed server policy. */
public data object RateLimited : ApiClientError("操作过于频繁,服务器已限流,请稍后再试。")
/** The FCM registration token failed the client-side charset/length check, or the server echoed
* a 400. */
public data object InvalidFcmToken : ApiClientError("推送注册令牌格式异常,请重启 App 重新注册推送。")
/** 400 from `GET /projects/detail` — the `path` query parameter is missing/empty. Also raised
* client-side for an empty path, before any network I/O. */
public data object ProjectPathInvalid : ApiClientError("项目路径为空或不合法。")
/** 404 from `GET /projects/detail` — no project at that path (moved/deleted/not a directory). */
public data object ProjectNotFound : ApiClientError("项目不存在(路径可能已移动或删除)。")
/** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
}

View File

@@ -0,0 +1,91 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.net.URI
/** Named HTTP status codes used by the client (no magic numbers, plan §4). */
internal object HttpStatus {
const val OK = 200
const val NO_CONTENT = 204
const val BAD_REQUEST = 400
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500
}
/** Header / content-type names (no magic strings inline). */
internal object HeaderName {
const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type"
}
internal object ContentType {
const val JSON = "application/json"
}
/**
* Whether a route mutates server state — THE security split (plan §4.3): `Origin` is stamped
* **iff** [GUARDED]. If the server ever reclassifies a RO route as guarded, tests go red instead of
* passing by coincidence.
*/
internal enum class OriginPolicy {
/** Read-only GET — MUST NOT carry Origin. */
READ_ONLY,
/** State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal; server 403s a
* missing/foreign Origin (CSWSH defence). */
GUARDED,
}
/**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
* sites). Origin stamping happens HERE and only here (single point).
*/
internal class ApiRoute(
val method: HttpMethod,
val path: String,
val originPolicy: OriginPolicy,
val body: ByteArray? = null,
val percentEncodedQuery: String? = null,
) {
/**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*/
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader
}
if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
}
return HttpRequest(method = method, url = url, headers = headers, body = body)
}
private companion object {
/**
* Rebuild `<scheme>://<host>[:<port>]<path>[?<query>]` from the dialed base URL, keeping the
* dialed port verbatim (like `wsUrl`, unlike the default-port-dropping Origin). The path and
* pre-encoded query are appended verbatim; any path/query/fragment/credentials the base URL
* carried are dropped.
*/
fun buildUrl(baseUrl: String, path: String, query: String?): String? {
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host ?: return null
if (host.isEmpty()) return null
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val queryPart = if (query != null) "?$query" else ""
return "$scheme://$serializedHost$portPart$path$queryPart"
}
}
}

View File

@@ -0,0 +1,127 @@
package wang.yaojia.webterm.api.routes
import kotlinx.serialization.Serializable
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.ModelJson
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` /
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
* pair (this client uses FCM, not APNs/VAPID).
*
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
* · `POST|DELETE /push/fcm-token`
*
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
* `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current
* server until A33 lands. The client builders exist now so the token lifecycle is ready the moment
* the server route ships; do not "fix" this as a mismatch.
*/
internal object Endpoints {
// ── RO ───────────────────────────────────────────────────────────────────────────────
fun liveSessions(): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY)
fun preview(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY)
fun events(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY)
fun uiConfig(): ApiRoute =
ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY)
fun projects(): ApiRoute =
ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY)
/**
* `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict
* RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes
* to a SPACE in Express's qs parser, and `&`/`=` would split the parameter).
*/
fun projectDetail(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/detail",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
// ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute =
ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED)
/** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) —
* callers must never persist/log it. */
fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute {
val body = ModelJson.encodeToString(
HookDecisionBody.serializer(),
HookDecisionBody(pathId(sessionId), decision.wire, token),
).encodeToByteArray()
return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body)
}
/** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */
fun putPrefs(prefs: UiPrefs): ApiRoute =
ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody())
fun registerFcmToken(normalized: String): ApiRoute =
fcmTokenRoute(HttpMethod.POST, normalized)
fun unregisterFcmToken(normalized: String): ApiRoute =
fcmTokenRoute(HttpMethod.DELETE, normalized)
private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute {
val body = ModelJson.encodeToString(
FcmTokenBody.serializer(),
FcmTokenBody(normalized),
).encodeToByteArray()
return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body)
}
private const val FCM_TOKEN_PATH = "/push/fcm-token"
/**
* Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
* matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
* on the JVM (unlike Swift's uppercase `uuidString`).
*/
private fun pathId(id: UUID): String = id.toString()
/** Strict RFC 3986 unreserved set — everything else is percent-encoded over UTF-8 bytes. */
private val UNRESERVED: Set<Char> =
("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~").toSet()
private fun percentEncode(value: String): String {
val sb = StringBuilder()
for (byte in value.encodeToByteArray()) {
val code = byte.toInt() and 0xFF
val ch = code.toChar()
if (ch in UNRESERVED) {
sb.append(ch)
} else {
sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
}
}
return sb.toString()
}
private val HEX = "0123456789ABCDEF".toCharArray()
@Serializable
private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String)
@Serializable
private data class FcmTokenBody(val token: String)
}

View File

@@ -0,0 +1,24 @@
package wang.yaojia.webterm.api.routes
/**
* Client-side FCM registration-token validator (validate at the boundary, plan §4). Deliberately
* LOOSE, per plan §4.5: non-empty, bounded length, `base64url` charset plus `:` — the FCM token
* format/length is undocumented and changes, so a strict length regex risks rejecting valid tokens.
*
* Unlike iOS's APNs hex rule, FCM tokens are case-sensitive → NOT lowercased. [normalize] returns
* the token unchanged when valid, or `null` (→ `ApiClientError.InvalidFcmToken` before any I/O).
*/
internal object FcmTokenRule {
/** Generous headroom bound; real tokens are ~150250 chars but the ceiling is undocumented. */
private const val MAX_LENGTH = 4096
/** base64url alphabet (`AZ az 09 - _`) plus the `:` that appears in FCM tokens. */
private val ALLOWED: Set<Char> =
(('A'..'Z') + ('a'..'z') + ('0'..'9') + listOf('-', '_', ':')).toSet()
fun normalize(raw: String): String? {
if (raw.isEmpty() || raw.length > MAX_LENGTH) return null
if (!raw.all { it in ALLOWED }) return null
return raw
}
}

View File

@@ -0,0 +1,72 @@
package wang.yaojia.webterm.api.models
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The prefs round-trip correctness trap: `PUT /prefs` replaces the WHOLE blob, so decode → mutate
* one known key → encode MUST carry every unknown top-level key through verbatim (including exact
* integer formatting), or an Android write clobbers web/iOS/future-server prefs.
*/
class UiPrefsTest {
@Test
fun sanitizesFavouritesAndCollapsedLikeTheWebClient() {
val prefs = UiPrefs.decode(
"""
{"favourites":["/a","/b","/a","",123],"collapsed":{"g1":true,"g2":false,"g3":"true","":true}}
""".trimIndent().toByteArray(),
)!!
// favourites: non-empty strings only, de-duplicated, order preserved; the number 123 dropped.
assertEquals(listOf("/a", "/b"), prefs.favourites)
// collapsed: only literal `true`; false, string "true", and the empty key are all dropped.
assertEquals(mapOf("g1" to true), prefs.collapsed)
}
@Test
fun mutatingOneKeyPreservesUnknownKeysAndIntegerFormatting() {
val original = UiPrefs.decode(
"""
{"favourites":["/old"],"collapsed":{"g":true},"schemaVersion":7,"vendor":{"nested":42,"ratio":1.5}}
""".trimIndent().toByteArray(),
)!!
val mutated = original.withFavourites(listOf("/new"))
val encoded = mutated.encodeBody().decodeToString()
// Known key was replaced...
assertEquals(listOf("/new"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
// ...collapsed (untouched known key) survived...
assertEquals(mapOf("g" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
// ...and every unknown key survived verbatim, integers still integers (42, not 42.0).
assertTrue(encoded.contains("\"schemaVersion\":7"), "unknown scalar key must round-trip: $encoded")
assertTrue(encoded.contains("\"nested\":42"), "nested integer must not become 42.0: $encoded")
assertTrue(encoded.contains("\"ratio\":1.5"), "nested double must round-trip: $encoded")
}
@Test
fun withCollapsedReplacesOnlyThatKey() {
val original = UiPrefs.decode("""{"favourites":["/keep"],"extra":"x"}""".toByteArray())!!
val encoded = original.withCollapsed(mapOf("ns" to true)).encodeBody().decodeToString()
assertEquals(listOf("/keep"), UiPrefs.decode(encoded.toByteArray())!!.favourites)
assertEquals(mapOf("ns" to true), UiPrefs.decode(encoded.toByteArray())!!.collapsed)
assertTrue(encoded.contains("\"extra\":\"x\""))
}
@Test
fun createBuildsAFreshBlobWithBothKnownKeys() {
val encoded = UiPrefs.create(favourites = listOf("/a"), collapsed = mapOf("g" to true))
.encodeBody().decodeToString()
assertEquals("""{"favourites":["/a"],"collapsed":{"g":true}}""", encoded)
}
@Test
fun nonObjectBodyDecodesToNull() {
assertNull(UiPrefs.decode("[]".toByteArray()))
assertNull(UiPrefs.decode("\"hi\"".toByteArray()))
assertNull(UiPrefs.decode("not json".toByteArray()))
}
}

View File

@@ -0,0 +1,65 @@
package wang.yaojia.webterm.api.pairing
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
/** Ports the tier table of iOS `HostClassificationTests` (plan §5.4, fail-safe unknown→public). */
class HostClassifierTest {
@Test
fun loopbackHostsClassifyAsLoopback() {
val hosts = listOf("localhost", "LOCALHOST", "127.0.0.1", "127.5.9.200", "::1", "[::1]")
hosts.forEach { assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(it), it) }
}
@Test
fun rfc1918AndLinkLocalAndMdnsClassifyAsPrivateLan() {
val hosts = listOf(
"10.0.0.5", "10.255.255.255",
"172.16.0.1", "172.20.10.1", "172.31.255.255",
"192.168.0.9", "192.168.1.1",
"169.254.1.1",
"mac-mini.local", "MAC-MINI.LOCAL", "printer.local",
)
hosts.forEach { assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(it), it) }
}
@Test
fun cgnatAndMagicDnsClassifyAsTailscale() {
val hosts = listOf(
"100.64.0.1", "100.100.1.1", "100.127.255.255",
"mac.tailnet.ts.net", "MAC.TAILNET.TS.NET", "host.ts.net",
)
hosts.forEach { assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(it), it) }
}
@Test
fun everythingElseFailsSafeToPublic() {
val hosts = listOf(
// routable public IPs
"8.8.8.8", "203.0.113.7",
// hostnames
"example.com", "claude.ai",
// boundary-miss IPv4 (just outside the private/tailscale ranges)
"172.15.0.1", "172.32.0.1", "100.63.0.1", "100.128.0.1",
// malformed / out-of-range / wrong arity → fail-safe public
"256.1.1.1", "1.2.3", "999.999.999.999", "not a host", "",
// non-loopback IPv6 (ULA / documentation) → fail-safe public (iOS v1 scope)
"fd00::1", "2001:db8::1",
)
hosts.forEach { assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(it), it) }
}
@Test
fun classifyEndpointDelegatesToHost() {
val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
val tailscale = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
val public = requireNotNull(HostEndpoint.fromBaseUrl("https://example.com"))
assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan))
assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify(tailscale))
assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify(public))
}
}

Some files were not shown because too many files have changed in this diff Show More