Compare commits

74 Commits

Author SHA1 Message Date
Yaojia Wang
1dbed54581 docs(progress): log the zero-touch enrollment + control-panel session
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Host track live+verified (h7fd8, auto-renew), phone track server-verified + iOS/
Android code-complete, control-panel deployed (panel.terminal.yaojia.wang),
Android app run on emulator. ~14 real-deploy bugs fixed. Branch merged to develop.
2026-07-23 06:28:59 +02:00
Yaojia Wang
675de771c7 feat(control-panel): web admin UI for the zero-touch tunnel
Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time,
signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy
that mints a fresh 60s manage capability token per call to the control-plane admin
API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security
headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests
pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
2026-07-19 19:47:51 +02:00
Yaojia Wang
7c1d43376d Merge feat/zero-touch-enrollment: zero-touch tunnel enrollment (host + phone)
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Host track LIVE + verified end-to-end (pair --install → CP native enroll →
launchd base-app+agent → frpc tunnel → h7fd8.terminal.yaojia.wang, auto-renew
proven). Phone track: CP login + device-enroll verified via curl; iOS wired,
Android wired. New CP production native-CA wiring, /auth/login + device:enroll
bearer, nginx enroll+renew vhost. ~12 real-deploy bugs fixed. 281 CP + 281 agent
tests + android assembleDebug/kover green.
2026-07-19 09:42:47 +02:00
Yaojia Wang
0b35dc043f feat(android): wire zero-touch device enrollment + fix renew/cache (B-track)
- wire the enroll flow into the app UI (EnrollmentScreen + ViewModel + Hilt DI +
  host-menu "自动获取证书"), mirroring iOS — Android previously only had manual
  .p12 import; the enroll library was built but unreachable.
- renew is now mTLS-only ({csr}-only body, no Authorization header) matching the
  /device/:id/renew contract (the enroll bearer is minutes-lived → silent
  rotation would have thrown weeks later).
- enroll refreshes the identity-repository cache so a mid-session-enrolled cert
  is presented on the next mTLS handshake without a process restart.
gradle :app:assembleDebug + api-client/client-tls-android unit tests + koverVerify
green. On-device QA (keygen/enroll/present) is the operator's step.
2026-07-19 08:31:29 +02:00
Yaojia Wang
c98f5e6a1f fix(agent): give launchd units a log sink (StandardOut/ErrorPath)
launchd has no default log destination (unlike systemd's journald), so a unit
that fails to start (bare node, missing env) was silent — which hid the EX_CONFIG
+ loadConfig failures during this deploy. Route both units' stdout+stderr to
<stateDir>/{base-app,agent}.log. 281 tests pass.
2026-07-19 07:57:22 +02:00
Yaojia Wang
1e398c7561 fix(agent): make native cert auto-renew actually work end-to-end
Two real-deploy renew bugs (found running the live /renew):
- the /renew mTLS request pinned the enroll caChain as the server CA → TLS
  'unable to get local issuer certificate' (the LE-fronted CP is publicly
  trusted). Verify the server against SYSTEM roots (drop ca), keep the client
  cert + rejectUnauthorized:true. (TlsClientOptions.ca now optional.)
- renewCert parsed {cert, caChain:string}, but the CP returns cert=base64(DER)
  + caChain=base64(DER)[]; normalize to PEM (shared certs/pem.ts, reused by
  native enroll). Verified live: cert rotated 13:41→next-day, frpc restarted,
  tunnel stayed up. 281 tests pass.
2026-07-19 07:56:01 +02:00
Yaojia Wang
55d177e9ee fix(control-plane): accept escaped-PEM client cert on /renew
nginx has no njs deployed, so the mTLS terminator forwards the verified client
cert via $ssl_client_escaped_cert (URL-encoded PEM). headerPresentedCert now
normalizes base64-DER, PEM, and escaped-PEM alike to raw DER. 281 tests pass.
2026-07-19 07:00:48 +02:00
Yaojia Wang
9f7f5c0c54 fix(agent): native enroll + launchd install real-deploy shakedown
Fixes found deploying `pair --install` against the live control-plane:
- native enroll tolerates hostContentSecret:null and converts the base64-DER
  cert + caChain[] the CP returns into PEM for frpc/keystore
- launchd/systemd units use an absolute node (process.execPath) + a PATH with
  /usr/local/bin (bare `node` died with EX_CONFIG 78)
- the agent unit now carries ENROLL_URL/RELAY_URL/STATE_DIR/LOCAL_TARGET_URL
  (`run` loadConfig()-validates them; missing → supervisor exited 1)
281 tests pass. Follow-up: add unit tests for the native-enroll tolerance +
agent-unit env (only install node-path is covered so far).
2026-07-18 15:46:20 +02:00
Yaojia Wang
6e04eb0661 docs: re-assess R1 as a real CP native-CA wiring task (done in A2-prep) 2026-07-18 15:04:04 +02:00
Yaojia Wang
af630143de feat(control-plane): production native-CA wiring for on-disk P-256 CAs (A2-prep)
Wire the prod boot to issue frp-client + device leaves from the existing on-disk
P-256 CAs: NATIVE_* env vars, a file-backed KmsResolver (loads the PEM key,
validates prime256v1, never logs key material), buildFileBackedNativeCas threaded
into buildControlPlane via a nativeCas override. Fail-closed on partial NATIVE_*.
281 tests pass.
2026-07-18 15:04:04 +02:00
Yaojia Wang
10688b0dd1 feat(desktop): ride an existing base app on :3000 instead of duplicating (S1)
When the always-on tunnel daemon already serves :3000, the app reuses it
(close() is a no-op) rather than spawning a second server on another port, so
the GUI and the tunnel share one base app.
2026-07-18 13:32:05 +02:00
Yaojia Wang
5e427dcf98 feat(android): device enrollment library + rotation (B4)
Hardware-backed (StrongBox/TEE) key + PKCS#10 CSR + /device/enroll client in
:api-client, presented via the existing X509KeyManager; renew body {csr}-only;
DeviceKeyProvider seam makes the orchestration JVM-testable. api-client tests +
koverVerify 80% gate pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
07bcbf0c08 feat(ios): device enrollment flow + silent cert rotation (B3)
Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
9a5909f672 feat(agent): auto-renew the host cert in the native run-loop (A5)
Wire createCertRotator/renewCert into superviseNative so the frp-client cert
renews silently at ~2/3 TTL over mTLS /renew and frpc restarts onto the fresh
leaf; failures retry with backoff, never crash the supervisor. mTLS transport
has a 15s timeout + 64KB response cap. 281 tests pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
fff011bb7f feat(control-plane): POST /auth/login mints device:enroll bearer (B1)
Unblocks the phone-enrollment track: an operator-password login mints a
short-lived device:enroll capability token that POST /device/enroll requires.
Constant-time (SHA-256 fixed-length) compare, per-client rate-limit, fail-closed
when unset. 260 tests pass.
2026-07-18 13:32:05 +02:00
Yaojia Wang
232ef22535 docs: zero-touch enrollment rollout & integration plan
Execution plan grounded in current VPS reality: pre-flight checks, Track A
(host auto-enroll via the agent) and Track B (phone auto-enroll), risks, and
the honest one-bootstrap-tap constraint. R1 (CA material) verified present on VPS.
2026-07-18 13:32:05 +02:00
Yaojia Wang
ca9eaa8f1f docs: mark all of Wave 5 done (fan-out, access token, android parity)
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-13 05:59:29 +02:00
Yaojia Wang
bc31de85dd feat(android): Projects/Diff/Worktree/git-write parity with web+iOS (W5)
Brings the native Android client to parity with the web/iOS Projects + git surface,
consuming the existing server endpoints — ZERO server change. (The "SDK-gated modules"
premise was stale; they were already online.)

- :api-client (pure Kotlin/JVM): PrStatus/GitLog/GitWrite models (tolerant decoders,
  safe-error), ProjectInfo + ahead/behind/lastCommitMs; 8 new Endpoints builders
  (projectPr/projectLog read-only; createWorktree/removeWorktree/prune/gitStage/
  gitCommit/gitPush guarded w/ Origin) + ApiClient methods (PR/log degrade in body;
  writes 200→Ok / 429→RateLimited / 4xx-5xx→Rejected(safe msg)).
- :app presenters (JVM-tested): DiffViewModel base-compare + git stage/commit/push;
  new WorktreeViewModel (create/remove/prune + client branch validation + isMain block);
  ProjectDetailViewModel failure-isolated PR-chip + recent-commits.
- Compose screens wired: ProjectDetail (PR chip tappable only for https, recent commits,
  worktree create/remove-with-force/prune, view-diff), Diff (base input, per-file
  stage/unstage, commit/push bar), Projects (ahead/behind sync chip). Nav closes the
  pre-existing "no inbound link to the diff screen" gap.

Verified independently: `./gradlew :app:assembleDebug test :api-client:koverVerify`
BUILD SUCCESSFUL; 348 unit tests pass (api-client 102 / app 246); coverage gate held;
git status android-only. Compose rendering/interaction deferred to on-device QA
(android/DEVICE_QA_CHECKLIST.md), as prior Android waves did.
2026-07-13 05:58:28 +02:00
Yaojia Wang
469037cb94 feat(auth): optional WEBTERM_TOKEN access-token gate (W5)
An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more
safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing
Origin/CSRF model — never replacing it — and is fully inert when unset.

- src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32
  bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws.
  parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled.
- WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start.
- GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets
  HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>).
- When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a
  central authGate over all remote HTTP require the cookie. Open: /login, /auth.
  Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local
  browser too).
- Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged).

Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale
substitute — on bare ws:// the token is cleartext + replayable; only hardens the
TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at
--test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red);
auth.ts 100% line coverage.
2026-07-13 05:25:07 +02:00
Yaojia Wang
9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00
Yaojia Wang
c81821b890 docs(plans): Wave 5 implementation plans (fan-out board, access token, android parity) 2026-07-13 04:37:24 +02:00
Yaojia Wang
6541246fc9 docs(roadmap): mark Wave 1-4 done (8 shipped), leave Wave 5 open 2026-07-13 04:25:16 +02:00
Yaojia Wang
a7eba2d43b docs(progress): log the Wave 1-4 roadmap batch (8 features, multi-agent plan+build+verify) 2026-07-12 22:10:57 +02:00
Yaojia Wang
19f241d7a3 feat(git): stage / commit / push from the diff viewer (W4)
The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).

- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
  throws). Path containment: every files[] entry realpath-contained under the repo,
  argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
  single -m argv. Push: current branch only — has-upstream → plain `git push`;
  no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
  ≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
  client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
  fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
  GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
  + isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
  (all text via textContent; re-loads the diff on success).

Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
2026-07-12 22:09:43 +02:00
Yaojia Wang
552f35c690 feat(projects): worktree remove + prune from any device (W4)
Closes the create-only loop — delete losing worktrees and prune stale ones without
a terminal. Destructive, so guarded hard:

- src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never
  rm -rf; git worktree remove [--force] / git worktree prune). Safeguards:
  (1) target realpath must match an entry git itself reports in `git worktree list`
  for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never
  invoked against them); (2) reject the MAIN worktree → 400; (3) realpath
  containment on request + each list entry, operating on git's canonical path (+ --);
  (4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors
  classified to fixed safe strings (raw stderr/paths never leaked).
- DELETE /projects/worktree + POST /projects/worktree/prune — both behind
  requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route.
- public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked)
  + a prune button; force needs an explicit second confirm (no single-click data loss).

Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers
reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force /
clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
2026-07-12 21:44:23 +02:00
Yaojia Wang
1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00
Yaojia Wang
7551f8a4b2 feat(projects): PR + CI/checks status chip via gh (W3)
Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.

- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
  mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
  isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
  CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
  degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
  Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
  (default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
  chip mounted in the project detail header (git repos only).

Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
2026-07-12 21:01:18 +02:00
Yaojia Wang
b119c31019 feat(diff): diff a whole branch vs a base (?base=<rev>) — review before landing (W3)
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.

- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
  attacker-supplied base never reaches a shell or acts as a git option:
  (1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
  chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
  <base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
  (3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
  divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
  disables Working/Staged tabs in base mode. public/projects.ts derives bases from
  the worktree branches ∪ current branch.

Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
2026-07-12 20:43:32 +02:00
Yaojia Wang
3076843e9c feat(queue): server-side PTY-inject + idle-drained follow-up queue (W2)
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.

- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
  + a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
  byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
  new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
  (QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
  exists, hasn't exited, produced no output during settle, and is idle — three
  guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
  (⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.

Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
2026-07-12 20:27:37 +02:00
Yaojia Wang
e062065cd3 feat(cockpit): approval preview — command/diff above Approve/Reject (W1)
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.

- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
  Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
  Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
  200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
  ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
  PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
  textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
  plan gates fall back to today's name-only bar.

Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
2026-07-12 20:04:18 +02:00
Yaojia Wang
debf47d99e feat(links): clickable URLs + file:line paths in the terminal (W1)
Terminal output URLs and file paths (e.g. src/app.ts:42) are now tappable — the
walk-away device is a phone, so this removes soft-keyboard copy gymnastics.

- public/link-paths.ts (new, pure): findPathMatches — links tokens with a '/',
  a :line suffix, or a code extension (src/app.ts:42, /abs/main.rs:10, README.md)
  while rejecting example.com / v1.2.3 / 12:34 / URL tails.
- terminal-session.ts: hardened URL handler (scheme allowlist http/https/mailto +
  window.open noopener,noreferrer, blocks javascript:/data:/file:) replacing the
  addon default; a path link provider → openPath() POSTs {file,line} to
  /open-in-editor (resolves rel paths against the OSC-7 cwd; in-flight guard).
- src/http/editor.ts: additive openFileInEditor + isGotoEditor (--goto file:line
  only for goto-capable editors; execFile, no shell). openInEditor untouched.
- src/server.ts: /open-in-editor branches on body.file vs body.path (same CSRF guard).

Deviation from the "no server change" brief: an additive openFileInEditor was
required because the existing route only opens directories, not file:line (Option A
in docs/plans/w1-clickable-links.md). Backward-compatible; src/types.ts untouched.

Verified independently: typecheck + build:web clean, 1661 tests pass (link-paths
95%+ cov). Note: xterm buffer-row indexing (getLine(n-1)/range.y=n) is asserted in
jsdom but merits a real-browser smoke check.
2026-07-12 19:44:40 +02:00
Yaojia Wang
3e49e36806 docs(plans): implementation-ready plans for roadmap Wave 1-4 features (8, parallel-generated) 2026-07-12 19:24:21 +02:00
Yaojia Wang
09134e5001 docs: add ROADMAP.md — prioritized, grounded feature backlog in build-order waves 2026-07-12 19:07:13 +02:00
Yaojia Wang
8be2b06564 fix(projects): worktree-create sent repoPath but the server reads path (400)
The 'New worktree' form POSTed { repoPath, branch } to /projects/worktree, but
server.ts:706 and the documented contract (FEATURE_WALKAWAY_WORKBENCH FR-B3.1)
read body.path — so every browser worktree-create returned 400 'path and branch
are required' and git worktree add never ran. Send 'path' instead. The existing
worktree-form test had locked in the wrong key (asserted repoPath), which is why
it stayed green while the feature was broken; corrected to assert 'path'.
2026-07-12 19:03:53 +02:00
Yaojia Wang
e7bfbe951d docs(readme): document the split-grid watch board (v0.8) + refresh test counts
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Adds a Split-grid watch board section (desktop 1×2/1×3/2×2/2×3 layouts,
click-to-focus + Ctrl+` cycle, per-quadrant inline approve / maximize / read-only
monitor, drag-to-quadrant, resizable splitters, saved presets) and updates the
stale ~470/~1470 test counts to ~1600.
2026-07-12 18:29:17 +02:00
Yaojia Wang
f8f82dce21 fix(grid): stop FitAddon double-counting pane padding (clipped last row)
The focused grid cell's last terminal row was clipped by the cell border/focus
ring at large/maximized window sizes. Root cause (measured in headless Chromium):
.term-pane is box-sizing:border-box, so getComputedStyle(pane).height — which
xterm's FitAddon reads to compute rows — returns the padding-box height and
double-counts the pane's own padding, packing one extra row that overruns the
border (overflow +6px). Fix: box-sizing:content-box on the grid pane so the height
reports the content box; FitAddon drops the phantom row and the 6px bottom padding
becomes real clearance. Verified overflow +6 → −10px (16px clear) at grid-4/grid-6,
1920×1200 (DPR 1 & 2), unchanged at 1200×800. Single mode untouched.
2026-07-12 18:14:55 +02:00
Yaojia Wang
c6d819f85f fix(desktop): mirror server runtime deps + drop dangling .bin from bundle
Two fixes required to repackage the macOS app after the split-grid frontend landed:

- The bundled node_modules is copied from desktop/node_modules, which only mirrored
  4 of the 6 server runtime deps. google-auth-library (imported at server startup by
  dist/push/fcm.js) and qrcode were missing, so the embedded server crashed on launch
  ("Cannot find package 'google-auth-library'"). Added both to desktop/package.json so
  they're installed + bundled. (The old build predated the FCM code, hiding this.)
- electron-builder died with "ENOENT .bin/asar": node_modules/.bin holds dev-tool
  symlinks (asar/tsc/esbuild) into packages the filter excludes, leaving dangling
  symlinks in the bundle that electron-builder stat()s. Excluded .bin/** from the copy
  (runtime deps load by path, not via .bin).

Verified: dist:mac builds clean (signed, DMG), installed to /Applications, embedded
server serves :3000 (HTTP 200) and the split-grid frontend loads.
2026-07-12 05:52:17 +02:00
Yaojia Wang
afc22989d6 Merge feat/split-grid-view: desktop split-grid watch board (v1–v3)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Multi-session 2×2/1×2/1×3/2×3 grid for the web/Mac client so several LIVE
terminals show at once. activeIndex stays the focused pane (keybar/voice/approval
unchanged); server + WS protocol untouched; single-pane mode unchanged.

v1  single/1×2/2×2, click-to-focus, per-quadrant inline ✓/✗, desktop gate + persist
v2  1×3/2×3, Ctrl+` focus cycle, per-quadrant maximize, drag-a-tab-to-quadrant
v3  read-only monitor quadrants (no shared-PTY shrink), resizable splitters, presets

Each phase: TDD → adversarial multi-lens review + per-finding verify → fixes.
Verified: typecheck + build:web clean, 1615 tests pass, coverage 89%/82%; the
maximize geometry fix and a full 9-flow live QA verified in real headless Chrome.
2026-07-12 04:54:59 +02:00
Yaojia Wang
733c8a8318 fix(grid): v3 review fixes — split-null crash, monitor race, drag-safe gutters
Adversarial review (4 lenses → per-finding verify) of v3 confirmed 6 issues:

- HIGH: splitForLayout({"grid-4":null}) threw a TypeError — null passed the
  `!== undefined` guard, then null.cols threw. Since splitForLayout runs on every
  grid render, one corrupt localStorage entry would brick the tab UI. Now guards
  `!== null && typeof === 'object'`.
- MED: a monitor toggled before the session finished attaching (id still null)
  showed the button active while the pane stayed live and sent a resize, and did
  not self-correct. onSessionId now reconciles a pending monitor once the id lands.
- MED: renderGutters destroyed + recreated handles on every applyLayout, dropping
  the drag listeners if a re-render fired mid-drag. A draggingGutter flag now skips
  the rebuild while dragging.
- LOW: grid-presets hardcoded the 1024px breakpoint → now uses GRID_MIN_WIDTH.

Regression tests added (null-split no-throw, attach reconcile, drag survives a
concurrent re-render). typecheck + build:web clean, 1615 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:25:04 +02:00
Yaojia Wang
007e598802 feat(grid): v3b/c — resizable splitters + saved layout presets
Resizable splitters: draggable gutters between grid tracks adjust per-layout
column/row fractions (fr units), applied as an inline grid-template and persisted
(web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between
adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested
helper in grid-layout.ts; the drag translates pixel motion into an fr delta.

Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current
layout + its split under a name, re-apply it in one click, or delete it
(web-terminal:grid-presets). Desktop-only, like the layout toggle.

- grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate,
  load/saveGridSplits, splitForLayout (validates stored shape).
- tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline
  template; gridArrangement()/applyGridPreset() hooks.
- main.ts: mount the presets dropdown. style.css: gutters + presets menu.
- tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets
  persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:09:49 +02:00
Yaojia Wang
cd97114f87 feat(grid): v3a — read-only monitor quadrants (no shared-PTY shrink)
A quadrant can be flipped to "monitor" mode via a 👁 header toggle: instead of
the live interactive terminal, it renders the session's screen from periodic
GET /live-sessions/:id/preview snapshots into a read-only xterm. A monitored
quadrant never attaches a WS and never sends a resize, so — unlike a live
quadrant (latest-writer-wins) — it does not drive the shared PTY size. That lets
you watch a session in a small quadrant without shrinking it for another device
using it full-screen (the cross-device shrink the split-grid design flagged).

- public/cell-monitor.ts (new): mountCellMonitor(host, id) — polling read-only
  preview, scaled to fit, best-effort, disposes cleanly (no write after dispose).
- tabs.ts: per-entry monitor state; applyLayout keeps the live pane hidden and
  starts/stops the monitor; 👁 toggle in the cell header; teardown on close /
  leaving grid mode.
- style.css: monitor button + .cell-monitor container.
- tests: cell-monitor.test.ts (poll→write, dispose, late-resolve guard) + monitor
  wiring in tabs.test.ts. typecheck + build clean, 1587 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:58:47 +02:00
Yaojia Wang
5475b661ae feat(grid): split-grid v2 — 1×3/2×3, drag-to-quadrant, Ctrl+` cycle, maximize
Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
  #term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
  is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
  overlay while siblings stay live behind it; follows focus, resets on layout
  change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
  existing tab-drag dragIndex); grid-only.

Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:49:39 +02:00
Yaojia Wang
06814ba276 feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)
When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.

Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.

- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
  matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
  applyLayout() owns show/hide + grid class + cell order + placeholders;
  board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
  delegate to it; renderCell/renderInlineApprove; refitVisible; notification
  suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
  keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
  pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).

Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:19:53 +02:00
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
630 changed files with 82020 additions and 574 deletions

3
.gitignore vendored
View File

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

View File

@@ -65,6 +65,8 @@ npm test # unit tests (vitest, all modules)
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1. Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST``0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
## Architecture (the parts that span files) ## Architecture (the parts that span files)
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server. The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.

View File

@@ -24,6 +24,14 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view. - **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs. - **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
### Split-grid watch board (v0.8, desktop)
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
### Claude Code cockpit ### Claude Code cockpit
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`. - **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others). - **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
@@ -107,11 +115,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. 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. `USE_TMUX=1 npm start` keeps sessions alive across a server restart.
### Tests ### Tests
```bash ```bash
npm test # vitest, all modules (~470 tests, 80% coverage gate) npm test # vitest, all modules (~1600 tests, 80% coverage gate)
npm run typecheck # tsc (backend + frontend) npm run typecheck # tsc (backend + frontend)
npm run build # compile backend to dist/ npm run build # compile backend to dist/
``` ```
@@ -198,6 +208,6 @@ The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a r
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session. The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else. Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7). Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).

View File

@@ -13,6 +13,7 @@
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit", "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": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
@@ -26,6 +27,7 @@
"@types/node": "^25.9.3", "@types/node": "^25.9.3",
"@types/ws": "^8.5.12", "@types/ws": "^8.5.12",
"@vitest/coverage-v8": "^4.1.9", "@vitest/coverage-v8": "^4.1.9",
"esbuild": "^0.28.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitest": "^4.1.9" "vitest": "^4.1.9"
} }

View File

@@ -0,0 +1,247 @@
/**
* Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
*
* The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
* leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
* that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
*
* - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
* over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
* after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
* token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
* - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
* log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
* retry with backoff. A failed renewal NEVER crashes the supervisor.
* - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
* mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
* is not enrolled (no identity), rather than throwing into the run-loop.
*/
import { request as httpsRequest } from 'node:https'
import type { AgentConfig } from '../config/agentConfig.js'
import type { Keystore } from '../keys/keystore.js'
import type { Logger } from '../log/logger.js'
import type { TimerLike } from '../transport/seams.js'
import { createBackoff } from '../transport/backoff.js'
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
import { createCertRotator, type CertRotator } from './rotation.js'
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/**
* Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
* silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
* forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
* timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
*/
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
/**
* Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
* beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
*/
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024
// --- mTLS fetch --------------------------------------------------------------------------------
/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
export interface MtlsRequestInit {
readonly method: string
readonly headers: Record<string, string>
readonly body?: string
}
export interface MtlsResponse {
readonly status: number
readonly body: string
}
export type MtlsRequest = (
url: string,
tls: TlsClientOptions,
init: MtlsRequestInit,
) => Promise<MtlsResponse>
/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
new Promise<MtlsResponse>((resolve, reject) => {
const req = httpsRequest(
url,
{
method: init.method,
headers: init.headers,
cert: tls.cert,
key: tls.key,
// ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a
// private CA is pinned (not for /renew).
...(tls.ca !== undefined ? { ca: tls.ca } : {}),
rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14)
},
(res) => {
const chunks: Buffer[] = []
let total = 0
res.on('data', (c: Buffer) => {
total += c.length
if (total > MAX_RENEW_RESPONSE_BYTES) {
res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
return
}
chunks.push(c)
})
res.on('end', () =>
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
)
res.on('error', reject) // a mid-stream socket error must reject, not hang
},
)
// HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
// rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
})
req.on('error', reject)
if (init.body !== undefined) req.write(init.body)
req.end()
})
function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
if (!headers) return {}
if (headers instanceof Headers) {
const out: Record<string, string> = {}
headers.forEach((v, k) => {
out[k] = v
})
return out
}
if (Array.isArray(headers)) return Object.fromEntries(headers)
return { ...(headers as Record<string, string>) }
}
/**
* Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
* `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
* with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
* `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
*/
export function createMtlsFetch(
ks: Keystore,
opts: { request?: MtlsRequest; certParser?: CertParser } = {},
): typeof fetch {
const request = opts.request ?? defaultMtlsRequest
const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string' ? input : input.toString()
// Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the
// SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
// enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
// node uses the default roots); rejectUnauthorized stays true.
const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
const reqInit: MtlsRequestInit = {
method: init?.method ?? 'GET',
headers: toHeaderRecord(init?.headers),
...(typeof init?.body === 'string' ? { body: init.body } : {}),
}
const { status, body } = await request(url, tls, reqInit)
return new Response(body, { status })
}
return shim as typeof fetch
}
// --- rotator wiring ----------------------------------------------------------------------------
/** Non-secret identifiers logged alongside renew events (INV9). */
export interface AutoRenewLogIds {
readonly subdomain: string | null
readonly hostId: string | null
}
/** The two run-loop effects the rotator drives. */
export interface AutoRenewHooks {
/** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
restartChild(): void
/** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
stop(): void
}
/** Handle for the wired auto-renew loop. */
export interface AutoRenewController {
stop(): void
}
/**
* Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
* error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
* the rotator's scheduled timer.
*/
export function wireAutoRenew(
rotator: CertRotator,
hooks: AutoRenewHooks,
logger: Logger,
ids: AutoRenewLogIds,
): AutoRenewController {
const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
rotator.onRotated(() => {
logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
hooks.restartChild()
})
rotator.onRevoked(() => {
logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
hooks.stop()
})
rotator.onError((err) => {
logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
...meta,
error: errorMessage(err),
})
})
rotator.start()
return { stop: () => rotator.stop() }
}
// --- builder -----------------------------------------------------------------------------------
/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
export interface NativeAutoRenewOpts {
readonly mtlsRequest?: MtlsRequest
readonly certParser?: CertParser
readonly timer?: TimerLike
readonly renewBeforeMs?: number
readonly retryBaseMs?: number
readonly now?: () => Date
readonly parseCert?: (pem: string) => Date
}
/**
* Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
* (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
* (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
*/
export function startNativeAutoRenew(
cfg: AgentConfig,
ks: Keystore,
hooks: AutoRenewHooks,
logger: Logger,
opts: NativeAutoRenewOpts = {},
): AutoRenewController | null {
const id = ks.loadIdentity()
if (id === null) {
logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
return null
}
const fetchImpl = createMtlsFetch(ks, {
...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
...(opts.certParser ? { certParser: opts.certParser } : {}),
})
const rotator = createCertRotator(cfg, id, ks, {
fetchImpl,
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
...(opts.timer ? { timer: opts.timer } : {}),
...(opts.now ? { now: opts.now } : {}),
...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
...(opts.retryBaseMs !== undefined
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
: {}),
})
return wireAutoRenew(rotator, hooks, logger, { subdomain: cfg.subdomain, hostId: cfg.hostId })
}

33
agent/src/certs/pem.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* PEM helpers shared by the native enroll (enroll/pair.ts) and renew (certs/rotation.ts) paths.
*
* The control-plane returns the frp-client leaf + CA chain as base64-encoded DER (cert as a string,
* caChain as a string[]); the keystore + frpc need PEM files. `derBase64ToPem` wraps a base64 DER body
* back into CERTIFICATE armor at 64 columns.
*/
/** base64(DER) → PEM (CERTIFICATE armor, 64-col wrapped). */
export function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
const body = derBase64.replace(/\s+/g, '')
const lines = body.match(/.{1,64}/g) ?? []
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
/**
* Normalize a control-plane cert response ({cert: base64 DER, caChain: base64 DER[]}) to PEM strings
* for the keystore. Throws if the shape is wrong. Shared by enroll + renew so both stay in lockstep.
*/
export function certResponseToPem(cert: unknown, caChain: unknown): { certPem: string; caChainPem: string } {
if (
typeof cert !== 'string' ||
!Array.isArray(caChain) ||
caChain.length === 0 ||
!caChain.every((c) => typeof c === 'string')
) {
throw new Error('cert response missing cert/caChain')
}
return {
certPem: derBase64ToPem(cert),
caChainPem: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
}
}

View File

@@ -13,7 +13,9 @@ import type { AgentConfig } from '../config/agentConfig.js'
import type { AgentIdentity } from '../keys/identity.js' import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js' import type { Keystore } from '../keys/keystore.js'
import type { TimerLike } from '../transport/seams.js' import type { TimerLike } from '../transport/seams.js'
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
import { buildCsr } from '../enroll/csr.js' import { buildCsr } from '../enroll/csr.js'
import { certResponseToPem } from './pem.js'
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
@@ -22,6 +24,8 @@ export interface CertRotator {
stop(): void stop(): void
onRotated(cb: () => void): void onRotated(cb: () => void): void
onRevoked(cb: () => void): void onRevoked(cb: () => void): void
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
onError(cb: (err: unknown) => void): void
} }
export type RenewOutcome = 'rotated' | 'revoked' export type RenewOutcome = 'rotated' | 'revoked'
@@ -61,11 +65,11 @@ export async function renewCert(
}) })
if (res.status === 403) return 'revoked' if (res.status === 403) return 'revoked'
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`) if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
const json = (await res.json()) as { cert?: string; caChain?: string } // The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') { // keystore + frpc (same shape as native enroll).
throw new Error('cert renewal response missing cert/caChain') const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
} const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
ks.saveCert(json.cert, json.caChain) // atomic whole-file install ks.saveCert(certPem, caChainPem) // atomic whole-file install
return 'rotated' return 'rotated'
} }
@@ -79,6 +83,8 @@ export function createCertRotator(
fetchImpl?: typeof fetch fetchImpl?: typeof fetch
now?: () => Date now?: () => Date
parseCert?: (pem: string) => Date parseCert?: (pem: string) => Date
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
retryBackoff?: BackoffPolicy
} = {}, } = {},
): CertRotator { ): CertRotator {
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
@@ -91,9 +97,11 @@ export function createCertRotator(
} }
const doFetch = opts.fetchImpl ?? fetch const doFetch = opts.fetchImpl ?? fetch
const now = opts.now ?? (() => new Date()) const now = opts.now ?? (() => new Date())
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
let handle: unknown = null let handle: unknown = null
let rotatedCb: (() => void) | null = null let rotatedCb: (() => void) | null = null
let revokedCb: (() => void) | null = null let revokedCb: (() => void) | null = null
let errorCb: ((err: unknown) => void) | null = null
function schedule(): void { function schedule(): void {
const certs = ks.loadCert() const certs = ks.loadCert()
@@ -109,12 +117,16 @@ export function createCertRotator(
revokedCb?.() revokedCb?.()
return return
} }
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
rotatedCb?.() rotatedCb?.()
schedule() schedule()
}) })
.catch(() => { .catch((err: unknown) => {
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile. // Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
handle = timer.setTimeout(runRenewal, renewBeforeMs) // with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
// failed renewal must NEVER tear the supervisor down.
errorCb?.(err)
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
}) })
} }
@@ -132,5 +144,8 @@ export function createCertRotator(
onRevoked(cb): void { onRevoked(cb): void {
revokedCb = cb revokedCb = cb
}, },
onError(cb): void {
errorCb = cb
},
} }
} }

View File

@@ -1,11 +1,20 @@
/** /**
* CLI entrypoint — PLAN_RELAY_AGENT T5. `pair | run | status | install | uninstall`. * CLI entrypoint — PLAN_RELAY_AGENT T5, extended for the native tunnel (PLAN_TUNNEL_AUTOMATION B5).
* All side effects (network/FS/tunnel) are injected via `CliDeps` so tests avoid real IO. * `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). * `status` prints host_id/subdomain/online ONLY — never key/cert material (INV9).
*/ */
import type { AgentConfig } from './config/agentConfig.js' import type { AgentConfig } from './config/agentConfig.js'
import type { AgentIdentity } from './keys/identity.js' import type { AgentIdentity } from './keys/identity.js'
import type { Keystore } from './keys/keystore.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' import type { EnrollResult } from 'relay-contracts'
export type CliCommand = 'pair' | 'run' | 'status' | 'install' | 'uninstall' 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 { export interface CliDeps {
loadConfig(): AgentConfig loadConfig(): AgentConfig
openKeystore(stateDir: string): Keystore openKeystore(stateDir: string): Keystore
/** Ed25519 identity for the legacy relay path. */
generateIdentity(): AgentIdentity 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> 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> 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> uninstallService(): Promise<void>
print(line: string): void print(line: string): void
} }
@@ -60,23 +97,60 @@ export function parseArgs(argv: readonly string[]): CliArgs {
return args 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). */ /** Dispatch a parsed CliArgs; returns a process exit code (0 = success). */
export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> { export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
const cfg = deps.loadConfig() const cfg = deps.loadConfig()
const ks = deps.openKeystore(cfg.stateDir) const ks = deps.openKeystore(cfg.stateDir)
switch (args.command) { switch (args.command) {
case 'pair': { 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() const id = ks.loadIdentity() ?? deps.generateIdentity()
ks.saveIdentity(id) ks.saveIdentity(id)
const enroll = await deps.redeem(cfg, args.code!, id, ks) const enroll = await deps.redeem(cfg, args.code!, id, ks)
deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`) deps.print(`paired: host ${enroll.hostId} subdomain ${enroll.subdomain}`)
if (args.flags['install']) await deps.installService(cfg)
return 0 return 0
} }
case 'run': { 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') 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) return deps.runTunnel(cfg, ks)
} }
case 'status': { case 'status': {
@@ -88,7 +162,7 @@ export async function runCli(args: CliArgs, deps: CliDeps): Promise<number> {
return 0 return 0
} }
case 'install': case 'install':
await deps.installService(cfg) await deps.installService(cfg, deps.resolveInstallOptions())
return 0 return 0
case 'uninstall': case 'uninstall':
await deps.uninstallService() await deps.uninstallService()

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

@@ -0,0 +1,252 @@
/**
* 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 { startNativeAutoRenew } from '../certs/nativeRenew.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, { allowMissingContentSecret: true })
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 + A5): 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), AND auto-renew the frp-client leaf at ~2/3 TTL so the tunnel
* never drops on cert expiry (A5): a successful renewal restarts frpc onto the fresh leaf, a 403
* revoke tears the tunnel down, and a failed renewal retries with backoff without crashing the
* supervisor. 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)
},
)
// A5: silently renew the leaf before it expires. Restart frpc onto the fresh cert on rotation;
// stop the whole supervisor on a 403 revoke (INV12). Null ⇒ unenrolled (auto-renew disabled).
const autoRenew = startNativeAutoRenew(
cfg,
ks,
{
restartChild: () => handle.restartChild(),
stop: () => {
void handle.stop()
},
},
logger,
)
const onSignal = (): void => {
void handle.stop()
}
process.once('SIGTERM', onSignal)
process.once('SIGINT', onSignal)
return handle.done.finally(() => {
monitor.stop()
autoRenew?.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 { homedir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { z } from 'zod' import { z } from 'zod'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export interface AgentConfig { export interface AgentConfig {
readonly relayUrl: string readonly relayUrl: string
@@ -19,9 +20,12 @@ export interface AgentConfig {
readonly hostId: string | null readonly hostId: string | null
} }
const LOOPBACK_HOSTNAMES: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]'] /**
* True iff `url` is ws:// to a loopback host (a well-formed 127.0.0.0/8 IPv4 literal, localhost, or
/** True iff `url` is ws:// to a loopback host (127.0.0.0/8, localhost, or ::1). */ * ::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 { export function isLoopbackWsUrl(url: string): boolean {
let parsed: URL let parsed: URL
try { try {
@@ -30,8 +34,7 @@ export function isLoopbackWsUrl(url: string): boolean {
return false return false
} }
if (parsed.protocol !== 'ws:') return false if (parsed.protocol !== 'ws:') return false
const host = parsed.hostname return isLoopbackHostLiteral(parsed.hostname)
return LOOPBACK_HOSTNAMES.includes(host) || host.startsWith('127.')
} }
function hasScheme(url: string, scheme: string): boolean { function hasScheme(url: string, scheme: string): boolean {

View File

@@ -52,9 +52,11 @@ const OID = 0x06
const UTF8_STRING = 0x0c const UTF8_STRING = 0x0c
const CONTEXT_0 = 0xa0 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_CN = Uint8Array.from([0x55, 0x04, 0x03])
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) 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). */ /** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array { function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
@@ -63,6 +65,24 @@ function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
return tlv(SEQUENCE, concat([algId, pubBits])) 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. */ /** X.501 Name with a single CN=<subject> RDN. */
function nameFromCn(cn: string): Uint8Array { function nameFromCn(cn: string): Uint8Array {
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))])) 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. * Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the identity's key. The
* The private key is used in-process only; never serialized into the output (INV4). * 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 { export function buildCsr(id: AgentIdentity, subject: string): string {
const version = tlv(INTEGER, Uint8Array.from([0x00])) const version = tlv(INTEGER, Uint8Array.from([0x00]))
const name = nameFromCn(subject) 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 attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes])) const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo const signature = id.sign(requestInfo) // over CertificationRequestInfo, per id.alg
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519)) const sigAlg = sigAlgFor(id)
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature])) const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits])) const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))

View File

@@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts'
import type { AgentIdentity } from '../keys/identity.js' import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js' import type { Keystore } from '../keys/keystore.js'
import { buildCsr } from './csr.js' import { buildCsr } from './csr.js'
import { derBase64ToPem } from '../certs/pem.js'
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */ /** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
export type EnrollMode = 'token' | 'ed25519' export type EnrollMode = 'token' | 'ed25519'
@@ -53,6 +54,12 @@ export interface RedeemOptions {
readonly agentToken?: string readonly agentToken?: string
readonly unwrapContentSecret?: UnwrapContentSecret readonly unwrapContentSecret?: UnwrapContentSecret
readonly subject?: string readonly subject?: string
/**
* Native frp-client enroll has NO E2E content secret — the control-plane returns
* `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain
* frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it.
*/
readonly allowMissingContentSecret?: boolean
} }
interface EnrollResponseJson { interface EnrollResponseJson {
@@ -63,10 +70,32 @@ interface EnrollResponseJson {
hostContentSecret: string // base64url over the wire hostContentSecret: string // base64url over the wire
} }
function parseEnrollResult(json: unknown): EnrollResult { function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
const j = json as Partial<EnrollResponseJson> const j = json as Partial<EnrollResponseJson>
if (typeof j.hostContentSecret !== 'string') { if (typeof j.hostContentSecret !== 'string') {
throw new EnrollError('enroll response missing hostContentSecret') if (!allowMissingContentSecret) {
throw new EnrollError('enroll response missing hostContentSecret')
}
// Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content
// key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored.
const caChain: unknown = j.caChain
if (
typeof j.hostId !== 'string' ||
typeof j.subdomain !== 'string' ||
typeof j.cert !== 'string' ||
!Array.isArray(caChain) ||
caChain.length === 0 ||
!caChain.every((c) => typeof c === 'string')
) {
throw new EnrollError('enroll response missing required fields')
}
return {
hostId: j.hostId,
subdomain: j.subdomain,
cert: derBase64ToPem(j.cert),
caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
hostContentSecret: new Uint8Array(0),
}
} }
const candidate = { const candidate = {
hostId: j.hostId, hostId: j.hostId,
@@ -128,10 +157,13 @@ export async function redeemPairingCode(
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`) throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
} }
const enroll = parseEnrollResult(json) const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
ks.saveCert(enroll.cert, enroll.caChain) ks.saveCert(enroll.cert, enroll.caChain)
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored). // FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
const unwrapped = unwrap(enroll.hostContentSecret, id) // Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
ks.saveContentSecret(unwrapped) if (enroll.hostContentSecret.length > 0) {
const unwrapped = unwrap(enroll.hostContentSecret, id)
ks.saveContentSecret(unwrapped)
}
return enroll return enroll
} }

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

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

View File

@@ -1,20 +1,37 @@
/** /**
* Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host). * 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 * Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`):
* key, the §4.2 enroll fingerprint, and an in-process `sign()`; there is NO API that returns or * - `ed25519` — the relay E2E rendezvous path (unchanged).
* serializes the private key. The raw private key material is held in a module-private closure. * - `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 { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
import type { KeyObject } from 'node:crypto' import type { KeyObject } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts' import { encodeBase64UrlBytes } from 'relay-contracts'
/** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */
export type KeyAlg = 'ed25519' | 'p256'
export interface AgentIdentity { 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 readonly publicKey: Uint8Array
/** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */ /** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */
readonly enrollFpr: string 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 sign(message: Uint8Array): Uint8Array
/** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */ /** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */
exportPrivatePkcs8Pem(): string exportPrivatePkcs8Pem(): string
@@ -39,6 +56,7 @@ function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdenti
const rawPub = rawPublicKey(publicKey) const rawPub = rawPublicKey(publicKey)
const enrollFpr = computeEnrollFpr(rawPub) const enrollFpr = computeEnrollFpr(rawPub)
return { return {
alg: 'ed25519',
publicKey: rawPub, publicKey: rawPub,
enrollFpr, enrollFpr,
sign(message: Uint8Array): Uint8Array { 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). */ /** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */
export function generateIdentity(): AgentIdentity { export function generateIdentity(): AgentIdentity {
const { privateKey, publicKey } = generateKeyPairSync('ed25519') const { privateKey, publicKey } = generateKeyPairSync('ed25519')
return buildIdentity(privateKey, publicKey) 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 { export function identityFromPrivatePem(pem: string): AgentIdentity {
const privateKey = createPrivateKey(pem) const privateKey = createPrivateKey(pem)
const publicKey = createPublicKey(privateKey) const publicKey = createPublicKey(privateKey)
return buildIdentity(privateKey, publicKey) 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. */ /** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */
export function verifySignature( export function verifySignature(
publicKey: Uint8Array, publicKey: Uint8Array,

View File

@@ -12,8 +12,9 @@ import {
writeFileSync, writeFileSync,
} from 'node:fs' } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { createPrivateKey } from 'node:crypto'
import type { AgentIdentity } from './identity.js' import type { AgentIdentity } from './identity.js'
import { identityFromPrivatePem } from './identity.js' import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js'
const SECRET_MODE = 0o600 const SECRET_MODE = 0o600
const DIR_MODE = 0o700 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 { function writeSecret(path: string, data: string | Uint8Array): void {
writeFileSync(path, data, { mode: SECRET_MODE }) writeFileSync(path, data, { mode: SECRET_MODE })
// Enforce 0600 even if a prior umask/file left it wider. // 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}`) throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`)
} }
try { try {
return identityFromPrivatePem(pem) return identityFromStoredPem(pem)
} catch (err) { } catch (err) {
throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`) 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,153 @@
/** /**
* Service install dispatcher — PLAN_RELAY_AGENT T17. Detects the platform, writes the unit, and * Service install dispatcher — PLAN_RELAY_AGENT T17, re-targeted for the native tunnel
* loads it. REFUSES to install as root (EXPLORE §4d least privilege). All IO is injected so the * (PLAN_TUNNEL_AUTOMATION B5). Detects the platform and emits TWO durable units — the base-app and
* logic is unit-testable without touching the real system. * 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 { dirname, join } from 'node:path'
import type { AgentConfig } from '../config/agentConfig.js' import type { AgentConfig } from '../config/agentConfig.js'
import { import {
agentLabel,
baseAppLabel,
buildLaunchdPlist, buildLaunchdPlist,
launchdLoadCommand, launchdLoadCommand,
launchdPlistPath, launchdPlistPath,
launchdUnloadCommand, launchdUnloadCommand,
type ServiceEnv,
} from './launchd.js' } from './launchd.js'
import { import {
agentUnitName,
baseAppUnitName,
buildSystemdUnit, buildSystemdUnit,
systemdDisableCommand, systemdDisableCommand,
systemdEnableCommand, systemdEnableCommand,
systemdUnitPath, systemdUnitPath,
type SystemdUnitOptions,
} from './systemd.js' } from './systemd.js'
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
import { isLoopbackHostLiteral } from '../net/loopbackLiteral.js'
export type ServicePlatform = 'launchd' | 'systemd' 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 { export class RootRefusedError extends Error {
constructor() { constructor() {
super('refusing to install the agent service as root — run as the logged-in user (least privilege)') super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
@@ -42,37 +171,109 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
return null return null
} }
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */ /**
export async function installService( * Resolve the BASE-APP env injected into the base-app unit. Runs the S-GATE on `BIND_HOST` (throws
_cfg: AgentConfig, * `BindHostError` on a non-loopback value, before any write) and, when a `domain` (+ `cfg.subdomain`)
platform: ServicePlatform, * is supplied, merges the tunnel origin `https://<subdomain>.<zone>.<domain>` into ALLOWED_ORIGINS —
deps: InstallDeps, * never weakening an origin the caller already provided. Immutable.
): Promise<void> { */
if (deps.getuid() === 0) throw new RootRefusedError() function resolveBaseAppEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
const bin = deps.binPath() const base = options.env ?? {}
if (platform === 'launchd') { const bindHost = normalizeBindHost(base.BIND_HOST) // S-GATE — throws on non-loopback
const path = launchdPlistPath(deps.homedir()) const withBind: ServiceEnv = { ...base, BIND_HOST: bindHost }
deps.writeFile(path, buildLaunchdPlist(bin)) if (!options.domain || !cfg.subdomain) return withBind
const { cmd, args } = launchdLoadCommand(path) const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
await deps.runCommand(cmd, args) return { ...withBind, ALLOWED_ORIGINS: mergeOrigins(withBind.ALLOWED_ORIGINS, origin) }
return
}
const path = systemdUnitPath(deps.homedir())
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
const { cmd, args } = systemdEnableCommand()
await deps.runCommand(cmd, args)
} }
/** Unload the service unit for `platform`. */ /**
* 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 rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
// subprocesses — resolve their tools.
const nodePath = process.execPath
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
// agent's own runtime config (NOT the base-app env) alongside PATH.
const agentEnv: Record<string, string> = {
PATH: unitPath,
ENROLL_URL: cfg.enrollUrl,
RELAY_URL: cfg.relayUrl,
STATE_DIR: cfg.stateDir,
LOCAL_TARGET_URL: cfg.localTargetUrl,
}
if (platform === 'launchd') {
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
deps.writeFile(
baseAppPath,
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
)
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(
agentPath,
buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel(), join(cfg.stateDir, 'agent.log')),
)
for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
}
return
}
const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnvWithPath, envFile: options.envFile }
: { env: baseAppEnvWithPath }
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(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, '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( export async function uninstallService(
platform: ServicePlatform, platform: ServicePlatform,
deps: Pick<InstallDeps, 'runCommand' | 'homedir'>, deps: Pick<InstallDeps, 'runCommand' | 'homedir'>,
): Promise<void> { ): Promise<void> {
if (platform === 'launchd') { if (platform === 'launchd') {
const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir())) for (const label of [agentLabel(), baseAppLabel()]) {
await deps.runCommand(cmd, args) const { cmd, args } = launchdUnloadCommand(launchdPlistPath(deps.homedir(), label))
await deps.runCommand(cmd, args)
}
return return
} }
const { cmd, args } = systemdDisableCommand() for (const unit of [agentUnitName(), baseAppUnitName()]) {
await deps.runCommand(cmd, args) const { cmd, args } = systemdDisableCommand(unit)
await deps.runCommand(cmd, args)
}
} }

View File

@@ -1,35 +1,108 @@
/** /**
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not * 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). * 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 { export function launchdLabel(): string {
return LABEL return AGENT_LABEL
} }
export function launchdPlistPath(homedir: string): string { export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
return `${homedir}/Library/LaunchAgents/${LABEL}.plist` return `${homedir}/Library/LaunchAgents/${label}.plist`
} }
/** Build the plist. ExecStart = `<bin> run`; RunAtLoad + KeepAlive (restart on failure). */ /** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
export function buildLaunchdPlist(binPath: string): string { 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,
logPath?: string,
): string {
return [ return [
'<?xml version="1.0" encoding="UTF-8"?>', '<?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">', '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">', '<plist version="1.0">',
'<dict>', '<dict>',
' <key>Label</key>', ' <key>Label</key>',
` <string>${LABEL}</string>`, ` <string>${escapeXml(label)}</string>`,
' <key>ProgramArguments</key>', ...programArgumentsBlock(programArguments),
' <array>',
` <string>${binPath}</string>`,
' <string>run</string>',
' </array>',
' <key>RunAtLoad</key>', ' <key>RunAtLoad</key>',
' <true/>', ' <true/>',
' <key>KeepAlive</key>', ' <key>KeepAlive</key>',
' <true/>', ' <true/>',
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
...(logPath !== undefined
? [
' <key>StandardOutPath</key>',
` <string>${escapeXml(logPath)}</string>`,
' <key>StandardErrorPath</key>',
` <string>${escapeXml(logPath)}</string>`,
]
: []),
...environmentVariablesBlock(env),
'</dict>', '</dict>',
'</plist>', '</plist>',
'', '',

View File

@@ -1,8 +1,13 @@
/** /**
* The ONE base-app touch-point — PLAN_RELAY_AGENT T17 (INDEX §0, EXPLORE §3 "Zero code change"). * 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 * 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"). * 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' import { existsSync, readFileSync, writeFileSync } from 'node:fs'
@@ -18,23 +23,40 @@ const defaultFs: OriginFsDeps = {
write: (p, c) => writeFileSync(p, c), write: (p, c) => writeFileSync(p, c),
} }
/** Compose the subdomain origin the base app must trust. */ /** Default DNS zone label (relay hosts). Native-tunnel hosts pass `terminal`. */
export function subdomainOrigin(subdomain: string, domain: string): string { export const DEFAULT_ORIGIN_ZONE = 'term'
return `https://${subdomain}.term.${domain}`
}
const KEY = 'ALLOWED_ORIGINS' 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 { function upsertOriginLine(content: string, origin: string): string {
const lines = content.length === 0 ? [] : content.split('\n') const lines = content.length === 0 ? [] : content.split('\n')
let found = false let found = false
const next = lines.map((line) => { const next = lines.map((line) => {
if (!line.startsWith(`${KEY}=`)) return line if (!line.startsWith(`${KEY}=`)) return line
found = true found = true
const current = line.slice(KEY.length + 1) return `${KEY}=${mergeOrigins(line.slice(KEY.length + 1), origin)}`
const origins = current.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
if (origins.includes(origin)) return line // idempotent — already trusted
return `${KEY}=${[...origins, origin].join(',')}`
}) })
if (!found) next.push(`${KEY}=${origin}`) if (!found) next.push(`${KEY}=${origin}`)
return next.join('\n') 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 * 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( export function ensureAllowedOrigin(
baseAppEnvPath: string, baseAppEnvPath: string,
subdomain: string, subdomain: string,
domain: string, domain: string,
fs: OriginFsDeps = defaultFs, fs: OriginFsDeps = defaultFs,
zone: string = DEFAULT_ORIGIN_ZONE,
): void { ): void {
const origin = subdomainOrigin(subdomain, domain) const origin = subdomainOrigin(subdomain, domain, zone)
const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : '' const existing = fs.exists(baseAppEnvPath) ? fs.read(baseAppEnvPath) : ''
const updated = upsertOriginLine(existing, origin) const updated = upsertOriginLine(existing, origin)
fs.write(baseAppEnvPath, updated.endsWith('\n') ? updated : `${updated}\n`) 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, * 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). * 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 { export function systemdUnitName(): string {
return UNIT_NAME return AGENT_UNIT
} }
export function systemdUnitPath(homedir: string): string { export function systemdUnitPath(homedir: string, unitName: string = AGENT_UNIT): string {
return `${homedir}/.config/systemd/user/${UNIT_NAME}` 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 [ return [
'[Unit]', '[Unit]',
'Description=web-terminal host agent (rendezvous relay)', `Description=${description}`,
'After=network-online.target', 'After=network-online.target',
'', '',
'[Service]', '[Service]',
'Type=simple', 'Type=simple',
`ExecStart=${binPath} run`, `ExecStart=${execStart}`,
'Restart=on-failure', 'Restart=on-failure',
'RestartSec=1', 'RestartSec=1',
`User=${user}`, `User=${user}`,
...environmentLines(options),
'', '',
'[Install]', '[Install]',
'WantedBy=default.target', 'WantedBy=default.target',
@@ -32,9 +131,13 @@ export function buildSystemdUnit(binPath: string, user: string): string {
].join('\n') ].join('\n')
} }
export function systemdEnableCommand(): { cmd: string; args: readonly string[] } { export function systemdEnableCommand(
return { cmd: 'systemctl', args: ['--user', 'enable', '--now', UNIT_NAME] } 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[] } { export function systemdDisableCommand(
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', UNIT_NAME] } unitName: string = AGENT_UNIT,
): { cmd: string; args: readonly string[] } {
return { cmd: 'systemctl', args: ['--user', 'disable', '--now', unitName] }
} }

View File

@@ -25,7 +25,13 @@ export class CertExpiredError extends Error {
export interface TlsClientOptions { export interface TlsClientOptions {
readonly cert: string readonly cert: string
readonly key: string readonly key: string
readonly ca: string /**
* CA(s) to verify the SERVER cert against. Set to the private tunnel CA for the relay dial; left
* undefined for a request whose server is publicly trusted (the LE-fronted control-plane /renew) so
* Node verifies against the system roots — pinning the private CA there fails with "unable to get
* local issuer certificate".
*/
readonly ca?: string
readonly rejectUnauthorized: true readonly rejectUnauthorized: true
} }

View File

@@ -0,0 +1,209 @@
/**
* 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
/**
* Kill the current child WITHOUT stopping the supervisor (A5): the restart-on-exit loop respawns a
* fresh frpc that re-reads the (now rotated) cert/key/CA files. A no-op once `stop()` was called —
* it must never resurrect a supervisor that is shutting down.
*/
restartChild(): void
}
/** 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,
restartChild(): void {
if (!stopped) child?.kill()
},
}
}

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://127.0.0.1:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true) expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://127.5.5.5: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('ws://10.0.0.5:3000')).toBe(false)
expect(isLoopbackWsUrl('wss://127.0.0.1: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', () => { it('loadAgentConfig fails fast on a missing relayUrl', () => {
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow() 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 { AgentIdentity } from '../src/keys/identity.js'
import type { EnrollResult } from 'relay-contracts' import type { EnrollResult } from 'relay-contracts'
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js' import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
import type { InstallOptions } from '../src/service/install.js'
const CFG: AgentConfig = { const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent', relayUrl: 'wss://relay/agent',
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
hostId: 'h-1', hostId: 'h-1',
} }
function fakeIdentity(): AgentIdentity { function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
return { return {
alg,
publicKey: new Uint8Array(32), publicKey: new Uint8Array(32),
enrollFpr: 'fpr', enrollFpr: 'fpr',
sign: () => new Uint8Array(64), 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 { return {
saveIdentity: vi.fn(), saveIdentity: vi.fn(),
loadIdentity: () => (enrolled ? fakeIdentity() : null), loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
saveCert: vi.fn(), saveCert: vi.fn(),
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null), loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
saveContentSecret: vi.fn(), 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[] } { function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
const out: string[] = [] const out: string[] = []
const d: CliDeps = { const d: CliDeps = {
loadConfig: () => CFG, loadConfig: () => CFG,
openKeystore: () => fakeKeystore(enrolled), openKeystore: () => fakeKeystore(enrolled),
generateIdentity: fakeIdentity, generateIdentity: () => fakeIdentity('ed25519'),
generateP256Identity: () => fakeIdentity('p256'),
redeem: async (): Promise<EnrollResult> => ({ redeem: async (): Promise<EnrollResult> => ({
hostId: 'h-1', hostId: 'h-1',
subdomain: 'host-42', subdomain: 'host-42',
@@ -48,7 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
caChain: 'CA', caChain: 'CA',
hostContentSecret: new Uint8Array([1]), hostContentSecret: new Uint8Array([1]),
}), }),
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
provisionFrpc: async () => '/opt/frpc',
writeFrpcConfig: vi.fn(),
nativeConfigExists: () => false,
runTunnel: async () => 0, runTunnel: async () => 0,
superviseFrpc: async () => 0,
resolveInstallOptions: () => NATIVE_OPTIONS,
installService: vi.fn(async () => {}), installService: vi.fn(async () => {}),
uninstallService: vi.fn(async () => {}), uninstallService: vi.fn(async () => {}),
print: (l) => out.push(l), 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 () => { it('pair happy path calls redeem and prints no secrets', async () => {
const { d, out } = deps() const { d, out } = deps()
const code = await runCli(parseArgs(['pair', 'ABCD']), d) 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')).toContain('host-42')
expect(out.join('\n')).not.toContain('PEM') expect(out.join('\n')).not.toContain('PEM')
}) })
})
it('pair --install installs the service', async () => { describe('runCli — native pair --install onboard (B5)', () => {
const install = vi.fn(async () => {}) it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
const { d } = deps({ installService: install }) 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) 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 () => { it('run before pairing fails fast', async () => {
@@ -100,6 +200,48 @@ describe('runCli (T5)', () => {
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError) 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 () => { it('status prints no key/cert material (INV9)', async () => {
const { d, out } = deps({}, true) const { d, out } = deps({}, true)
await runCli({ command: 'status', flags: {} }, d) await runCli({ command: 'status', flags: {} }, d)

View File

@@ -1,8 +1,52 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { X509Certificate } from 'node:crypto' import { X509Certificate, createPublicKey, verify } from 'node:crypto'
import { generateIdentity } from '../src/keys/identity.js' import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { buildCsr } from '../src/enroll/csr.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)', () => { describe('PKCS#10 CSR (T4)', () => {
it('emits a PEM CERTIFICATE REQUEST', () => { it('emits a PEM CERTIFICATE REQUEST', () => {
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com') const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
expect(typeof X509Certificate).toBe('function') 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,188 @@
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)
})
it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => {
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
const children = [makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate
})
await flush()
expect(handle.isChildAlive()).toBe(true) // child[0]
handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf
expect(children[0]!.killed).toBe(true)
await flush()
expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk
expect(handle.isChildAlive()).toBe(true)
await handle.stop()
})
it('restartChild() after stop is a no-op (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
handle.restartChild() // must not resurrect the supervisor
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 { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { createPublicKey, verify } from 'node:crypto'
import { import {
computeEnrollFpr, computeEnrollFpr,
generateIdentity, generateIdentity,
generateP256Identity,
identityFromPrivatePem, identityFromPrivatePem,
p256IdentityFromPrivatePem,
verifySignature, verifySignature,
} from '../src/keys/identity.js' } from '../src/keys/identity.js'
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
// No function exports raw private key bytes onto the network surface. // No function exports raw private key bytes onto the network surface.
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/) 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 type { AgentConfig } from '../src/config/agentConfig.js'
import { import {
BindHostError,
RootRefusedError, RootRefusedError,
assertNativeZone,
buildInstallOptions,
detectPlatform, detectPlatform,
installService, installService,
normalizeBindHost,
uninstallService, uninstallService,
type InstallDeps, type InstallDeps,
} from '../src/service/install.js' } 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 = { const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent', relayUrl: 'wss://relay/agent',
@@ -17,7 +23,12 @@ const CFG: AgentConfig = {
hostId: 'h-1', 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 writes: Array<[string, string]> = []
const runs: Array<[string, readonly string[]]> = [] const runs: Array<[string, readonly string[]]> = []
return { 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)', () => { describe('detectPlatform (T17)', () => {
it('maps darwin→launchd, linux→systemd, else null', () => { it('maps darwin→launchd, linux→systemd, else null', () => {
expect(detectPlatform('darwin')).toBe('launchd') expect(detectPlatform('darwin')).toBe('launchd')
@@ -42,35 +60,363 @@ describe('detectPlatform (T17)', () => {
}) })
}) })
describe('installService (T17)', () => { describe('installService — least privilege (T17)', () => {
it('refuses to install as root (negative, least privilege)', async () => { it('refuses to install as root (negative, least privilege)', async () => {
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError) 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() const d = deps(0)
await installService(CFG, 'systemd', d) await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
const [, unit] = d.writes[0]! expect(d.writes).toHaveLength(0)
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run') expect(d.runs).toHaveLength(0)
expect(unit).toContain('User=alice') })
expect(unit).not.toContain('User=root') })
expect(unit).toContain('Restart=on-failure')
expect(d.runs[0]![0]).toBe('systemctl') 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()
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => { await installService(CFG, 'systemd', d)
const d = deps() // exactly two units
await installService(CFG, 'launchd', d) expect(d.writes).toHaveLength(2)
const [path, plist] = d.writes[0]! const baseApp = unitWith(d, baseAppUnitName())
expect(path).toContain('LaunchAgents') const agent = unitWith(d, agentUnitName())
expect(plist).toContain('<string>run</string>') // agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
expect(plist).toContain('<key>KeepAlive</key>') // that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
expect(d.runs[0]![0]).toBe('launchctl') expect(agent).toContain('/usr/local/bin/web-terminal-agent run')
}) expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/)
expect(baseApp).toContain('ExecStart=')
it('uninstall unloads cleanly', async () => { expect(baseApp).toContain('server.js')
const d = deps() expect(baseApp).not.toContain('web-terminal-agent run')
await uninstallService('launchd', d) // both least-privilege + restart-on-failure
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']]) for (const unit of [baseApp, agent]) {
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
}
// both enabled
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppLabel())
const agent = unitWith(d, agentLabel())
expect(agent).toContain('<string>run</string>')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('<string>run</string>')
for (const plist of [baseApp, agent]) {
expect(plist).toContain('<key>KeepAlive</key>')
}
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="PORT=3000"')
// the agent unit must NOT carry the base-app env
expect(agent).not.toContain('BIND_HOST')
expect(agent).not.toContain('PORT=3000')
})
it('uninstall tears down BOTH units (launchd unload)', async () => {
const d = deps()
await uninstallService('launchd', d)
const targets = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
})
it('uninstall tears down BOTH units (systemd disable)', async () => {
const d = deps()
await uninstallService('systemd', d)
const units = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(units).toContain(baseAppUnitName())
expect(units).toContain(agentUnitName())
})
})
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
it('normalizeBindHost defaults an absent value to loopback', () => {
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
expect(normalizeBindHost('')).toBe('127.0.0.1')
})
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
expect(normalizeBindHost('::1')).toBe('::1')
expect(normalizeBindHost('localhost')).toBe('localhost')
})
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
})
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
BindHostError,
)
})
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
})
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
const d = deps()
await expect(
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
const d = deps()
await expect(
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
})
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).not.toContain('0.0.0.0')
})
})
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
const options = buildInstallOptions({})
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
})
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
const options = buildInstallOptions({
BIND_HOST: '127.0.0.2',
PORT: '3000',
SHELL_PATH: '/bin/zsh',
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
expect(options.env).toEqual({
BIND_HOST: '127.0.0.2',
PORT: '3000',
SHELL_PATH: '/bin/zsh',
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
expect(options.env).toEqual({
BIND_HOST: '127.0.0.1',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
it('omits unset/empty passthrough vars', () => {
const options = buildInstallOptions({ PORT: '', SHELL_PATH: '/bin/bash' })
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SHELL_PATH: '/bin/bash' })
})
it('derives domain + default `terminal` zone from TUNNEL_DOMAIN', () => {
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang' })
expect(options.domain).toBe('yaojia.wang')
expect(options.zone).toBe('terminal')
})
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
const options = buildInstallOptions({
TUNNEL_DOMAIN: 'yaojia.wang',
TUNNEL_ZONE: 'term',
AGENT_ENV_FILE: '/etc/wt.env',
})
expect(options.zone).toBe('term')
expect(options.envFile).toBe('/etc/wt.env')
})
it('omits domain/zone when no TUNNEL_DOMAIN is set', () => {
const options = buildInstallOptions({})
expect(options.domain).toBeUndefined()
expect(options.zone).toBeUndefined()
})
})
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
expect(() => assertNativeZone('terminal')).not.toThrow()
expect(() => assertNativeZone('term')).toThrow(/terminal/)
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
})
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
})
})
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>BIND_HOST</key>')
expect(baseApp).toContain('<string>127.0.0.1</string>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(baseApp).toContain('<key>PORT</key>')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(baseApp).toContain('Environment="PORT=3000"')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
envFile: '/etc/web-terminal.env',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
})
})
describe('unit writers — escaping & control-char hardening', () => {
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://a',
PORT: '3000',
})
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
})
it('launchd: no env → no EnvironmentVariables block', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
expect(plist).not.toContain('EnvironmentVariables')
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
})
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
).toThrow(/control character/)
})
it('systemd: rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
/control character/,
)
})
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
).toThrow(/control character/)
})
it('AG2: rejects a newline in the User field', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
/control character/,
)
})
it('AG2: rejects a newline in the Description field', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
).toThrow(/control character/)
})
it('AG2: rejects a newline in the EnvironmentFile path', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
).toThrow(/control character/)
})
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
).toThrow(/control character/)
})
it('systemd: default (no env) omits Environment lines', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice')
expect(unit).not.toContain('Environment')
}) })
}) })

View File

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

@@ -0,0 +1,279 @@
/**
* A5 native cert auto-renew wiring — TDD.
*
* Covers the host-side glue that was the "one real host code gap": the native run-loop must actually
* RENEW the frp-client leaf (not merely monitor its freshness). Three units:
* - `createMtlsFetch` — the injected `fetchImpl` `renewCert` uses: it POSTs /renew over mTLS
* presenting the CURRENT keystore leaf (read fresh on every call, so a post-rotation renewal
* authenticates with the new leaf) and maps the transport response to a `Response`.
* - `wireAutoRenew` — routes the rotator's rotated→restartChild(+log), revoked→stop(+log),
* error→log(no secret) callbacks and starts it.
* - `startNativeAutoRenew` — the end-to-end builder `superviseNative` calls (identity + mTLS fetch
* + rotator), returning null (disabled) when unenrolled.
*/
import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AgentConfig } from '../src/config/agentConfig.js'
import { generateP256Identity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import { createLogger } from '../src/log/logger.js'
import type { CertRotator } from '../src/certs/rotation.js'
import {
createMtlsFetch,
startNativeAutoRenew,
wireAutoRenew,
type MtlsRequest,
} from '../src/certs/nativeRenew.js'
import { FakeTimer } from './fixtures/fakes.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://cp.example.com/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
function enrolledKs(): { dir: string; ks: ReturnType<typeof openKeystore> } {
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
const ks = openKeystore(dir)
ks.saveIdentity(generateP256Identity())
ks.saveCert('LEAFCERT', 'CACHAIN')
return { dir, ks }
}
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
describe('createMtlsFetch (A5)', () => {
it('presents the current keystore cert/key/CA over mTLS and maps the transport response', async () => {
const { dir, ks } = enrolledKs()
const seen: Array<{ url: string; tls: Record<string, unknown>; init: Record<string, unknown> }> = []
const request: MtlsRequest = async (url, tls, init) => {
seen.push({ url, tls: tls as unknown as Record<string, unknown>, init: init as unknown as Record<string, unknown> })
return { status: 200, body: JSON.stringify({ cert: 'NEW', caChain: 'NEWCA' }) }
}
const f = createMtlsFetch(ks, { request, certParser: farFuture })
const res = await f('https://cp.example.com/renew', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"csr":"x"}',
})
expect(res.status).toBe(200)
expect(res.ok).toBe(true)
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
expect(seen[0]!.url).toBe('https://cp.example.com/renew')
expect(seen[0]!.tls.cert).toBe('LEAFCERT')
// /renew verifies the LE-fronted control-plane against the SYSTEM roots, so no private CA is pinned
// (pinning the enroll caChain here fails with "unable to get local issuer certificate").
expect(seen[0]!.tls.ca).toBeUndefined()
expect(seen[0]!.tls.rejectUnauthorized).toBe(true)
expect(String(seen[0]!.tls.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key, mTLS only
expect(seen[0]!.init.method).toBe('POST')
expect(seen[0]!.init.body).toBe('{"csr":"x"}')
rmSync(dir, { recursive: true, force: true })
})
it('reads the current cert on every call so a post-rotation renewal uses the new leaf', async () => {
const { dir, ks } = enrolledKs()
const certs: string[] = []
const request: MtlsRequest = async (_url, tls) => {
certs.push(tls.cert)
return { status: 200, body: '{}' }
}
const f = createMtlsFetch(ks, { request, certParser: farFuture })
await f('https://x/renew', { method: 'POST' })
ks.saveCert('ROTATEDLEAF', 'CACHAIN') // simulate a completed rotation
await f('https://x/renew', { method: 'POST' })
expect(certs).toEqual(['LEAFCERT', 'ROTATEDLEAF'])
rmSync(dir, { recursive: true, force: true })
})
it('propagates a not-enrolled keystore as a throw (renewCert then retries with backoff)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
const ks = openKeystore(dir)
const request: MtlsRequest = async () => ({ status: 200, body: '{}' })
const f = createMtlsFetch(ks, { request, certParser: farFuture })
await expect(f('https://x/renew', { method: 'POST' })).rejects.toThrow()
rmSync(dir, { recursive: true, force: true })
})
})
describe('wireAutoRenew (A5)', () => {
function fakeRotator(): {
rotator: CertRotator
fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void }
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} {
const fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void } = {}
const start = vi.fn()
const stop = vi.fn()
const rotator: CertRotator = {
start,
stop,
onRotated: (cb) => {
fire.rotated = cb
},
onRevoked: (cb) => {
fire.revoked = cb
},
onError: (cb) => {
fire.error = cb
},
}
return { rotator, fire, start, stop }
}
it('routes rotated→restartChild, revoked→stop, error→log; starts and stops the rotator', () => {
const { rotator, fire, start, stop } = fakeRotator()
const restartChild = vi.fn()
const stopSupervisor = vi.fn()
const lines: string[] = []
const logger = createLogger('info', (l) => lines.push(l))
const controller = wireAutoRenew(rotator, { restartChild, stop: stopSupervisor }, logger, {
subdomain: 'host-42',
hostId: 'h-1',
})
expect(start).toHaveBeenCalledTimes(1)
fire.rotated!()
expect(restartChild).toHaveBeenCalledTimes(1)
fire.revoked!()
expect(stopSupervisor).toHaveBeenCalledTimes(1)
fire.error!(new Error('network down'))
controller.stop()
expect(stop).toHaveBeenCalledTimes(1)
const joined = lines.join('\n')
expect(joined).toContain('host-42') // non-secret identifier is logged
expect(joined).not.toContain('LEAFCERT') // never a leaf/key/CSR
})
})
describe('startNativeAutoRenew (A5 end-to-end)', () => {
it('a scheduled 200 renewal rotates the leaf on disk and restarts frpc with it', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
const request: MtlsRequest = async () => ({
status: 200,
body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }),
})
const restartChild = vi.fn()
const stop = vi.fn()
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild, stop },
createLogger('error', () => {}),
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
)!
expect(controller).not.toBeNull()
timer.advance(1000) // renewal fires at ~2/3 TTL
await flush()
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF') // atomic persist
expect(restartChild).toHaveBeenCalledTimes(1) // frpc restarted onto the fresh leaf
expect(stop).not.toHaveBeenCalled()
controller.stop()
rmSync(dir, { recursive: true, force: true })
})
it('a scheduled 403 renewal tears the tunnel down (revoked) and never rotates', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
const request: MtlsRequest = async () => ({ status: 403, body: '' })
const restartChild = vi.fn()
const stop = vi.fn()
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild, stop },
createLogger('error', () => {}),
{ mtlsRequest: request, certParser: farFuture, timer, renewBeforeMs: 1000, now: () => new Date(0), parseCert: () => new Date(2000) },
)!
timer.advance(1000)
await flush()
expect(stop).toHaveBeenCalledTimes(1)
expect(restartChild).not.toHaveBeenCalled()
expect(ks.loadCert()!.certPem).toBe('LEAFCERT') // untouched
controller.stop()
rmSync(dir, { recursive: true, force: true })
})
it('a failing renewal is logged (no secret) and retried without crashing, then rotates', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let calls = 0
const request: MtlsRequest = async () => {
calls += 1
if (calls === 1) throw new Error('ECONNREFUSED')
return { status: 200, body: JSON.stringify({ cert: 'FRESHLEAF', caChain: ['CACHAIN'] }) }
}
const restartChild = vi.fn()
const stop = vi.fn()
const lines: string[] = []
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild, stop },
createLogger('warn', (l) => lines.push(l)),
{
mtlsRequest: request,
certParser: farFuture,
timer,
renewBeforeMs: 1000,
retryBaseMs: 500,
now: () => new Date(0),
parseCert: () => new Date(2000),
},
)!
timer.advance(1000) // first attempt throws
await flush()
expect(restartChild).not.toHaveBeenCalled()
expect(stop).not.toHaveBeenCalled() // a failure NEVER tears down
expect(lines.join('\n')).toContain('retry')
timer.advance(500) // backoff retry fires and succeeds
await flush()
expect(ks.loadCert()!.certPem).toContain('FRESHLEAF')
expect(restartChild).toHaveBeenCalledTimes(1)
expect(lines.join('\n')).not.toContain('LEAFCERT')
controller.stop()
rmSync(dir, { recursive: true, force: true })
})
it('returns null (auto-renew disabled) when the keystore has no identity', () => {
const dir = mkdtempSync(join(tmpdir(), 'wta-nr-'))
const ks = openKeystore(dir)
const lines: string[] = []
const controller = startNativeAutoRenew(
CFG,
ks,
{ restartChild: () => {}, stop: () => {} },
createLogger('warn', (l) => lines.push(l)),
{},
)
expect(controller).toBeNull()
expect(lines.join('\n')).toContain('auto-renew disabled')
rmSync(dir, { recursive: true, force: true })
})
})

View File

@@ -0,0 +1,169 @@
/**
* A5 default mTLS transport (`defaultMtlsRequest`) — the ONE seam the other nativeRenew tests inject
* past, so the real `node:https` transport had zero coverage. These tests mock `node:https` and drive
* the actual transport to prove:
* - the exact request options (rejectUnauthorized:true + client cert/key + pinned CA + method/body),
* - the HIGH fix: a request timeout is armed and a stalled peer REJECTS (never hangs forever),
* - the MEDIUM fix: an oversized response body is capped (destroyed + rejected, not buffered).
*/
import { EventEmitter } from 'node:events'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }))
vi.mock('node:https', () => ({ request: requestMock, default: { request: requestMock } }))
import { generateP256Identity } from '../src/keys/identity.js'
import { openKeystore } from '../src/keys/keystore.js'
import {
createMtlsFetch,
RENEW_REQUEST_TIMEOUT_MS,
MAX_RENEW_RESPONSE_BYTES,
} from '../src/certs/nativeRenew.js'
const farFuture = (): { validTo: Date } => ({ validTo: new Date(Date.now() + 86_400_000) })
/** Fake `http.ClientRequest`: records setTimeout/write/end and emits 'error' on destroy(err). */
class FakeClientRequest extends EventEmitter {
readonly setTimeoutCalls: Array<{ ms: number; cb: () => void }> = []
readonly written: string[] = []
ended = false
destroyedWith: Error | undefined
setTimeout(ms: number, cb: () => void): this {
this.setTimeoutCalls.push({ ms, cb })
return this
}
write(chunk: string): boolean {
this.written.push(chunk)
return true
}
end(): this {
this.ended = true
return this
}
destroy(err?: Error): this {
this.destroyedWith = err
if (err) this.emit('error', err)
return this
}
}
/** Fake `http.IncomingMessage`: an EventEmitter with a statusCode and a real destroy(). */
class FakeIncomingMessage extends EventEmitter {
statusCode = 200
destroyed = false
destroy(): this {
this.destroyed = true
return this
}
}
type ReqCb = (res: FakeIncomingMessage) => void
let dirs: string[] = []
function enrolledKs(): ReturnType<typeof openKeystore> {
const dir = mkdtempSync(join(tmpdir(), 'wta-nrt-'))
dirs.push(dir)
const ks = openKeystore(dir)
ks.saveIdentity(generateP256Identity())
ks.saveCert('LEAFCERT', 'CACHAIN')
return ks
}
beforeEach(() => {
requestMock.mockReset()
})
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true })
dirs = []
})
describe('defaultMtlsRequest transport (A5 — real node:https)', () => {
it('passes rejectUnauthorized:true + the client cert/key/CA + method/body, and arms the timeout', async () => {
const ks = enrolledKs()
let seenUrl = ''
let seenOpts: Record<string, unknown> = {}
let seenReq: FakeClientRequest | undefined
requestMock.mockImplementation((url: string, opts: Record<string, unknown>, cb: ReqCb) => {
seenUrl = url
seenOpts = opts
const req = new FakeClientRequest()
seenReq = req
queueMicrotask(() => {
const res = new FakeIncomingMessage()
res.statusCode = 200
cb(res)
res.emit('data', Buffer.from('{"cert":"NEW",'))
res.emit('data', Buffer.from('"caChain":"NEWCA"}'))
res.emit('end')
})
return req
})
const f = createMtlsFetch(ks, { certParser: farFuture }) // NO request seam → real defaultMtlsRequest
const res = await f('https://cp.example.com/renew', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"csr":"x"}',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ cert: 'NEW', caChain: 'NEWCA' })
expect(seenUrl).toBe('https://cp.example.com/renew')
expect(seenOpts.rejectUnauthorized).toBe(true) // anti-MITM (INV14)
expect(seenOpts.method).toBe('POST')
expect(seenOpts.cert).toBe('LEAFCERT')
// ca omitted ⇒ verify the LE-fronted control-plane against the system roots (not the private CA).
expect(seenOpts.ca).toBeUndefined()
expect(String(seenOpts.key)).toContain('PRIVATE KEY') // in-process PKCS#8 key
// HIGH fix: a socket timeout is armed with the sane default so a stall can never hang forever.
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
expect(seenReq!.written).toEqual(['{"csr":"x"}'])
expect(seenReq!.ended).toBe(true)
})
it('HIGH: a stalled peer (accepts TLS, never responds) REJECTS via the timeout, not hangs', async () => {
const ks = enrolledKs()
let seenReq: FakeClientRequest | undefined
requestMock.mockImplementation(() => {
const req = new FakeClientRequest()
seenReq = req
return req // never invokes the response callback — a stalled control-plane
})
const f = createMtlsFetch(ks, { certParser: farFuture })
const p = f('https://cp.example.com/renew', { method: 'POST', body: '{}' })
// Fire the armed socket-timeout callback (what node does when the socket idles past the limit).
expect(seenReq!.setTimeoutCalls).toHaveLength(1)
expect(seenReq!.setTimeoutCalls[0]!.ms).toBe(RENEW_REQUEST_TIMEOUT_MS)
seenReq!.setTimeoutCalls[0]!.cb()
await expect(p).rejects.toThrow(/timed out/i)
expect(seenReq!.destroyedWith).toBeInstanceOf(Error) // socket was torn down
})
it('MEDIUM: an oversized response body is capped — res destroyed + promise rejected', async () => {
const ks = enrolledKs()
let seenRes: FakeIncomingMessage | undefined
requestMock.mockImplementation((_url: string, _opts: unknown, cb: ReqCb) => {
const req = new FakeClientRequest()
queueMicrotask(() => {
const res = new FakeIncomingMessage()
res.statusCode = 200
seenRes = res
cb(res)
res.emit('data', Buffer.alloc(MAX_RENEW_RESPONSE_BYTES + 1)) // one byte over the cap
// deliberately NO 'end' — a capped stream must reject on its own, not wait for end
})
return req
})
const f = createMtlsFetch(ks, { certParser: farFuture })
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(/cap|exceed/i)
expect(seenRes!.destroyed).toBe(true)
})
})

View File

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

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

@@ -11,6 +11,7 @@ import {
renewCert, renewCert,
renewalUrlFor, renewalUrlFor,
} from '../src/certs/rotation.js' } from '../src/certs/rotation.js'
import { createBackoff } from '../src/transport/backoff.js'
import { FakeTimer } from './fixtures/fakes.js' import { FakeTimer } from './fixtures/fakes.js'
const CFG: AgentConfig = { const CFG: AgentConfig = {
@@ -52,10 +53,10 @@ describe('renewCert (T13)', () => {
it('installs a fresh cert atomically on success (same key)', async () => { it('installs a fresh cert atomically on success (same key)', async () => {
const { dir, ks } = enrolledKs() const { dir, ks } = enrolledKs()
const before = ks.loadIdentity()!.publicKey const before = ks.loadIdentity()!.publicKey
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch) const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
expect(out).toBe('rotated') expect(out).toBe('rotated')
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' }) expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
// pubkey unchanged — only the cert rotated // pubkey unchanged — only the cert rotated
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true) expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
rmSync(dir, { recursive: true, force: true }) rmSync(dir, { recursive: true, force: true })
@@ -101,7 +102,7 @@ describe('createCertRotator (T13)', () => {
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, { const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer, timer,
renewBeforeMs: 1000, renewBeforeMs: 1000,
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch, fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
now: () => new Date(0), now: () => new Date(0),
parseCert: () => new Date(2000), parseCert: () => new Date(2000),
}) })
@@ -113,7 +114,47 @@ describe('createCertRotator (T13)', () => {
timer.advance(1000) timer.advance(1000)
await flush() await flush()
expect(rotated).toBe(1) expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toBe('NEWCERT') expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let calls = 0
const fetchImpl = (async () => {
calls += 1
if (calls === 1) throw new Error('network down')
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch
const errors: unknown[] = []
let rotated = 0
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
fetchImpl,
now: () => new Date(0),
parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms
})
rotator.onError((e) => errors.push(e))
rotator.onRotated(() => {
rotated += 1
})
rotator.start()
timer.advance(1000) // first attempt fires → throws
await flush()
expect(errors).toHaveLength(1)
expect(rotated).toBe(0)
// The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500
// must fire it. A crash-loop never escapes here (the supervisor keeps running).
timer.advance(500)
await flush()
expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop() rotator.stop()
rmSync(dir, { recursive: true, force: true }) rmSync(dir, { recursive: true, force: true })
}) })

View File

@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest'
import { decodeMuxFrame, encodeGoaway, encodeMuxFrame, encodeOpen, type MuxOpen } from 'relay-contracts'
import type { AgentConfig } from '../src/config/agentConfig.js'
import type { Keystore } from '../src/keys/keystore.js'
import { createBackoff } from '../src/transport/backoff.js'
import { runTunnel, type RunTunnelDeps } from '../src/transport/runTunnel.js'
import { FakeTimer, FakeWs } from './fixtures/fakes.js'
/**
* C2 supervisor: proves runTunnel ports the cafeDemo assembly into a supervised loop —
* bytes splice both ways, a dead session reconnects, and a `revoked` GOAWAY stops for good (INV12).
*/
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://x/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: 'host-42',
hostId: 'h-1',
}
const OPEN: MuxOpen = {
streamId: 5,
subdomain: 'host-42',
requestPath: '/term?join=abc',
originHeader: 'https://host-42.term.example.com',
remoteAddrHash: 'x',
capabilityTokenRef: 'jti',
}
const KS = {} as unknown as Keystore // unused when connectRelay/dialLoopback are injected
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
function emitOpen(upstream: FakeWs, open: MuxOpen): void {
const payload = encodeOpen(open)
upstream.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'open', fin: false, rst: false, streamId: open.streamId, payloadLen: payload.length },
payload,
),
)
}
function emitGoAwayRevoked(upstream: FakeWs): void {
const payload = encodeGoaway(0, 'revoked')
upstream.emitMessage(
encodeMuxFrame(
{ version: 1, type: 'goaway', fin: false, rst: false, streamId: 0, payloadLen: payload.length },
payload,
),
)
}
function baseDeps(over: Partial<RunTunnelDeps>): Partial<RunTunnelDeps> {
return {
timer: new FakeTimer(), // never auto-fires ⇒ heartbeat is inert during the test
sleep: async () => {},
backoff: createBackoff(),
...over,
}
}
describe('runTunnel supervisor (C2)', () => {
it('splices bytes both ways through the loopback', async () => {
const upstream = new FakeWs()
const loopback = new FakeWs()
const connectRelay = vi.fn(async () => upstream)
const dialLoopback = vi.fn(async () => loopback)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: dialLoopback as never }))
await flush()
emitOpen(upstream, OPEN)
await flush()
expect(dialLoopback).toHaveBeenCalledWith('/term?join=abc', 'https://host-42.term.example.com')
upstream.emitMessage(encodeMuxFrame({ version: 1, type: 'data', fin: false, rst: false, streamId: 5, payloadLen: 3 }, new Uint8Array([104, 105, 10])))
expect(loopback.sent.at(-1)).toEqual(new Uint8Array([104, 105, 10]))
loopback.emit('message', new Uint8Array([79, 75])) // "OK" echoes back upstream as DATA
const last = decodeMuxFrame(upstream.sent.at(-1)!)
expect(last.header.type).toBe('data')
expect([...last.payload]).toEqual([79, 75])
await handle.stop()
expect(await handle.done).toBe(0)
})
it('reconnects after the tunnel dies', async () => {
const sockets = [new FakeWs(), new FakeWs()]
let i = 0
const connectRelay = vi.fn(async () => sockets[i++]!)
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
expect(connectRelay).toHaveBeenCalledTimes(1)
sockets[0]!.emit('close') // first session dies ⇒ supervisor redials
await flush()
expect(connectRelay).toHaveBeenCalledTimes(2)
await handle.stop()
})
it('a revoked GOAWAY tears down and NEVER reconnects (INV12)', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
let socket: FakeWs | undefined
const wrapped = vi.fn(async () => {
socket = new FakeWs()
return socket
})
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay: wrapped, dialLoopback: async () => new FakeWs() }))
await flush()
expect(wrapped).toHaveBeenCalledTimes(1)
emitGoAwayRevoked(socket!)
await flush()
expect(await handle.done).toBe(0)
expect(wrapped).toHaveBeenCalledTimes(1) // no reconnect after revocation
expect(connectRelay).not.toHaveBeenCalled()
})
it('stop() ends the loop with exit code 0 and does not reconnect', async () => {
const connectRelay = vi.fn(async () => new FakeWs())
const handle = await runTunnel(CFG, KS, baseDeps({ connectRelay, dialLoopback: async () => new FakeWs() }))
await flush()
await handle.stop()
expect(await handle.done).toBe(0)
expect(connectRelay).toHaveBeenCalledTimes(1)
})
})

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,95 @@
# 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).
## Projects / git parity (W5 — presenters JVM-tested, Compose device-QA)
- [ ] Project card **sync chip**: `↑ahead` / `↓behind` render only when non-zero; no chip when there is
no upstream (fields absent).
- [ ] Project detail **PR chip**: `availability=ok` → tappable chip opens the PR in the browser ONLY when
the url is https (a non-https / junk url is inert, non-clickable); `no-pr` / `not-installed` /
`unauthenticated` / `disabled` / `error` each render the degraded copy inertly; check-count colour
(fail=red / pending=amber / pass=green).
- [ ] Project detail **recent commits**: list renders short-hash + subject inertly; unavailable state on a
log failure does NOT hide the rest of the detail (failure-isolated).
- [ ] **New worktree** inline form: valid `branch` (+optional `base`) → create → list refreshes; an invalid
branch name is rejected with NO network call; a disabled-403 shows the server's safe message.
- [ ] Per-worktree **remove**: the button is absent on the `main` worktree; the confirm dialog offers a
**Force** checkbox; a dirty-worktree 409 surfaces "force required" inertly; **prune** button works.
- [ ] Diff **base-rev** input: entering a rev enters base mode (Working/Staged toggle hidden, `vs <rev>`
shown, git-write controls hidden); Clear returns to working/staged; junk rev → server 400 surfaced.
- [ ] Diff **stage/unstage**: per-file button (Working→"暂存", Staged→"取消暂存") posts the file and
refreshes; **commit** field + button (empty message rejected client-side; Ok shows the short sha) ;
**push** button (Ok shows branch→remote; 409 shows the inert server message; 429 shows rate-limited).
## 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.

131
android/README.md Normal file
View File

@@ -0,0 +1,131 @@
# 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).
## Build environment (SDK installed — all modules build)
The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
against SDK 35/36.
- **Pure Kotlin/JVM (`./gradlew test`):** `:wire-protocol`, `:session-core`, `:api-client`,
`:client-tls`, `:test-support`, `:transport-okhttp`.
- **Android-framework (online in [`settings.gradle.kts`](settings.gradle.kts)):**
`:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
Setup: `local.properties``sdk.dir=/usr/local/share/android-commandlinetools`;
`google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
`./gradlew test :app:assembleDebug koverVerify`.
## 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)| ✅ built |
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
> 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
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
│ │ │ │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp
└──────── :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,172 @@
package wang.yaojia.webterm.api.enroll
/**
* B4 · Manual, canonical-DER encoder for a P-256 PKCS#10 `CertificationRequest` — a byte-for-byte
* port of the iOS `ClientTLS.CertificateSigningRequest`.
*
* Built by hand (no JCA CSR helper) so the exact bytes are under our control and the request is
* signed by the [CsrSigner] (an AndroidKeyStore hardware key in production, a software P-256 key in
* tests) via `SHA256withECDSA`. The output must satisfy the control-plane `verifyCsrPoPEc`: an EC
* P-256 `SubjectPublicKeyInfo` (`id-ecPublicKey` + `prime256v1`), an `ecdsa-with-SHA256`
* self-signature, and a valid PoP. Encoding is strictly canonical DER (minimal lengths) so the
* server's re-serialization of `CertificationRequestInfo` matches the bytes we signed.
*
* ```
* CertificationRequest ::= SEQUENCE {
* certificationRequestInfo CertificationRequestInfo,
* signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
* signature BIT STRING } -- X9.62 DER ECDSA-Sig
*
* CertificationRequestInfo ::= SEQUENCE {
* version INTEGER { v1(0) },
* subject Name,
* subjectPKInfo SubjectPublicKeyInfo,
* attributes [0] IMPLICIT SET OF Attribute } -- empty
* ```
*/
public object CertificateSigningRequest {
/** P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes. */
private const val UNCOMPRESSED_P256_POINT_LENGTH = 65
/**
* Build and self-sign a P-256 PKCS#10 CSR DER for [signer]'s key.
*
* @param subjectCommonName the CSR subject CN. The device leaf's identity is driven server-side
* by the ownership-verified subdomain SAN, so this is descriptive only; it must be non-empty.
* @param signer the P-256 hardware key that provides the public key and signs the
* `CertificationRequestInfo`.
* @throws CsrException.InvalidSubject on an empty CN; [CsrException.InvalidPublicKey] if the
* signer's public key is not a 65-byte X9.63 P-256 point.
*/
public fun der(subjectCommonName: String, signer: CsrSigner): ByteArray {
if (subjectCommonName.isEmpty()) throw CsrException.InvalidSubject
val publicPoint = signer.publicKeyX963()
if (publicPoint.size != UNCOMPRESSED_P256_POINT_LENGTH || publicPoint[0].toInt() != 0x04) {
throw CsrException.InvalidPublicKey
}
val requestInfo = certificationRequestInfo(subjectCommonName, publicPoint)
val signature = signer.sign(requestInfo)
return DerWriter.sequence(
listOf(
requestInfo,
ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER,
DerWriter.bitString(signature),
),
)
}
// ── CertificationRequestInfo ────────────────────────────────────────────────────────────
private fun certificationRequestInfo(subjectCommonName: String, publicPoint: ByteArray): ByteArray =
DerWriter.sequence(
listOf(
DerWriter.INTEGER_0, // version v1(0)
name(subjectCommonName),
subjectPublicKeyInfo(publicPoint),
DerWriter.EMPTY_ATTRIBUTES_CONTEXT0, // [0] IMPLICIT SET OF Attribute (empty)
),
)
/** `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN. */
private fun name(commonName: String): ByteArray {
val attribute = DerWriter.sequence(
listOf(DerWriter.oid(Oid.COMMON_NAME), DerWriter.utf8String(commonName)),
)
val rdn = DerWriter.set(listOf(attribute))
return DerWriter.sequence(listOf(rdn))
}
/**
* `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` + `prime256v1` named curve, then
* the uncompressed point as a BIT STRING.
*/
private fun subjectPublicKeyInfo(publicPoint: ByteArray): ByteArray {
val algorithm = DerWriter.sequence(
listOf(DerWriter.oid(Oid.EC_PUBLIC_KEY), DerWriter.oid(Oid.PRIME256V1)),
)
return DerWriter.sequence(listOf(algorithm, DerWriter.bitString(publicPoint)))
}
/**
* `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per RFC 5758), which is
* exactly what the server's verifier expects.
*/
private val ECDSA_WITH_SHA256_ALGORITHM_IDENTIFIER: ByteArray =
DerWriter.sequence(listOf(DerWriter.oid(Oid.ECDSA_WITH_SHA256)))
}
/** Object identifiers (DER content bytes; tag/length added by [DerWriter.oid]). */
private object Oid {
/** 1.2.840.10045.2.1 — id-ecPublicKey. */
val EC_PUBLIC_KEY = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01)
/** 1.2.840.10045.3.1.7 — prime256v1 / secp256r1. */
val PRIME256V1 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07)
/** 1.2.840.10045.4.3.2 — ecdsa-with-SHA256. */
val ECDSA_WITH_SHA256 = byteArrayOf(0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02)
/** 2.5.4.3 — id-at-commonName. */
val COMMON_NAME = byteArrayOf(0x55, 0x04, 0x03)
}
/**
* A tiny canonical-DER encoder. Every helper returns a fully-formed TLV so callers just concatenate
* children — canonical minimal-length encoding throughout. Internal so its byte layout is
* unit-testable in isolation.
*/
internal object DerWriter {
private const val TAG_INTEGER: Byte = 0x02
private const val TAG_BIT_STRING: Byte = 0x03
private const val TAG_OID: Byte = 0x06
private const val TAG_UTF8_STRING: Byte = 0x0C
private const val TAG_SEQUENCE: Byte = 0x30
private const val TAG_SET: Byte = 0x31
private const val TAG_CONTEXT0_CONSTRUCTED: Byte = 0xA0.toByte()
/** `INTEGER 0` — the fixed PKCS#10 version v1(0). */
val INTEGER_0: ByteArray = byteArrayOf(TAG_INTEGER, 0x01, 0x00)
/** `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`. */
val EMPTY_ATTRIBUTES_CONTEXT0: ByteArray = byteArrayOf(TAG_CONTEXT0_CONSTRUCTED, 0x00)
fun sequence(children: List<ByteArray>): ByteArray = tlv(TAG_SEQUENCE, concat(children))
fun set(children: List<ByteArray>): ByteArray = tlv(TAG_SET, concat(children))
fun oid(content: ByteArray): ByteArray = tlv(TAG_OID, content)
fun utf8String(value: String): ByteArray = tlv(TAG_UTF8_STRING, value.encodeToByteArray())
/** BIT STRING with zero unused bits (all our bit strings are byte-aligned). */
fun bitString(content: ByteArray): ByteArray = tlv(TAG_BIT_STRING, byteArrayOf(0x00) + content)
/** Tag-Length-Value with canonical DER length encoding. */
private fun tlv(tag: Byte, value: ByteArray): ByteArray = byteArrayOf(tag) + length(value.size) + value
/** DER length: short form (<128) or long form (`0x80 | byteCount`, big-endian). */
private fun length(count: Int): ByteArray {
if (count < 0x80) return byteArrayOf(count.toByte())
var value = count
val bytes = ArrayDeque<Byte>()
while (value > 0) {
bytes.addFirst((value and 0xFF).toByte())
value = value ushr 8
}
return byteArrayOf((0x80 or bytes.size).toByte()) + bytes.toByteArray()
}
private fun concat(chunks: List<ByteArray>): ByteArray {
val total = chunks.sumOf { it.size }
val out = ByteArray(total)
var offset = 0
for (chunk in chunks) {
System.arraycopy(chunk, 0, out, offset, chunk.size)
offset += chunk.size
}
return out
}
}

View File

@@ -0,0 +1,37 @@
package wang.yaojia.webterm.api.enroll
/**
* B4 · The signing key abstraction the PKCS#10 CSR encoder ([CertificateSigningRequest]) drives —
* the Android analogue of iOS `P256HardwareKey`.
*
* In production this is backed by a NON-EXPORTABLE P-256 key living inside AndroidKeyStore
* (StrongBox → TEE), so `sign` runs inside secure hardware and the private key never leaves it
* (`:client-tls-android` `HardwareBackedKey`). In JVM unit tests it is backed by a software P-256
* key via the SAME `Signature("SHA256withECDSA")` path, so the CSR-encoding bytes are exercised
* identically without an emulator.
*/
public interface CsrSigner {
/**
* The public key in ANSI X9.63 uncompressed form: `0x04 || X(32) || Y(32)` (65 bytes for
* P-256). This is exactly what wraps into the CSR's `SubjectPublicKeyInfo` BIT STRING.
*/
public fun publicKeyX963(): ByteArray
/**
* ECDSA-sign `message` over SHA-256, returning the X9.62 DER signature
* (`SEQUENCE { r INTEGER, s INTEGER }`) — exactly the shape a PKCS#10 `signature` BIT STRING
* and the server's `verifyCsrPoPEc` expect. The digest is computed by the algorithm
* (`SHA256withECDSA`), so callers pass the raw message (the DER of `CertificationRequestInfo`),
* NOT a pre-hash.
*/
public fun sign(message: ByteArray): ByteArray
}
/** Structural failures building a CSR — the client refuses to emit a malformed request. */
public sealed class CsrException(message: String) : Exception(message) {
/** The signer's public key was not the expected 65-byte X9.63 uncompressed P-256 point. */
public data object InvalidPublicKey : CsrException("CSR public key is not a 65-byte X9.63 P-256 point")
/** The subject CN was empty (not encodable / rejected by the server). */
public data object InvalidSubject : CsrException("CSR subject common name must not be empty")
}

View File

@@ -0,0 +1,184 @@
package wang.yaojia.webterm.api.enroll
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* B4 · Talks to the control-plane device-enrollment API over the shared [HttpTransport] seam (the
* same seam `:transport-okhttp` implements and `:test-support` fakes), so the enroll flow rides the
* app's normal HTTP stack. Android analogue of iOS `DeviceEnrollmentClient`, extended with the login
* step (B4 pinned contract):
*
* `POST /auth/login` `{ password }` → 201 `{ enrollToken, accountId, expiresIn }`
* `POST /device/enroll` [Bearer enrollToken] `{ csr, keyAlg:'ec-p256', subdomain,
* deviceName, attestation? }` → 201 `{ deviceId, cert, caChain,
* notBefore, notAfter, renewAfter }`
* `POST /device/:id/renew` [mTLS current device cert] `{ csr }` ONLY → 201 (same shape). The
* server schema is `.strict()`; NO keyAlg/subdomain/deviceName.
*
* Deliberately logic-free about TLS/keys: it only builds requests and maps responses. The `csr` is
* sent as standard base64(DER), which the server's `decodeCsrWire` accepts directly; response DERs
* are standard-base64 (`bytesToBase64` = Node `Buffer.toString('base64')`).
*
* Immutable: constructed once with a [baseUrl] + [http]; the short-lived enroll bearer is passed
* per-call and never held/logged (leaked-bearer blast radius).
*/
public class DeviceEnrollmentClient(
baseUrl: String,
private val http: HttpTransport,
) {
/** Base control-plane URL with any trailing slash removed, so `base + path` is well-formed. */
private val base: String = baseUrl.trim().trimEnd('/')
/**
* One-time operator login → a short-lived `device:enroll` bearer. An empty [password] is
* rejected client-side (`InvalidRequest`) before any network I/O — never send a blank credential.
*/
public suspend fun login(password: String): LoginResult {
if (password.isEmpty()) throw DeviceEnrollmentError.InvalidRequest
val body = EnrollJson.encodeToString(LoginRequestBody.serializer(), LoginRequestBody(password))
val response = http.send(jsonRequest(HttpMethod.POST, PATH_LOGIN, body.encodeToByteArray(), bearer = null))
val dto = decodeOn201(response, LoginResponseDto.serializer())
return LoginResult(
enrollToken = dto.enrollToken,
accountId = dto.accountId,
expiresInSeconds = dto.expiresIn,
)
}
/**
* Enroll a freshly-generated hardware key: POST the [csrDer] under the enroll [bearerToken],
* receive the leaf. Required fields are validated client-side (`InvalidRequest`) before any I/O.
*/
public suspend fun enroll(
bearerToken: String,
csrDer: ByteArray,
subdomain: String,
deviceName: String,
attestation: String? = null,
): EnrollmentResult {
if (bearerToken.isEmpty() || csrDer.isEmpty() || subdomain.isEmpty() || deviceName.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
val body = EnrollJson.encodeToString(
EnrollRequestBody.serializer(),
EnrollRequestBody(
csr = base64(csrDer),
keyAlg = KEY_ALG_EC_P256,
subdomain = subdomain,
deviceName = deviceName,
attestation = attestation,
),
)
val response = http.send(jsonRequest(HttpMethod.POST, PATH_ENROLL, body.encodeToByteArray(), bearerToken))
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
}
/**
* Renew against the SAME hardware key: a fresh CSR to `/device/:id/renew` (silent-rotation seam).
*
* The renew endpoint is authenticated by the CURRENT device certificate over mTLS — the body is
* `{ csr }` ONLY and NO bearer is sent (mirrors iOS, which passes `bearerToken: nil`). [bearerToken]
* is therefore OPTIONAL and defaults to absent; the `Authorization` header is omitted when it is
* null/blank. The seam still accepts a bearer for a hypothetical bearer-authenticated renew, but the
* production caller passes none. [deviceId] and [csrDer] are validated client-side before any I/O.
*/
public suspend fun renew(deviceId: String, csrDer: ByteArray, bearerToken: String? = null): EnrollmentResult {
if (deviceId.isEmpty() || csrDer.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
// Body is `{ csr }` ONLY — the renew endpoint authenticates by the presented mTLS device cert
// and its schema is `.strict()`, so any enroll-only extra (keyAlg/subdomain/deviceName) is
// rejected. Identity/key come from the current cert + registry record, never the body.
val body = EnrollJson.encodeToString(
RenewRequestBody.serializer(),
RenewRequestBody(csr = base64(csrDer)),
)
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/renew"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearerToken))
return toResult(decodeOn201(response, EnrollResponseDto.serializer()))
}
// ── Request/response plumbing ────────────────────────────────────────────────────────────
private fun jsonRequest(
method: HttpMethod,
path: String,
jsonBody: ByteArray,
bearer: String?,
): HttpRequest {
val headers = LinkedHashMap<String, String>()
headers[HEADER_CONTENT_TYPE] = CONTENT_TYPE_JSON
// Omit Authorization entirely when there is no bearer (the mTLS-only renew path) — an empty
// string must never emit a bare "Bearer " header.
if (!bearer.isNullOrEmpty()) headers[HEADER_AUTHORIZATION] = "$BEARER_PREFIX$bearer"
return HttpRequest(method = method, url = base + path, headers = headers, body = jsonBody)
}
/** 201 → decode with [serializer]; else → [DeviceEnrollmentError.Http] with the server's `error`
* code (never the raw body); an undecodable 201 body → [DeviceEnrollmentError.MalformedResponse]. */
private fun <T> decodeOn201(response: HttpResponse, serializer: kotlinx.serialization.KSerializer<T>): T {
if (response.status != HTTP_CREATED) {
throw DeviceEnrollmentError.Http(response.status, errorCode(response.body))
}
return runCatching { EnrollJson.decodeFromString(serializer, response.body.decodeToString()) }
.getOrNull() ?: throw DeviceEnrollmentError.MalformedResponse
}
private fun toResult(dto: EnrollResponseDto): EnrollmentResult {
val certificate = decodeBase64OrNull(dto.cert) ?: throw DeviceEnrollmentError.MalformedResponse
val chain = dto.caChain.map { entry ->
decodeBase64OrNull(entry) ?: throw DeviceEnrollmentError.MalformedResponse
}
return EnrollmentResult(
deviceId = dto.deviceId,
certificate = certificate,
caChain = chain,
notBefore = parseInstantOrNull(dto.notBefore),
notAfter = parseInstantOrNull(dto.notAfter),
renewAfter = parseInstantOrNull(dto.renewAfter),
)
}
private fun errorCode(body: ByteArray): String? =
runCatching { EnrollJson.decodeFromString(ErrorDto.serializer(), body.decodeToString()).error }.getOrNull()
private companion object {
const val PATH_LOGIN = "/auth/login"
const val PATH_ENROLL = "/device/enroll"
const val PATH_DEVICE = "/device"
const val KEY_ALG_EC_P256 = "ec-p256"
const val HTTP_CREATED = 201
const val HEADER_CONTENT_TYPE = "Content-Type"
const val HEADER_AUTHORIZATION = "Authorization"
const val CONTENT_TYPE_JSON = "application/json"
const val BEARER_PREFIX = "Bearer "
private val BASE64_ENCODER = java.util.Base64.getEncoder()
private val BASE64_DECODER = java.util.Base64.getDecoder()
fun base64(bytes: ByteArray): String = BASE64_ENCODER.encodeToString(bytes)
fun decodeBase64OrNull(text: String): ByteArray? =
runCatching { BASE64_DECODER.decode(text) }.getOrNull()
/** Percent-encode a `:id` path segment's non-unreserved bytes (defence: device ids are
* server-minted UUIDs, but never build a URL from an unescaped field). */
fun encodePathSegment(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 UNRESERVED: Set<Char> =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
private val HEX = "0123456789ABCDEF".toCharArray()
}
}

View File

@@ -0,0 +1,54 @@
package wang.yaojia.webterm.api.enroll
import java.math.BigInteger
import java.security.interfaces.ECPublicKey
/**
* B4 · Pure encoder from a JCA [ECPublicKey] to the ANSI X9.63 uncompressed point
* `0x04 || X || Y` that a P-256 `SubjectPublicKeyInfo` BIT STRING carries.
*
* Kept in the pure `:api-client` module (no Android dependency) so it is reused by BOTH the
* JVM-unit-test software signer AND the framework `HardwareBackedKey` (`:client-tls-android`),
* and so this security-load-bearing byte layout is unit-tested at JVM speed.
*/
public object EcPointEncoding {
/** P-256 field element width in bytes (256 bits). */
public const val P256_COORDINATE_BYTES: Int = 32
/** Uncompressed-point prefix (`0x04`) per SEC 1 §2.3.3. */
private const val UNCOMPRESSED_PREFIX: Byte = 0x04
/**
* Encode [publicKey]'s affine (x, y) as `0x04 || X(32) || Y(32)` (65 bytes). Each coordinate is
* an unsigned big-endian integer left-padded (or, defensively, high-byte-trimmed) to exactly
* [P256_COORDINATE_BYTES]. Throws [IllegalArgumentException] if a coordinate genuinely does not
* fit 32 bytes (i.e. the key is not on a 256-bit curve).
*/
public fun x963(publicKey: ECPublicKey): ByteArray {
val point = publicKey.w
val x = toFixedLengthUnsigned(point.affineX, P256_COORDINATE_BYTES)
val y = toFixedLengthUnsigned(point.affineY, P256_COORDINATE_BYTES)
val out = ByteArray(1 + P256_COORDINATE_BYTES * 2)
out[0] = UNCOMPRESSED_PREFIX
System.arraycopy(x, 0, out, 1, P256_COORDINATE_BYTES)
System.arraycopy(y, 0, out, 1 + P256_COORDINATE_BYTES, P256_COORDINATE_BYTES)
return out
}
/**
* Convert a non-negative [value] to a big-endian byte array of exactly [length] bytes. A
* `BigInteger` may carry a leading 0x00 sign byte (drop it) or be shorter than [length]
* (left-pad with zeros). A value that needs MORE than [length] significant bytes is rejected —
* silently truncating a coordinate would corrupt the key.
*/
internal fun toFixedLengthUnsigned(value: BigInteger, length: Int): ByteArray {
require(value.signum() >= 0) { "EC coordinate must be non-negative" }
val raw = value.toByteArray() // big-endian, possibly with a leading 0x00 sign byte
val start = if (raw.size > length && raw[0].toInt() == 0) 1 else 0
val significant = raw.size - start
require(significant <= length) { "EC coordinate does not fit $length bytes (got $significant)" }
val out = ByteArray(length)
System.arraycopy(raw, start, out, length - significant, significant)
return out
}
}

View File

@@ -0,0 +1,124 @@
package wang.yaojia.webterm.api.enroll
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.time.Instant
/**
* B4 · Typed result of the one-time operator login (`POST /auth/login`). The [enrollToken] is a
* short-lived `device:enroll` bearer — hold it ONLY for the immediately-following enroll call and
* NEVER persist or log it. [accountId] identifies the tenant the device will be scoped under.
*/
public data class LoginResult(
val enrollToken: String,
val accountId: String,
val expiresInSeconds: Long,
)
/**
* B4 · Typed result of a successful `POST /device/enroll` (or `/device/:id/renew`): the issued leaf
* plus its issuer chain and rotation timing. The private key is NOT here — it stays non-exportable
* in AndroidKeyStore. Mirrors iOS `EnrollmentResult`.
*
* NOTE: [certificate]/[caChain] are `ByteArray`, so the generated `data class` equality is by
* reference (transient DER carriers, not value-equality keys) — compare with `contentEquals`.
*/
public data class EnrollmentResult(
val deviceId: String,
/** Leaf certificate DER (decoded from the response's base64). */
val certificate: ByteArray,
/** Issuer chain DERs (device-CA etc.), leaf excluded. */
val caChain: List<ByteArray>,
val notBefore: Instant?,
val notAfter: Instant?,
/** When to renew from the same hardware key (~2/3 of the lifetime). */
val renewAfter: Instant?,
) {
/**
* The rotation seam: is the leaf due for renewal as of [now]? A missing [renewAfter] never
* triggers (fail-safe — the TLS stack is the real gate; the scheduler only pre-empts expiry).
*/
public fun isRenewalDue(now: Instant = Instant.now()): Boolean {
val due = renewAfter ?: return false
return !now.isBefore(due) // now >= renewAfter
}
}
/** Typed failures for the device-enrollment surface. Transport-level errors propagate UNWRAPPED. */
public sealed class DeviceEnrollmentError(message: String) : Exception(message) {
/**
* A non-success HTTP status with the server's uniform `{ error }` code, if any (401
* missing/rejected token, 403 subdomain-not-owned, 429 rate_limited, 400 rejected
* CSR/subdomain). Never leaks the response body.
*/
public data class Http(val status: Int, val code: String?) :
DeviceEnrollmentError("device enrollment rejected: HTTP $status" + (code?.let { " ($it)" } ?: ""))
/** A success body that did not decode to the expected shape (or an undecodable base64 cert). */
public data object MalformedResponse :
DeviceEnrollmentError("device enrollment response was not the expected shape")
/** A required request field was empty — rejected client-side BEFORE any network I/O. */
public data object InvalidRequest :
DeviceEnrollmentError("device enrollment request was missing a required field")
}
// ── Wire DTOs + JSON config (internal to the enroll package) ─────────────────────────────────────
/**
* ENCODE omits absent optionals (`encodeDefaults = false` drops the default-null `attestation`;
* `explicitNulls = false` never writes an explicit `null`) and DECODE is tolerant of unknown keys
* (the server is untrusted at this boundary). `keyAlg` carries NO default, so it is ALWAYS encoded.
*/
internal val EnrollJson: Json = Json {
encodeDefaults = false
explicitNulls = false
ignoreUnknownKeys = true
isLenient = true
}
@Serializable
internal data class LoginRequestBody(val password: String)
@Serializable
internal data class EnrollRequestBody(
val csr: String,
val keyAlg: String,
val subdomain: String,
val deviceName: String,
val attestation: String? = null,
)
/**
* The `/device/:id/renew` request body. The endpoint authenticates by the presented mTLS device cert
* and its server schema is `{ csr }` ONLY (`.strict()`), so it carries the single new CSR and NO
* enroll-only fields (keyAlg/subdomain/deviceName) — an extra key would be rejected as a 400.
*/
@Serializable
internal data class RenewRequestBody(val csr: String)
@Serializable
internal data class LoginResponseDto(
val enrollToken: String,
val accountId: String,
val expiresIn: Long,
)
@Serializable
internal data class EnrollResponseDto(
val deviceId: String,
val cert: String,
val caChain: List<String> = emptyList(),
val notBefore: String? = null,
val notAfter: String? = null,
val renewAfter: String? = null,
)
@Serializable
internal data class ErrorDto(val error: String? = null)
/** Parse an ISO-8601 instant, degrading an absent/unparseable value to null (dates are advisory). */
internal fun parseInstantOrNull(text: String?): Instant? {
if (text == null) return null
return runCatching { Instant.parse(text) }.getOrNull()
}

View File

@@ -0,0 +1,32 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
/**
* One commit from `git log` (`src/types.ts` `CommitLogEntry`). [hash] and [at] are REQUIRED — a
* commit missing either is dropped by the list-lossy [CommitLogEntryListSerializer] (its siblings
* survive). [subject] defaults to empty so a subject-less commit still decodes. `at` = `%ct * 1000`
* (epoch millis). All fields are rendered INERT (plain text; no autolink) at the screen (plan §8).
*/
@Serializable
public data class CommitLogEntry(
val hash: String,
val at: Long,
val subject: String = "",
)
/**
* `GET /projects/log` result (`src/types.ts` `GitLogResult`). [truncated] = more commits exist
* beyond the server cap. The commit list decodes lossily (drop-one-keep-rest).
*/
@Serializable
public data class GitLogResult(
@Serializable(with = CommitLogEntryListSerializer::class)
val commits: List<CommitLogEntry> = emptyList(),
val truncated: Boolean = false,
)
/** Drops a commit missing `hash`/`at`, keeps the rest (nested list-lossy, like worktrees). */
internal object CommitLogEntryListSerializer :
KSerializer<List<CommitLogEntry>> by LossyListSerializer(CommitLogEntry.serializer())

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* A client result union for the six guarded git-write ops (worktree create/remove/prune, git
* stage/commit/push). It carries the server's SAFE body only — never raw git stderr (the server
* classifies + sanitizes every failure, `src/http/git-ops.ts` / `worktrees.ts`, SEC-M10):
*
* - [Ok] — a 200 with the op's route-specific payload [T].
* - [Rejected] — a 4xx/5xx with the server's inert `error` string ([message]) to display verbatim.
* 403 is OVERLOADED (Origin-guard failure AND the disabled kill-switch both 403) so the client
* cannot tell them apart by status — it surfaces [message] inertly rather than inventing a typed
* variant (plan Edge cases / Security).
* - [RateLimited] — a 429 (stage/commit share one limiter, push a tighter one). Do NOT auto-retry.
*
* Nothing here throws on a bad body: a missing/garbled payload degrades to defaults (empty sha,
* empty pruned list) rather than crashing (tolerant-decode discipline, plan §8).
*/
public sealed interface GitWriteOutcome<out T> {
/** 200 — the op succeeded; [payload] is the route-specific success body. */
public data class Ok<out T>(val payload: T) : GitWriteOutcome<T>
/** A 4xx/5xx failure carrying the server's SAFE [message] (inert; may be null if unparseable). */
public data class Rejected(val status: Int, val message: String?) : GitWriteOutcome<Nothing>
/** 429 — the server rate-limited this write. */
public data object RateLimited : GitWriteOutcome<Nothing>
}
// ── Per-op 200 payloads (all fields optional/defaulted → a garbled body degrades, never throws) ──
/** `POST /projects/git/stage` 200 → `{ ok, staged, count }`. */
@Serializable
public data class StageResult(val staged: Boolean = false, val count: Int = 0)
/** `POST /projects/git/commit` 200 → `{ ok, commit }` (short sha; may be `""` — empty is valid). */
@Serializable
public data class CommitResult(val commit: String = "")
/** `POST /projects/git/push` 200 → `{ ok, branch, remote }`. */
@Serializable
public data class PushResult(val branch: String? = null, val remote: String? = null)
/** `POST /projects/worktree` 200 → `{ ok, path, branch }`. */
@Serializable
public data class CreateWorktreeResult(val path: String? = null, val branch: String? = null)
/** `DELETE /projects/worktree` 200 → `{ ok, path }` (git's canonical removed path). */
@Serializable
public data class RemoveWorktreeResult(val path: String? = null)
/** `POST /projects/worktree/prune` 200 → `{ ok, pruned: [...] }` (empty = nothing to prune). */
@Serializable
public data class PruneWorktreesResult(val pruned: List<String> = emptyList())
/** Shape of a failure body — worktree routes emit `{ error }`, git-ops `{ ok:false, error }`; both
* carry `error` as a SAFE string. Decoded to surface [error] inertly. */
@Serializable
internal data class GitErrorBody(val ok: Boolean = false, val error: String? = null)
/**
* Decode a guarded 200 body into [T], degrading a missing/garbled body to the payload's defaults
* (never throws — the caller already knows the status is 200).
*/
internal fun <T> decodeGitPayload(bytes: ByteArray, deserializer: kotlinx.serialization.KSerializer<T>): T =
LossyDecode.objectOrNull(bytes, deserializer) ?: ModelJson.decodeFromString(deserializer, "{}")
/** Read the SAFE `error` string from a failure body; null when the body is empty/unparseable. */
internal fun decodeGitError(bytes: ByteArray): String? =
LossyDecode.objectOrNull(bytes, GitErrorBody.serializer())?.error

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,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
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
/**
* Why a [PrStatus] has (or lacks) PR data (`src/types.ts` `PrAvailability`). Drives the detail
* chip's copy. Decoded via [PrAvailabilitySerializer]: an unknown/future value **degrades to
* [ERROR]** (never throws) — a new server availability must never make the chip crash.
*/
public enum class PrAvailability(public val wire: String) {
/** A PR exists for the current branch; the sibling fields are populated. */
OK("ok"),
/** gh works but the branch has no PR (or no remote/default repo). */
NO_PR("no-pr"),
/** `gh` binary not found on PATH (ENOENT). */
NOT_INSTALLED("not-installed"),
/** gh present but not logged in (needs `gh auth login`). */
UNAUTHENTICATED("unauthenticated"),
/** `GH_ENABLED=0` — feature off, never spawns gh. */
DISABLED("disabled"),
/** gh spawned but failed for another reason (timeout, etc.); also the unknown/missing fallback. */
ERROR("error"),
;
public companion object {
/** Map the wire string; unknown → [ERROR] (mirror of the FE never treating non-`ok` as fatal). */
public fun fromWire(wire: String): PrAvailability =
entries.firstOrNull { it.wire == wire } ?: ERROR
}
}
/**
* Decode [PrAvailability] by its `wire` value; an unknown/future value maps to [PrAvailability.ERROR]
* rather than throwing (mirror of [ClaudeStatusSerializer]). Serializes back the `wire` string.
*/
internal object PrAvailabilitySerializer : KSerializer<PrAvailability> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("PrAvailability", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): PrAvailability =
PrAvailability.fromWire(decoder.decodeString())
override fun serialize(encoder: Encoder, value: PrAvailability) =
encoder.encodeString(value.wire)
}
/** Rolled-up CI check counts from gh's statusCheckRollup (`src/types.ts` `PrCheckSummary`). */
@Serializable
public data class PrCheckSummary(
val total: Int = 0,
val passing: Int = 0,
val failing: Int = 0,
val pending: Int = 0,
)
/**
* `GET /projects/pr` result (`src/types.ts` `PrStatus`). Every field except [availability] is
* optional (present only when `availability == ok`); [availability] itself defaults to
* [PrAvailability.ERROR] so a body missing the field still decodes (never throws). `state` /
* `mergeable` are lower-cased string unions on the wire — kept as raw INERT strings here (rendered
* as plain text; no enum needed for display).
*/
@Serializable
public data class PrStatus(
@Serializable(with = PrAvailabilitySerializer::class)
val availability: PrAvailability = PrAvailability.ERROR,
val number: Int? = null,
val title: String? = null,
val url: String? = null,
val state: String? = null,
val isDraft: Boolean? = null,
val mergeable: String? = null,
val headRefName: String? = null,
val baseRefName: String? = null,
val checks: PrCheckSummary? = null,
)

View File

@@ -0,0 +1,75 @@
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,
/** W3 sync chip — commits on HEAD not on `@{u}` (best-effort; absent when no upstream). */
val ahead: Int? = null,
/** W3 sync chip — commits on `@{u}` not on HEAD (best-effort; absent when no upstream). */
val behind: Int? = null,
/** HEAD commit time in ms (`git log -1 --format=%ct * 1000`); absent on a fresh/empty repo. */
val lastCommitMs: 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,265 @@
package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
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.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
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 /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
* (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
* status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
* A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
* repo. Empty path rejected client-side before any I/O.
*/
public suspend fun projectPr(path: String): PrStatus {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectPr(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
* path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
* client-side before any I/O; `n` is clamped in the route builder.
*/
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.projectLog(path, n))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
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)
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
* decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
* carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
* kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
* (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
*/
private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
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,52 @@
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("读取项目详情失败,请稍后再试。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
/** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
}

View File

@@ -0,0 +1,95 @@
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
/** Inclusive bounds of the 4xx/5xx band a guarded-write maps to a `Rejected` outcome. */
const val CLIENT_ERROR_MIN = 400
const val SERVER_ERROR_MAX = 599
}
/** 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,219 @@
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)
/** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
fun projectPr(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/pr",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
/**
* `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
* `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
*/
fun projectLog(path: String, n: Int?): ApiRoute {
val query = StringBuilder("path=").append(percentEncode(path))
if (n != null) {
val clamped = n.coerceIn(1, GIT_LOG_MAX)
query.append("&n=").append(clamped)
}
return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
}
// ── 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"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
/** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
"/projects/worktree",
CreateWorktreeBody.serializer(),
CreateWorktreeBody(path, branch, base),
)
/** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.DELETE,
"/projects/worktree",
RemoveWorktreeBody.serializer(),
RemoveWorktreeBody(path, worktreePath, force),
)
/** `POST /projects/worktree/prune` — `{ path }`. */
fun pruneWorktrees(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))
// ── G: git write (stage / commit / push) ───────────────────────────────────────────────
/** `POST /projects/git/stage` — `{ path, files, stage }`. */
fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
/** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
}
/** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
private const val GIT_LOG_MAX = 50
/**
* 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)
@Serializable
private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)
@Serializable
private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)
@Serializable
private data class PruneBody(val path: String)
@Serializable
private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)
@Serializable
private data class CommitBody(val path: String, val message: String)
@Serializable
private data class PushBody(val path: 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,211 @@
package wang.yaojia.webterm.api.enroll
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.security.KeyPairGenerator
import java.security.Signature
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/**
* B4 · Proves the manual PKCS#10 encoder produces a well-formed, self-signed P-256 CSR that the
* control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1 SPKI, ecdsa-with-SHA256
* self-signature) accepts. Runs headless with a SOFTWARE P-256 key via the SAME
* `Signature("SHA256withECDSA")` path the on-device AndroidKeyStore key uses — so the signing path
* is byte-identical. Real StrongBox keygen is device-only (`:client-tls-android`).
*/
class CertificateSigningRequestTest {
/** Software P-256 signer via the SAME JCA `SHA256withECDSA` path used on-device (no StrongBox). */
private class SoftwareEcSigner : CsrSigner {
val keyPair = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(keyPair.public as ECPublicKey)
override fun sign(message: ByteArray): ByteArray =
Signature.getInstance("SHA256withECDSA").apply {
initSign(keyPair.private)
update(message)
}.sign()
}
@Test
fun csrIsCanonicalPkcs10SequenceOfExactlyThreeElements() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
val outer = TestDer.read(der, 0)!!
assertEquals(0x30, outer.tag, "outer CertificationRequest is a SEQUENCE")
assertEquals(der.size, outer.end, "no trailing garbage after the CSR")
val parts = TestDer.children(der, outer)
assertEquals(3, parts.size)
assertEquals(0x30, parts[0].tag) // certificationRequestInfo
assertEquals(0x30, parts[1].tag) // signatureAlgorithm
assertEquals(0x03, parts[2].tag) // signature BIT STRING
}
@Test
fun csrSelfSignatureVerifiesAgainstTheEmbeddedP256Key() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
// Extract the exact CertificationRequestInfo bytes that were signed and the ECDSA signature
// (the same crypto check verifyCsrPoPEc's req.verify() runs).
val outer = TestDer.read(der, 0)!!
val parts = TestDer.children(der, outer)
val infoBytes = der.copyOfRange(parts[0].start, parts[0].end)
val bitString = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
val ok = Signature.getInstance("SHA256withECDSA").apply {
initVerify(signer.keyPair.public)
update(infoBytes)
}.verify(signature)
assertTrue(ok, "the CSR self-signature must verify against its own SubjectPublicKeyInfo")
}
@Test
fun csrEmbedsAP256SubjectPublicKeyInfoTheServerVerifierAccepts() {
val signer = SoftwareEcSigner()
val point = signer.publicKeyX963()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
val outer = TestDer.read(der, 0)!!
val info = TestDer.children(der, outer)[0]
val infoChildren = TestDer.children(der, info)
assertEquals(4, infoChildren.size)
// version v1(0)
assertArrayEquals(byteArrayOf(0x02, 0x01, 0x00), der.copyOfRange(infoChildren[0].start, infoChildren[0].end))
assertEquals(0xA0, infoChildren[3].tag) // [0] IMPLICIT attributes
assertEquals(0, infoChildren[3].valueEnd - infoChildren[3].valueStart) // empty SET
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
val spki = infoChildren[2]
val spkiChildren = TestDer.children(der, spki)
assertEquals(2, spkiChildren.size)
val algIdChildren = TestDer.children(der, spkiChildren[0])
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs verifyCsrPoPEc pins.
assertArrayEquals(
byteArrayOf(0x06, 0x07, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x02, 0x01),
der.copyOfRange(algIdChildren[0].start, algIdChildren[0].end),
)
assertArrayEquals(
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x03, 0x01, 0x07),
der.copyOfRange(algIdChildren[1].start, algIdChildren[1].end),
)
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
val bitString = spkiChildren[1]
assertEquals(0x03, bitString.tag)
assertEquals(0x00, der[bitString.valueStart].toInt() and 0xFF)
assertArrayEquals(point, der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd))
}
@Test
fun signatureAlgorithmIsEcdsaWithSha256() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("web-terminal-device", signer)
val outer = TestDer.read(der, 0)!!
val algId = TestDer.children(der, outer)[1]
val oid = TestDer.children(der, algId)[0]
assertArrayEquals(
byteArrayOf(0x06, 0x08, 0x2A, 0x86.toByte(), 0x48, 0xCE.toByte(), 0x3D, 0x04, 0x03, 0x02),
der.copyOfRange(oid.start, oid.end),
)
}
@Test
fun subjectCommonNameIsEncodedAsUtf8String() {
val signer = SoftwareEcSigner()
val der = CertificateSigningRequest.der("my-pixel", signer)
val outer = TestDer.read(der, 0)!!
val info = TestDer.children(der, outer)[0]
val name = TestDer.children(der, info)[1] // subject Name
val rdn = TestDer.children(der, name)[0] // SET
val attr = TestDer.children(der, rdn)[0] // SEQUENCE { OID, value }
val attrChildren = TestDer.children(der, attr)
// OID 2.5.4.3 (commonName), then a UTF8String (tag 0x0C) carrying the CN bytes.
assertArrayEquals(byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03), der.copyOfRange(attrChildren[0].start, attrChildren[0].end))
assertEquals(0x0C, attrChildren[1].tag)
assertArrayEquals("my-pixel".toByteArray(), der.copyOfRange(attrChildren[1].valueStart, attrChildren[1].valueEnd))
}
@Test
fun emptySubjectCommonNameIsRejected() {
val signer = SoftwareEcSigner()
assertThrows(CsrException.InvalidSubject::class.java) {
CertificateSigningRequest.der("", signer)
}
}
@Test
fun aNon65BytePublicKeyIsRejected() {
val badSigner = object : CsrSigner {
override fun publicKeyX963(): ByteArray = ByteArray(64) { 0x04 } // wrong length
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
}
assertThrows(CsrException.InvalidPublicKey::class.java) {
CertificateSigningRequest.der("d", badSigner)
}
}
@Test
fun aPublicKeyWithoutTheUncompressedPrefixIsRejected() {
val badSigner = object : CsrSigner {
override fun publicKeyX963(): ByteArray = ByteArray(65) { 0x02 } // right length, wrong prefix
override fun sign(message: ByteArray): ByteArray = ByteArray(0)
}
assertThrows(CsrException.InvalidPublicKey::class.java) {
CertificateSigningRequest.der("d", badSigner)
}
}
}
/**
* A throwaway canonical-DER reader for structural assertions (the enroll path itself does no DER
* parsing — the server verifies; this mirrors the iOS `TestDER` test helper).
*/
internal object TestDer {
data class Element(val tag: Int, val start: Int, val valueStart: Int, val valueEnd: Int) {
val end: Int get() = valueEnd
}
fun read(bytes: ByteArray, start: Int): Element? {
if (start < 0 || start + 1 >= bytes.size) return null
val tag = bytes[start].toInt() and 0xFF
var index = start + 1
val first = bytes[index].toInt() and 0xFF
index += 1
var length = 0
if (first and 0x80 == 0) {
length = first
} else {
val count = first and 0x7F
if (count == 0 || count > 4 || index + count > bytes.size) return null
repeat(count) {
length = (length shl 8) or (bytes[index].toInt() and 0xFF)
index += 1
}
}
val valueEnd = index + length
if (valueEnd > bytes.size) return null
return Element(tag = tag, start = start, valueStart = index, valueEnd = valueEnd)
}
fun children(bytes: ByteArray, parent: Element): List<Element> {
val elements = mutableListOf<Element>()
var index = parent.valueStart
while (index < parent.valueEnd) {
val element = read(bytes, index) ?: break
elements.add(element)
index = element.valueEnd
}
return elements
}
}

View File

@@ -0,0 +1,268 @@
package wang.yaojia.webterm.api.enroll
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
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.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.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.time.Instant
import java.util.Base64
/**
* B4 · DeviceEnrollmentClient request-building + response-mapping against the pinned login/enroll
* contract, driven by the shared `FakeHttpTransport` (no network). Mirrors the iOS
* `DeviceEnrollmentClientTests`, extended with the login step.
*/
class DeviceEnrollmentClientTest {
private companion object {
const val BASE = "https://cp.terminal.yaojia.wang"
const val BEARER = "device-enroll-token-abc"
}
private val transport = FakeHttpTransport()
private val client = DeviceEnrollmentClient(BASE, transport)
private fun bodyObject(request: HttpRequest): JsonObject =
Json.parseToJsonElement(request.body!!.decodeToString()) as JsonObject
private fun enrollResponse(
deviceId: String = "dev-1",
cert: ByteArray = byteArrayOf(0x30, 0x01, 0x02),
caChain: List<ByteArray> = listOf(byteArrayOf(0x30, 0xAA.toByte())),
notBefore: String = "2026-07-08T00:00:00.000Z",
notAfter: String = "2026-10-06T00:00:00.000Z",
renewAfter: String = "2026-09-05T00:00:00.000Z",
): ByteArray {
val b64 = Base64.getEncoder()
val chainJson = caChain.joinToString(",") { "\"${b64.encodeToString(it)}\"" }
return """
{"deviceId":"$deviceId","cert":"${b64.encodeToString(cert)}","caChain":[$chainJson],
"notBefore":"$notBefore","notAfter":"$notAfter","renewAfter":"$renewAfter"}
""".trimIndent().toByteArray()
}
// ── login ────────────────────────────────────────────────────────────────────────────────
@Test
fun loginPostsPasswordAndMapsThe201Bearer() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/auth/login",
status = 201,
body = """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray(),
)
val result = client.login("hunter2")
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth/login", request.url)
assertEquals("application/json", request.headers["Content-Type"])
assertNull(request.headers["Authorization"], "login carries no bearer")
assertEquals("hunter2", bodyObject(request)["password"]!!.jsonPrimitive.content)
assertEquals("tok-xyz", result.enrollToken)
assertEquals("acct-1", result.accountId)
assertEquals(600L, result.expiresInSeconds)
}
@Test
fun loginRejectsAnEmptyPasswordBeforeAnyNetworkIo() = runTest {
val error = runCatching { client.login("") }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.InvalidRequest, error)
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty password")
}
@Test
fun loginSurfacesA401AsHttpWithTheServerCode() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/auth/login",
status = 401,
body = """{"error":"rejected"}""".toByteArray(),
)
val error = runCatching { client.login("wrong") }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
}
// ── enroll ───────────────────────────────────────────────────────────────────────────────
@Test
fun enrollBuildsABearerAuthenticatedPostWithTheContractBody() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse(),
)
val csr = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
client.enroll(BEARER, csr, subdomain = "alice", deviceName = "Alice Pixel")
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/device/enroll", request.url)
assertEquals("Bearer $BEARER", request.headers["Authorization"])
assertEquals("application/json", request.headers["Content-Type"])
val obj = bodyObject(request)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
assertEquals("ec-p256", obj["keyAlg"]!!.jsonPrimitive.content)
assertEquals("alice", obj["subdomain"]!!.jsonPrimitive.content)
assertEquals("Alice Pixel", obj["deviceName"]!!.jsonPrimitive.content)
assertFalse(obj.containsKey("attestation"), "attestation is omitted when not provided")
}
@Test
fun enrollForwardsAttestationWhenProvided() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
client.enroll(BEARER, byteArrayOf(0x01), "a", "d", attestation = "attest-blob")
assertEquals("attest-blob", bodyObject(transport.recordedRequests.single())["attestation"]!!.jsonPrimitive.content)
}
@Test
fun enrollMapsA201IntoATypedResult() = runTest {
val cert = byteArrayOf(0x30, 0x82.toByte(), 0x01, 0x23)
val ca = byteArrayOf(0x30, 0x82.toByte(), 0x02, 0x00)
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
body = enrollResponse(deviceId = "dev-xyz", cert = cert, caChain = listOf(ca)),
)
val result = client.enroll(BEARER, byteArrayOf(0x01), "alice", "Pixel")
assertEquals("dev-xyz", result.deviceId)
assertArrayEquals(cert, result.certificate)
assertEquals(1, result.caChain.size)
assertArrayEquals(ca, result.caChain.single())
assertTrue(result.renewAfter!!.isBefore(result.notAfter))
}
@Test
fun enrollRejectsEmptyRequiredFieldsBeforeAnyNetworkIo() = runTest {
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll("", byteArrayOf(1), "a", "d") }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, ByteArray(0), "a", "d") }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "", "d") }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "") }.exceptionOrNull())
assertTrue(transport.recordedRequests.isEmpty())
}
@Test
fun enrollSurfacesA403SubdomainNotOwnedWithTheServerCode() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 403,
body = """{"error":"rejected"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.Http(403, "rejected"),
runCatching { client.enroll(BEARER, byteArrayOf(1), "bob", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollSurfacesA429RateLimited() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 429,
body = """{"error":"rate_limited"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.Http(429, "rate_limited"),
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollThrowsMalformedResponseOnANonJson201Body() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = "not json".toByteArray())
assertEquals(
DeviceEnrollmentError.MalformedResponse,
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollThrowsMalformedResponseWhenTheCertIsNotValidBase64() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
body = """{"deviceId":"d","cert":"@@not-base64@@","caChain":[]}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.MalformedResponse,
runCatching { client.enroll(BEARER, byteArrayOf(1), "a", "d") }.exceptionOrNull(),
)
}
@Test
fun enrollDegradesAbsentDatesToNull() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201,
body = """{"deviceId":"d","cert":"MAEC","caChain":[]}""".toByteArray(),
)
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
assertNull(result.notAfter)
assertNull(result.renewAfter)
assertFalse(result.isRenewalDue(Instant.parse("2030-01-01T00:00:00Z")), "absent renewAfter never triggers")
}
// ── renew (silent rotation seam — mTLS-only, NO bearer) ─────────────────────────────────────
@Test
fun renewTargetsDeviceIdRenewWithTheMinimalBodyAndNoAuthorizationHeader() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
)
val csr = byteArrayOf(0x02)
// Production renew passes NO bearer — the endpoint authenticates by the current device cert (mTLS).
val result = client.renew("dev-9", csr)
val request = transport.recordedRequests.single()
assertEquals("$BASE/device/dev-9/renew", request.url)
assertNull(request.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
val obj = bodyObject(request)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
// The server's /device/:id/renew authenticates by the presented mTLS device cert and its body
// schema is `{ csr }` ONLY (.strict()) — any enroll-only extra (keyAlg/subdomain/deviceName)
// is rejected. The renew wire body must therefore carry the single `csr` key and nothing else.
assertEquals(setOf("csr"), obj.keys, "renew body is {csr}-only — no keyAlg/subdomain/deviceName")
assertFalse(obj.containsKey("keyAlg"), "renew must not send the enroll-only keyAlg field")
assertFalse(obj.containsKey("subdomain"), "renew body carries no subdomain/deviceName")
assertEquals("dev-9", result.deviceId)
}
@Test
fun renewForwardsAnExplicitBearerWhenTheOptionalSeamIsUsed() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201, body = enrollResponse(deviceId = "dev-9"),
)
// The bearer is optional/absent by default; when a caller DOES pass one it rides as a header.
client.renew("dev-9", byteArrayOf(0x02), bearerToken = BEARER)
assertEquals("Bearer $BEARER", transport.recordedRequests.single().headers["Authorization"])
}
@Test
fun renewRejectsEmptyDeviceIdOrCsrBeforeAnyNetworkIo() = runTest {
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("", byteArrayOf(1)) }.exceptionOrNull())
assertEquals(DeviceEnrollmentError.InvalidRequest, runCatching { client.renew("d", ByteArray(0)) }.exceptionOrNull())
assertTrue(transport.recordedRequests.isEmpty())
}
// ── isRenewalDue seam ──────────────────────────────────────────────────────────────────────
@Test
fun isRenewalDueFlipsAtRenewAfter() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/device/enroll", status = 201, body = enrollResponse())
val result = client.enroll(BEARER, byteArrayOf(1), "a", "d")
assertFalse(result.isRenewalDue(Instant.parse("2026-09-04T00:00:00Z")))
assertTrue(result.isRenewalDue(Instant.parse("2026-09-06T00:00:00Z")))
}
private fun assertArrayEquals(expected: ByteArray, actual: ByteArray) =
org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual)
}

View File

@@ -0,0 +1,57 @@
package wang.yaojia.webterm.api.enroll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import java.math.BigInteger
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/** B4 · The X9.63 uncompressed-point encoder — the security-load-bearing SubjectPublicKeyInfo bytes. */
class EcPointEncodingTest {
@Test
fun encodesAGeneratedP256KeyAs65UncompressedBytesRoundTrippingToTheCoordinates() {
val kp = KeyPairGenerator.getInstance("EC").apply {
initialize(ECGenParameterSpec("secp256r1"))
}.generateKeyPair()
val pub = kp.public as ECPublicKey
val encoded = EcPointEncoding.x963(pub)
assertEquals(65, encoded.size, "0x04 || X(32) || Y(32)")
assertEquals(0x04, encoded[0].toInt() and 0xFF, "uncompressed-point prefix")
// The 32-byte big-endian halves must be exactly the affine coordinates.
val x = BigInteger(1, encoded.copyOfRange(1, 33))
val y = BigInteger(1, encoded.copyOfRange(33, 65))
assertEquals(pub.w.affineX, x)
assertEquals(pub.w.affineY, y)
}
@Test
fun leftPadsAShortCoordinateToTheFixedWidth() {
// A small value must be left-padded with leading zeros to exactly 32 bytes.
val padded = EcPointEncoding.toFixedLengthUnsigned(BigInteger.valueOf(1), 32)
assertEquals(32, padded.size)
assertEquals(1, padded[31].toInt())
assertEquals(0, padded[0].toInt())
}
@Test
fun dropsTheBigIntegerSignByteWhenPresent() {
// A value whose top bit is set carries a leading 0x00 sign byte in BigInteger.toByteArray();
// it must be dropped, not counted toward the width.
val highBit = BigInteger(1, ByteArray(32) { 0xFF.toByte() })
val encoded = EcPointEncoding.toFixedLengthUnsigned(highBit, 32)
assertEquals(32, encoded.size)
assertEquals(0xFF, encoded[0].toInt() and 0xFF)
}
@Test
fun rejectsACoordinateThatDoesNotFit() {
val tooBig = BigInteger.ONE.shiftLeft(256) // needs 33 bytes
assertThrows(IllegalArgumentException::class.java) {
EcPointEncoding.toFixedLengthUnsigned(tooBig, 32)
}
}
}

View File

@@ -0,0 +1,64 @@
package wang.yaojia.webterm.api.models
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
/**
* GitLogResult list-lossy decode (plan Phase A.2): a well-formed `{commits,truncated}` decodes; a
* commit missing `hash`/`at` is dropped while its siblings survive; `truncated` passes through; a
* subject-less commit still decodes (subject defaults to empty).
*/
class GitLogTest {
private fun decode(json: String): GitLogResult? =
LossyDecode.objectOrNull(json.toByteArray(), GitLogResult.serializer())
@Test
fun `decodes commits and truncated`() {
val json = """
{ "truncated": true, "commits": [
{ "hash":"abc123", "at": 1710000000000, "subject":"first" },
{ "hash":"def456", "at": 1710000005000, "subject":"second" }
] }
""".trimIndent()
val result = decode(json)!!
assertTrue(result.truncated)
assertEquals(2, result.commits.size)
assertEquals("abc123", result.commits[0].hash)
assertEquals(1710000000000L, result.commits[0].at)
assertEquals("first", result.commits[0].subject)
}
@Test
fun `drops a commit missing hash or at, keeping the rest`() {
val json = """
{ "truncated": false, "commits": [
{ "at": 1, "subject":"no hash" },
{ "hash":"keep", "at": 2, "subject":"kept" },
{ "hash":"noAt", "subject":"no at" }
] }
""".trimIndent()
val result = decode(json)!!
assertFalse(result.truncated)
assertEquals(1, result.commits.size)
assertEquals("keep", result.commits.single().hash)
}
@Test
fun `a subject-less commit still decodes with an empty subject`() {
val result = decode("""{ "commits":[ { "hash":"h", "at": 5 } ] }""")!!
assertEquals(1, result.commits.size)
assertEquals("", result.commits.single().subject)
assertFalse(result.truncated) // default
}
@Test
fun `a non-object body degrades to null`() {
org.junit.jupiter.api.Assertions.assertNull(decode("[]"))
org.junit.jupiter.api.Assertions.assertNull(decode("garbage"))
}
}

View File

@@ -0,0 +1,71 @@
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
/**
* GitWrite payload + error decode (plan Phase A.3): each 200 payload decodes; a failure body
* `{ok:false,error:"…"}` (git-ops) and `{error:"…"}` (worktrees) both yield the SAFE `error` string;
* a garbled 200 body degrades to the payload defaults (never throws). The empty-sha commit case is
* exercised (server can return `{ok:true, commit:""}`).
*/
class GitWriteTest {
@Test
fun `stage payload decodes staged and count`() {
val r = decodeGitPayload("""{"ok":true,"staged":true,"count":3}""".toByteArray(), StageResult.serializer())
assertEquals(StageResult(staged = true, count = 3), r)
}
@Test
fun `commit payload decodes the sha and tolerates an empty sha`() {
assertEquals("a1b2c3", decodeGitPayload("""{"ok":true,"commit":"a1b2c3"}""".toByteArray(), CommitResult.serializer()).commit)
assertEquals("", decodeGitPayload("""{"ok":true,"commit":""}""".toByteArray(), CommitResult.serializer()).commit)
}
@Test
fun `push payload decodes branch and remote`() {
val r = decodeGitPayload("""{"ok":true,"branch":"main","remote":"origin"}""".toByteArray(), PushResult.serializer())
assertEquals("main", r.branch)
assertEquals("origin", r.remote)
}
@Test
fun `worktree create and remove and prune payloads decode`() {
val create = decodeGitPayload("""{"ok":true,"path":"/wt/x","branch":"feat"}""".toByteArray(), CreateWorktreeResult.serializer())
assertEquals("/wt/x", create.path)
assertEquals("feat", create.branch)
val remove = decodeGitPayload("""{"ok":true,"path":"/wt/x"}""".toByteArray(), RemoveWorktreeResult.serializer())
assertEquals("/wt/x", remove.path)
val prune = decodeGitPayload("""{"ok":true,"pruned":["a","b"]}""".toByteArray(), PruneWorktreesResult.serializer())
assertEquals(listOf("a", "b"), prune.pruned)
}
@Test
fun `a garbled 200 body degrades to payload defaults, never throwing`() {
assertEquals(StageResult(), decodeGitPayload("not json".toByteArray(), StageResult.serializer()))
assertEquals(CommitResult(), decodeGitPayload("[]".toByteArray(), CommitResult.serializer()))
assertTrue(decodeGitPayload("{}".toByteArray(), PruneWorktreesResult.serializer()).pruned.isEmpty())
}
@Test
fun `a git-ops failure body yields the safe error string`() {
assertEquals("Nothing to commit.", decodeGitError("""{"ok":false,"error":"Nothing to commit."}""".toByteArray()))
}
@Test
fun `a worktree failure body (no ok field) still yields the error string`() {
assertEquals("Worktree creation is disabled.", decodeGitError("""{"error":"Worktree creation is disabled."}""".toByteArray()))
}
@Test
fun `an empty or errorless failure body yields null`() {
assertNull(decodeGitError(ByteArray(0)))
assertNull(decodeGitError("""{"ok":false}""".toByteArray()))
assertNull(decodeGitError("not json".toByteArray()))
}
}

View File

@@ -0,0 +1,77 @@
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.Test
/**
* PrStatus tolerant decode (plan Phase A.1): a full `availability:"ok"` body decodes every field; an
* unknown/missing `availability` degrades to [PrAvailability.ERROR] (never throws); `PrCheckSummary`
* counts round-trip; a non-object body degrades rather than crashing. Mirrors the FE never treating a
* non-`ok` availability as an HTTP error.
*/
class PrStatusTest {
private fun decode(json: String): PrStatus? =
LossyDecode.objectOrNull(json.toByteArray(), PrStatus.serializer())
@Test
fun `decodes a full ok body with all fields and check counts`() {
val json = """
{ "availability":"ok", "number":42, "title":"Add worktrees", "url":"https://x/pull/42",
"state":"open", "isDraft":false, "mergeable":"mergeable",
"headRefName":"feat/wt", "baseRefName":"main",
"checks": { "total":5, "passing":3, "failing":1, "pending":1 } }
""".trimIndent()
val pr = decode(json)!!
assertEquals(PrAvailability.OK, pr.availability)
assertEquals(42, pr.number)
assertEquals("Add worktrees", pr.title)
assertEquals("https://x/pull/42", pr.url)
assertEquals("open", pr.state)
assertEquals(false, pr.isDraft)
assertEquals("mergeable", pr.mergeable)
assertEquals("feat/wt", pr.headRefName)
assertEquals("main", pr.baseRefName)
assertEquals(PrCheckSummary(total = 5, passing = 3, failing = 1, pending = 1), pr.checks)
}
@Test
fun `an unknown availability degrades to ERROR, never throwing`() {
val pr = decode("""{ "availability":"quantum-flux" }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
}
@Test
fun `a body missing availability defaults to ERROR and leaves optional fields null`() {
val pr = decode("""{ "number":7 }""")!!
assertEquals(PrAvailability.ERROR, pr.availability)
assertEquals(7, pr.number)
assertNull(pr.title)
assertNull(pr.checks)
}
@Test
fun `each known availability maps from its wire value`() {
assertEquals(PrAvailability.NO_PR, PrAvailability.fromWire("no-pr"))
assertEquals(PrAvailability.NOT_INSTALLED, PrAvailability.fromWire("not-installed"))
assertEquals(PrAvailability.UNAUTHENTICATED, PrAvailability.fromWire("unauthenticated"))
assertEquals(PrAvailability.DISABLED, PrAvailability.fromWire("disabled"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("error"))
assertEquals(PrAvailability.ERROR, PrAvailability.fromWire("")) // empty → ERROR
}
@Test
fun `a non-object body degrades to null rather than throwing`() {
assertNull(decode("[]"))
assertNull(decode("not json"))
assertNull(LossyDecode.objectOrNull(ByteArray(0), PrStatus.serializer()))
}
@Test
fun `unknown top-level keys are ignored`() {
val pr = decode("""{ "availability":"ok", "futureField":123, "nested":{"a":1} }""")!!
assertEquals(PrAvailability.OK, pr.availability)
}
}

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,112 @@
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.GitWriteOutcome
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* Status-code → outcome mapping for the W5 git surface (plan Phase A.5): PR 200/400/404; log
* decode + errors; each guarded write 200→Ok, 403→Rejected(body.error), 409→Rejected, 429→
* RateLimited. Also asserts the transport RECEIVED an Origin on writes and NOT on reads.
*/
class ApiClientGitTest {
private companion object {
const val BASE = "http://h:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
// ── PR (RO; degrade lives in the 200 body, not the status) ───────────────────────────────
@Test
fun `projectPr decodes a 200 degrade body and maps 400 404`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"not-installed"}""".toByteArray())
assertEquals(PrAvailability.NOT_INSTALLED, client.projectPr("/r").availability)
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 400)
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("/r") })
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectPr("/r") })
// Empty path is rejected before any I/O.
assertEquals(ApiClientError.ProjectPathInvalid, errorOf { client.projectPr("") })
}
@Test
fun `projectPr never treats a garbled 200 body as an error (degrades to ERROR)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = "not json".toByteArray())
assertEquals(PrAvailability.ERROR, client.projectPr("/r").availability)
}
// ── log ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `projectLog decodes 200 and maps 404 and 500`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/log?path=%2Fr",
body = """{"commits":[{"hash":"h","at":1,"subject":"s"}],"truncated":false}""".toByteArray(),
)
assertEquals(1, client.projectLog("/r").commits.size)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 404)
assertEquals(ApiClientError.ProjectNotFound, errorOf { client.projectLog("/r") })
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", status = 500)
assertEquals(ApiClientError.GitLogUnavailable, errorOf { client.projectLog("/r") })
}
// ── guarded writes: outcome mapping ──────────────────────────────────────────────────────
@Test
fun `a guarded write 200 yields Ok with the decoded payload`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"abc"}""".toByteArray())
val outcome = client.gitCommit("/r", "msg")
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals("abc", (outcome as GitWriteOutcome.Ok).payload.commit)
// The write stamped an Origin.
assertTrue(transport.recordedRequests.last().headers.containsKey(HeaderName.ORIGIN))
}
@Test
fun `403 disabled and 409 both surface Rejected with the safe error string`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/projects/worktree",
status = 403, body = """{"error":"Worktree creation is disabled."}""".toByteArray(),
)
val disabled = client.createWorktree("/r", "b", null)
assertEquals(GitWriteOutcome.Rejected(403, "Worktree creation is disabled."), disabled)
transport.queueSuccess(
method = HttpMethod.DELETE, url = "$BASE/projects/worktree",
status = 409, body = """{"error":"Worktree has uncommitted changes; force required."}""".toByteArray(),
)
val dirty = client.removeWorktree("/r", "/r/x", false)
assertEquals(GitWriteOutcome.Rejected(409, "Worktree has uncommitted changes; force required."), dirty)
}
@Test
fun `429 yields RateLimited and never auto-retries`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 429, body = """{"error":"Too many requests."}""".toByteArray())
assertEquals(GitWriteOutcome.RateLimited, client.gitPush("/r"))
assertEquals(1, transport.recordedRequests.size) // exactly one attempt
}
@Test
fun `a stage 200 decodes staged and count and threads the files body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true,"staged":true,"count":2}""".toByteArray())
val outcome = client.gitStage("/r", listOf("a", "b"), stage = true)
assertTrue(outcome is GitWriteOutcome.Ok)
assertEquals(2, (outcome as GitWriteOutcome.Ok).payload.count)
assertEquals("""{"path":"/r","files":["a","b"],"stage":true}""", transport.recordedRequests.last().body?.decodeToString())
}
}

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,161 @@
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.assertNotNull
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 wang.yaojia.webterm.wire.HttpRequest
import java.util.UUID
/**
* Request-shape + Origin-iff-guarded (plan §4.3 铁律) for the W5 git surface: the two NEW reads
* (`/projects/pr`, `/projects/log`) carry **no** Origin; the six writes (worktree×3, git×3) carry a
* byte-equal Origin and a JSON body — including a `DELETE /projects/worktree` that carries a body
* (the highest-risk integration gotcha). A route reclassified read↔write turns this red.
*/
class GitRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private fun last(): HttpRequest = transport.recordedRequests.last()
private fun assertGuarded(r: HttpRequest) =
assertEquals(ORIGIN, r.headers[HeaderName.ORIGIN], "guarded write must stamp byte-equal Origin")
private fun assertReadOnly(r: HttpRequest) =
assertFalse(r.headers.containsKey(HeaderName.ORIGIN), "read-only route must NOT stamp Origin")
// ── reads: no Origin, correct verb + strict-encoded query ────────────────────────────────
@Test
fun `projectPr is a read-only GET with a strict-encoded path and no Origin`() = runTest {
val path = "/home/me/my repo/a+b&c"
val url = "$BASE/projects/pr?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c"
transport.queueSuccess(url = url, body = """{"availability":"no-pr"}""".toByteArray())
client.projectPr(path)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertEquals(url, r.url)
assertReadOnly(r)
}
@Test
fun `projectLog omits n when null and appends a clamped n when set`() = runTest {
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = null)
assertEquals("$BASE/projects/log?path=%2Fp", last().url)
assertReadOnly(last())
// n above GIT_LOG_MAX (50) clamps to 50; below 1 clamps to 1.
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=50", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 999)
assertEquals("$BASE/projects/log?path=%2Fp&n=50", last().url)
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fp&n=1", body = """{"commits":[],"truncated":false}""".toByteArray())
client.projectLog("/p", n = 0)
assertEquals("$BASE/projects/log?path=%2Fp&n=1", last().url)
}
// ── writes: Origin stamped, correct verb, JSON body ──────────────────────────────────────
@Test
fun `createWorktree is a guarded POST with a path-branch-base body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = "main")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
assertEquals("""{"path":"/repo","branch":"feat/x","base":"main"}""", r.body?.decodeToString())
}
@Test
fun `createWorktree omits base when null`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.createWorktree("/repo", "feat/x", base = null)
assertEquals("""{"path":"/repo","branch":"feat/x"}""", last().body?.decodeToString())
}
@Test
fun `removeWorktree is a guarded DELETE that CARRIES a JSON body`() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
client.removeWorktree("/repo", "/repo-worktrees/x", force = true)
val r = last()
assertEquals(HttpMethod.DELETE, r.method)
assertEquals("$BASE/projects/worktree", r.url)
assertGuarded(r)
assertNotNull(r.body, "DELETE /projects/worktree MUST carry a request body")
assertEquals("""{"path":"/repo","worktreePath":"/repo-worktrees/x","force":true}""", r.body?.decodeToString())
}
@Test
fun `pruneWorktrees is a guarded POST with a path body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true,"pruned":[]}""".toByteArray())
client.pruneWorktrees("/repo")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals("$BASE/projects/worktree/prune", r.url)
assertGuarded(r)
assertEquals("""{"path":"/repo"}""", r.body?.decodeToString())
}
@Test
fun `gitStage commit push are guarded POSTs with exact bodies`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
client.gitStage("/repo", listOf("a.kt", "b.kt"), stage = true)
assertEquals("$BASE/projects/git/stage", last().url)
assertGuarded(last())
assertEquals("""{"path":"/repo","files":["a.kt","b.kt"],"stage":true}""", last().body?.decodeToString())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true,"commit":"x"}""".toByteArray())
client.gitCommit("/repo", "a message")
assertEquals("""{"path":"/repo","message":"a message"}""", last().body?.decodeToString())
assertGuarded(last())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.gitPush("/repo")
assertEquals("$BASE/projects/git/push", last().url)
assertEquals("""{"path":"/repo"}""", last().body?.decodeToString())
assertGuarded(last())
}
@Test
fun `every guarded write carries Origin and every read does not (batch invariant)`() = runTest {
transport.queueSuccess(url = "$BASE/projects/pr?path=%2Fr", body = """{"availability":"ok"}""".toByteArray())
transport.queueSuccess(url = "$BASE/projects/log?path=%2Fr", body = """{"commits":[],"truncated":false}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree/prune", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", body = """{"ok":true}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", body = """{"ok":true}""".toByteArray())
client.projectPr("/r"); client.projectLog("/r", null)
assertReadOnly(transport.recordedRequests[0])
assertReadOnly(transport.recordedRequests[1])
client.createWorktree("/r", "b", null)
client.removeWorktree("/r", "/r/x", false)
client.pruneWorktrees("/r")
client.gitStage("/r", listOf("f"), true)
client.gitCommit("/r", "m")
client.gitPush("/r")
assertTrue(transport.recordedRequests.drop(2).all { it.headers.containsKey(HeaderName.ORIGIN) })
}
}

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

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