31 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
444 changed files with 51593 additions and 446 deletions

3
.gitignore vendored
View File

@@ -18,3 +18,6 @@ npm-debug.log*
# test coverage
coverage/
.gstack/
# deploy secrets (RELAY-PHASE1) — .env.example is committed, .env is not
deploy/.env

View File

@@ -107,11 +107,13 @@ npm run setup-hooks # adds the hooks + statusLine to ~/.claude/s
```
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.
> **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`.
`USE_TMUX=1 npm start` keeps sessions alive across a server restart.
### Tests
```bash
npm test # vitest, all modules (~470 tests, 80% coverage gate)
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
npm run typecheck # tsc (backend + frontend)
npm run build # compile backend to dist/
```

View File

@@ -13,6 +13,7 @@
"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"
@@ -26,6 +27,7 @@
"@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"
}

View File

@@ -1,11 +1,20 @@
/**
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`.
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO.
* 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'
@@ -24,13 +33,41 @@ export class CliUsageError extends Error {
}
}
/** 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>
installService(cfg: AgentConfig): Promise<void>
/** 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
}
@@ -60,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs {
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}`)
if (args.flags['install']) await deps.installService(cfg)
return 0
}
case 'run': {
if (ks.loadIdentity() === null || ks.loadCert() === null) {
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': {
@@ -88,7 +162,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
return 0
}
case 'install':
await deps.installService(cfg)
await deps.installService(cfg, deps.resolveInstallOptions())
return 0
case 'uninstall':
await deps.uninstallService()

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

@@ -9,6 +9,7 @@
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
@@ -19,9 +20,12 @@ export interface AgentConfig {
readonly hostId: string | null
}
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]']
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */
/**
* 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 {
@@ -30,8 +34,7 @@ export function isLoopbackWsUrl(url: string): boolean {
return false
}
if (parsed.protocol !== 'ws:') return false
const host = parsed.hostname
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
return isLoopbackHostLiteral(parsed.hostname)
}
function hasScheme(url: string, scheme: string): boolean {

View File

@@ -52,9 +52,11 @@ const OID = 0x06
const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
// 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 {
@@ -63,6 +65,24 @@ function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
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))]))
@@ -77,18 +97,20 @@ function toPem(der: Uint8Array, label: string): string {
}
/**
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
* The private key is used in-process only; never serialized into the output (INV4).
* 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 = spkiFromRawEd25519(id.publicKey)
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) // Ed25519 over CertificationRequestInfo
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
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]))

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

View File

@@ -1,20 +1,37 @@
/**
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host).
*
* Ed25519 keypair generated locally with node:crypto. `AgentIdentity` exposes ONLY the public
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or
* serializes the private key. The raw private key material is held in a module-private closure.
* 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 {
/** Ed25519 raw 32-byte public key → stored in host registry (§4.2 agent_pubkey). */
/** 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
/** Ed25519 signature over `message`, using the in-process private key. Never returns the key. */
/**
* 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
@@ -39,6 +56,7 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
const rawPub = rawPublicKey(publicKey)
const enrollFpr = computeEnrollFpr(rawPub)
return {
alg: 'ed25519',
publicKey: rawPub,
enrollFpr,
sign(message: Uint8Array): Uint8Array {
@@ -53,19 +71,61 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
}
}
/**
* 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)
}
/** Reconstruct an identity from a stored PKCS#8 PEM private key (keystore load path). */
/**
* 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,

View File

@@ -12,8 +12,9 @@ import {
writeFileSync,
} from 'node:fs'
import { join } from 'node:path'
import { createPrivateKey } from 'node:crypto'
import type { AgentIdentity } from './identity.js'
import { identityFromPrivatePem } from './identity.js'
import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
const SECRET_MODE = 0o600
const DIR_MODE = 0o700
@@ -54,6 +55,32 @@ function ensureDir(stateDir: string): void {
}
}
/**
* 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.
@@ -80,7 +107,7 @@ export function openKeystore(stateDir: string): Keystore {
throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
}
try {
return identityFromPrivatePem(pem)
return identityFromStoredPem(pem)
} catch (err) {
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`)
}

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

@@ -1,24 +1,152 @@
/**
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the
* logic is unit-testable without touching the real system.
* 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)')
@@ -42,37 +170,85 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
return null
}
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
export async function installService(
_cfg: AgentConfig,
platform: ServicePlatform,
deps: InstallDeps,
): Promise<void> {
if (deps.getuid() === 0) throw new RootRefusedError()
const bin = deps.binPath()
if (platform === 'launchd') {
const path = launchdPlistPath(deps.homedir())
deps.writeFile(path, buildLaunchdPlist(bin))
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
return
}
const path = systemdUnitPath(deps.homedir())
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
const { cmd, args } = systemdEnableCommand()
await deps.runCommand(cmd, args)
/**
* 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) }
}
/** Unload the service unit for `platform`. */
/**
* 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') {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir()))
for (const label of [agentLabel(), baseAppLabel()]) {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
await deps.runCommand(cmd, args)
}
return
}
const { cmd, args } = systemdDisableCommand()
for (const unit of [agentUnitName(), baseAppUnitName()]) {
const { cmd, args } = systemdDisableCommand(unit)
await deps.runCommand(cmd, args)
}
}

View File

@@ -1,35 +1,97 @@
/**
* 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`).
*/
const LABEL = 'com.web-terminal.agent'
/** 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 LABEL
return AGENT_LABEL
}
export function launchdPlistPath(homedir: string): string {
return `${homedir}/Library/LaunchAgents/${LABEL}.plist`
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
return `${homedir}/Library/LaunchAgents/${label}.plist`
}
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */
export function buildLaunchdPlist(binPath: string): string {
/** 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>${LABEL}</string>`,
' <key>ProgramArguments</key>',
' <array>',
` <string>${binPath}</string>`,
' <string>run</string>',
' </array>',
` <string>${escapeXml(label)}</string>`,
...programArgumentsBlock(programArguments),
' <key>RunAtLoad</key>',
' <true/>',
' <key>KeepAlive</key>',
' <true/>',
...environmentVariablesBlock(env),
'</dict>',
'</plist>',
'',

View File

@@ -1,8 +1,13 @@
/**
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change").
* APPENDS `https://<subdomain>.term.<domain>` to the base app's ALLOWED_ORIGINS env (idempotent),
* 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'
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
write: (p, c) => writeFileSync(p, c),
}
/** Compose the subdomain origin the base app must trust. */
export function subdomainOrigin(subdomain: string, domain: string): string {
return `https://${subdomain}.term.${domain}`
}
/** 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
const current = line.slice(KEY.length + 1)
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
if (origins.includes(origin)) return line // idempotent — already trusted
return `${KEY}=${[...origins, origin].join(',')}`
return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
})
if (!found) next.push(`${KEY}=${origin}`)
return next.join('\n')
@@ -42,15 +64,17 @@ function upsertOriginLine(content: string, origin: string): string {
/**
* Idempotently append the subdomain origin to ALLOWED_ORIGINS in `baseAppEnvPath`. Never removes an
* existing origin. Creates the file/line if absent.
* 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)
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

@@ -1,30 +1,129 @@
/**
* 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`).
*/
const UNIT_NAME = 'web-terminal-agent.service'
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 UNIT_NAME
return AGENT_UNIT
}
export function systemdUnitPath(homedir: string): string {
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
return `${homedir}/.config/systemd/user/${unitName}`
}
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
export function buildSystemdUnit(binPath: string, user: string): string {
/**
* 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=web-terminal host agent (rendezvous relay)',
`Description=${description}`,
'After=network-online.target',
'',
'[Service]',
'Type=simple',
`ExecStart=${binPath} run`,
`ExecStart=${execStart}`,
'Restart=on-failure',
'RestartSec=1',
`User=${user}`,
...environmentLines(options),
'',
'[Install]',
'WantedBy=default.target',
@@ -32,9 +131,13 @@ export function buildSystemdUnit(binPath: string, user: string): string {
].join('\n')
}
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] }
export function systemdEnableCommand(
unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', unitName] }
}
export function systemdDisableCommand(): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] }
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,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,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

@@ -33,10 +33,20 @@ describe('AgentConfig validation', () => {
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()
})

View File

@@ -4,6 +4,7 @@ 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',
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function fakeIdentity(): AgentIdentity {
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
return {
alg,
publicKey: new Uint8Array(32),
enrollFpr: 'fpr',
sign: () => new Uint8Array(64),
@@ -24,10 +26,10 @@ function fakeIdentity(): AgentIdentity {
}
}
function fakeKeystore(enrolled: boolean): Keystore {
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
return {
saveIdentity: vi.fn(),
loadIdentity: () => (enrolled ? fakeIdentity() : null),
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
saveCert: vi.fn(),
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
saveContentSecret: vi.fn(),
@@ -35,12 +37,19 @@ function fakeKeystore(enrolled: boolean): Keystore {
}
}
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,
generateIdentity: () => fakeIdentity('ed25519'),
generateP256Identity: () => fakeIdentity('p256'),
redeem: async (): Promise<EnrollResult> => ({
hostId: 'h-1',
subdomain: 'host-42',
@@ -48,7 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
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),
@@ -79,7 +94,7 @@ describe('parseArgs (T5)', () => {
})
})
describe('runCli (T5)', () => {
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)
@@ -87,12 +102,97 @@ describe('runCli (T5)', () => {
expect(out.join('\n')).toContain('host-42')
expect(out.join('\n')).not.toContain('PEM')
})
})
it('pair --install installs the service', async () => {
const install = vi.fn(async () => {})
const { d } = deps({ installService: install })
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(install).toHaveBeenCalledOnce()
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 () => {
@@ -100,6 +200,48 @@ describe('runCli (T5)', () => {
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)

View File

@@ -1,8 +1,52 @@
import { describe, expect, it } from 'vitest'
import { X509Certificate } from 'node:crypto'
import { generateIdentity } from '../src/keys/identity.js'
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')
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
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 })
}
})
})

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

@@ -1,10 +1,13 @@
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'
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
// 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')
})
})

View File

@@ -1,12 +1,18 @@
import { describe, expect, it, vi } from 'vitest'
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',
@@ -17,7 +23,12 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
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 {
@@ -34,6 +45,13 @@ function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs:
}
}
/** 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')
@@ -42,35 +60,361 @@ describe('detectPlatform (T17)', () => {
})
})
describe('installService (T17)', () => {
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('systemd: writes a run-as-user unit and enables it', async () => {
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)
const [, unit] = d.writes[0]!
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
// 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')
expect(d.runs[0]![0]).toBe('systemctl')
}
// both enabled
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
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)
const [path, plist] = d.writes[0]!
expect(path).toContain('LaunchAgents')
expect(plist).toContain('<string>run</string>')
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.runs[0]![0]).toBe('launchctl')
}
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('uninstall unloads cleanly', async () => {
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)
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
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')
})
})

View File

@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { generateIdentity } from '../src/keys/identity.js'
import { 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[] = []
@@ -67,4 +68,50 @@ describe('Keystore (INV4/INV5)', () => {
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/)
})
})

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

@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { ensureAllowedOrigin, subdomainOrigin, type OriginFsDeps } from '../src/service/originConfig.js'
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 }
@@ -41,3 +47,38 @@ describe('ensureAllowedOrigin (T17, EXPLORE §3)', () => {
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',
)
})
})

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

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))
}
}

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.api.pairing
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import java.security.cert.CertificateException
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
/** Re-derives iOS `PairingError.classify` for JVM exceptions (plan R12: drop localNetworkDenied). */
class PairingErrorTest {
private val lan = requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
@Test
fun connectionRefusedMapsToHostUnreachable() {
val result = PairingError.classify(ConnectException("Connection refused"), lan)
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
}
@Test
fun dnsFailureMapsToHostUnreachable() {
val result = PairingError.classify(UnknownHostException("no such host"), lan)
assertInstanceOf(PairingError.HostUnreachable::class.java, result)
}
@Test
fun socketTimeoutMapsToTimeout() {
assertEquals(PairingError.Timeout, PairingError.classify(SocketTimeoutException("read timed out"), lan))
}
@Test
fun interruptedIoMapsToTimeout() {
// OkHttp's overall call-timeout surfaces as a bare InterruptedIOException.
assertEquals(PairingError.Timeout, PairingError.classify(InterruptedIOException("timeout"), lan))
}
@Test
fun sslHandshakeFailureMapsToTlsFailure() {
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLHandshakeException("handshake_failure"), lan))
}
@Test
fun genericSslAndCertFailuresMapToTlsFailure() {
assertEquals(PairingError.TlsFailure, PairingError.classify(SSLException("tls"), lan))
assertEquals(PairingError.TlsFailure, PairingError.classify(CertificateException("bad cert"), lan))
}
@Test
fun cleartextBlockMapsToCleartextBlockedWithHost() {
val err = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted")
val result = PairingError.classify(err, lan)
assertInstanceOf(PairingError.CleartextBlocked::class.java, result)
assertEquals("192.168.1.5", (result as PairingError.CleartextBlocked).host)
}
@Test
fun wrappedCauseIsWalked() {
// OkHttp frequently wraps the real cause; the chain walk must see it.
val wrappedConnect = IOException("unexpected end of stream", ConnectException("refused"))
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(wrappedConnect, lan))
val wrappedTls = IOException("io", SSLHandshakeException("handshake"))
assertEquals(PairingError.TlsFailure, PairingError.classify(wrappedTls, lan))
}
@Test
fun unrecognizedErrorUsesFallbackWhenProvided() {
// Probe step ②: after ① passed, an unclassifiable connect failure is the Origin 401.
val fallback = PairingError.OriginRejected("hint")
assertEquals(fallback, PairingError.classify(IllegalStateException("weird"), lan, unrecognizedFallback = fallback))
}
@Test
fun unrecognizedErrorWithoutFallbackIsHostUnreachable() {
assertInstanceOf(
PairingError.HostUnreachable::class.java,
PairingError.classify(IllegalStateException("weird"), lan),
)
}
@Test
fun causeCycleTerminates() {
// A→B→A cycle must not loop forever; B is a ConnectException so it classifies as reachable-failure.
val a = IOException("a")
val b = ConnectException("b")
a.initCause(b)
b.initCause(a)
assertInstanceOf(PairingError.HostUnreachable::class.java, PairingError.classify(a, lan))
}
@Test
fun originRejectedHintDerivesFromOriginHeaderWithoutDefaultPort() {
val lanHint = PairingError.originRejectedHint(lan)
assertTrue(lanHint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
val tls = requireNotNull(HostEndpoint.fromBaseUrl("https://mac.tailnet.ts.net"))
val tlsHint = PairingError.originRejectedHint(tls)
assertTrue(tlsHint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
assertFalse(tlsHint.contains(":443"), "default https port must be omitted (no :443 迷信)")
}
}

View File

@@ -0,0 +1,284 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.net.UnknownServiceException
import javax.net.ssl.SSLHandshakeException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Ports iOS `PairingProbeTests`: the two-step probe (① GET reachability/shape, ② WS attach→adopt→
* kill-with-Origin), each failure mode mapped to a [PairingError], full-success leaves no orphan
* session, and the injected-deadline timeout — all under `runTest` virtual time (zero real waits).
*/
class PairingProbeTest {
private data class Fixture(
val endpoint: HostEndpoint,
val http: FakeHttpTransport,
val ws: FakeTermTransport,
val liveSessionsUrl: String,
val killUrl: String,
)
private fun fixture(base: String = BASE): Fixture {
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl(base))
return Fixture(
endpoint = endpoint,
http = FakeHttpTransport(),
ws = FakeTermTransport(),
liveSessionsUrl = "$base/live-sessions",
killUrl = "$base/live-sessions/$SESSION_ID",
)
}
/** Non-timeout cases use `timeout = null` so the probe never enters the race — deterministic. */
private suspend fun runProbe(f: Fixture, timeout: Duration? = null): PairingProbeResult =
runPairingProbeCore(f.endpoint, f.http, f.ws, timeout)
private fun failureError(result: PairingProbeResult): PairingError {
assertInstanceOf(PairingProbeResult.Failure::class.java, result)
return (result as PairingProbeResult.Failure).error
}
// ── Probe ① failure branches ────────────────────────────────────────────────────────────────
@Test
fun stepOneConnectionRefusedMapsToHostUnreachableAndNeverTouchesWs() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = ConnectException("refused"))
val result = runProbe(f)
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(result))
assertTrue(f.ws.connectAttempts.isEmpty(), "probe must not touch WS when ① fails")
}
@Test
fun stepOneHtmlBodyMapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "<html><body>router admin</body></html>".toByteArray())
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOne404MapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, status = 404)
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOneNonArrayJsonMapsToNotWebTerminal() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "{\"ok\":true}".toByteArray())
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
@Test
fun stepOneTlsFailureMapsToTlsFailure() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = SSLHandshakeException("handshake_failure"))
assertEquals(PairingError.TlsFailure, failureError(runProbe(f)))
}
@Test
fun stepOneTransportTimeoutMapsToTimeout() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = SocketTimeoutException("read timed out"))
assertEquals(PairingError.Timeout, failureError(runProbe(f)))
}
@Test
fun stepOneCleartextBlockMapsToCleartextBlockedWithHost() = runTest {
val f = fixture()
f.http.queueFailure(
url = f.liveSessionsUrl,
error = UnknownServiceException("CLEARTEXT communication to 192.168.1.5 not permitted"),
)
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.CleartextBlocked::class.java, error)
assertEquals("192.168.1.5", (error as PairingError.CleartextBlocked).host)
}
@Test
fun stepOneDnsFailureMapsToHostUnreachable() = runTest {
val f = fixture()
f.http.queueFailure(url = f.liveSessionsUrl, error = UnknownHostException("nxdomain"))
assertInstanceOf(PairingError.HostUnreachable::class.java, failureError(runProbe(f)))
}
// ── Probe ② failure branches ────────────────────────────────────────────────────────────────
@Test
fun stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() = runTest {
// ① passes; ② upgrade fails (a 401 is a shapeless connect error at the transport layer).
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.scriptConnectFailure()
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.OriginRejected::class.java, error)
val hint = (error as PairingError.OriginRejected).hint
assertTrue(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"), hint)
assertFalse(hint.contains(":443"), hint)
}
@Test
fun originRejectedHintOmitsDefaultPortForHttps() = runTest {
val f = fixture(base = "https://mac.tailnet.ts.net")
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.scriptConnectFailure()
val error = failureError(runProbe(f))
assertInstanceOf(PairingError.OriginRejected::class.java, error)
val hint = (error as PairingError.OriginRejected).hint
assertTrue(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"), hint)
assertFalse(hint.contains(":443"), hint)
}
@Test
fun stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() = runTest {
// A queued finish is flushed into the connection at connect → stream closes before `attached`.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.ws.finishFrames()
assertEquals(PairingError.HttpOkButNotWebTerminal, failureError(runProbe(f)))
}
// ── Full pass + kill round-trip ──────────────────────────────────────────────────────────────
@Test
fun fullProbeSuccessAttachesWithNullSessionIdThenKillsWithOrigin() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit(ATTACHED_FRAME)
val result = runProbe(f)
// Success payload is the probed endpoint.
assertEquals(PairingProbeResult.Success(f.endpoint), result)
// attach(null) is the only WS frame, with an explicit JSON null sessionId key.
assertEquals(1, f.ws.sentFrames.size)
val attach = Json.parseToJsonElement(f.ws.sentFrames.single()).jsonObject
assertEquals("attach", attach["type"]?.jsonPrimitive?.content)
assertTrue(attach["sessionId"] is JsonNull, "sessionId key must be present and JSON null")
// Exactly two HTTP requests: RO GET (NO Origin) then guarded DELETE (byte-equal Origin).
val requests = f.http.recordedRequests
assertEquals(2, requests.size)
assertFalse(requests.first().headers.containsKey("Origin"), "RO GET must not carry Origin")
val kill = requests.last()
assertEquals(HttpMethod.DELETE, kill.method)
assertEquals(f.killUrl, kill.url)
assertEquals(f.endpoint.originHeader, kill.headers["Origin"])
// The probe never holds the connection.
assertEquals(1, f.ws.closeCallCount)
}
@Test
fun publicWrapperHappyPathReturnsEndpoint() = runTest {
// The public entry uses the default Tunables deadline; immediate fakes never trip it.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit(ATTACHED_FRAME)
val result = runPairingProbe(f.endpoint, f.http, f.ws)
assertEquals(PairingProbeResult.Success(f.endpoint), result)
assertEquals(1, f.ws.closeCallCount)
}
@Test
fun outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 204)
f.ws.emit("""{"type":"output","data":"replay"}""")
f.ws.emit(ATTACHED_FRAME)
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
}
@Test
fun killRejectedByOriginGuardMapsToOriginRejected() = runTest {
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 403)
f.ws.emit(ATTACHED_FRAME)
assertInstanceOf(PairingError.OriginRejected::class.java, failureError(runProbe(f)))
}
@Test
fun killReturning404StillCountsAsSuccess() = runTest {
// Session exited between attach and kill — the goal state (no orphan) is already reached.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
f.http.queueSuccess(method = HttpMethod.DELETE, url = f.killUrl, status = 404)
f.ws.emit(ATTACHED_FRAME)
assertEquals(PairingProbeResult.Success(f.endpoint), runProbe(f))
}
// ── Timeout (virtual time, zero real waits) ──────────────────────────────────────────────────
@Test
fun probeTimesOutWhenServerNeverSendsAttached() = runTest {
// ① passes; ② connects but the server never replies `attached` → the probe hangs on the
// frame stream until the injected deadline cancels it.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
val result = runProbe(f, timeout = 10.seconds)
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
}
@Test
fun timeoutPathAlwaysClosesTheConnectionExactlyOnce() = runTest {
// ① passes; ② connects but the server never sends `attached` → the probe hangs on the frame
// stream until the injected deadline cancels it MID-adopt. The connection must still close
// (try/finally + NonCancellable), or the probe leaks the WS + its orphan session.
val f = fixture()
f.http.queueSuccess(url = f.liveSessionsUrl, body = "[]".toByteArray())
val result = runProbe(f, timeout = 10.seconds)
assertEquals(PairingProbeResult.Failure(PairingError.Timeout), result)
assertEquals(1, f.ws.closeCallCount, "the probe must close the WS even on the timeout path")
}
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
const val ATTACHED_FRAME = """{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"""
}
}

View File

@@ -0,0 +1,86 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
import java.util.UUID
/** Status-code → typed [ApiClientError] mapping per route, plus transport errors propagating raw. */
class ApiClientErrorMappingTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
@Test
fun killSessionMapsNotFoundAndForbidden() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.killSession(ID) })
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 403)
assertEquals(ApiClientError.Forbidden, errorOf { client.killSession(ID) })
}
@Test
fun hookDecisionMapsForbiddenToRejectedAnd429ToRateLimited() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 403)
assertEquals(ApiClientError.DecisionRejected, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 429)
assertEquals(ApiClientError.RateLimited, errorOf { client.hookDecision(ID, HookDecision.DENY, "t") })
}
@Test
fun projectDetailMapsEmptyPathAndAllServerErrorStatuses() = runTest {
// Empty path is rejected BEFORE any I/O.
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("") })
assertTrue(transport.recordedRequests.isEmpty(), "empty path must not hit the network")
val url = "$BASE/projects/detail?path=%2Fp"
transport.queueSuccess(url = url, status = 400)
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectDetail("/p") })
transport.queueSuccess(url = url, status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectDetail("/p") })
transport.queueSuccess(url = url, status = 500)
assertEquals(ApiClientError.ProjectDetailUnavailable, errorOf { client.projectDetail("/p") })
}
@Test
fun putPrefsMapsForbiddenAndUnexpected() = runTest {
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 403)
assertEquals(ApiClientError.Forbidden, errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", status = 418)
assertEquals(ApiClientError.UnexpectedStatus(418), errorOf { client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()) })
}
@Test
fun readOnlyRoutesMapNonOkToUnexpectedOrSessionNotFound() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 500, body = "boom".toByteArray())
assertEquals(ApiClientError.UnexpectedStatus(500), errorOf { client.liveSessions() })
// preview/events/uiConfig go through requireOk: 404 → SessionNotFound.
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/preview", status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.preview(ID) })
}
@Test
fun transportLevelErrorsPropagateUnwrapped() = runTest {
transport.queueFailure(url = "$BASE/live-sessions", error = IOException("connection refused"))
val error = errorOf { client.liveSessions() }
assertTrue(error is IOException)
assertEquals("connection refused", error?.message)
}
}

View File

@@ -0,0 +1,167 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) across all 12 routes. Verifies method, URL,
* the presence/absence of the `Origin` header, and the exact JSON body for the mutating routes.
*/
class ApiRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun lastRequest(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(request: HttpRequest) =
assertEquals(ORIGIN, request.headers[HeaderName.ORIGIN], "guarded route must stamp byte-equal Origin")
private fun assertReadOnly(request: HttpRequest) =
assertFalse(request.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
// ── read-only routes: correct verb+url, NO Origin ───────────────────────────────────────
@Test
fun liveSessionsIsPlainReadOnlyGet() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
client.liveSessions()
val request = lastRequest()
assertEquals(HttpMethod.GET, request.method)
assertEquals("$BASE/live-sessions", request.url)
assertReadOnly(request)
assertNull(request.body)
}
@Test
fun previewEventsUiConfigProjectsPrefsAreReadOnlyGets() = runTest {
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/preview",
body = """{"id":"$ID_STR","cols":80,"rows":24,"data":""}""".toByteArray(),
)
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
transport.queueSuccess(url = "$BASE/projects", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/prefs", body = "{}".toByteArray())
client.preview(ID)
client.events(ID)
client.uiConfig()
client.projects()
client.prefs()
val urls = transport.recordedRequests.map { it.url }
assertEquals(
listOf(
"$BASE/live-sessions/$ID_STR/preview",
"$BASE/live-sessions/$ID_STR/events",
"$BASE/config/ui",
"$BASE/projects",
"$BASE/prefs",
),
urls,
)
transport.recordedRequests.forEach {
assertEquals(HttpMethod.GET, it.method)
assertReadOnly(it)
}
}
@Test
fun projectDetailStrictlyPercentEncodesPathInTheQuery() = runTest {
val path = "/home/me/my repo/a+b&c"
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
body = """{"name":"repo","path":"$path","isGit":true}""".toByteArray(),
)
client.projectDetail(path)
val request = lastRequest()
assertEquals(HttpMethod.GET, request.method)
assertEquals("$BASE/projects/detail?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c", request.url)
assertReadOnly(request)
}
// ── guarded routes: correct verb+url, Origin stamped, exact body ────────────────────────
@Test
fun killSessionIsGuardedDeleteWithNoBody() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
client.killSession(ID)
val request = lastRequest()
assertEquals(HttpMethod.DELETE, request.method)
assertEquals("$BASE/live-sessions/$ID_STR", request.url)
assertGuarded(request)
assertNull(request.body)
}
@Test
fun hookDecisionIsGuardedPostWithExactBodyAndJsonContentType() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
client.hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
val request = lastRequest()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/hook/decision", request.url)
assertGuarded(request)
assertEquals(ContentType.JSON, request.headers[HeaderName.CONTENT_TYPE])
assertEquals(
"""{"sessionId":"$ID_STR","decision":"allow","token":"cap-tok-123"}""",
request.body?.decodeToString(),
)
}
@Test
fun putPrefsIsGuardedPutEchoingTheFullBlob() = runTest {
val body = """{"favourites":["/a"],"collapsed":{}}"""
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = body.toByteArray())
client.putPrefs(UiPrefs.create(favourites = listOf("/a")))
val request = lastRequest()
assertEquals(HttpMethod.PUT, request.method)
assertEquals("$BASE/prefs", request.url)
assertGuarded(request)
assertEquals(body, request.body?.decodeToString())
}
@Test
fun fcmTokenRegisterAndUnregisterAreGuardedWithTokenBody() = runTest {
val token = "fVoT9x-abc_DEF:api-901"
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/push/fcm-token", status = 204)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/push/fcm-token", status = 204)
client.registerFcmToken(token)
client.unregisterFcmToken(token)
val post = transport.recordedRequests[0]
val delete = transport.recordedRequests[1]
assertEquals(HttpMethod.POST, post.method)
assertEquals(HttpMethod.DELETE, delete.method)
assertTrue(post.url.endsWith("/push/fcm-token"))
assertGuarded(post)
assertGuarded(delete)
assertEquals("""{"token":"$token"}""", post.body?.decodeToString())
assertEquals("""{"token":"$token"}""", delete.body?.decodeToString())
}
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/** The intentionally-LOOSE FCM token validator (plan §4.5) + register/unregister error mapping. */
class FcmTokenTest {
private companion object {
const val BASE = "http://h:3000"
const val URL = "$BASE/push/fcm-token"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun acceptsBase64UrlPlusColonAndIsCaseSensitive() {
// base64url chars + ':' , mixed case preserved (FCM tokens are case-sensitive → not lowered).
val token = "cRZ7-x_Y9:APA91bH-Ab_CdEf"
assertEquals(token, FcmTokenRule.normalize(token))
}
@Test
fun rejectsEmptyOverlongAndOutOfCharsetTokens() {
assertNull(FcmTokenRule.normalize(""))
assertNull(FcmTokenRule.normalize("a".repeat(4097)))
assertNull(FcmTokenRule.normalize("has space"))
assertNull(FcmTokenRule.normalize("has/slash"))
assertNull(FcmTokenRule.normalize("emoji😀"))
assertNotNull(FcmTokenRule.normalize("a".repeat(4096))) // exactly at the bound is fine
}
@Test
fun invalidTokenIsRejectedBeforeAnyNetworkIO() = runTest {
val error = runCatching { client.registerFcmToken("bad token") }.exceptionOrNull()
assertEquals(ApiClientError.InvalidFcmToken, error)
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an invalid token")
}
@Test
fun registerMaps400To403To429() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 400)
assertEquals(ApiClientError.InvalidFcmToken, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 403)
assertEquals(ApiClientError.Forbidden, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 429)
assertEquals(ApiClientError.RateLimited, runCatching { client.registerFcmToken("okTok") }.exceptionOrNull())
}
@Test
fun registerAndUnregisterSucceedOn204() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = URL, status = 204)
transport.queueSuccess(method = HttpMethod.DELETE, url = URL, status = 204)
// No exception = success.
client.registerFcmToken("okTok")
client.unregisterFcmToken("okTok")
assertEquals(2, transport.recordedRequests.size)
}
}

View File

@@ -0,0 +1,102 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/** End-to-end decode of each model from a well-formed body (exercises the custom serializers). */
class HappyPathDecodeTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun previewDecodesGeometryAndOpaqueData() = runTest {
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/preview",
body = """{"id":"$ID_STR","cols":100,"rows":30,"data":"hello"}""".toByteArray(),
)
val preview = client.preview(ID)
assertEquals(ID, preview.id)
assertEquals(100, preview.cols)
assertEquals("hello", preview.data)
}
@Test
fun uiConfigDecodesAllowAutoMode() = runTest {
transport.queueSuccess(url = "$BASE/config/ui", body = """{"allowAutoMode":true}""".toByteArray())
assertTrue(client.uiConfig().allowAutoMode)
}
@Test
fun liveSessionsDecodesTelemetry() = runTest {
val body = """
[{"id":"$ID_STR","createdAt":10,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24,
"telemetry":{"at":99,"model":"opus","costUsd":0.42,"linesAdded":10,"pr":{"number":7,"url":"http://x"}}}]
""".trimIndent()
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
val session = client.liveSessions().single()
assertEquals("opus", session.telemetry?.model)
assertEquals(0.42, session.telemetry?.costUsd)
assertEquals(7, session.telemetry?.pr?.number)
}
@Test
fun projectDetailDecodesWorktreesSessionsAndClaudeMd() = runTest {
val body = """
{"name":"repo","path":"/r","isGit":true,"branch":"main","dirty":true,
"worktrees":[{"path":"/r","branch":"main","isMain":true,"isCurrent":true},
{"path":"/r-wt","head":"abc123","isMain":false,"isCurrent":false}],
"sessions":[{"id":"$ID_STR","title":"claude","status":"waiting","clientCount":1,"createdAt":5,"exited":false}],
"hasClaudeMd":true,"claudeMd":"# Repo"}
""".trimIndent()
transport.queueSuccess(url = "$BASE/projects/detail?path=%2Fr", body = body.toByteArray())
val detail = client.projectDetail("/r")
assertEquals("main", detail.branch)
assertEquals(2, detail.worktrees.size)
assertTrue(detail.worktrees[0].isMain)
assertEquals("abc123", detail.worktrees[1].head)
assertEquals("claude", detail.sessions.single().title)
assertEquals("# Repo", detail.claudeMd)
}
@Test
fun prefsRoundTripsThroughGetAndPutEcho() = runTest {
transport.queueSuccess(
url = "$BASE/prefs",
body = """{"favourites":["/a"],"collapsed":{"g":true},"vendor":1}""".toByteArray(),
)
val prefs = client.prefs()
assertEquals(listOf("/a"), prefs.favourites)
// PUT returns the server's sanitized echo — treat IT as truth (server dropped "vendor").
transport.queueSuccess(
method = HttpMethod.PUT,
url = "$BASE/prefs",
body = """{"favourites":["/a","/b"],"collapsed":{}}""".toByteArray(),
)
val echoed = client.putPrefs(prefs.withFavourites(listOf("/a", "/b")))
assertEquals(listOf("/a", "/b"), echoed.favourites)
}
@Test
fun prefsNonObjectBodyThrowsInvalidResponseBody() = runTest {
transport.queueSuccess(url = "$BASE/prefs", body = "[]".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.prefs() }.exceptionOrNull(),
)
}
}

View File

@@ -0,0 +1,101 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID
/** Tolerant decode at the UNTRUSTED server boundary (plan §4): drop bad elements, never crash. */
class TolerantDecodeTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
@Test
fun liveSessionsDropsMalformedEntriesAndMapsUnknownStatusToUnknown() = runTest {
// Entry 1: valid, unknown status string. Entry 2: missing required clientCount → dropped.
// Entry 3: non-UUID id → dropped. Entry 4: valid, big-ms createdAt (Long), no telemetry.
val body = """
[
{"id":"$ID_STR","createdAt":1700000000000,"clientCount":2,"status":"reticulating","exited":false,"cols":80,"rows":24},
{"id":"22222222-2222-4222-8222-222222222222","createdAt":1,"status":"idle","exited":false,"cols":80,"rows":24},
{"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
{"id":"33333333-3333-4333-8333-333333333333","createdAt":1700000000001,"clientCount":0,"status":"working","exited":true,"cwd":"/w","cols":120,"rows":40,"lastOutputAt":1700000000005}
]
""".trimIndent()
transport.queueSuccess(url = "$BASE/live-sessions", body = body.toByteArray())
val sessions = client.liveSessions()
assertEquals(2, sessions.size)
assertEquals(ID, sessions[0].id)
assertEquals(ClaudeStatus.UNKNOWN, sessions[0].status) // unknown wire value → UNKNOWN, not dropped
assertEquals(1_700_000_000_000L, sessions[0].createdAt) // ms fits Long, would overflow Int
assertEquals(ClaudeStatus.WORKING, sessions[1].status)
assertEquals(1_700_000_000_005L, sessions[1].lastOutputAt)
}
@Test
fun liveSessionsNonArrayBodyThrowsInvalidResponseBody() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "{}".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.liveSessions() }.exceptionOrNull(),
)
}
@Test
fun projectsKeepsProjectButDropsOnlyItsMalformedNestedSession() = runTest {
val body = """
[
{"name":"repo","path":"/r","isGit":true,"branch":"main","sessions":[
{"id":"$ID_STR","status":"working","clientCount":1,"createdAt":1,"exited":false},
{"id":"bad","status":"working","clientCount":1,"createdAt":1,"exited":false}
]}
]
""".trimIndent()
transport.queueSuccess(url = "$BASE/projects", body = body.toByteArray())
val projects = client.projects()
assertEquals(1, projects.size) // project survives its bad nested session
assertEquals(1, projects[0].sessions.size)
assertEquals(ID, projects[0].sessions[0].id)
}
@Test
fun eventsNonArrayBodyDegradesToEmptyAndUnknownClassSurvives() = runTest {
// A non-array body (timeline capture disabled) → [] rather than an error.
transport.queueSuccess(url = "$BASE/live-sessions/$ID_STR/events", body = "{}".toByteArray())
assertTrue(client.events(ID).isEmpty())
// Unknown class decodes fine (shape ok); hasKnownClass=false for downstream filtering.
transport.queueSuccess(
url = "$BASE/live-sessions/$ID_STR/events",
body = """[{"at":5,"class":"weird","label":"did a thing"}]""".toByteArray(),
)
val events = client.events(ID)
assertEquals(1, events.size)
assertFalse(events[0].hasKnownClass)
assertEquals("did a thing", events[0].label)
}
@Test
fun singleObjectRoutesThrowInvalidResponseBodyOnGarbage() = runTest {
transport.queueSuccess(url = "$BASE/config/ui", body = "not json".toByteArray())
assertEquals(
ApiClientError.InvalidResponseBody,
runCatching { client.uiConfig() }.exceptionOrNull(),
)
}
}

View File

@@ -0,0 +1,122 @@
// ─────────────────────────────────────────────────────────────────────────────
// :app — the Android application (Compose Material 3 + Adaptive design system,
// Hilt DI skeleton, launcher MainActivity). Mirrors iOS App/WebTerm.
//
// A13 establishes the Android UI-stack version matrix for every later Android task
// (the app-stack baseline, analogous to how A1 established the JVM catalog). All
// UI versions are resolved in gradle/libs.versions.toml.
//
// Plugins (AGP 9 has BUILT-IN Kotlin → never apply org.jetbrains.kotlin.android):
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
// ─────────────────────────────────────────────────────────────────────────────
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "wang.yaojia.webterm"
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
// Kotlin 2.3.21 requires compiling against SDK 36+ (BOM 2025.11 → ui 1.9.5).
// targetSdk stays 35 per plan §2 (compileSdk ≥ targetSdk is the normal rule).
compileSdk = 36
defaultConfig {
applicationId = "wang.yaojia.webterm"
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
compose = true
}
buildTypes {
debug {
// No applicationId suffix — keep it stable for deep-link testing (A32).
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
// Modules A15 wires into the DI graph.
implementation(project(":wire-protocol"))
implementation(project(":session-core"))
implementation(project(":api-client"))
implementation(project(":transport-okhttp"))
implementation(project(":client-tls"))
// A15: bridge the mTLS device identity (:client-tls-android) onto the shared client, and
// provide the DataStore-backed stores (:host-registry). :client-tls-android exposes
// :client-tls via `api`, but the explicit dep above is kept for clarity.
implementation(project(":client-tls-android"))
implementation(project(":host-registry"))
// Terminal render (A16): RemoteTerminalSession/RemoteTerminalView for the live terminal (A21) and
// the raw Termux TerminalEmulator for A18's off-screen thumbnail rasterisation (§6.7). :terminal-view
// scopes Termux as `implementation`, so :app names the emulator directly for the off-screen path.
implementation(project(":terminal-view"))
implementation(libs.termux.terminal.emulator)
// QR pairing (A19): CameraX + on-device ML Kit barcode scanning.
implementation(libs.bundles.camerax)
implementation(libs.mlkit.barcode.scanning)
// Push (A30) + nav/deep-links (A32).
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.androidx.biometric)
implementation(libs.androidx.navigation.compose)
// OkHttp is `implementation` (not `api`) in :transport-okhttp, so :app names OkHttpClient /
// ConnectionPool directly in NetworkModule/TlsModule.
implementation(libs.okhttp)
// Coroutines are used directly by the wiring (EventBus fan-out, engine confinement scope).
implementation(libs.kotlinx.coroutines.core)
// Preferences DataStore is named in StorageModule's @Provides return types.
implementation(libs.androidx.datastore.preferences)
// AndroidX foundation
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Compose (the BOM governs every version below)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Hilt (Dagger) — KSP annotation processing
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// JVM unit tests (no device) — the frozen design-token spec, EventBus fan-out, and the
// RetainedSessionHolder lifecycle invariant driven by a fake transport under virtual time.
testImplementation(libs.bundles.unit.test)
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
testRuntimeOnly(libs.junit.platform.launcher)
}
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}

4
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,4 @@
# WebTerm :app R8/ProGuard rules.
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Talk to the WebTerm server over WS/HTTP. No NEARBY_WIFI_DEVICES: Android
has no local-network permission prompt; plain WS to a LAN IP needs none
(plan §8 / R12). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Push (A30/A31) — requested with rationale at runtime on API 33+. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- QR pairing (A19) — CameraX preview; requested with rationale at runtime, denial
degrades to manual URL entry. android:required=false so non-camera devices install. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application
android:name=".WebTermApp"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.WebTerm"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.WebTerm">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- A32 deep links → DeepLinkRouter (v4-UUID whitelist; the manifest never validates). -->
<!-- Custom scheme: webterminal://open?host=<uuid>&join=<uuid> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="webterminal" android:host="open" />
</intent-filter>
<!-- Verified App Link: https://terminal.yaojia.wang/open?host=&join= .
autoVerify needs assetlinks.json (release SHA-256) served at
/.well-known/assetlinks.json — a deploy artifact (§8), not built here. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="terminal.yaojia.wang" android:pathPrefix="/open" />
</intent-filter>
</activity>
<!-- Push (A30) — FCM data-only entry point (server sends no notification block). -->
<service
android:name=".push.FcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Deny action: auth-free, no-UI, expedited POST (goAsync). -->
<receiver
android:name=".push.DenyBroadcastReceiver"
android:exported="false" />
<!-- Allow action: translucent, excluded-from-recents trampoline hosting BiometricPrompt
(a receiver/service cannot present it — R1). -->
<activity
android:name=".push.AllowTrampolineActivity"
android:theme="@style/Theme.WebTerm.Translucent"
android:excludeFromRecents="true"
android:taskAffinity=""
android:exported="false" />
</application>
</manifest>

View File

@@ -0,0 +1,176 @@
package wang.yaojia.webterm
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.nav.DeepLinkRouter
import wang.yaojia.webterm.nav.NavRoutes
import wang.yaojia.webterm.nav.WebTermNavHost
import wang.yaojia.webterm.push.PushCoordinator
import wang.yaojia.webterm.wiring.AppEnvironment
import javax.inject.Inject
/**
* The single launcher Activity and composition root. A `@AndroidEntryPoint` [FragmentActivity]
* (FragmentActivity so the biometric/allow trampoline hierarchy is consistent) that:
*
* - **warms the mTLS/OkHttp stack off `Main`** ([AppEnvironment.warmUp]) before any terminal bind;
* - **registers this device's FCM token with every paired host on app start** ([PushCoordinator]);
* - **hands the launch/new intents to [DeepLinkRouter]** (the ONE whitelist parser) and navigates only a
* validated [NavRoutes.forDeepLink] route — the manifest `<intent-filter>`s deliver, never validate;
* - renders [WebTermNavHost] with the cold-start start destination ([AppEnvironment.coldStartPolicy]).
*/
@AndroidEntryPoint
public class MainActivity : FragmentActivity() {
@Inject
public lateinit var appEnvironment: AppEnvironment
@Inject
public lateinit var pushCoordinator: PushCoordinator
/** The latest deep-link URI to route (null = nothing pending). Fed by onCreate + onNewIntent. */
private val pendingDeepLink = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Build the shared OkHttp/mTLS stack OFF-Main before the first bind (warmUp hops to IO itself);
// a real failure is swallowed (the terminal screen surfaces its own retry, FIX 5) but cancellation
// is rethrown so a destroyed Activity tears the warm-up down cleanly.
lifecycleScope.launch {
try {
appEnvironment.warmUp()
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
// best-effort pre-warm; TerminalScreen re-runs warmUp with an actionable retry.
}
}
// App start: register the current FCM token with every paired host (best-effort self-heal).
pushCoordinator.registerAllHosts()
handleDeepLinkIntent(intent)
setContent {
WebTermTheme {
Surface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
NotificationPermissionGate()
ColdStartHost(
env = appEnvironment,
navController = navController,
pendingDeepLink = pendingDeepLink,
onHostPaired = { host -> pushCoordinator.registerHost(host) },
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleDeepLinkIntent(intent)
}
/** Capture a VIEW deep link's data URI; a launcher/push-body intent has none → nothing to route. */
private fun handleDeepLinkIntent(intent: Intent?) {
pendingDeepLink.value = intent?.data?.toString()
}
}
/**
* Resolve the cold-start destination ([AppEnvironment.coldStartPolicy]) then render the graph. Shows a
* spinner until the (suspend) host-presence read completes.
*/
@Composable
private fun ColdStartHost(
env: AppEnvironment,
navController: NavHostController,
pendingDeepLink: MutableStateFlow<String?>,
onHostPaired: (Host) -> Unit,
) {
val startRoute by produceState<String?>(initialValue = null, key1 = env) {
value = NavRoutes.startRouteFor(env.coldStartPolicy.initialRoute())
}
val route = startRoute
if (route == null) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
WebTermNavHost(
env = env,
startRoute = route,
navController = navController,
onHostPaired = onHostPaired,
)
// Route pending deep links ONLY here — inside the `route != null` branch, so `WebTermNavHost`
// (which calls navController.setGraph) has composed and the graph is set before the effect body
// runs. On a cold launch the VIEW intent is captured before setContent, but its navigation is
// deferred to this point instead of racing an unset graph (which silently dropped the link).
DeepLinkEffect(pending = pendingDeepLink, navController = navController)
}
}
/**
* Route a pending deep-link URI through [DeepLinkRouter] (the ONE whitelist parser) and navigate only a
* validated route; invalid/ambiguous links are ignored (never partially applied). Clears the pending
* value once handled.
*/
@Composable
private fun DeepLinkEffect(
pending: MutableStateFlow<String?>,
navController: NavHostController,
) {
val uri by pending.collectAsStateWithLifecycle()
LaunchedEffect(uri) {
val target = uri ?: return@LaunchedEffect
NavRoutes.forDeepLink(DeepLinkRouter.route(target))?.let { route ->
runCatching { navController.navigate(route) }
}
pending.value = null
}
}
/** Request POST_NOTIFICATIONS once on first launch (API 33+); a denial only stops push from being SHOWN. */
@Composable
private fun NotificationPermissionGate() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

View File

@@ -0,0 +1,12 @@
package wang.yaojia.webterm
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
/**
* Application entry point. `@HiltAndroidApp` triggers Hilt's code generation and
* creates the app-level `SingletonComponent` (see `di/AppModule`). The real
* composition root / wiring is A15 — this stays intentionally empty.
*/
@HiltAndroidApp
public class WebTermApp : Application()

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.wire.TimelineEvent
/**
* # AwayDigestView (A22) — the "what happened while I was away" reattach summary.
*
* The Android analogue of the iOS away-digest banner (plan §1): shown ABOVE the terminal on the
* first reconnect after a gap, summarising the [AwayDigest] reduced over the events since the user
* left — tool-run and waiting counts plus done/stuck flags. Its **展开** ("expand") affordance opens
* the A28 activity-timeline sheet via [onExpand] (this component only exposes the seam; A28 wires it).
*
* An [empty][AwayDigest.isEmpty] digest renders **nothing** (all-zero suppressed) — the caller
* ([GateViewModel] holds `null` for an empty digest) normally never passes one, but the guard makes
* the component safe in isolation. Recent-event labels are server-derived text rendered as **inert
* [Text]** (no linkify/markdown, §8).
*/
@Composable
public fun AwayDigestView(
digest: AwayDigest,
onExpand: () -> Unit,
modifier: Modifier = Modifier,
) {
if (digest.isEmpty) return // all-zero suppressed; render nothing.
WebTermCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(
text = summaryLine(digest),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
digest.recent.lastOrNull()?.let { latest ->
// INERT: server-derived phrase rendered verbatim, single line (§8).
Text(
text = latest.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onExpand) { Text(text = "展开") }
}
}
}
}
/** The one-line count summary, e.g. "离开期间3 次工具调用 · 1 次等待 · 已完成". Pure presentation. */
internal fun summaryLine(digest: AwayDigest): String {
val parts = mutableListOf<String>()
if (digest.toolRuns > 0) parts += "${digest.toolRuns} 次工具调用"
if (digest.waitingCount > 0) parts += "${digest.waitingCount} 次等待"
if (digest.sawDone) parts += "已完成"
if (digest.sawStuck) parts += "疑似卡住"
val body = if (parts.isEmpty()) "有活动" else parts.joinToString(" · ")
return "离开期间:$body"
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "AwayDigestView")
@Composable
private fun AwayDigestViewPreview() {
WebTermTheme {
AwayDigestView(
digest = AwayDigest(
toolRuns = 3,
waitingCount = 1,
sawDone = true,
sawStuck = false,
recent = listOf(TimelineEvent(at = 0L, eventClass = "tool", toolName = "Bash", label = "ran Bash")),
),
onExpand = {},
)
}
}

View File

@@ -0,0 +1,145 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
/**
* # ContinueLastBanner (A29) — the "继续上次会话" re-entry affordance.
*
* The visible half of the continue-last-session cold-start UX (plan §1, §5 A29). When a host still has a
* live last session ([LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore], kept current
* by [SessionActivityBridge][wang.yaojia.webterm.wiring.SessionActivityBridge]), this banner is layered
* on top of the sessions landing; tapping it re-opens THAT session (full scrollback replay) instead of
* spawning a new one. It renders in two layouts:
* - [ContinueLastVariant.Stack] — a full-width strip pinned above a stacked (phone) session list;
* - [ContinueLastVariant.Sidebar] — a compact rounded card for the tablet nav sidebar.
*
* ### Shown IFF a last session exists
* The pure [continueLastModel] maps the persisted last-session id to a [ContinueLastModel] (or `null`),
* and the composable renders NOTHING for a `null` model — so "shown iff a last session exists" is the
* single JVM-tested rule, no device needed. Layout/gesture polish is device-QA (plan §7).
*
* ### Inert rendering (plan §8)
* The session id is a SERVER-issued (untrusted) string; it is shown as a plain, inert [Text] — no
* Markdown / autolink / `AnnotatedString` linkify.
*/
/** Which layout the banner renders in — a top strip (phone) or a sidebar card (tablet). */
public enum class ContinueLastVariant { Stack, Sidebar }
/** The banner's derived state: the last session that can be re-opened. */
public data class ContinueLastModel(val sessionId: String)
/**
* PURE derivation: a non-blank persisted [lastSessionId] yields a [ContinueLastModel] (banner shown);
* `null`/blank yields `null` (banner hidden). Blank is treated as absent so a corrupt persisted value
* never renders an un-openable banner.
*/
public fun continueLastModel(lastSessionId: String?): ContinueLastModel? =
lastSessionId?.takeIf { it.isNotBlank() }?.let { ContinueLastModel(it) }
/**
* The banner. Renders NOTHING when [model] is `null` (no last session). Tapping the whole surface fires
* [onContinue] with the session id to re-open. [variant] picks the stack vs sidebar layout.
*/
@Composable
public fun ContinueLastBanner(
model: ContinueLastModel?,
onContinue: (String) -> Unit,
modifier: Modifier = Modifier,
variant: ContinueLastVariant = ContinueLastVariant.Stack,
) {
if (model == null) return
val shape: Shape = when (variant) {
ContinueLastVariant.Stack -> RectangleShape
ContinueLastVariant.Sidebar -> RoundedCornerShape(Radius.md12)
}
val widthModifier =
if (variant == ContinueLastVariant.Stack) Modifier.fillMaxWidth() else Modifier
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shape = shape,
modifier = modifier
.then(widthModifier)
.clickable { onContinue(model.sessionId) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
) {
Text(text = "", style = MaterialTheme.typography.titleMedium)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
) {
Text(text = "继续上次会话", style = MaterialTheme.typography.bodyMedium)
// Untrusted server-issued id — inert Text, no autolink/markdown (§8).
Text(
text = shortSessionId(model.sessionId),
style = WebTermType.metaMono,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(text = "", style = MaterialTheme.typography.titleMedium)
}
}
}
/** A short, inert label for the (untrusted) session id — the first id segment, capped so it never wraps. */
private fun shortSessionId(sessionId: String): String =
sessionId.substringBefore('-').take(SHORT_ID_MAX)
private const val SHORT_ID_MAX: Int = 12
// ── Preview ───────────────────────────────────────────────────────────────────
@Preview(name = "ContinueLastBanner — stack")
@Composable
private fun ContinueLastBannerStackPreview() {
WebTermTheme {
ContinueLastBanner(
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
onContinue = {},
variant = ContinueLastVariant.Stack,
)
}
}
@Preview(name = "ContinueLastBanner — sidebar", widthDp = 220)
@Composable
private fun ContinueLastBannerSidebarPreview() {
WebTermTheme {
ContinueLastBanner(
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
onContinue = {},
variant = ContinueLastVariant.Sidebar,
modifier = Modifier.padding(Spacing.md12),
)
}
}

View File

@@ -0,0 +1,89 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.StatusBadge
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.GateKind
/**
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
*
* The Android analogue of the iOS tool-gate card (public/tabs.ts:334-350): a `WebTermCard` holding
* a "needs me" status badge, the server-supplied [detail][GateState.detail] rendered as **inert
* [Text]** (untrusted OSC/tool text — no Markdown / autolink, plan §8), and two actions: **批准**
* (approve → `Approve(mode=null)`) and **拒绝** (reject).
*
* ### Epoch capture (the stale-guard seam)
* Each button hands back the [epoch][GateState.epoch] of the gate IT rendered, so a tap is bound to
* the gate the user actually saw. The screen (A21) wires `onDecide = { d, e -> vm.decide(d, e) }`;
* [GateViewModel.decide] then drops the tap if that epoch is no longer the live gate.
*
* This card is for [GateKind.TOOL] gates only; plan gates use [PlanGateSheet] (three-way).
* Layout / colors are device-QA; the wired decision + epoch capture is the load-bearing contract.
*
* @param onDecide invoked with the tapped [GateDecision] and this gate's epoch.
*/
@Composable
public fun GateBanner(
gate: GateState,
onDecide: (GateDecision, Int) -> Unit,
modifier: Modifier = Modifier,
) {
WebTermCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
StatusBadge(status = DisplayStatus.PendingApproval, showsLabel = true)
gate.detail?.let { detail ->
// INERT: untrusted server string rendered verbatim, single-line, no linkify (§8).
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
Text(text = "拒绝")
}
Button(onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }) {
Text(text = "批准")
}
}
}
}
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "GateBanner")
@Composable
private fun GateBannerPreview() {
WebTermTheme {
GateBanner(
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
onDecide = { _, _ -> },
)
}
}

View File

@@ -0,0 +1,278 @@
package wang.yaojia.webterm.components
import android.content.res.Configuration
import android.view.KeyEvent
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.LayoutTokens
import wang.yaojia.webterm.designsystem.Opacity
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.KeyByteMap
/**
* # KeyBar (A17) — the mobile IME-bypass touch key-bar + hardware-chord router.
*
* The Android port of `public/keybar.ts`: a horizontal strip of the Claude Code
* high-frequency keys a phone soft keyboard can't easily produce (Esc / Esc² /
* ⇧Tab / arrows / Enter / Ctrl-chords / Tab / `/`), most-used first.
*
* ### Byte source of truth (plan §6.3, A6)
* Every label→bytes lookup — buttons AND hardware chords — resolves through
* [KeyByteMap] (byte-for-byte matched to the web `KEY_MAP`). NO escape sequence is
* ever hand-written here; hand-writing `ESC[A` etc. is a review finding.
*
* ### IME bypass (plan §6.3 source #2, R8)
* A key-bar tap calls [emitKey] → `onSend(bytes)` DIRECTLY, mirroring the web
* `ws.send` bypass. It never routes through a `BasicTextField` / the emulator's
* `InputConnection`, so tapping a key never pops the soft keyboard. `onSend` is
* wired by A21 to `TerminalSessionController.sendInput`.
*
* ### DECCKM split (plan §6.3 source #3)
* Hardware keys are routed by [HardwareKeyRouter]: Esc / Ctrl-<letter> / ⇧Tab are
* mapped app-side via [KeyByteMap]; **arrows / Enter / (plain) Tab are LEFT to
* Termux's `KeyHandler`** (via `RemoteTerminalView.onKeyCommand` returning false)
* so `cursorKeysApplication` (DECCKM) still emits `ESC O A` — never `ESC [ A` —
* for vim/htop.
*
* Compose layout / IME-inset behaviour / real key events are DEVICE-QA (plan §7);
* the byte contract + the routing decision are the JVM-tested core.
*/
/** One key-bar button: fixed glyph + Chinese caption + the [KeyByteMap.Key] it emits. */
public data class KeyBarButton(
val label: String,
val caption: String,
val key: KeyByteMap.Key,
val title: String,
val isPrimary: Boolean = false,
)
/**
* Button layout — most-used Claude Code keys first, mirroring the web
* `KEYBAR_BUTTONS` (public/keybar.ts) order, glyphs and captions 1:1. The 🎤 voice
* button (web A2) is out of scope for A17.
*/
public val KEYBAR_LAYOUT: List<KeyBarButton> = listOf(
KeyBarButton("Esc", "中断", KeyByteMap.Key.ESC, "Esc — interrupt Claude / dismiss", isPrimary = true),
KeyBarButton("Esc²", "回溯", KeyByteMap.Key.ESC_ESC, "Esc Esc — rewind / clear draft"),
KeyBarButton("⇧Tab", "模式", KeyByteMap.Key.SHIFT_TAB, "Shift+Tab — cycle plan / auto-accept mode"),
KeyBarButton("", "上一个", KeyByteMap.Key.ARROW_UP, "Up — previous option / history"),
KeyBarButton("", "下一个", KeyByteMap.Key.ARROW_DOWN, "Down — next option / history"),
KeyBarButton("", "确认", KeyByteMap.Key.ENTER, "Enter — confirm"),
KeyBarButton("^C", "取消", KeyByteMap.Key.CTRL_C, "Ctrl+C — cancel / quit"),
KeyBarButton("^R", "搜历史", KeyByteMap.Key.CTRL_R, "Ctrl+R — reverse-search command history"),
KeyBarButton("^O", "详情", KeyByteMap.Key.CTRL_O, "Ctrl+O — expand transcript / tool detail"),
KeyBarButton("^L", "重绘", KeyByteMap.Key.CTRL_L, "Ctrl+L — redraw screen"),
KeyBarButton("^T", "任务", KeyByteMap.Key.CTRL_T, "Ctrl+T — toggle task list"),
KeyBarButton("^B", "后台", KeyByteMap.Key.CTRL_B, "Ctrl+B — background running task"),
KeyBarButton("^D", "退出", KeyByteMap.Key.CTRL_D, "Ctrl+D — exit session (EOF)"),
KeyBarButton("Tab", "补全", KeyByteMap.Key.TAB, "Tab — complete / toggle"),
KeyBarButton("", "左移", KeyByteMap.Key.ARROW_LEFT, "Left — move cursor left"),
KeyBarButton("", "右移", KeyByteMap.Key.ARROW_RIGHT, "Right — move cursor right"),
KeyBarButton("/", "命令", KeyByteMap.Key.SLASH, "Slash — command launcher"),
)
/**
* The single tap→wire funnel: resolve [key]'s bytes through [KeyByteMap] and hand
* them VERBATIM to [sink] (invariant #9 — no content filtering). This is exactly
* what a button's `onClick` invokes, and what the JVM byte-contract test drives.
*/
internal fun emitKey(key: KeyByteMap.Key, sink: (String) -> Unit) {
sink(KeyByteMap.bytes(key))
}
/** Web parity: the key-bar is a mobile affordance, hidden on wide screens (web hides it >768px). */
internal const val WIDE_SCREEN_MAX_DP: Int = 768
/**
* Whether the key-bar should render: shown on narrow (phone-width) screens only,
* AND hidden when a hardware keyboard is present (the physical keys make the
* touch bar redundant — the iPad-equivalent auto-hide, plan §"iPad-equivalent").
*/
internal fun shouldShowKeyBar(screenWidthDp: Int, hardwareKeyboardPresent: Boolean): Boolean =
screenWidthDp <= WIDE_SCREEN_MAX_DP && !hardwareKeyboardPresent
/**
* Best-effort hardware-keyboard heuristic from the current [Configuration]
* (accepted-less-reliable, plan §"iPad-equivalent" — no `GCKeyboard` equivalent):
* an alphabetic keyboard that is currently exposed (lid open / dock attached).
*/
internal fun isHardwareKeyboardPresent(keyboard: Int, hardKeyboardHidden: Int): Boolean =
keyboard == Configuration.KEYBOARD_QWERTY && hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO
/**
* The mobile key-bar overlay. Pinned ABOVE the IME via [WindowInsets.ime] (falling
* back to the navigation-bar inset when the keyboard is hidden), horizontally
* scrollable so every key stays reachable on a narrow phone. Renders nothing on
* wide screens or when a hardware keyboard is present ([shouldShowKeyBar]).
*
* @param onSend the IME-bypass sink — A21 wires it to `TerminalSessionController.sendInput`.
*/
@Composable
public fun KeyBar(
onSend: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val config = LocalConfiguration.current
val hardwareKeyboard = isHardwareKeyboardPresent(config.keyboard, config.hardKeyboardHidden)
if (!shouldShowKeyBar(config.screenWidthDp, hardwareKeyboard)) return
Row(
modifier = modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars))
.horizontalScroll(rememberScrollState())
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
for (button in KEYBAR_LAYOUT) {
KeyBarKey(button = button, onSend = onSend)
}
}
}
/** One inert key button. A tap emits bytes directly via [emitKey] — never through a text field. */
@Composable
private fun KeyBarKey(button: KeyBarButton, onSend: (String) -> Unit) {
val container =
if (button.isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
val content =
if (button.isPrimary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
Surface(
onClick = { emitKey(button.key, onSend) },
shape = RoundedCornerShape(Radius.sm8),
color = container,
contentColor = content,
modifier = Modifier
.defaultMinSize(minWidth = LayoutTokens.minHitTarget, minHeight = LayoutTokens.minHitTarget)
.semantics { contentDescription = button.title },
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
) {
Text(text = button.label, style = MaterialTheme.typography.labelLarge)
Text(
text = button.caption,
style = MaterialTheme.typography.labelSmall,
color = content.copy(alpha = Opacity.stale),
)
}
}
}
/**
* The DECCKM split router (plan §6.3 source #3): decides whether a hardware key is
* handled app-side (mapped through [KeyByteMap]) or LEFT to Termux's `KeyHandler`.
*
* Pure decision logic (primitive keyCode + modifier booleans, mirroring the Android
* `KeyEvent` constants) so it is JVM-unit-testable with no device / Robolectric.
* The Android glue lives in [handle].
*/
public object HardwareKeyRouter {
/** The ONLY Ctrl-<letter> chords the app claims — exactly the web key-bar's control keys. */
private val CTRL_LETTER_KEYS: Map<Int, KeyByteMap.Key> = mapOf(
KeyEvent.KEYCODE_C to KeyByteMap.Key.CTRL_C,
KeyEvent.KEYCODE_R to KeyByteMap.Key.CTRL_R,
KeyEvent.KEYCODE_O to KeyByteMap.Key.CTRL_O,
KeyEvent.KEYCODE_L to KeyByteMap.Key.CTRL_L,
KeyEvent.KEYCODE_T to KeyByteMap.Key.CTRL_T,
KeyEvent.KEYCODE_B to KeyByteMap.Key.CTRL_B,
KeyEvent.KEYCODE_D to KeyByteMap.Key.CTRL_D,
)
/**
* Resolve a hardware key to a routing decision.
*
* - **⇧Tab** → app-handled `ESC[Z`; **plain Tab** defers (completion / DECCKM nav).
* - **Esc** (unmodified) → app-handled `ESC`.
* - **Ctrl+{C,R,O,L,T,B,D}** → app-handled control byte via [KeyByteMap].
* - **Everything else** (arrows, Enter, plain Tab, plain letters, unmapped Ctrl
* chords) → [HardwareKeyResult.DeferToTerminal] so Termux's `KeyHandler`
* emits the DECCKM-correct sequence (`ESC O A` under application-cursor mode).
*/
public fun resolve(keyCode: Int, ctrl: Boolean, shift: Boolean, alt: Boolean): HardwareKeyResult {
// ⇧Tab is app-handled; plain Tab is left to Termux (do this BEFORE the generic defer).
if (keyCode == KeyEvent.KEYCODE_TAB) {
return if (shift && !ctrl && !alt) app(KeyByteMap.Key.SHIFT_TAB) else HardwareKeyResult.DeferToTerminal
}
// Esc → interrupt Claude; a modified Escape is not our chord.
if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
return if (!ctrl && !shift && !alt) app(KeyByteMap.Key.ESC) else HardwareKeyResult.DeferToTerminal
}
// The mapped Ctrl-<letter> chords only; unmapped Ctrl letters defer to Termux.
if (ctrl && !alt) {
CTRL_LETTER_KEYS[keyCode]?.let { return app(it) }
}
// Arrows / Enter / plain Tab / plain keys → Termux KeyHandler (DECCKM-correct, §6.3).
return HardwareKeyResult.DeferToTerminal
}
/**
* Android glue for `RemoteTerminalView.onKeyCommand`: extract modifiers from
* [event], route via [resolve], and on an app-handled DOWN emit the bytes to
* [onSend], returning true to CONSUME the event. Returns false otherwise so the
* stock Termux path (`KeyHandler`) handles arrows/Enter/Tab. Device-QA'd
* (touches `KeyEvent` accessor methods).
*/
public fun handle(keyCode: Int, event: KeyEvent, onSend: (String) -> Unit): Boolean {
if (event.action != KeyEvent.ACTION_DOWN) return false
return when (val result = resolve(keyCode, event.isCtrlPressed, event.isShiftPressed, event.isAltPressed)) {
is HardwareKeyResult.AppHandled -> {
onSend(result.bytes)
true
}
HardwareKeyResult.DeferToTerminal -> false
}
}
private fun app(key: KeyByteMap.Key): HardwareKeyResult =
HardwareKeyResult.AppHandled(KeyByteMap.bytes(key))
}
/** The routing decision for one hardware key (the DECCKM split, plan §6.3). */
public sealed interface HardwareKeyResult {
/** The app consumes the key and sends these exact [bytes] (resolved via [KeyByteMap]). */
public data class AppHandled(val bytes: String) : HardwareKeyResult
/** The key is left to Termux's `KeyHandler` so DECCKM stays correct (arrows/Enter/Tab). */
public data object DeferToTerminal : HardwareKeyResult
}
// ── Preview ───────────────────────────────────────────────────────────────────
@Preview(name = "KeyBar")
@Composable
private fun KeyBarPreview() {
WebTermTheme {
KeyBar(onSend = {})
}
}

View File

@@ -0,0 +1,101 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.GateKind
/**
* # PlanGateSheet (A22) — the plan-gate three-way approval bottom sheet.
*
* The Android analogue of the iOS ExitPlanMode gate (public/tabs.ts:334-350): a Material 3
* [ModalBottomSheet] with three actions —
* - **批准** → approve + review (`Approve(mode="default")`, the default action),
* - **批准并自动接受** → approve + auto-accept edits (`Approve(mode="acceptEdits")`),
* - **继续计划** → keep planning (wires to `reject`).
*
* `mode` is the **TOP-LEVEL** `approve.mode` wire key (plan §1/§4.1) — carried by the resolved
* [GateState.Affordance.clientMessage] via [GateViewModel.decide], never hand-built here. The
* server-supplied [detail][GateState.detail] renders as **inert [Text]** (untrusted, §8).
*
* ### Epoch capture (stale-guard seam)
* Each action hands back this gate's [epoch][GateState.epoch] so the tap is bound to the gate on
* screen; [GateViewModel.decide] drops it if the live gate has moved on. Dismissing the sheet
* (scrim/back) resolves NOTHING — the gate stays held until an explicit decision.
*
* Sheet presentation / detents are device-QA; the wired decisions + epoch capture are the contract.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
public fun PlanGateSheet(
gate: GateState,
onDecide: (GateDecision, Int) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(text = "批准执行计划", style = MaterialTheme.typography.titleMedium)
gate.detail?.let { detail ->
// INERT: untrusted plan text rendered verbatim, no linkify/markdown (§8).
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
Button(
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
modifier = Modifier.fillMaxWidth(),
) { Text(text = "批准") }
OutlinedButton(
onClick = { onDecide(GateDecision.ACCEPT_EDITS, gate.epoch) },
modifier = Modifier.fillMaxWidth(),
) { Text(text = "批准并自动接受") }
TextButton(
onClick = { onDecide(GateDecision.REJECT, gate.epoch) },
modifier = Modifier.fillMaxWidth(),
) { Text(text = "继续计划") }
}
}
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Preview(name = "PlanGateSheet")
@Composable
private fun PlanGateSheetPreview() {
WebTermTheme {
PlanGateSheet(
gate = GateState(kind = GateKind.PLAN, detail = "Refactor the auth module into 3 files.", epoch = 5),
onDecide = { _, _ -> },
onDismiss = {},
)
}
}

View File

@@ -0,0 +1,97 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.nav.PointerMenuPolicy
/**
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
*
* The Android analogue of iOS's pointer context menu (open-in-cwd / kill / copy). A reusable wrapper
* that installs a `Modifier.pointerInput` detecting a **secondary** pointer button (mouse right-click,
* trackpad two-finger / secondary click) and anchors a Material `DropdownMenu` at the click position.
*
* ### Gating (plan §5 A26, §"iPad-equivalent", R13, §8)
* The whole affordance is gated by the pure [PointerMenuPolicy.enabled] predicate — enabled **only** in
* [LayoutMode.LIST_DETAIL] on a genuine tablet (`smallestScreenWidthDp >= 600`). When disabled the
* wrapper is a transparent pass-through (no pointer interception, no menu) so phones behave exactly as
* before. This is `Modifier.pointerInput` + an anchored `DropdownMenu` — deliberately NOT Compose
* Desktop's `ContextMenuArea`, which is not stable on Android (platform review).
*
* The pointer detection / menu placement / gesture behaviour is device-QA (plan §7); the gate is the
* JVM-tested [PointerMenuPolicy] core.
*
* @param mode the current [LayoutMode] from [wang.yaojia.webterm.nav.LayoutPolicy].
* @param actions the menu entries (e.g. 在当前目录开新会话 / 终止 / 复制), each a label + callback.
* @param smallestScreenWidthDp the device's smallest-width; defaults to the current configuration.
* @param content the wrapped content the secondary-click targets.
*/
@Composable
public fun PointerContextMenu(
mode: LayoutMode,
actions: List<ContextMenuAction>,
modifier: Modifier = Modifier,
smallestScreenWidthDp: Int = LocalConfiguration.current.smallestScreenWidthDp,
content: @Composable () -> Unit,
) {
// Gate: on a phone / compact layout the menu never exists and the pointer is never intercepted.
if (!PointerMenuPolicy.enabled(mode, smallestScreenWidthDp)) {
Box(modifier = modifier) { content() }
return
}
var expanded by remember { mutableStateOf(false) }
var anchor by remember { mutableStateOf(DpOffset.Zero) }
val density = LocalDensity.current
Box(
modifier = modifier.pointerInput(actions) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.buttons.isSecondaryPressed) {
val position = event.changes.first().position
anchor = with(density) { DpOffset(position.x.toDp(), position.y.toDp()) }
expanded = true
// Consume so the underlying content doesn't also react to the secondary press.
event.changes.forEach { it.consume() }
}
}
}
},
) {
content()
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
offset = anchor,
) {
for (action in actions) {
DropdownMenuItem(
text = { Text(action.label) },
onClick = {
expanded = false
action.onClick()
},
)
}
}
}
}
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)

View File

@@ -0,0 +1,111 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
/**
* # QuickReply (A25) — the floating quick-reply chip row.
*
* A horizontal strip of the editable palette ([QuickReplyStore]). It **floats only
* while a gate is held** (plan §1 quick-reply row) — the moment Claude is waiting on
* the user is exactly when a one-tap canned reply is useful — and each chip, on tap,
* sends its [text][QuickReplyChip.text] VERBATIM via the injected [onSend].
*
* ### The load-bearing seam (JVM-tested; layout/float animation are device-QA §7)
* - **Visibility:** [shouldShowQuickReply] gates the row on `gateHeld && hasChips`.
* - **Send:** a tap calls [sendQuickReply] → `onSend(chip.text)` with NO added byte.
* A21 wires `onSend` to `TerminalSessionController.sendInput` (invariant #1 —
* input passed through verbatim), and `gateHeld` to the [GateViewModel]
* [wang.yaojia.webterm.viewmodels.GateViewModel] held-gate state.
*
* The chip label is the user's OWN palette text (not untrusted server data), rendered
* as a single-line, ellipsised [Text] — no Markdown / autolink.
*
* @param chips the palette to show, in display order.
* @param gateHeld whether a gate is currently held (drives the float).
* @param onSend receives the tapped chip's exact text.
*/
@Composable
public fun QuickReply(
chips: List<QuickReplyChip>,
gateHeld: Boolean,
onSend: (String) -> Unit,
modifier: Modifier = Modifier,
) {
if (!shouldShowQuickReply(gateHeld = gateHeld, hasChips = chips.isNotEmpty())) return
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(Radius.pill),
tonalElevation = Spacing.xs2,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
chips.forEach { chip ->
SuggestionChip(
onClick = { sendQuickReply(chip, onSend) },
label = {
Text(
text = chip.text,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
shape = RoundedCornerShape(Radius.pill),
colors = SuggestionChipDefaults.suggestionChipColors(),
)
}
}
}
}
/**
* The tap→emit seam (mirrors `KeyBar.emitKey`): send the chip's [text][QuickReplyChip.text]
* VERBATIM. Kept as a top-level function so the exact-byte contract is JVM-testable
* without driving Compose.
*/
internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
onSend(chip.text)
}
/** The row floats only while a gate is held AND the palette is non-empty. */
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
gateHeld && hasChips
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "QuickReply (gate held)")
@Composable
private fun QuickReplyPreview() {
WebTermTheme {
QuickReply(
chips = QuickReplyDefaults.chips,
gateHeld = true,
onSend = {},
)
}
}

View File

@@ -0,0 +1,237 @@
package wang.yaojia.webterm.components
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.put
import java.util.UUID
/**
* # QuickReplyChip (A25) — one entry of the editable quick-reply palette.
*
* A canned snippet the user can tap to send while a gate is held. [id] is a stable
* identity used for edit/remove/reorder (survives a [text] change); [text] is the
* exact string sent VERBATIM as input via `TerminalSessionController.sendInput`
* (plan §1 quick-reply row / §5 A25). No trailing byte is added — the palette owner
* types whatever they want submitted.
*/
public data class QuickReplyChip(
val id: String,
val text: String,
)
/**
* Sensible starter palette, used the first time the store is read (no stored blob
* yet). Ids are stable so a default chip can still be edited/removed/reordered by id.
* Once the user mutates the palette (including deleting every chip → an empty list),
* their choice is persisted and these defaults are never re-seeded.
*/
public object QuickReplyDefaults {
public val chips: List<QuickReplyChip> = listOf(
QuickReplyChip("qr-continue", "continue"),
QuickReplyChip("qr-yes", "yes"),
QuickReplyChip("qr-no", "no"),
QuickReplyChip("qr-1", "1"),
QuickReplyChip("qr-2", "2"),
)
}
/**
* Frozen CRUD contract for the editable quick-reply palette (plan §5 A25). Every
* mutation returns the NEW list (immutable style, mirrors [HostStore]
* [wang.yaojia.webterm.hostregistry.HostStore]); nothing is mutated in place.
* Cross-device sync is explicitly OUT of scope — each device's store is independent
* (plan §1 "Cross-device palette sync" DEFERRED).
*
* Implementations: [DataStoreQuickReplyStore] (real, Preferences-DataStore-backed)
* and [InMemoryQuickReplyStore] (in-`main` double for JVM tests / previews).
*/
public interface QuickReplyStore {
/** The palette in display order (defaults on first read; the user's list thereafter). */
public suspend fun loadAll(): List<QuickReplyChip>
/**
* Append a new chip carrying [text] (a fresh id is minted). Blank [text] is an
* explicit no-op (returns the list unchanged) — a boundary guard so the palette
* never accumulates empty chips. Non-blank [text] is stored VERBATIM (leading /
* trailing whitespace preserved). Returns the new list.
*/
public suspend fun add(text: String): List<QuickReplyChip>
/**
* Replace the [text] of the chip with [id] (position preserved). Unknown [id] or
* blank [text] → no-op (unchanged list). Returns the new list.
*/
public suspend fun edit(id: String, text: String): List<QuickReplyChip>
/** Remove the chip with [id]. Unknown [id] is a no-op. Returns the new list. */
public suspend fun remove(id: String): List<QuickReplyChip>
/**
* Move the chip at [fromIndex] to [toIndex] (drag-reorder). Out-of-range indices
* are a no-op (returns the unchanged list). Returns the new list.
*/
public suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip>
}
// ── Pure collection transforms shared by all QuickReplyStore implementations (DRY) ──
// Never mutate the receiver — always return a fresh list. `internal` so both stores
// and the same-module unit tests use them without widening the public surface.
/** Append a chip with [id]/[text], unless [text] is blank (boundary guard → no-op). */
internal fun List<QuickReplyChip>.adding(text: String, id: String): List<QuickReplyChip> =
if (text.isBlank()) this else this + QuickReplyChip(id, text)
/** Replace the [text] of the chip with [id] (position kept). Blank text / unknown id → no-op. */
internal fun List<QuickReplyChip>.editing(id: String, text: String): List<QuickReplyChip> =
if (text.isBlank()) this else map { if (it.id == id) it.copy(text = text) else it }
/** A copy without the chip whose id equals [id] (unknown id → an unchanged copy). */
internal fun List<QuickReplyChip>.removingId(id: String): List<QuickReplyChip> =
filter { it.id != id }
/** Immutable move [from] → [to]; out-of-range (either end) is a no-op. */
internal fun List<QuickReplyChip>.moving(from: Int, to: Int): List<QuickReplyChip> {
if (from !in indices || to !in indices || from == to) return this
val next = toMutableList()
next.add(to, next.removeAt(from))
return next
}
/**
* Pure JSON codec for the persisted palette (JVM-testable, no Context). A chip list
* ⇆ a JSON array of `{"id","text"}` objects. Encode is total; decode is defensive —
* the blob is untrusted at rest, so a corrupt array or a chip missing a string
* id/text is dropped rather than crashing. Uses the kotlinx JSON element API (no
* `@Serializable` codegen → no serialization compiler plugin needed on :app; the
* runtime lib is on the classpath via `:wire-protocol`'s `api` dependency).
*/
internal object QuickReplyCodec {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
fun encode(chips: List<QuickReplyChip>): String =
buildJsonArray {
chips.forEach { chip ->
addJsonObject {
put("id", chip.id)
put("text", chip.text)
}
}
}.toString()
fun decode(raw: String?): List<QuickReplyChip> {
if (raw.isNullOrBlank()) return emptyList()
val array = try {
json.parseToJsonElement(raw) as? JsonArray ?: return emptyList()
} catch (_: Exception) {
return emptyList() // corrupt blob at rest → start clean, never crash
}
return array.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj["id"].asStringOrNull()
val text = obj["text"].asStringOrNull()
if (id.isNullOrEmpty() || text.isNullOrEmpty()) null else QuickReplyChip(id, text)
}
}
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
}
/**
* Preferences-DataStore-backed [QuickReplyStore]. The whole palette is ONE JSON
* string under [PALETTE_KEY], serialized by [QuickReplyCodec]; writes are an atomic
* read-modify-write via [DataStore.edit], reusing the shared immutable transforms.
*
* Key-absent (first run) reads/seeds [QuickReplyDefaults]; once any write lands, the
* stored list wins — including an empty list, so "delete all" is respected. The
* [DataStore] is injected (constructed from a Context in :app DI), so this class has
* no Android-framework surface of its own and is exercised in JVM tests via an
* in-memory `DataStore<Preferences>` double.
*/
public class DataStoreQuickReplyStore(
private val dataStore: DataStore<Preferences>,
private val newId: () -> String = { UUID.randomUUID().toString() },
) : QuickReplyStore {
override suspend fun loadAll(): List<QuickReplyChip> {
val raw = dataStore.data.first()[PALETTE_KEY]
return if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
}
override suspend fun add(text: String): List<QuickReplyChip> =
writeTransform { it.adding(text, newId()) }
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
writeTransform { it.editing(id, text) }
override suspend fun remove(id: String): List<QuickReplyChip> =
writeTransform { it.removingId(id) }
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
writeTransform { it.moving(fromIndex, toIndex) }
private suspend fun writeTransform(
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
): List<QuickReplyChip> {
lateinit var updated: List<QuickReplyChip>
dataStore.edit { prefs ->
val raw = prefs[PALETTE_KEY]
val current = if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
updated = transform(current)
prefs[PALETTE_KEY] = QuickReplyCodec.encode(updated)
}
return updated
}
private companion object {
val PALETTE_KEY = stringPreferencesKey("quickReplyPalette")
}
}
/**
* In-memory [QuickReplyStore] — lives in `main` (not `test`) so it doubles for the
* DataStore store in JVM CRUD tests AND backs Compose previews. Seeded with
* [QuickReplyDefaults] by default. A [Mutex] serializes the read-modify-write so
* concurrent callers can't interleave; state is replaced wholesale on every change.
*/
public class InMemoryQuickReplyStore(
initial: List<QuickReplyChip> = QuickReplyDefaults.chips,
private val newId: () -> String = { UUID.randomUUID().toString() },
) : QuickReplyStore {
private val mutex = Mutex()
private var chips: List<QuickReplyChip> = initial.toList()
override suspend fun loadAll(): List<QuickReplyChip> = mutex.withLock { chips }
override suspend fun add(text: String): List<QuickReplyChip> =
write { it.adding(text, newId()) }
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
write { it.editing(id, text) }
override suspend fun remove(id: String): List<QuickReplyChip> =
write { it.removingId(id) }
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
write { it.moving(fromIndex, toIndex) }
private suspend fun write(
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
): List<QuickReplyChip> = mutex.withLock {
val updated = transform(chips)
chips = updated
updated
}
}

View File

@@ -0,0 +1,216 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.FailureReason
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.WireConstants
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* # ReconnectBanner (A21) — the connection-status / EXIT banner row.
*
* The Android port of iOS `ReconnectBanner` + `TerminalViewModel.bannerModel` (plan §1, §5 A21). A
* single strip pinned above the terminal that reflects the engine's connection lifecycle:
* - `connecting` — first dial (spinner);
* - `reconnecting(attempt, countdown)` — the back-off ladder (spinner);
* - `failed(replayTooLarge)` — a NON-retryable wall: actionable copy, **NO spinner**, "新会话" action;
* - `exited(code, reason)` — the shell is gone: `code == -1` is a spawn-failure (reason required),
* otherwise a normal exit; both offer "开新会话".
*
* ### Precedence (the load-bearing rule — plan §1)
* **Terminal phases (`failed` / `exited`) OUTRANK the transient ones (`connecting` / `reconnecting`).**
* Once the session is terminally failed or exited, a stray transient connection event must never
* overwrite the banner — the reducer [bannerModel] enforces this. This mirrors the iOS `bannerModel`
* computed property where the exit/failure state is checked before the reconnect state.
*
* ### Inert rendering (plan §8)
* The exit `reason` is an untrusted server string — it is rendered as a plain, inert Compose [Text]
* with no Markdown / autolink / `AnnotatedString` linkify.
*/
/**
* What the banner shows, derived purely from the [SessionEvent] stream by [bannerModel]. A sealed model
* so the precedence rule and the "no-spinner on failure" rule are data, unit-testable without a device.
*/
public sealed interface BannerModel {
/** Whether a progress spinner is shown — true ONLY for the transient (retrying) phases. */
public val showsSpinner: Boolean
/** Whether the banner offers a "new session in cwd" action (`attach(null, cwd)`). */
public val offersNewSession: Boolean
/** Connected / idle — nothing to show. */
public data object Hidden : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = false
}
/** The first connection is being established. */
public data object Connecting : BannerModel {
override val showsSpinner: Boolean get() = true
override val offersNewSession: Boolean get() = false
}
/** The transport dropped; retry [attempt] fires after [countdown] (the [ConnectionState.Reconnecting] ladder). */
public data class Reconnecting(val attempt: Int, val countdown: Duration) : BannerModel {
override val showsSpinner: Boolean get() = true
override val offersNewSession: Boolean get() = false
}
/**
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
*/
public data class Failed(val reason: FailureReason) : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true
}
/**
* The shell exited. [isSpawnFailure] (`code == -1`) means the PTY never spawned — [reason] is then
* required and drives the copy. Either way the session is over; offers "开新会话".
*/
public data class Exited(val code: Int, val reason: String?) : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true
/** `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) → the spawn itself failed. */
public val isSpawnFailure: Boolean get() = code == WireConstants.SPAWN_FAILED_EXIT_CODE
}
}
/**
* PURE reducer: fold one [SessionEvent] into the [current] [BannerModel]. Only connection-lifecycle and
* exit events affect the banner; every other event (`Output`/`Gate`/`Digest`/`Telemetry`/`Adopted`)
* leaves it unchanged.
*
* The precedence rule (plan §1): the terminal phases ([BannerModel.Failed] / [BannerModel.Exited]) are
* STICKY over the transient phases — a `connecting`/`reconnecting`/`connected`/`closed` event arriving
* after the session already failed or exited does NOT override the terminal banner. A fresh terminal
* event always wins (a later exit/failure replaces an earlier one).
*/
public fun bannerModel(current: BannerModel, event: SessionEvent): BannerModel = when (event) {
is Exited -> BannerModel.Exited(event.code, event.reason) // terminal — always wins
is Connection -> reduceConnection(current, event.state)
else -> current
}
private fun reduceConnection(current: BannerModel, state: ConnectionState): BannerModel = when (state) {
is ConnectionState.Failed -> BannerModel.Failed(state.reason) // terminal — always wins
ConnectionState.Connecting -> current.orKeepTerminal(BannerModel.Connecting)
is ConnectionState.Reconnecting -> current.orKeepTerminal(BannerModel.Reconnecting(state.attempt, state.next))
ConnectionState.Connected -> current.orKeepTerminal(BannerModel.Hidden)
ConnectionState.Closed -> current.orKeepTerminal(BannerModel.Hidden)
}
/** A terminal banner outranks any transient update; otherwise take the [transient] one. */
private fun BannerModel.orKeepTerminal(transient: BannerModel): BannerModel =
if (this is BannerModel.Failed || this is BannerModel.Exited) this else transient
// ── UI ───────────────────────────────────────────────────────────────────────
/**
* The banner row. Renders NOTHING for [BannerModel.Hidden]. [onNewSession] fires the "new session in
* cwd" action (the exit-banner and replay-too-large affordances both route to it).
*
* Compose layout / spinner animation is device-QA (plan §7); the reduced [model] + its copy/affordance
* mapping is the JVM-tested core.
*/
@Composable
public fun ReconnectBanner(
model: BannerModel,
onNewSession: () -> Unit,
modifier: Modifier = Modifier,
) {
if (model is BannerModel.Hidden) return
val container = when (model) {
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.errorContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
val content = when (model) {
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.onErrorContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(color = container, contentColor = content, modifier = modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
) {
if (model.showsSpinner) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
}
// Untrusted server strings (exit reason) render as inert Text — no autolink/markdown (§8).
Text(
text = bannerCopy(model),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (model.offersNewSession) {
TextButton(onClick = onNewSession) { Text(newSessionLabel(model)) }
}
}
}
}
/** The Chinese status copy for [model]. Kept separate from the reducer so copy never leaks into logic. */
private fun bannerCopy(model: BannerModel): String = when (model) {
BannerModel.Hidden -> ""
BannerModel.Connecting -> "连接中…"
is BannerModel.Reconnecting ->
"连接断开,重连中(第 ${model.attempt} 次,${model.countdown.inWholeSeconds} 秒后重试)"
is BannerModel.Failed -> when (model.reason) {
FailureReason.REPLAY_TOO_LARGE ->
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
}
is BannerModel.Exited ->
if (model.isSpawnFailure) {
"会话启动失败:${model.reason ?: "未知原因"}"
} else {
"会话已结束(退出码 ${model.code}"
}
}
/** "开新会话" for a clean exit, "新会话" for the replay-too-large wall — both call [ReconnectBanner]'s onNewSession. */
private fun newSessionLabel(model: BannerModel): String =
if (model is BannerModel.Failed) "新会话" else "开新会话"
// ── Preview ───────────────────────────────────────────────────────────────────
@Preview(name = "ReconnectBanner — reconnecting")
@Composable
private fun ReconnectBannerReconnectingPreview() {
WebTermTheme {
ReconnectBanner(model = BannerModel.Reconnecting(attempt = 2, countdown = 4.seconds), onNewSession = {})
}
}
@Preview(name = "ReconnectBanner — exited")
@Composable
private fun ReconnectBannerExitedPreview() {
WebTermTheme {
ReconnectBanner(model = BannerModel.Exited(code = 0, reason = null), onNewSession = {})
}
}

View File

@@ -0,0 +1,243 @@
package wang.yaojia.webterm.components
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.Opacity
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.StatusBadge
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.SessionRow
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.StatusTelemetry
import java.util.UUID
/**
* # SessionListRow (A20) — one dashboard row for a running/just-exited session.
*
* Renders the parts the plan §1 session-list row enumerates: **preview thumbnail** (left) · **status
* badge** + sanitized **title** + **unread dot** (top line) · **cols×rows** geometry + cwd (meta line) ·
* **telemetry chips** (bottom line). Every server-influenced string ([SessionRow.title] — already run
* through `TitleSanitizer` in the ViewModel — plus cwd and the telemetry chip labels) renders as INERT
* [Text]: no linkify / Markdown / `AnnotatedString` autolink (plan §8). Exited rows dim to
* [Opacity.exited].
*
* The whole row is tap-to-open ([onOpen]); swipe-to-kill is owned by the enclosing `SwipeToDismissBox` in
* `SessionListScreen`. Layout/gesture behaviour is device-QA (plan §7); the row's DERIVED display values
* are computed by the JVM-tested `sessionRowsOf` in the ViewModel.
*
* @param thumbnails the off-screen preview seam — production wires it to the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (`(sessionId, lastOutputAt)`-keyed,
* §6.7); `null` renders the placeholder tile (the JVM/preview path draws no bitmap).
*/
@Composable
public fun SessionListRow(
row: SessionRow,
onOpen: () -> Unit,
modifier: Modifier = Modifier,
thumbnails: SessionThumbnails? = null,
nowMs: Long = System.currentTimeMillis(),
) {
Row(
modifier = modifier
.fillMaxWidth()
.alpha(if (row.displayStatus == DisplayStatus.Exited) Opacity.exited else 1f)
.clip(RoundedCornerShape(Radius.md12))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onOpen)
.padding(Spacing.md12),
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
verticalAlignment = Alignment.CenterVertically,
) {
ThumbnailTile(session = row.info, thumbnails = thumbnails)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
TitleLine(row = row)
Text(
text = metaLine(row.info),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
row.info.telemetry?.let { telemetry ->
TelemetryChips(telemetry = telemetry, nowMs = nowMs)
}
}
}
}
/** Top line: status badge · unread dot · sanitized title (or an id/cwd fallback when the title is empty). */
@Composable
private fun TitleLine(row: SessionRow) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
) {
StatusBadge(status = row.displayStatus)
if (row.isUnread) UnreadDot()
Text(
text = row.title.ifBlank { fallbackLabel(row.info) },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
/** The unread dot (accent-filled), shown when server output is newer than the local seen-watermark (§1). */
@Composable
private fun UnreadDot() {
Box(
modifier = Modifier
.size(Spacing.sm8)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
/**
* The preview thumbnail tile. Loads the bitmap once per `(id, lastOutputAt)` from the [thumbnails] seam
* (so an unchanged screen is served from the pipeline's cache) and draws it; while loading (or when no
* seam is wired) it shows a terminal-colored placeholder so the row height never jumps.
*/
@Composable
private fun ThumbnailTile(session: LiveSessionInfo, thumbnails: SessionThumbnails?) {
var bitmap by remember(session.id, session.lastOutputAt) { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(session.id, session.lastOutputAt, thumbnails) {
bitmap = thumbnails?.bitmapFor(session)
}
Box(
modifier = Modifier
.width(THUMBNAIL_WIDTH)
.height(THUMBNAIL_HEIGHT)
.clip(RoundedCornerShape(Radius.sm8))
.background(WebTermColors.terminalBackground),
contentAlignment = Alignment.Center,
) {
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(THUMBNAIL_HEIGHT),
)
}
}
}
/** "161×50 · ~/src/web-terminal" — tabular geometry + cwd (× is U+00D7). Both inert (§8). */
private fun metaLine(info: LiveSessionInfo): String {
val dims = "${info.cols}×${info.rows}"
val cwd = info.cwd?.takeIf { it.isNotBlank() }
return if (cwd != null) "$dims · $cwd" else dims
}
/** When a session has no OSC title, label it by cwd (last segment) or the short id — never blank. */
private fun fallbackLabel(info: LiveSessionInfo): String {
val cwd = info.cwd?.takeIf { it.isNotBlank() }
if (cwd != null) return cwd.trimEnd('/').substringAfterLast('/').ifBlank { cwd }
return info.id.toString().substringBefore('-')
}
/**
* The off-screen thumbnail seam. Production is a thin adapter over the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline]
* (`{ session -> pipeline.thumbnail(session) }`); the default `null` seam renders the placeholder tile so
* the row composes with no `android.graphics` dependency under preview/JVM.
*/
public fun interface SessionThumbnails {
/** The cached/rendered preview bitmap for [session], or `null` when unavailable (placeholder shown). */
public suspend fun bitmapFor(session: LiveSessionInfo): Bitmap?
}
private val THUMBNAIL_WIDTH = Spacing.xxl24 * 4 // 96dp
private val THUMBNAIL_HEIGHT = Spacing.xxl24 * 2.5f // 60dp
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "SessionListRow")
@Composable
private fun SessionListRowPreview() {
WebTermTheme {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
SessionListRow(
row = SessionRow(
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L),
displayStatus = DisplayStatus.Working,
title = "web-terminal",
isUnread = true,
),
onOpen = {},
)
SessionListRow(
row = SessionRow(
info = previewInfo(status = ClaudeStatus.IDLE, title = "", cols = 80, rows = 24),
displayStatus = DisplayStatus.Exited,
title = "",
isUnread = false,
),
onOpen = {},
)
}
}
}
private fun previewInfo(
status: ClaudeStatus,
title: String,
cols: Int = 161,
rows: Int = 50,
lastOutputAt: Long? = null,
): LiveSessionInfo = LiveSessionInfo(
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
createdAt = 1L,
clientCount = 1,
status = status,
exited = false,
cwd = "/Users/dev/src/web-terminal",
title = title.ifBlank { null },
cols = cols,
rows = rows,
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
lastOutputAt = lastOutputAt,
)

View File

@@ -0,0 +1,114 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.TelemetryChip
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.wire.PrInfo
import wang.yaojia.webterm.wire.RateInfo
import wang.yaojia.webterm.wire.StatusTelemetry
import wang.yaojia.webterm.wire.Tunables
import java.util.Locale
import kotlin.math.roundToInt
/**
* # TelemetryChips (A22) — the statusLine telemetry chip row.
*
* A horizontally-scrollable row of A13 [TelemetryChip] primitives — context %, $cost, model, PR,
* rate — derived from the latest [StatusTelemetry] frame (plan §1). Every chip **greys out together**
* when the frame is stale (its [at][StatusTelemetry.at] older than
* [TELEMETRY_STALE_TTL_MS][Tunables.TELEMETRY_STALE_TTL_MS]); a chip switches to the amber warning
* color when its metric crosses a threshold (near-full context, near-limit rate, PR changes-requested).
*
* Chip text is inert monospaced [TelemetryChip] output (no linkify, §8). The model string is
* server-controlled but renders as a plain chip label. Layout is device-QA; the staleness rule and
* chip derivation are the JVM-tested core ([isTelemetryStale] / [telemetryChipModels]).
*
* @param nowMs current wall clock (ms since epoch); the screen re-supplies it on a tick so chips grey
* as the frame ages.
*/
@Composable
public fun TelemetryChips(
telemetry: StatusTelemetry,
nowMs: Long,
modifier: Modifier = Modifier,
) {
val stale = isTelemetryStale(telemetry.at, nowMs)
val models = telemetryChipModels(telemetry)
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
for (model in models) {
TelemetryChip(text = model.text, isStale = stale, isWarning = model.isWarning)
}
}
}
/** One derived chip: pre-formatted [text] plus whether it crossed a warning threshold. */
public data class TelemetryChipModel(val text: String, val isWarning: Boolean = false)
/** Context %, 5-hour and 7-day rate at/above which a chip turns amber (mirrors the web statusline). */
private const val CONTEXT_WARN_PCT: Double = 90.0
private const val RATE_WARN_PCT: Double = 90.0
private const val REVIEW_CHANGES_REQUESTED: String = "CHANGES_REQUESTED"
/**
* True iff [telemetry.at][StatusTelemetry.at] (server receive time) is older than the stale TTL as of
* [nowMs]. Boundary: exactly-TTL-old is NOT yet stale ("older than", [Tunables.TELEMETRY_STALE_TTL_MS]).
* Pure — the JVM-tested staleness threshold.
*/
public fun isTelemetryStale(atMs: Long, nowMs: Long): Boolean =
nowMs - atMs > Tunables.TELEMETRY_STALE_TTL_MS
/**
* Derive the ordered chip list from a telemetry frame, skipping absent metrics. Pure + deterministic
* so the formatting + warning thresholds are unit-tested without Compose. Order mirrors the web
* statusLine: context · cost · model · PR · rate.
*/
public fun telemetryChipModels(telemetry: StatusTelemetry): List<TelemetryChipModel> {
val chips = mutableListOf<TelemetryChipModel>()
telemetry.contextUsedPct?.let {
chips += TelemetryChipModel("ctx ${it.roundToInt()}%", isWarning = it >= CONTEXT_WARN_PCT)
}
telemetry.costUsd?.let {
chips += TelemetryChipModel(String.format(Locale.US, "$%.2f", it))
}
telemetry.model?.takeIf { it.isNotBlank() }?.let {
chips += TelemetryChipModel(it)
}
telemetry.pr?.let { chips += prChip(it) }
telemetry.rate?.fiveHourPct?.let {
chips += TelemetryChipModel("5h ${it.roundToInt()}%", isWarning = it >= RATE_WARN_PCT)
}
return chips
}
private fun prChip(pr: PrInfo): TelemetryChipModel =
TelemetryChipModel("PR #${pr.number}", isWarning = pr.reviewState == REVIEW_CHANGES_REQUESTED)
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "TelemetryChips")
@Composable
private fun TelemetryChipsPreview() {
WebTermTheme {
TelemetryChips(
telemetry = StatusTelemetry(
contextUsedPct = 92.0,
costUsd = 0.1234,
model = "opus-4.8",
pr = PrInfo(number = 7, url = "", reviewState = REVIEW_CHANGES_REQUESTED),
rate = RateInfo(fiveHourPct = 40.0),
at = 0L,
),
nowMs = 0L,
)
}
}

View File

@@ -0,0 +1,94 @@
package wang.yaojia.webterm.designsystem
/**
* # WebTerm design system — the FROZEN numeric spec (pure Kotlin, JVM-testable).
*
* Mirrors the iOS `DS` token vocabulary
* (`ios/App/WebTerm/DesignSystem/Tokens.swift`). Every visual constant lives here
* as a raw ARGB [Long] / [Float] / [Int] with **no Compose import**, so it can be
* unit-tested on the plain JVM with no device (see `DesignSpecTest`). `Tokens.kt`
* builds Compose `Color`/`Dp` values from these; screens must reference the tokens,
* never inline a hex/gap.
*
* Direction: "精致原生" (refined native), dark-appearance-first, amber-gold accent
* matched to the desktop/web theme (`public/style.css` `--accent`). Color is NEVER
* the only status signal — pair it with the shape/label in `StatusStyle`.
*/
public object DesignSpec {
// ── Accent (amber gold — matches desktop --accent / --accent-2) ─────────────
// Adaptive: dark = #E3A64A (gold), light = #C9892F (deeper gold) for contrast
// on a light background. Gold needs DARK ink on top → use [ON_ACCENT].
public const val ACCENT_DARK: Long = 0xFFE3A64AL // #E3A64A (web --accent)
public const val ACCENT_LIGHT: Long = 0xFFC9892FL // #C9892F (web --accent-2)
public const val ON_ACCENT: Long = 0xFF1A1305L // #1A1305 (web --on-accent)
// ── Semantic status colors (match the desktop/web status palette) ───────────
// These are the ONLY status colors. `StatusStyle` pairs each with a distinct
// shape so status is never conveyed by color alone (color-blind safe).
public const val STATUS_WORKING: Long = 0xFF46D07FL // #46D07F (web --green)
public const val STATUS_WAITING: Long = 0xFFF5B14CL // #F5B14C (web --amber)
public const val STATUS_STUCK: Long = 0xFFFF6B6BL // #FF6B6B (web --red)
public const val STATUS_IDLE: Long = 0xFF8B8578L // warm secondary gray
public const val STATUS_UNKNOWN: Long = 0xFF6E6A61L // neutral gray (no signal yet)
// ── Timeline event classes (A28) ────────────────────────────────────────────
public const val TIMELINE_TOOL: Long = 0xFF5E9EFFL // indigo — a tool run
public const val TIMELINE_USER: Long = 0xFFAF7BFFL // violet — a user message
// ── Surfaces & text — DARK scheme (the default appearance) ──────────────────
public const val DARK_BACKGROUND: Long = 0xFF14130FL // warm near-black chrome
public const val DARK_SURFACE: Long = 0xFF1C1A15L
public const val DARK_CARD: Long = 0xFF24211AL // grouped-content step-up
public const val DARK_HAIRLINE: Long = 0xFF35322AL // separator/border
public const val DARK_TEXT_PRIMARY: Long = 0xFFECE9E3L // warm off-white (web --text)
public const val DARK_TEXT_SECONDARY: Long = 0xFFA8A296L
public const val DARK_TEXT_TERTIARY: Long = 0xFF6E6A61L
// ── Surfaces & text — LIGHT scheme ──────────────────────────────────────────
public const val LIGHT_BACKGROUND: Long = 0xFFFAF8F3L // warm off-white
public const val LIGHT_SURFACE: Long = 0xFFFFFFFFL
public const val LIGHT_CARD: Long = 0xFFF0EDE6L
public const val LIGHT_HAIRLINE: Long = 0xFFD8D3C8L
public const val LIGHT_TEXT_PRIMARY: Long = 0xFF1A1712L
public const val LIGHT_TEXT_SECONDARY: Long = 0xFF6B6559L
public const val LIGHT_TEXT_TERTIARY: Long = 0xFF9A9488L
// ── Terminal canvas (FIXED — independent of app theme) ──────────────────────
// A terminal reads as dark regardless of app appearance (like the desktop).
// Values mirror the web chrome: --bg #100F0D / --text #ECE9E3, gold caret.
public const val TERMINAL_BACKGROUND: Long = 0xFF100F0DL // web --bg
public const val TERMINAL_FOREGROUND: Long = 0xFFECE9E3L // web --text
public const val TERMINAL_CARET: Long = 0xFFE3A64AL // gold caret
// ── Spacing scale — 2·4·8·12·16·20·24 (dp). No off-scale gaps. ──────────────
public const val SPACE_XS2: Float = 2f
public const val SPACE_XS4: Float = 4f
public const val SPACE_SM8: Float = 8f
public const val SPACE_MD12: Float = 12f
public const val SPACE_LG16: Float = 16f
public const val SPACE_XL20: Float = 20f
public const val SPACE_XXL24: Float = 24f
// ── Corner radii (dp) ───────────────────────────────────────────────────────
public const val RADIUS_SM8: Float = 8f
public const val RADIUS_MD12: Float = 12f
public const val RADIUS_LG16: Float = 16f
public const val RADIUS_PILL: Float = 999f
// ── Stroke (dp) ──────────────────────────────────────────────────────────────
public const val STROKE_HAIRLINE: Float = 1f
// ── Opacity (dimming multipliers) ────────────────────────────────────────────
public const val OPACITY_STALE: Float = 0.45f // telemetry past its TTL
public const val OPACITY_EXITED: Float = 0.55f // a session that has exited
public const val OPACITY_PRESSED: Float = 0.72f // pressed-state feedback
public const val OPACITY_ACCENT_SOFT: Float = 0.15f // faint accent wash
// ── Layout (dp) ───────────────────────────────────────────────────────────────
public const val MIN_HIT_TARGET: Float = 44f // Material/HIG minimum touch target
// ── Motion (ms) — subtle, eased; ALWAYS gate through reduce-motion ──────────
public const val MOTION_FAST_MS: Int = 180
public const val MOTION_BASE_MS: Int = 250
}

View File

@@ -0,0 +1,179 @@
package wang.yaojia.webterm.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.wire.ClaudeStatus
/**
* # Primitives — reusable Compose building blocks (mirrors iOS `Primitives.swift`).
*
* `StatusBadge`, `TelemetryChip`, `WebTermCard`. Each pulls ALL constants from the
* design tokens — no inline magic. Untrusted server strings render as INERT
* [Text] (no Markdown / linkify / AnnotatedString autolink), per plan §8.
*/
/**
* The visual states a status indicator can show — a superset of the wire
* [ClaudeStatus] plus two App-layer emphasis states (mirrors iOS `DisplayStatus`):
* `PendingApproval` ("needs me", outranks status) and `Exited` (read-only).
*/
public enum class DisplayStatus {
Working, Waiting, Idle, Stuck, Unknown, PendingApproval, Exited;
public companion object {
/** Bridge from the wire enum (no pending/exited emphasis — callers add). */
public fun from(status: ClaudeStatus): DisplayStatus = when (status) {
ClaudeStatus.WORKING -> Working
ClaudeStatus.WAITING -> Waiting
ClaudeStatus.IDLE -> Idle
ClaudeStatus.STUCK -> Stuck
ClaudeStatus.UNKNOWN -> Unknown
}
}
}
/**
* Resolved visuals for one status: a semantic [color], a DISTINCT [symbol] glyph
* (so shape alone disambiguates — color-blind safe), and a Chinese [label] (also
* the accessibility description). Pure + deterministic (mirrors iOS `StatusStyle`).
*/
public data class StatusStyle(
val color: Color,
val symbol: String,
val label: String,
) {
public companion object {
public fun of(status: DisplayStatus): StatusStyle = when (status) {
DisplayStatus.Working -> StatusStyle(WebTermColors.statusWorking, "", "运行中")
DisplayStatus.Waiting -> StatusStyle(WebTermColors.statusWaiting, "", "等待中")
DisplayStatus.Idle -> StatusStyle(WebTermColors.statusIdle, "", "空闲")
DisplayStatus.Stuck -> StatusStyle(WebTermColors.statusStuck, "", "卡住")
DisplayStatus.Unknown -> StatusStyle(WebTermColors.statusUnknown, "?", "未知")
DisplayStatus.PendingApproval -> StatusStyle(WebTermColors.statusWaiting, "!", "等待审批")
DisplayStatus.Exited -> StatusStyle(WebTermColors.statusIdle, "", "已退出")
}
public fun of(status: ClaudeStatus): StatusStyle = of(DisplayStatus.from(status))
}
}
/**
* Color + distinct glyph (+ optional Chinese label) for one status. Status is
* conveyed by shape, color AND the semantics label — never color alone.
*/
@Composable
public fun StatusBadge(
status: DisplayStatus,
modifier: Modifier = Modifier,
showsLabel: Boolean = false,
) {
val style = StatusStyle.of(status)
Row(
modifier = modifier.semantics { contentDescription = style.label },
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = style.symbol, color = style.color, style = MaterialTheme.typography.labelMedium)
if (showsLabel) {
Text(
text = style.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
/**
* One pill of monospaced-tabular telemetry (context %, $cost, model, PR…). Greys
* out when [isStale]; switches to the waiting/amber color when [isWarning].
*/
@Composable
public fun TelemetryChip(
text: String,
modifier: Modifier = Modifier,
isStale: Boolean = false,
isWarning: Boolean = false,
) {
val fg = if (isWarning) WebTermColors.statusWaiting else MaterialTheme.colorScheme.onSurfaceVariant
Text(
text = text,
style = WebTermType.metaMono,
color = fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
.alpha(if (isStale) Opacity.stale else 1f)
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.pill))
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
)
}
/**
* Standard card container: card surface + hairline border + `md12` radius +
* standard padding. The uniform card spec for rows, grid cells and panels.
*/
@Composable
public fun WebTermCard(
modifier: Modifier = Modifier,
padding: androidx.compose.ui.unit.Dp = Spacing.md12,
content: @Composable () -> Unit,
) {
androidx.compose.foundation.layout.Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.md12))
.border(Stroke.hairline, MaterialTheme.colorScheme.outline, RoundedCornerShape(Radius.md12))
.padding(padding),
) {
content()
}
}
// ── Previews ────────────────────────────────────────────────────────────────
@Preview(name = "StatusBadge")
@Composable
private fun StatusBadgePreview() {
WebTermTheme {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md12)) {
DisplayStatus.entries.forEach { StatusBadge(status = it, showsLabel = true) }
}
}
}
@Preview(name = "TelemetryChip")
@Composable
private fun TelemetryChipPreview() {
WebTermTheme {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TelemetryChip(text = "ctx 92%", isWarning = true)
TelemetryChip(text = "$0.1234")
TelemetryChip(text = "PR #7", isStale = true)
}
}
}
@Preview(name = "Card")
@Composable
private fun CardPreview() {
WebTermTheme {
WebTermCard {
Text(text = "web-terminal · 161×50", style = WebTermType.metaMono)
}
}
}

View File

@@ -0,0 +1,94 @@
package wang.yaojia.webterm.designsystem
import android.provider.Settings
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
/**
* Whether the OS "remove animations" accessibility setting is on. Motion tokens
* are ALWAYS routed through this (the analogue of iOS `DS.Motion.gated`): when
* true, callers collapse animations to instant. Provided by [WebTermTheme].
*/
public val LocalReduceMotion: androidx.compose.runtime.ProvidableCompositionLocal<Boolean> =
staticCompositionLocalOf { false }
/**
* # WebTermTheme — the app's Material 3 theme (mirrors the iOS `DS` appearance).
*
* Dark-appearance-first: the [darkColorScheme] carries the real design intent;
* the [lightColorScheme] is a faithful light counterpart. The amber-gold accent
* maps to `primary` (with dark `onPrimary` ink — gold needs dark text). Surface
* and text colors come from [WebTermColors]. The terminal canvas colors are NOT
* part of the scheme — they are fixed and read directly from [WebTermColors] by
* the terminal view, independent of app appearance.
*/
@Composable
public fun WebTermTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val reduceMotion = rememberReduceMotion()
CompositionLocalProvider(LocalReduceMotion provides reduceMotion) {
MaterialTheme(
colorScheme = colorScheme,
typography = WebTermType.ramp,
content = content,
)
}
}
/** Reads the OS animator-duration-scale; 0 means "remove animations" is on. */
@Composable
private fun rememberReduceMotion(): Boolean {
val context = LocalContext.current
return remember(context) {
val scale = Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
scale == 0f
}
}
// The scheme-independent status/accent colors stay in WebTermColors; the scheme
// here only maps surfaces/text/accent into Material 3's slots.
private val DarkColorScheme: ColorScheme = darkColorScheme(
primary = WebTermColors.dark.accent,
onPrimary = WebTermColors.onAccent,
secondary = WebTermColors.dark.accent,
onSecondary = WebTermColors.onAccent,
background = WebTermColors.dark.background,
onBackground = WebTermColors.dark.textPrimary,
surface = WebTermColors.dark.surface,
onSurface = WebTermColors.dark.textPrimary,
surfaceVariant = WebTermColors.dark.card,
onSurfaceVariant = WebTermColors.dark.textSecondary,
outline = WebTermColors.dark.hairline,
error = WebTermColors.statusStuck,
)
private val LightColorScheme: ColorScheme = lightColorScheme(
primary = WebTermColors.light.accent,
onPrimary = WebTermColors.onAccent,
secondary = WebTermColors.light.accent,
onSecondary = WebTermColors.onAccent,
background = WebTermColors.light.background,
onBackground = WebTermColors.light.textPrimary,
surface = WebTermColors.light.surface,
onSurface = WebTermColors.light.textPrimary,
surfaceVariant = WebTermColors.light.card,
onSurfaceVariant = WebTermColors.light.textSecondary,
outline = WebTermColors.light.hairline,
error = WebTermColors.statusStuck,
)

View File

@@ -0,0 +1,116 @@
package wang.yaojia.webterm.designsystem
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* # Tokens — the Compose-facing design vocabulary (mirrors iOS `DS`).
*
* Thin wrappers that lift [DesignSpec]'s raw numbers into Compose types
* (`Color`, `Dp`). Screens/components reference these — never an inline hex, gap
* or radius. Split into small objects that mirror the iOS `DS.*` groups.
*/
/**
* The two adaptive palettes (dark = default) plus the theme-independent tokens
* (accent, semantic status, terminal canvas). The scheme-specific surface/text
* colors are consumed by [WebTermTheme] to build the Material 3 `ColorScheme`.
*/
public object WebTermColors {
// Accent — theme-adaptive; injected once via the color scheme's `primary`.
public val accentDark: Color = Color(DesignSpec.ACCENT_DARK)
public val accentLight: Color = Color(DesignSpec.ACCENT_LIGHT)
public val onAccent: Color = Color(DesignSpec.ON_ACCENT)
/** Faint accent wash (selection/soft highlight) — web --accent-soft. */
public val accentSoft: Color = Color(DesignSpec.ACCENT_DARK).copy(alpha = DesignSpec.OPACITY_ACCENT_SOFT)
// Semantic status (theme-independent — same hex reads on desktop/iOS/Android).
public val statusWorking: Color = Color(DesignSpec.STATUS_WORKING)
public val statusWaiting: Color = Color(DesignSpec.STATUS_WAITING)
public val statusStuck: Color = Color(DesignSpec.STATUS_STUCK)
public val statusIdle: Color = Color(DesignSpec.STATUS_IDLE)
public val statusUnknown: Color = Color(DesignSpec.STATUS_UNKNOWN)
// Timeline event classes (A28).
public val timelineTool: Color = Color(DesignSpec.TIMELINE_TOOL)
public val timelineUser: Color = Color(DesignSpec.TIMELINE_USER)
// Terminal canvas — FIXED, independent of app theme.
public val terminalBackground: Color = Color(DesignSpec.TERMINAL_BACKGROUND)
public val terminalForeground: Color = Color(DesignSpec.TERMINAL_FOREGROUND)
public val terminalCaret: Color = Color(DesignSpec.TERMINAL_CARET)
/** One appearance's surface + text colors. Consumed by [WebTermTheme]. */
@Immutable
public data class Scheme(
val background: Color,
val surface: Color,
val card: Color,
val hairline: Color,
val textPrimary: Color,
val textSecondary: Color,
val textTertiary: Color,
val accent: Color,
)
public val dark: Scheme = Scheme(
background = Color(DesignSpec.DARK_BACKGROUND),
surface = Color(DesignSpec.DARK_SURFACE),
card = Color(DesignSpec.DARK_CARD),
hairline = Color(DesignSpec.DARK_HAIRLINE),
textPrimary = Color(DesignSpec.DARK_TEXT_PRIMARY),
textSecondary = Color(DesignSpec.DARK_TEXT_SECONDARY),
textTertiary = Color(DesignSpec.DARK_TEXT_TERTIARY),
accent = accentDark,
)
public val light: Scheme = Scheme(
background = Color(DesignSpec.LIGHT_BACKGROUND),
surface = Color(DesignSpec.LIGHT_SURFACE),
card = Color(DesignSpec.LIGHT_CARD),
hairline = Color(DesignSpec.LIGHT_HAIRLINE),
textPrimary = Color(DesignSpec.LIGHT_TEXT_PRIMARY),
textSecondary = Color(DesignSpec.LIGHT_TEXT_SECONDARY),
textTertiary = Color(DesignSpec.LIGHT_TEXT_TERTIARY),
accent = accentLight,
)
}
/** Spacing scale — 2·4·8·12·16·20·24 (mirrors `DS.Space`). */
public object Spacing {
public val xs2: Dp = DesignSpec.SPACE_XS2.dp
public val xs4: Dp = DesignSpec.SPACE_XS4.dp
public val sm8: Dp = DesignSpec.SPACE_SM8.dp
public val md12: Dp = DesignSpec.SPACE_MD12.dp
public val lg16: Dp = DesignSpec.SPACE_LG16.dp
public val xl20: Dp = DesignSpec.SPACE_XL20.dp
public val xxl24: Dp = DesignSpec.SPACE_XXL24.dp
}
/** Corner radii (mirrors `DS.Radius`). */
public object Radius {
public val sm8: Dp = DesignSpec.RADIUS_SM8.dp
public val md12: Dp = DesignSpec.RADIUS_MD12.dp
public val lg16: Dp = DesignSpec.RADIUS_LG16.dp
public val pill: Dp = DesignSpec.RADIUS_PILL.dp
}
/** Border widths (mirrors `DS.Stroke`). */
public object Stroke {
public val hairline: Dp = DesignSpec.STROKE_HAIRLINE.dp
}
/** Dimming multipliers (mirrors `DS.Opacity`). */
public object Opacity {
public const val stale: Float = DesignSpec.OPACITY_STALE
public const val exited: Float = DesignSpec.OPACITY_EXITED
public const val pressed: Float = DesignSpec.OPACITY_PRESSED
}
/** Non-spacing layout constants (mirrors `DS.Layout`). */
public object LayoutTokens {
public val minHitTarget: Dp = DesignSpec.MIN_HIT_TARGET.dp
}

View File

@@ -0,0 +1,47 @@
package wang.yaojia.webterm.designsystem
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* # Typography — the type ramp (mirrors iOS `DS.Typography`).
*
* The proportional ramp is Material 3's default [Typography] (scales with the
* system font-size setting, the a11y "keep it scalable" point). Numbers /
* dimensions / cost / `cols×rows` / timestamps use [monoTabular]: a monospace
* family with **tabular figures** (`tnum`) so columns line up and digits don't
* jitter as values change — the analogue of iOS `DS.Typography.mono(_:)`.
*/
public object WebTermType {
/** Material 3 default ramp — proportional, system-scalable. */
public val ramp: Typography = Typography()
/**
* Monospace + tabular-figures style at the given [size]. Use for anything
* numeric that must align or not jump: `cols×rows`, device/client counts,
* `$cost`, context %, relative timestamps.
*/
public fun monoTabular(size: Int = 13): TextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontFeatureSettings = TABULAR_FIGURES,
fontSize = size.sp,
)
/** The canonical meta-number style: caption-sized mono + tabular. */
public val metaMono: TextStyle = monoTabular(size = 12)
/** The terminal-line style: mono at body size (the emulator overrides glyphs). */
public val terminal: TextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontFeatureSettings = TABULAR_FIGURES,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
)
/** OpenType feature string enabling tabular (fixed-advance) numerals. */
private const val TABULAR_FIGURES: String = "tnum"
}

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool
import okhttp3.OkHttpClient
import wang.yaojia.webterm.transport.ClientIdentity
import wang.yaojia.webterm.transport.ClientIdentityProvider
import wang.yaojia.webterm.transport.OkHttpClientFactory
import wang.yaojia.webterm.transport.OkHttpHttpTransport
import wang.yaojia.webterm.transport.OkHttpTermTransport
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import javax.inject.Singleton
/**
* Network boundary (A15): the ONE shared [OkHttpClient] both transports use, the mTLS bridge, and the
* WS + REST transports. One client → the `SSLSocketFactory` + `cache(null)` posture apply uniformly to
* WS and REST, and the connection pool is shared (plan §2/§8).
*
* ### mTLS bridge (A11 [IdentityRepository] → A7 [ClientIdentityProvider])
* `:transport-okhttp` never depends on `:client-tls-android`; this module is the bridge. The provider
* reads the repository's stable `(SSLSocketFactory, X509TrustManager)` pair; the factory is backed by a
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
* client rebuild (R4). The provider is read ONCE, when the client is built.
*
* ### Breaking the construction cycle (A11's frozen constructor)
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
* [ConnectionPool]: the repository is given a lightweight evict-only client that SHARES that pool
* ([TlsModule]), and the shared client + its WS variant both use the same pool, so `evictAll()` drops
* the real pooled/resumed connections. The graph is then acyclic (client → provider → repo → pool).
*/
@Module
@InstallIn(SingletonComponent::class)
public object NetworkModule {
/** The one connection pool shared by the transports AND the repository's evict-only client. */
@Provides
@Singleton
public fun provideConnectionPool(): ConnectionPool = ConnectionPool()
/** Bridge the mTLS device identity onto the shared client (read once, at client build). */
@Provides
@Singleton
public fun provideClientIdentityProvider(
identityRepository: IdentityRepository,
): ClientIdentityProvider = ClientIdentityProvider {
val material = identityRepository.sslMaterial()
ClientIdentity(material.sslSocketFactory, material.trustManager)
}
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
@Provides
@Singleton
public fun provideOkHttpClient(
identityProvider: ClientIdentityProvider,
connectionPool: ConnectionPool,
): OkHttpClient =
OkHttpClientFactory.create(identityProvider)
.newBuilder()
.connectionPool(connectionPool)
.build()
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
@Provides
@Singleton
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
@Provides
@Singleton
public fun provideHttpTransport(client: OkHttpClient): HttpTransport = OkHttpHttpTransport(client)
}

View File

@@ -0,0 +1,22 @@
package wang.yaojia.webterm.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.push.PushRegistrarTokenSink
import wang.yaojia.webterm.push.PushTokenSink
/**
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
* `@BindsOptionalOf` (in `push/PushTokenSink.kt`). With a concrete `@Binds` present, the
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
*/
@Module
@InstallIn(SingletonComponent::class)
public interface PushModule {
@Binds
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
}

View File

@@ -0,0 +1,33 @@
package wang.yaojia.webterm.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wiring.ColdStartPolicy
import wang.yaojia.webterm.wiring.DefaultColdStartPolicy
import javax.inject.Singleton
/**
* Session-wiring boundary (A15): the cold-start route seam.
*
* The per-host / per-session builders (`ApiClientFactory`, `SessionEngineFactory`) and the composition
* root (`AppEnvironment`) are constructor-injected (`@Inject`), so Hilt provides them without an entry
* here — this module only wires the [ColdStartPolicy] interface binding.
*
* NOTE (FIX 1): there is deliberately NO `EventBus` provider. The engine→UI fan-out is PER-SESSION —
* a fresh `EventBus` is minted inside `RetainedSessionHolder.bind()` alongside each engine's confined
* scope, so two sessions never share one bus (an app-wide singleton would cross-talk `Output`/`Gate`
* between sessions since `SessionEvent` carries no session id).
*/
@Module
@InstallIn(SingletonComponent::class)
public object SessionModule {
/** Bind the cold-start seam A29 consumes to the host-presence policy. */
@Provides
@Singleton
public fun provideColdStartPolicy(hostStore: HostStore): ColdStartPolicy =
DefaultColdStartPolicy(hostStore)
}

View File

@@ -0,0 +1,47 @@
package wang.yaojia.webterm.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore
import javax.inject.Singleton
/**
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
*/
@Module
@InstallIn(SingletonComponent::class)
public object StorageModule {
@Provides
@Singleton
public fun provideDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(DATASTORE_NAME) }
@Provides
@Singleton
public fun provideHostStore(dataStore: DataStore<Preferences>): HostStore =
DataStoreHostStore(dataStore)
@Provides
@Singleton
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
DataStoreLastSessionStore(dataStore)
private const val DATASTORE_NAME: String = "webterm"
}

View File

@@ -0,0 +1,56 @@
package wang.yaojia.webterm.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool
import okhttp3.OkHttpClient
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.tlsandroid.TinkCertStore
import javax.inject.Singleton
/**
* mTLS device-identity boundary (A15 → A11): the AndroidKeyStore importer (the ONE non-exportable key
* home), the Tink-AEAD-encrypted cert-chain store, and the [IdentityRepository] that ties them to the
* shared client for no-relaunch rotation (`connectionPool.evictAll()`).
*
* The repository's constructor is I/O-free (SSL material is built from standard JCA; keystore/Tink I/O
* is lazy). Per A11's KDoc, the FIRST identity touch (summary / mutation / first handshake) does
* synchronous AndroidKeyStore + Tink work and MUST be triggered off `Dispatchers.Main` — that warm-up
* is A21/A27's concern, not this wiring's.
*
* The repository is given an evict-only client that SHARES [NetworkModule]'s [ConnectionPool] (see the
* cycle-break note there), so `evictAll()` drops the real transports' pooled/resumed connections
* without this module depending on the shared client (which would reintroduce the cycle).
*/
@Module
@InstallIn(SingletonComponent::class)
public object TlsModule {
@Provides
@Singleton
public fun provideKeyStoreImporter(): AndroidKeyStoreImporter = AndroidKeyStoreImporter()
@Provides
@Singleton
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
@Provides
@Singleton
public fun provideIdentityRepository(
importer: AndroidKeyStoreImporter,
certStore: CertStore,
connectionPool: ConnectionPool,
): IdentityRepository {
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
return AndroidIdentityRepository(importer, certStore, evictClient)
}
}

View File

@@ -0,0 +1,18 @@
package wang.yaojia.webterm.nav
import kotlin.coroutines.cancellation.CancellationException
/**
* Like [runCatching] but NEVER swallows structured-concurrency cancellation: a [CancellationException]
* is rethrown so a cancelled `produceState` / `LaunchedEffect` producer tears down cleanly, while any
* real failure (a DataStore/transport error) degrades to a [Result.failure] the caller maps to a null /
* fallback UI state. `inline` so the suspend calls in [block] inline into the caller's suspend context.
*/
internal inline fun <T> runCatchingCancellable(block: () -> T): Result<T> =
try {
Result.success(block())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}

View File

@@ -0,0 +1,170 @@
package wang.yaojia.webterm.nav
import wang.yaojia.webterm.wire.Validation
import java.net.URI
/**
* # DeepLinkRouter (A32) — the ONE deep-link validation surface.
*
* Android analogue of iOS `DeepLinkRouter` (`ios/App/WebTerm/DeepLinkRouter.swift`). Three external
* entry points share ONE whitelist parser so there is never a second, drift-prone regex:
*
* - `webterminal://open?host=<uuid>&join=<uuid>` — the **custom scheme** (registered by the
* `<intent-filter>` on the launcher Activity), and
* - `https://<APP_LINK_HOST>/open?host=<uuid>&join=<uuid>` — the **verified App Link** (an
* `autoVerify=true` `<intent-filter>`, gated by a `assetlinks.json` deploy artifact), and
* - the FCM push-tap payload `{sessionId,…}` (A30 reuses [route] — same UUID rules, no second parser).
*
* ## Security contract (plan §8 "App Links", §1 deep-links row)
* A deep link is EXTERNAL, untrusted input. Every field is whitelist-validated: scheme / action /
* verified host / query keys, and BOTH ids as v4 UUIDs through the frozen [Validation.isValidSessionId]
* (never re-regexed here — M7). ANY invalid, ambiguous (duplicate key), or missing part collapses the
* whole link to [DeepLinkRoute.Ignore] — it is **never partially applied** (a valid host with a bad
* session id does NOT open the host). Ignores are tallied by [DeepLinkTally], never echoed (the URL is
* untrusted — log the counter only, never the link).
*
* ## Purity
* PURE Kotlin/JVM — parses with `java.net.URI` (stdlib, not an Android import), exactly like
* [wang.yaojia.webterm.wire.HostEndpoint]. The caller (the launcher Activity / the A30 push tap)
* converts the platform `android.net.Uri`/`RemoteMessage.data` to a `String`/`Map` and hands it here,
* so this whole surface is JVM-unit-testable with no device. Host EXISTENCE (is this a paired host?)
* is resolved LATER against the `HostStore`, never here — the router only proves syntactic validity.
*/
public object DeepLinkRouter {
/** Custom-scheme name — case-insensitive per RFC 3986, compared lowercased. */
public const val SCHEME: String = "webterminal"
/** Custom-scheme action (the URI "host" of `webterminal://open`). */
public const val ACTION: String = "open"
/**
* The verified App-Links domain. `assetlinks.json` must be served over HTTPS (no redirect) at
* `https://<APP_LINK_HOST>/.well-known/assetlinks.json` carrying the RELEASE signing-cert SHA-256
* (a deploy artifact, plan §8). The tunnel presents a real LE cert for this host.
*/
public const val APP_LINK_HOST: String = "terminal.yaojia.wang"
/** The App-Links path (the https analogue of the custom-scheme [ACTION]). */
public const val APP_LINK_PATH: String = "/open"
/** Query key carrying the paired-host id (a v4 UUID resolved against the store later). */
public const val QUERY_HOST: String = "host"
/** Query key carrying the session id to join (a v4 UUID). */
public const val QUERY_JOIN: String = "join"
/** Push-payload key carrying the gate's session id (payload minimized to `sessionId/cls/token`, §8). */
public const val PUSH_SESSION_ID: String = "sessionId"
/** URI paths accepted for the custom scheme (`webterminal://open` with an empty or root path). */
private val EMPTY_PATHS: Set<String> = setOf("", "/")
/** URI paths accepted for the App Link (`/open`, with or without a trailing slash). */
private val APP_LINK_PATHS: Set<String> = setOf(APP_LINK_PATH, "$APP_LINK_PATH/")
/**
* Parse an incoming deep-link URI string. Handles BOTH the custom scheme and the verified App Link;
* returns [DeepLinkRoute.OpenSession] only when the shape is whitelisted AND both ids are v4 UUIDs,
* else [DeepLinkRoute.Ignore]. Never throws — a malformed URI is caught and ignored.
*/
public fun route(uri: String): DeepLinkRoute {
val parsed = try {
URI(uri)
} catch (_: Exception) {
return DeepLinkRoute.Ignore
}
if (!isWhitelistedShape(parsed)) return DeepLinkRoute.Ignore
val query = parseQuery(parsed.rawQuery)
val hostId = uniqueValidatedId(query, QUERY_HOST) ?: return DeepLinkRoute.Ignore
val sessionId = uniqueValidatedId(query, QUERY_JOIN) ?: return DeepLinkRoute.Ignore
return DeepLinkRoute.OpenSession(hostId = hostId, sessionId = sessionId)
}
/**
* Parse an FCM push-tap payload (A30). The payload is minimized to `sessionId/cls/token`; only a
* v4-valid `sessionId` yields [DeepLinkRoute.GateSession] — there is no host id in the payload
* (A30's notification handler owns host resolution). Anything else → [DeepLinkRoute.Ignore].
*/
public fun route(pushData: Map<String, String>): DeepLinkRoute {
val sessionId = pushData[PUSH_SESSION_ID]?.let(::validatedId) ?: return DeepLinkRoute.Ignore
return DeepLinkRoute.GateSession(sessionId = sessionId)
}
/** True when [uri] matches the custom-scheme OR the verified-App-Link shape (scheme/host/path). */
private fun isWhitelistedShape(uri: URI): Boolean {
val scheme = uri.scheme?.lowercase() ?: return false
val host = uri.host?.lowercase() ?: return false
val path = uri.path ?: ""
return when (scheme) {
SCHEME -> host == ACTION && path in EMPTY_PATHS
"https" -> host == APP_LINK_HOST && path in APP_LINK_PATHS
else -> false
}
}
/**
* Exactly ONE occurrence of [key], and its value is a v4 UUID. Zero occurrences (missing key) or
* duplicates (ambiguous input) → null, so the link is ignored rather than partially applied
* (mirrors iOS `uniqueValidatedId`).
*/
private fun uniqueValidatedId(query: Map<String, List<String>>, key: String): String? {
val values = query[key] ?: return null
if (values.size != 1) return null
return validatedId(values.first())
}
/**
* The frozen-contract v4 check FIRST ([Validation.isValidSessionId], M7). Returns the canonical
* (unchanged) id string when valid, else null. UUIDs never need percent-decoding, so a value that
* is not a bare v4 UUID (encoded, empty, garbage) simply fails the whitelist → ignored.
*/
private fun validatedId(raw: String): String? = if (Validation.isValidSessionId(raw)) raw else null
/**
* Split a raw query string (`a=1&b=2&a=3`) into name → ordered values. Preserves duplicates so
* [uniqueValidatedId] can reject ambiguous links. A segment without `=` maps to an empty value; an
* empty/blank name is dropped. Values are NOT decoded (see [validatedId]).
*/
private fun parseQuery(rawQuery: String?): Map<String, List<String>> {
if (rawQuery.isNullOrEmpty()) return emptyMap()
val out = LinkedHashMap<String, MutableList<String>>()
for (segment in rawQuery.split("&")) {
if (segment.isEmpty()) continue
val eq = segment.indexOf('=')
val name = if (eq >= 0) segment.substring(0, eq) else segment
val value = if (eq >= 0) segment.substring(eq + 1) else ""
if (name.isEmpty()) continue
out.getOrPut(name) { mutableListOf() }.add(value)
}
return out
}
}
/**
* A deep-link parse outcome carrying RAW, syntactically-validated ids (host existence is resolved later
* against the `HostStore`, never here). Mirrors iOS `DeepLinkRouter.Route`.
*/
public sealed interface DeepLinkRoute {
/** `webterminal://open` / verified App Link with both ids valid v4 → open [sessionId] on [hostId]. */
public data class OpenSession(val hostId: String, val sessionId: String) : DeepLinkRoute
/** Push-tap with a valid [sessionId] (no host id in the payload; A30 resolves the host). */
public data class GateSession(val sessionId: String) : DeepLinkRoute
/** Non-whitelisted / ambiguous / invalid / missing input. Never partially applied; tallied. */
public data object Ignore : DeepLinkRoute
}
/**
* Immutable tally of invalid deep links dropped (the Android analogue of iOS
* `DeepLinkHandler.ignoredCount`). Kept as a pure reducer so counting stays side-effect-free and
* JVM-testable; the app holds one instance in its state and swaps in the [record] result. The dropped
* URL is NEVER stored — only the counter — because the link is untrusted external input (§8).
*/
public data class DeepLinkTally(val ignored: Int = 0) {
/** Return a new tally: +1 when [route] is [DeepLinkRoute.Ignore], otherwise unchanged. */
public fun record(route: DeepLinkRoute): DeepLinkTally =
if (route is DeepLinkRoute.Ignore) copy(ignored = ignored + 1) else this
}

View File

@@ -0,0 +1,75 @@
package wang.yaojia.webterm.nav
/**
* # LayoutPolicy (A26) — the single adaptive-layout decision point.
*
* The Android mirror of iOS `LayoutPolicy` (`ios/App/WebTerm/Wiring/LayoutMode.swift`): the **only**
* place that turns a window size class into a layout decision. Views must never scatter
* `if windowSizeClass == …` — every branch reads this one pure predicate, so the decision is 100%
* JVM-unit-testable with plain enum inputs (no Compose, no device — plan §"iPad-equivalent", R13).
*
* The thin `WindowSizeClass → WindowWidth` extraction lives in the Compose layer
* ([wang.yaojia.webterm.screens.AdaptiveHome]); everything HERE is pure Kotlin so it stays in the
* JVM test path exactly like iOS's `LayoutPolicyTests`.
*/
/**
* A plain, Compose-free mirror of `androidx.window.core.layout.WindowWidthSizeClass` (the analogue of
* SwiftUI's `UserInterfaceSizeClass`). Kept as our own enum so [LayoutPolicy.mode] is a pure function
* unit-tested without pulling the window/Compose types onto the JVM test classpath.
*/
public enum class WindowWidth {
/** Phone portrait / small split / Slide-Over — the compact, single-pane path. */
COMPACT,
/** Large phone landscape / small foldable / medium split — wide enough for sidebar + detail. */
MEDIUM,
/** Tablet full-screen / large foldable / desktop-class window — sidebar + detail. */
EXPANDED,
}
/**
* The adaptive root's layout mode (mirrors iOS `LayoutMode.stack` / `.split`).
*
* - [STACK] — the compact path: single-pane navigation (list → push terminal), byte-for-byte the
* existing phone flow.
* - [LIST_DETAIL] — the expanded/medium path: session list (list pane) + terminal (detail pane)
* side by side, driven by Material 3 Adaptive `ListDetailPaneScaffold`.
*/
public enum class LayoutMode {
/** Compact: single pane, stack navigation (iPhone / Slide-Over analogue). */
STACK,
/** Medium / expanded: sidebar + detail split (iPad analogue). */
LIST_DETAIL,
}
/**
* The one size-class decision. [WindowWidth.COMPACT] → [LayoutMode.STACK]; [WindowWidth.MEDIUM] and
* [WindowWidth.EXPANDED] → [LayoutMode.LIST_DETAIL]. Pure; mirrors iOS
* `LayoutPolicy.mode(horizontalSizeClass:)`.
*/
public object LayoutPolicy {
public fun mode(width: WindowWidth): LayoutMode = when (width) {
WindowWidth.COMPACT -> LayoutMode.STACK
WindowWidth.MEDIUM, WindowWidth.EXPANDED -> LayoutMode.LIST_DETAIL
}
}
/**
* The pointer-context-menu gating predicate (plan §5 A26, §"iPad-equivalent", R13, §8).
*
* A right-click / trackpad-secondary context menu is a large-screen affordance only — it is enabled
* **iff** the layout is [LayoutMode.LIST_DETAIL] AND the device is a genuine tablet
* (`smallestScreenWidthDp >= 600`, the Material tablet breakpoint). A large phone in landscape can
* momentarily report an expanded width but keeps `smallestScreenWidthDp < 600`, so the two-part gate
* blocks the desktop menu there. Pure so it is JVM-unit-tested with plain inputs.
*/
public object PointerMenuPolicy {
/** The Material tablet breakpoint (dp). `smallestScreenWidthDp` at/above this = a real large screen. */
public const val TABLET_MIN_WIDTH_DP: Int = 600
public fun enabled(mode: LayoutMode, smallestScreenWidthDp: Int): Boolean =
mode == LayoutMode.LIST_DETAIL && smallestScreenWidthDp >= TABLET_MIN_WIDTH_DP
}

View File

@@ -0,0 +1,266 @@
package wang.yaojia.webterm.nav
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.window.core.layout.WindowSizeClass
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.wiring.AppEnvironment
import wang.yaojia.webterm.wiring.ColdStartRoute
/**
* # WebTerm navigation host (app-assembly of A19A29).
*
* The single `NavHost` for the app: it maps each [NavRoutes] destination onto the real screen pane
* ([PairingPane] / [SessionsHome] / [ProjectsHome] / [TerminalHost] / [ProjectDetailPane] / [DiffPane] /
* [ClientCertPane] / [GatePane]), wires the inter-screen callbacks to `navController` navigations, and
* resolves every collaborator (ViewModels, per-host `ApiClient`, the terminal holder) off [env]. The
* start destination is chosen by [wang.yaojia.webterm.wiring.ColdStartPolicy] (surfaced through
* [NavRoutes.startRouteFor]) and passed in as [startRoute].
*
* ## Deep links go through [DeepLinkRouter], NOT `navDeepLink`
* A `navDeepLink { uriPattern = "webterminal://open?host={host}&join={join}" }` would let the OS navigate
* on ANY `{host}`/`{join}` value, bypassing the v4-UUID whitelist. Instead the launcher Activity (and the
* push tap) run [DeepLinkRouter.route] first and navigate only a whitelist-validated [NavRoutes.forDeepLink]
* route — the manifest `<intent-filter>`s deliver the intent; validation is never delegated to the graph.
* That is the single non-skippable check (plan §8 "App Links").
*
* The `NavHost` composition itself is device-QA (plan §7); the pure route mappers ([NavRoutes]) are
* JVM-unit-tested (`DeepLinkRouterTest`, `NavRoutesTest`).
*/
@Composable
public fun WebTermNavHost(
env: AppEnvironment,
startRoute: String,
navController: NavHostController = rememberNavController(),
modifier: Modifier = Modifier,
onHostPaired: (Host) -> Unit = {},
) {
NavHost(
navController = navController,
startDestination = startRoute,
modifier = modifier,
) {
composable(NavRoutes.PAIRING) {
PairingPane(
env = env,
onPaired = { host ->
onHostPaired(host)
navController.navigate(NavRoutes.SESSIONS) {
popUpTo(NavRoutes.PAIRING) { inclusive = true }
launchSingleTop = true
}
},
onImportCert = { navController.navigate(NavRoutes.CERT) },
)
}
composable(NavRoutes.SESSIONS) { SessionsHome(env = env, navController = navController) }
composable(NavRoutes.PROJECTS) { ProjectsHome(env = env, navController = navController) }
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
composable(
route = TERMINAL_ROUTE,
arguments = listOf(
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
navArgument(NavArg.SESSION_ID) { type = NavType.StringType },
navArgument(NavArg.CWD) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
val hostId = entry.arguments?.getString(NavArg.HOST_ID)
TerminalHost(
env = env,
hostId = hostId,
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
cwd = entry.arguments?.getString(NavArg.CWD),
onNewSessionInCwd = { req ->
if (hostId != null) navController.navigate(newTerminalRoute(hostId, req.cwd))
},
)
}
composable(
route = PROJECT_DETAIL_ROUTE,
arguments = listOf(
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
navArgument(NavArg.PATH) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
ProjectDetailPane(
env = env,
hostId = entry.arguments?.getString(NavArg.HOST_ID),
path = entry.arguments?.getString(NavArg.PATH),
navController = navController,
)
}
composable(
route = DIFF_ROUTE,
arguments = listOf(
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
navArgument(NavArg.PATH) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
DiffPane(
env = env,
hostId = entry.arguments?.getString(NavArg.HOST_ID),
path = entry.arguments?.getString(NavArg.PATH),
onBack = { navController.popBackStack() },
)
}
composable(
route = NavRoutes.GATE_PATTERN,
arguments = listOf(navArgument(NavArg.SESSION_ID) { type = NavType.StringType }),
) { entry ->
GatePane(
env = env,
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
navController = navController,
)
}
}
}
// ── Registered routes carrying an OPTIONAL/encoded arg (kept OUT of the pure [NavRoutes] object) ──────
/** The registered terminal route: [NavRoutes.TERMINAL_PATTERN] plus an optional `cwd` query arg. */
internal val TERMINAL_ROUTE: String = "${NavRoutes.TERMINAL_PATTERN}?${NavArg.CWD}={${NavArg.CWD}}"
/** The registered project-detail route: hostId path arg + an encoded `path` query arg. */
internal val PROJECT_DETAIL_ROUTE: String = "projectDetail/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
/** The registered diff route: hostId path arg + an encoded `path` query arg. */
internal val DIFF_ROUTE: String = "diff/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
/**
* Build a concrete route that spawns a NEW session on [hostId] in [cwd] (`attach(null, cwd)`). The cwd
* is percent-encoded with [Uri.encode] here (the Android layer) so [NavRoutes] stays a pure,
* JVM-testable object with no `android.net.Uri` edge.
*/
internal fun newTerminalRoute(hostId: String, cwd: String?): String {
val base = NavRoutes.terminalNew(hostId)
return if (cwd.isNullOrEmpty()) base else "$base?${NavArg.CWD}=${Uri.encode(cwd)}"
}
/** Build a concrete project-detail route for [hostId] + [path] (path percent-encoded). */
internal fun projectDetailRoute(hostId: String, path: String): String =
"projectDetail/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
/** Build a concrete diff route for [hostId] + [path] (path percent-encoded). */
internal fun diffRoute(hostId: String, path: String): String =
"diff/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
/**
* Extract the layout [WindowWidth] from a Compose [WindowSizeClass] (the thin bridge kept out of the pure
* [LayoutPolicy]). Mirrors the Material width breakpoints: EXPANDED (≥840dp) → medium (≥600dp) → compact.
*/
internal fun windowWidthOf(sizeClass: WindowSizeClass): WindowWidth = when {
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> WindowWidth.EXPANDED
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> WindowWidth.MEDIUM
else -> WindowWidth.COMPACT
}
/** Nav-arg keys, shared between the route patterns and the argument readers. */
public object NavArg {
public const val HOST_ID: String = "hostId"
public const val SESSION_ID: String = "sessionId"
/** Optional new-session working directory (query arg on the terminal route). */
public const val CWD: String = "cwd"
/** Project/diff path (encoded query arg). */
public const val PATH: String = "path"
}
/**
* The app's navigation destinations and the pure route mappers. Route strings are the ONE source of
* truth for both [WebTermNavHost] and the callers that navigate; the mappers are pure (no Android edge)
* so the cold-start and deep-link seams stay JVM-unit-testable without a device.
*/
public object NavRoutes {
/** Pairing / add-host flow (A19) — the cold-start landing when no host is paired. */
public const val PAIRING: String = "pairing"
/** Session chooser / dashboard (A20) — the cold-start landing when a host is paired. */
public const val SESSIONS: String = "sessions"
/** Projects grid (A23). */
public const val PROJECTS: String = "projects"
/** Device-certificate management (A27). */
public const val CERT: String = "cert"
/** Terminal route pattern with the required host + session path args (A21). */
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"
/**
* Push-gate route pattern (A30): a session id with NO host id in the payload — the notification
* handler resolves the host, then this destination surfaces the gate for [sessionId].
*/
public const val GATE_PATTERN: String = "gate/{${NavArg.SESSION_ID}}"
/**
* Sentinel session-arg meaning "spawn a NEW session" (`attach(null)`) — a fresh session has no
* server id yet, so the terminal route carries this in the [NavArg.SESSION_ID] slot and
* [attachSessionId] maps it back to `null` at the bind site.
*/
public const val NEW_SESSION_SENTINEL: String = "new"
/**
* The cold-start start destination, keyed off [ColdStartRoute] (no host → pairing, else sessions).
* Pure; mirrors the [wang.yaojia.webterm.wiring.ColdStartPolicy] decision onto a nav route.
*/
public fun startRouteFor(route: ColdStartRoute): String = when (route) {
ColdStartRoute.PAIRING -> PAIRING
ColdStartRoute.SESSIONS -> SESSIONS
}
/** Build a concrete terminal route for a resolved (hostId, sessionId) pair. */
public fun terminal(hostId: String, sessionId: String): String = "terminal/$hostId/$sessionId"
/** Build a concrete terminal route that spawns a NEW session on [hostId] ([NEW_SESSION_SENTINEL]). */
public fun terminalNew(hostId: String): String = "terminal/$hostId/$NEW_SESSION_SENTINEL"
/**
* Map a terminal-route session arg to the id to bind: the [NEW_SESSION_SENTINEL] (a fresh spawn)
* becomes `null` (`attach(null)`), any other value passes through verbatim. Pure → JVM-tested.
*/
public fun attachSessionId(sessionArg: String?): String? =
sessionArg?.takeUnless { it == NEW_SESSION_SENTINEL }
/** Build a concrete push-gate route for a resolved sessionId. */
public fun gate(sessionId: String): String = "gate/$sessionId"
/**
* The deep-link entry point: turn a [DeepLinkRouter] result into the concrete nav route to navigate
* to, or null for [DeepLinkRoute.Ignore] (the caller does nothing — the ignore is already tallied).
* This is the ONE place a validated deep link becomes a navigation, so the launcher Activity and the
* push tap share it. Pure → JVM-tested.
*/
public fun forDeepLink(route: DeepLinkRoute): String? = when (route) {
is DeepLinkRoute.OpenSession -> terminal(route.hostId, route.sessionId)
is DeepLinkRoute.GateSession -> gate(route.sessionId)
DeepLinkRoute.Ignore -> null
}
}

View File

@@ -0,0 +1,236 @@
package wang.yaojia.webterm.nav
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.ClientCertScreen
import wang.yaojia.webterm.screens.DiffScreen
import wang.yaojia.webterm.screens.PairingScreen
import wang.yaojia.webterm.screens.ProjectDetailScreen
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.ProjectsGateway
import wang.yaojia.webterm.wiring.AppEnvironment
import java.util.UUID
// ── Pairing (A19) ─────────────────────────────────────────────────────────────────────────────────────
/**
* Hosts [PairingScreen] over a fresh [PairingViewModel]: the two-step probe uses the shared transports
* (resolved lazily off `Main` inside the probe), and the tunnel cert-gate reads the device identity off
* `Dispatchers.IO`. The screen does not self-bind, so this pane binds it to a lifecycle scope.
*/
@Composable
public fun PairingPane(
env: AppEnvironment,
onPaired: (Host) -> Unit,
onImportCert: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) {
PairingViewModel(
hostStore = env.hostStore,
prober = env.buildPairingProber(),
hasDeviceCert = {
withContext(Dispatchers.IO) {
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
}
},
)
}
LaunchedEffect(viewModel) { viewModel.bind(this) }
PairingScreen(viewModel = viewModel, onPaired = onPaired, onImportCert = onImportCert, modifier = modifier)
}
// ── Device certificate (A27) ────────────────────────────────────────────────────────────────────────
/** Hosts [ClientCertScreen] over a fresh [ClientCertViewModel] on the shared mTLS device identity. */
@Composable
public fun ClientCertPane(
env: AppEnvironment,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) { ClientCertViewModel(repository = env.identityRepository) }
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
}
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
/**
* The project-detail destination: resolves [hostId] → its per-host projects gateway, then renders
* [ProjectDetailContent]. "在此启动 Claude" spawns a new session in the project cwd.
*/
@Composable
public fun ProjectDetailPane(
env: AppEnvironment,
hostId: String?,
path: String?,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
if (hostId == null || path == null) {
MessagePane("无效的项目。", modifier)
return
}
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
}
val resolved = host
if (resolved == null) {
MessagePane("正在打开项目…", modifier)
return
}
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
ProjectDetailContent(
gateway = gateway,
path = path,
onBack = { navController.popBackStack() },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
modifier = modifier,
)
}
/** Shared project-detail body — reused by the stacked destination and the tablet detail pane. */
@Composable
public fun ProjectDetailContent(
gateway: ProjectsGateway,
path: String,
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
}
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
/**
* The read-only git-diff destination (A24): resolves [hostId] → the RO diff fetcher over the shared
* [HttpTransport][wang.yaojia.webterm.wire.HttpTransport]. Composed for completeness; no inbound link is
* wired yet (the project-detail screen exposes no "view diff" callback to connect).
*/
@Composable
public fun DiffPane(
env: AppEnvironment,
hostId: String?,
path: String?,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
if (hostId == null || path == null) {
MessagePane("无效的路径。", modifier)
return
}
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
}
val resolved = host
if (resolved == null) {
MessagePane("正在加载 diff…", modifier)
return
}
val viewModel = remember(resolved, path) {
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
}
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
}
// ── Push-gate deep link (A30) ─────────────────────────────────────────────────────────────────────────
/**
* The push-gate destination: a valid session id arrives with NO host (payload minimization, §4.5), so this
* best-effort scans the paired hosts' live sessions for a match and redirects to that host's terminal
* (which surfaces the held gate); if none matches, it falls back to the sessions chooser. All resolution
* runs off `Main`; device-QA.
*/
@Composable
public fun GatePane(
env: AppEnvironment,
sessionArg: String?,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val sessionId = NavRoutes.attachSessionId(sessionArg)
LaunchedEffect(sessionId) {
val route = gateTargetRoute(env, sessionId)
runCatching {
navController.navigate(route) {
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
launchSingleTop = true
}
}
}
MessagePane("正在定位会话…", modifier)
}
/**
* Resolve where a push-gate deep link should land: the terminal of the host that currently owns
* [sessionId] (which surfaces the held gate), or the sessions chooser when the id is missing/invalid or
* no paired host has it.
*/
private suspend fun gateTargetRoute(env: AppEnvironment, sessionId: String?): String {
if (sessionId == null) return NavRoutes.SESSIONS
val uuid = runCatching { UUID.fromString(sessionId) }.getOrNull() ?: return NavRoutes.SESSIONS
val hostId = resolveHostForSession(env, uuid) ?: return NavRoutes.SESSIONS
return NavRoutes.terminal(hostId, sessionId)
}
/** Scan paired hosts' `GET /live-sessions` for [id]; return the first host that has it (off `Main`). */
private suspend fun resolveHostForSession(env: AppEnvironment, id: UUID): String? =
withContext(Dispatchers.IO) {
for (host in runCatchingCancellable { env.hostStore.loadAll() }.getOrDefault(emptyList())) {
val hasIt = runCatchingCancellable {
env.apiClientFactory.create(host.endpoint).liveSessions().any { it.id == id }
}.getOrDefault(false)
if (hasIt) return@withContext host.id
}
null
}
// ── Shared placeholders ───────────────────────────────────────────────────────────────────────────────
/** Centered informational text for the tablet detail pane's empty state. */
@Composable
public fun DetailPlaceholder(text: String, modifier: Modifier = Modifier) {
CenteredMessage(text, modifier)
}
/** Centered informational text for a loading / invalid-argument pane. */
@Composable
public fun MessagePane(text: String, modifier: Modifier = Modifier) {
CenteredMessage(text, modifier)
}
@Composable
private fun CenteredMessage(text: String, modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(Spacing.xxl24),
)
}
}

View File

@@ -0,0 +1,103 @@
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
package wang.yaojia.webterm.nav
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.AdaptiveHome
import wang.yaojia.webterm.screens.ProjectsScreen
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ProjectsViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
/**
* The Projects grid (A23) inside the A26 adaptive shell: [ProjectsScreen] (list pane) beside the selected
* [ProjectDetailContent] (detail pane) on large screens, or a single grid on compact where a card tap
* pushes the project-detail destination. "在此启动 Claude" (from a card or the detail) is routed through
* [ProjectsViewModel.requestOpenClaude] → a validated [ProjectOpenRequest], which this pane consumes into
* a `newTerminalRoute` navigation (`attach(null, cwd=projectPath)`).
*
* Projects run against the FIRST paired host (single-host is the common case; cross-host project selection
* is a device-QA refinement). The navigation-suite "会话" entry returns to [SessionsHome].
*/
@Composable
public fun ProjectsHome(
env: AppEnvironment,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val host by produceState<Host?>(initialValue = null) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull() }.getOrNull()
}
val resolved = host
if (resolved == null) {
DetailPlaceholder("尚未配对主机。", modifier)
return
}
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
val viewModel = remember(gateway) { ProjectsViewModel(gateway) }
val state by viewModel.uiState.collectAsStateWithLifecycle()
// "Open Claude here" → spawn a new session in the project cwd, then clear the one-shot signal.
LaunchedEffect(state.openRequest) {
val request = state.openRequest ?: return@LaunchedEffect
navController.navigate(newTerminalRoute(resolved.id, request.cwd))
viewModel.consumeOpenRequest()
}
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
var selectedPath by rememberSaveable { mutableStateOf<String?>(null) }
AdaptiveHome(
listPane = {
ProjectsScreen(
viewModel = viewModel,
onOpenDetail = { path ->
if (mode == LayoutMode.LIST_DETAIL) {
selectedPath = path
} else {
navController.navigate(projectDetailRoute(resolved.id, path))
}
},
)
},
detailPane = {
val path = selectedPath
if (path != null) {
ProjectDetailContent(
gateway = gateway,
path = path,
onBack = { selectedPath = null },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
)
} else {
DetailPlaceholder("选择一个项目查看详情。")
}
},
selectedDestination = 1,
onSelectDestination = { index ->
if (index == 0) {
navController.navigate(NavRoutes.SESSIONS) {
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
launchSingleTop = true
}
}
},
windowSizeClass = sizeClass,
modifier = modifier,
)
}

View File

@@ -0,0 +1,119 @@
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
package wang.yaojia.webterm.nav
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import wang.yaojia.webterm.components.ContinueLastBanner
import wang.yaojia.webterm.components.continueLastModel
import wang.yaojia.webterm.screens.AdaptiveHome
import wang.yaojia.webterm.screens.SessionListScreen
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
import wang.yaojia.webterm.viewmodels.SessionListViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
import java.util.UUID
/**
* The sessions landing (A20) inside the A26 adaptive shell: a [SessionListScreen] (list pane) beside the
* live [TerminalHost] (detail pane) on large screens, or a single list on compact where a row tap pushes
* the terminal destination. The [ContinueLastBanner] (A29) is layered above the list. The navigation-suite
* "项目" entry navigates to [ProjectsHome].
*
* ### Adaptive open behaviour
* On [LayoutMode.LIST_DETAIL] a row tap fills the detail pane (a per-session holder keyed under THIS
* back-stack entry); on [LayoutMode.STACK] it pushes a full-screen terminal destination. New-session and
* continue-last always push, so a fresh spawn gets its own nav-scoped holder.
*
* The [SessionListViewModel] host menu drives its own active-host selection; this pane reads the resolved
* active host id from its state to build the terminal endpoint / continue-last pointer.
*/
@Composable
public fun SessionsHome(
env: AppEnvironment,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) {
SessionListViewModel(
hostStore = env.hostStore,
gatewayFactory = { host -> ApiClientSessionGateway(env.apiClientFactory.create(host.endpoint)) },
)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
val activeHostId = state.activeHostId
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
var selectedSessionId by rememberSaveable { mutableStateOf<String?>(null) }
val openSession: (UUID) -> Unit = { id ->
val sid = id.toString()
if (mode == LayoutMode.LIST_DETAIL) {
selectedSessionId = sid
} else {
activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) }
}
}
val openNewSession: () -> Unit = {
activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) }
}
AdaptiveHome(
listPane = {
Column(modifier = Modifier.fillMaxSize()) {
val lastSessionId by produceState<String?>(initialValue = null, key1 = activeHostId) {
value = activeHostId?.let { runCatchingCancellable { env.lastSessionStore.lastSessionId(it) }.getOrNull() }
}
ContinueLastBanner(
model = continueLastModel(lastSessionId),
onContinue = { sid -> activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) } },
)
Box(modifier = Modifier.weight(1f)) {
SessionListScreen(
viewModel = viewModel,
onOpenSession = openSession,
onNewSession = openNewSession,
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
onImportCert = { navController.navigate(NavRoutes.CERT) },
)
}
}
},
detailPane = {
val sid = selectedSessionId
if (sid != null && activeHostId != null) {
TerminalHost(
env = env,
hostId = activeHostId,
sessionArg = sid,
cwd = null,
onNewSessionInCwd = { req ->
navController.navigate(newTerminalRoute(activeHostId, req.cwd))
},
holderKey = "detail/$activeHostId/$sid",
)
} else {
DetailPlaceholder("选择一个会话查看终端。")
}
},
selectedDestination = 0,
onSelectDestination = { index ->
if (index == 1) navController.navigate(NavRoutes.PROJECTS) { launchSingleTop = true }
},
windowSizeClass = sizeClass,
modifier = modifier,
)
}

View File

@@ -0,0 +1,121 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package wang.yaojia.webterm.nav
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.delay
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.NewSessionRequest
import wang.yaojia.webterm.screens.TerminalScreen
import wang.yaojia.webterm.screens.TimelineSheet
import wang.yaojia.webterm.viewmodels.TimelineViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
import wang.yaojia.webterm.wiring.RetainedSessionHolder
import wang.yaojia.webterm.wiring.SessionActivityBridge
import java.util.UUID
/** How long (steps × step-ms) to wait for the holder to publish its controller before giving up. */
private const val BRIDGE_WAIT_STEPS: Int = 200
private const val BRIDGE_WAIT_STEP_MS: Long = 50L
/**
* # TerminalHost — the composition-root host for one terminal destination (A21/A22/A28/A29).
*
* Resolves [hostId] → its paired [Host] (for the dial endpoint), obtains the config-surviving
* [RetainedSessionHolder] (per (host, session) via [hiltViewModel] — the nav back-stack entry is the
* `ViewModelStoreOwner`; [holderKey] scopes a distinct holder when several are hosted under ONE owner,
* e.g. the tablet detail pane), and renders [TerminalScreen]. It adds the two composition-root wirings
* A21 left as seams:
*
* - **A29 last-session lifecycle** — a per-session [SessionActivityBridge] folds the controller's
* control-event stream into [LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore]
* (set-on-`Adopted`, clear-on-`Exited`), so cold-start's「继续上次会话」points at the live session.
* - **A28 timeline** — the away-digest「展开」opens the [TimelineSheet] for the current session, its
* [TimelineViewModel] built over the per-host `ApiClient.events`.
*
* All Compose/lifecycle/emulator behaviour is device-QA (plan §7).
*
* @param sessionArg the raw route session arg ([NavRoutes.NEW_SESSION_SENTINEL] = spawn a new session).
* @param cwd new-session working directory (only meaningful when spawning; ignored on re-attach).
* @param onNewSessionInCwd nav callback for the toolbar/exit "在当前目录开新会话" action.
* @param holderKey optional [hiltViewModel] key so the tablet detail pane can host a holder distinct
* from the stacked terminal destination under the SAME owner (null = the owner's default holder).
*/
@Composable
public fun TerminalHost(
env: AppEnvironment,
hostId: String?,
sessionArg: String?,
cwd: String?,
onNewSessionInCwd: (NewSessionRequest) -> Unit,
modifier: Modifier = Modifier,
holderKey: String? = null,
) {
if (hostId == null) {
MessagePane("无效的主机。", modifier)
return
}
// Resolve the paired host for its dial endpoint (untrusted at rest; a removed host stays "loading").
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
}
val resolved = host
if (resolved == null) {
MessagePane("正在打开会话…", modifier)
return
}
val sessionId = NavRoutes.attachSessionId(sessionArg)
val holder: RetainedSessionHolder = hiltViewModel(key = holderKey ?: "$hostId/$sessionArg")
// A29: drive LastSessionStore off the session's control-event stream once the holder publishes its
// controller (bind() sets it during TerminalScreen's composition, after warm-up). A bounded wait
// avoids a busy-spin if warm-up fails; the collection is cancelled when this composable leaves.
LaunchedEffect(holder, hostId) {
var controller = holder.controller
var waited = 0
while (controller == null && waited < BRIDGE_WAIT_STEPS) {
delay(BRIDGE_WAIT_STEP_MS)
controller = holder.controller
waited++
}
controller?.let { live ->
SessionActivityBridge(lastSessionStore = env.lastSessionStore, hostId = hostId)
.observe(live.controlEvents())
}
}
var showTimeline by remember(hostId, sessionArg) { mutableStateOf(false) }
TerminalScreen(
holder = holder,
endpoint = resolved.endpoint,
sessionId = sessionId,
cwd = cwd,
onNewSessionInCwd = onNewSessionInCwd,
modifier = modifier,
onWarmUp = { env.warmUp() },
onDigestExpand = { if (sessionId != null) showTimeline = true },
)
if (showTimeline && sessionId != null) {
val sessionUuid = remember(sessionId) { runCatching { UUID.fromString(sessionId) }.getOrNull() }
val api = remember(resolved) { env.apiClientFactory.create(resolved.endpoint) }
// A fresh VM each time the sheet opens (this block re-enters composition) → exactly one fetch.
val timelineVm = remember { TimelineViewModel.forSession(sessionUuid) { id -> api.events(id) } }
if (timelineVm != null) {
TimelineSheet(viewModel = timelineVm, onDismiss = { showTimeline = false })
} else {
showTimeline = false
}
}
}

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