Compare commits

...

41 Commits

Author SHA1 Message Date
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
201 changed files with 25416 additions and 347 deletions

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.
`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)
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.
- **⌂ 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
- **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).
@@ -113,7 +121,7 @@ This wires Claude Code's hooks → **live per-tab status**, the **statusLine gau
### Tests
```bash
npm test # vitest, all modules (~1470 tests, 80% coverage gate)
npm test # vitest, all modules (~1600 tests, 80% coverage gate)
npm run typecheck # tsc (backend + frontend)
npm run build # compile backend to dist/
```
@@ -200,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.
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).

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

View File

@@ -23,6 +23,7 @@ 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,
@@ -98,7 +99,7 @@ async function enrollNative(
id: AgentIdentity,
ks: Keystore,
): Promise<NativeEnrollResult> {
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks)
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
}
@@ -156,9 +157,12 @@ export function readFrpcLog(stateDir: string): string {
}
/**
* Native run-loop (B4/H4): supervise the pinned frpc child with restart-on-exit backoff while a
* 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). Resolves when the supervisor stops (SIGTERM/SIGINT).
* 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')
@@ -184,12 +188,28 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
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())
return handle.done.finally(() => {
monitor.stop()
autoRenew?.stop()
})
}
/** Build the concrete CliDeps used by the real CLI entrypoint. */

View File

@@ -15,6 +15,7 @@ import type { EnrollResult } from 'relay-contracts'
import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js'
import { buildCsr } from './csr.js'
import { derBase64ToPem } from '../certs/pem.js'
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
export type EnrollMode = 'token' | 'ed25519'
@@ -53,6 +54,12 @@ export interface RedeemOptions {
readonly agentToken?: string
readonly unwrapContentSecret?: UnwrapContentSecret
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 {
@@ -63,11 +70,33 @@ interface EnrollResponseJson {
hostContentSecret: string // base64url over the wire
}
function parseEnrollResult(json: unknown): EnrollResult {
function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
const j = json as Partial<EnrollResponseJson>
if (typeof j.hostContentSecret !== 'string') {
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 = {
hostId: j.hostId,
subdomain: j.subdomain,
@@ -128,10 +157,13 @@ export async function redeemPairingCode(
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)
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
if (enroll.hostContentSecret.length > 0) {
const unwrapped = unwrap(enroll.hostContentSecret, id)
ks.saveContentSecret(unwrapped)
}
return enroll
}

View File

@@ -11,6 +11,7 @@
* - 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 {
agentLabel,
@@ -202,13 +203,37 @@ export async function installService(
// so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath()
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
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, baseAppEnv, baseAppLabel()))
deps.writeFile(
baseAppPath,
buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel(), join(cfg.stateDir, 'base-app.log')),
)
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, 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)
@@ -217,8 +242,8 @@ export async function installService(
}
const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile }
: { env: baseAppEnv }
? { env: baseAppEnvWithPath, envFile: options.envFile }
: { env: baseAppEnvWithPath }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile(
baseAppPath,
@@ -227,7 +252,7 @@ export async function installService(
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile(
agentPath,
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
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)

View File

@@ -78,6 +78,7 @@ export function buildLaunchdPlist(
programArguments: readonly string[],
env: ServiceEnv = {},
label: string = AGENT_LABEL,
logPath?: string,
): string {
return [
'<?xml version="1.0" encoding="UTF-8"?>',
@@ -91,6 +92,16 @@ export function buildLaunchdPlist(
' <true/>',
' <key>KeepAlive</key>',
' <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>',
'</plist>',

View File

@@ -25,7 +25,13 @@ export class CertExpiredError extends Error {
export interface TlsClientOptions {
readonly cert: 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
}

View File

@@ -62,6 +62,12 @@ export interface FrpSuperviseHandle {
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. */
@@ -196,5 +202,8 @@ export function superviseFrpc(
},
done,
isChildAlive: () => child?.isAlive() ?? false,
restartChild(): void {
if (!stopped) child?.kill()
},
}
}

View File

@@ -145,4 +145,44 @@ describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
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

@@ -81,8 +81,10 @@ describe('installService — two distinct units (FIX M-host-2service)', () => {
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
// agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
// that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
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=')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('web-terminal-agent run')

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

@@ -11,6 +11,7 @@ import {
renewCert,
renewalUrlFor,
} from '../src/certs/rotation.js'
import { createBackoff } from '../src/transport/backoff.js'
import { FakeTimer } from './fixtures/fakes.js'
const CFG: AgentConfig = {
@@ -52,10 +53,10 @@ describe('renewCert (T13)', () => {
it('installs a fresh cert atomically on success (same key)', async () => {
const { dir, ks } = enrolledKs()
const before = ks.loadIdentity()!.publicKey
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' }))
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
expect(out).toBe('rotated')
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' })
expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
// pubkey unchanged — only the cert rotated
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
rmSync(dir, { recursive: true, force: true })
@@ -101,7 +102,7 @@ describe('createCertRotator (T13)', () => {
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
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),
parseCert: () => new Date(2000),
})
@@ -113,7 +114,47 @@ describe('createCertRotator (T13)', () => {
timer.advance(1000)
await flush()
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()
rmSync(dir, { recursive: true, force: true })
})

View File

@@ -66,6 +66,25 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] 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.

View File

@@ -9,22 +9,20 @@ This directory is a **Gradle multi-module** project. The module set mirrors the
SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1).
## ⚠️ No-SDK constraint (why only 5 modules build here)
## Build environment (SDK installed — all modules build)
The current build environment has **no Android SDK**. Everything that can be pure
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
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.
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):**
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`.
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts)
(dirs + a `build.gradle.kts` stub exist, marked `// TODO(android-sdk)`):
- **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`.
To bring the Android modules online later: install an SDK, add
`local.properties``sdk.dir`, add the Android Gradle Plugin + `google()` to
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in
each stub.
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)
@@ -35,10 +33,10 @@ each stub.
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated |
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated |
| 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
@@ -47,16 +45,16 @@ each stub.
### Dependency graph (arrows = "depends on")
```
:app (SDK-gated)
:app
┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated)
│ │
│ │ │ ▼
│ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet)
:wire-protocol ◀──────────── :transport-okhttp
└──────── :test-support → test source sets only
```

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

@@ -34,6 +34,12 @@ public data class ProjectInfo(
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(),
)

View File

@@ -1,13 +1,24 @@
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
@@ -87,6 +98,43 @@ public class ApiClient(
}
}
/**
* `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 {
@@ -132,6 +180,50 @@ public class ApiClient(
}
}
// ── 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) {

View File

@@ -44,6 +44,9 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 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

@@ -14,6 +14,10 @@ internal object HttpStatus {
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). */

View File

@@ -57,6 +57,28 @@ internal object Endpoints {
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 =
@@ -92,6 +114,58 @@ internal object Endpoints {
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
@@ -124,4 +198,22 @@ internal object Endpoints {
@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,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,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,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

@@ -11,8 +11,11 @@ import okhttp3.OkHttpClient
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.tlsandroid.TinkCertStore
import wang.yaojia.webterm.tlsandroid.TinkEnrollmentRecordStore
import javax.inject.Singleton
/**
@@ -41,16 +44,38 @@ public object TlsModule {
@Singleton
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
/** The Tink-AEAD-encrypted enrollment record (deviceId + key alias) the zero-`.p12` renew path reads. */
@Provides
@Singleton
public fun provideIdentityRepository(
public fun provideEnrollmentRecordStore(
@ApplicationContext context: Context,
): EnrollmentRecordStore = TinkEnrollmentRecordStore(context)
/**
* The ONE mTLS device-identity repository, provided as the concrete type so BOTH the
* [IdentityRepository] surface (import/rotate/remove/summary) and the narrow [IdentityCacheRefresher]
* seam (B4 enroll cache-freshness) resolve to the SAME singleton instance.
*/
@Provides
@Singleton
public fun provideAndroidIdentityRepository(
importer: AndroidKeyStoreImporter,
certStore: CertStore,
connectionPool: ConnectionPool,
): IdentityRepository {
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s
): AndroidIdentityRepository {
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()/refresh's
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
return AndroidIdentityRepository(importer, certStore, evictClient)
}
@Provides
@Singleton
public fun provideIdentityRepository(repository: AndroidIdentityRepository): IdentityRepository = repository
/** FIX 3: the enroll/renew commit refreshes THIS same repository's in-memory cache (no restart). */
@Provides
@Singleton
public fun provideIdentityCacheRefresher(repository: AndroidIdentityRepository): IdentityCacheRefresher =
repository
}

View File

@@ -67,6 +67,8 @@ public fun WebTermNavHost(
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
composable(NavRoutes.ENROLL) { EnrollmentPane(env = env, onBack = { navController.popBackStack() }) }
composable(
route = TERMINAL_ROUTE,
arguments = listOf(
@@ -211,6 +213,9 @@ public object NavRoutes {
/** Device-certificate management (A27). */
public const val CERT: String = "cert"
/** Zero-`.p12` device enrollment (B4) — obtains a hardware-bound cert with no file. */
public const val ENROLL: String = "enroll"
/** Terminal route pattern with the required host + session path args (A21). */
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"

View File

@@ -20,10 +20,13 @@ import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.ClientCertScreen
import wang.yaojia.webterm.screens.DiffScreen
import wang.yaojia.webterm.screens.EnrollmentScreen
import wang.yaojia.webterm.screens.PairingScreen
import wang.yaojia.webterm.screens.ProjectDetailScreen
import wang.yaojia.webterm.viewmodels.ApiClientGitWriteGateway
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
import wang.yaojia.webterm.viewmodels.PairingViewModel
@@ -74,6 +77,38 @@ public fun ClientCertPane(
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
}
// ── Device enrollment (B4, zero-.p12 auto-cert) ─────────────────────────────────────────────────────
/**
* Hosts [EnrollmentScreen] over a fresh [EnrollmentViewModel] (mirrors iOS `AppCoordinator`'s
* `makeEnrollmentViewModel`). The enroll flow and the installed-summary read run OFF `Main`
* (`Dispatchers.IO`) — resolving the enroller resolves the lazy shared client (keystore/TLS I/O) and the
* enroll itself does hardware-keygen + network. The typed control-plane URL builds the enroller per attempt.
*/
@Composable
public fun EnrollmentPane(
env: AppEnvironment,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) {
EnrollmentViewModel(
enrollOperation = { password, subdomain, deviceName, controlPlaneUrl ->
withContext(Dispatchers.IO) {
env.enrollmentFlowFactory.create(controlPlaneUrl).enroll(password, subdomain, deviceName)
}
},
loadSummary = {
withContext(Dispatchers.IO) {
runCatching { env.identityRepository.currentSummary() }.getOrNull()
}
},
defaultDeviceName = android.os.Build.MODEL ?: "",
)
}
EnrollmentScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
}
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
/**
@@ -106,6 +141,7 @@ public fun ProjectDetailPane(
path = path,
onBack = { navController.popBackStack() },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
modifier = modifier,
)
}
@@ -118,9 +154,10 @@ public fun ProjectDetailContent(
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) {
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier, onViewDiff = onViewDiff)
}
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
@@ -151,7 +188,12 @@ public fun DiffPane(
return
}
val viewModel = remember(resolved, path) {
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
DiffViewModel(
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
path = path,
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
)
}
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
}

View File

@@ -83,6 +83,7 @@ public fun ProjectsHome(
path = path,
onBack = { selectedPath = null },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
onViewDiff = { diffPath -> navController.navigate(diffRoute(resolved.id, diffPath)) },
)
} else {
DetailPlaceholder("选择一个项目查看详情。")

View File

@@ -87,6 +87,7 @@ public fun SessionsHome(
onOpenSession = openSession,
onNewSession = openNewSession,
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
onEnroll = { navController.navigate(NavRoutes.ENROLL) },
onImportCert = { navController.navigate(NavRoutes.CERT) },
)
}

View File

@@ -15,13 +15,17 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -44,15 +48,17 @@ import wang.yaojia.webterm.viewmodels.DiffPhase
import wang.yaojia.webterm.viewmodels.DiffRow
import wang.yaojia.webterm.viewmodels.DiffUiState
import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.DiffWriteBanner
/**
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer.
* # DiffScreen (A24 + W5) — the git-diff viewer with base-compare + git-write.
*
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
* toggle (hidden in base mode), a **base-rev** input (a third mode), per-file **Stage/Unstage** buttons
* (working/staged mode only), a **commit** message field + **Commit** / **Push** buttons, and a result
* **banner**. Every server-derived string (paths, hunk headers, code lines, git error messages) is
* rendered as **inert [Text]** — never `ClickableText`/autolink/markdown (plan §8). Interaction is
* device-QA (plan §7).
*/
@Composable
public fun DiffScreen(
@@ -61,29 +67,37 @@ public fun DiffScreen(
modifier: Modifier = Modifier,
onRefresh: () -> Unit = {},
onBack: (() -> Unit)? = null,
onSetBase: (String?) -> Unit = {},
onToggleStage: (String, Boolean) -> Unit = { _, _ -> },
onCommit: (String) -> Unit = {},
onPush: () -> Unit = {},
onDismissBanner: () -> Unit = {},
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
if (state.truncated) {
DiffNotice("Diff truncated — too large to display fully.")
}
DiffHeader(state = state, onSelectStaged = onSelectStaged, onBack = onBack, onSetBase = onSetBase)
if (state.truncated) DiffNotice("Diff truncated — too large to display fully.")
state.writeBanner?.let { WriteBanner(it, onDismissBanner) }
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
when (state.phase) {
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
DiffPhase.EMPTY -> CenteredMessage("No changes")
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
DiffPhase.LOADED -> DiffList(rows = state.rows)
DiffPhase.LOADED -> DiffList(rows = state.rows, writeEnabled = state.writeEnabled, staged = state.staged, onToggleStage = onToggleStage)
}
}
if (state.writeEnabled) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
CommitBar(writing = state.writing, onCommit = onCommit, onPush = onPush)
}
}
}
}
/**
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path).
* toggle/refresh/base/git-write callbacks. The nav layer supplies the already-constructed presenter.
*/
@Composable
public fun DiffScreen(
@@ -92,7 +106,6 @@ public fun DiffScreen(
onBack: (() -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
LaunchedEffect(viewModel) { viewModel.bind(this) }
DiffScreen(
state = state,
@@ -100,50 +113,60 @@ public fun DiffScreen(
modifier = modifier,
onRefresh = viewModel::refresh,
onBack = onBack,
onSetBase = viewModel::setBase,
onToggleStage = viewModel::toggleStage,
onCommit = viewModel::commit,
onPush = viewModel::push,
onDismissBanner = viewModel::clearWriteBanner,
)
}
@Composable
private fun DiffHeader(
staged: Boolean,
state: DiffUiState,
onSelectStaged: (Boolean) -> Unit,
onBack: (() -> Unit)?,
onSetBase: (String?) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (onBack != null) {
TextButton(onClick = onBack) { Text("Back") }
}
Text(
text = "Diff",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
var baseInput by remember(state.base) { mutableStateOf(state.base ?: "") }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
if (onBack != null) TextButton(onClick = onBack) { Text("Back") }
Text(text = "Diff", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground)
Spacer(modifier = Modifier.width(Spacing.sm8))
FilterChip(
selected = !staged,
onClick = { onSelectStaged(false) },
label = { Text("Working") },
)
FilterChip(
selected = staged,
onClick = { onSelectStaged(true) },
label = { Text("Staged") },
if (state.base == null) {
// Working/Staged toggle is suppressed in base mode (server ignores staged then).
FilterChip(selected = !state.staged, onClick = { onSelectStaged(false) }, label = { Text("Working") })
FilterChip(selected = state.staged, onClick = { onSelectStaged(true) }, label = { Text("Staged") })
} else {
Text(text = "vs ${state.base}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(top = Spacing.xs4)) {
OutlinedTextField(
value = baseInput,
onValueChange = { baseInput = it },
label = { Text("对比基点 (base rev)") },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = { onSetBase(baseInput.takeIf { it.isNotBlank() }) }) { Text("对比") }
if (state.base != null) OutlinedButton(onClick = { baseInput = ""; onSetBase(null) }) { Text("清除") }
}
}
}
@Composable
private fun DiffList(rows: List<DiffRow>) {
private fun DiffList(
rows: List<DiffRow>,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { row ->
when (row) {
is DiffFileHeaderRow -> FileHeader(row)
is DiffFileHeaderRow -> FileHeader(row, writeEnabled = writeEnabled, staged = staged, onToggleStage = onToggleStage)
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
@@ -153,11 +176,14 @@ private fun DiffList(rows: List<DiffRow>) {
}
@Composable
private fun FileHeader(row: DiffFileHeaderRow) {
private fun FileHeader(
row: DiffFileHeaderRow,
writeEnabled: Boolean,
staged: Boolean,
onToggleStage: (String, Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -172,6 +198,42 @@ private fun FileHeader(row: DiffFileHeaderRow) {
)
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
if (writeEnabled) {
// In staged view we offer Unstage; in working view we offer Stage.
TextButton(onClick = { onToggleStage(row.stagePath, !staged) }) { Text(if (staged) "取消暂存" else "暂存") }
}
}
}
@Composable
private fun CommitBar(writing: Boolean, onCommit: (String) -> Unit, onPush: () -> Unit) {
var message by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = message,
onValueChange = { message = it },
label = { Text("提交信息") },
singleLine = true,
enabled = !writing,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(enabled = !writing, onClick = { onCommit(message); message = "" }) { Text("提交") }
OutlinedButton(enabled = !writing, onClick = onPush) { Text("推送") }
}
}
}
@Composable
private fun WriteBanner(banner: DiffWriteBanner, onDismiss: () -> Unit) {
val color = if (banner.isError) WebTermColors.statusStuck else WebTermColors.statusWorking
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(text = banner.message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("×") }
}
}
@@ -185,9 +247,7 @@ private fun DiffText(text: String, color: Color) {
softWrap = false,
maxLines = 1,
overflow = TextOverflow.Clip,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = 1.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = 1.dp),
)
}
@@ -207,17 +267,13 @@ private fun DiffNotice(message: String) {
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
)
}
@Composable
private fun CenteredMessage(message: String) {
CenteredContent {
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
CenteredContent { Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
@Composable
@@ -246,17 +302,15 @@ private fun markerFor(kind: DiffLineKind): String = when (kind) {
@Composable
private fun DiffScreenPreview() {
val rows = listOf<DiffRow>(
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffFileHeaderRow(0, "src/app/Main.kt", "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
)
WebTermTheme {
DiffScreen(
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true),
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true, canWrite = true),
onSelectStaged = {},
)
}

View File

@@ -0,0 +1,385 @@
package wang.yaojia.webterm.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.CertSummaryView
import wang.yaojia.webterm.viewmodels.EnrollError
import wang.yaojia.webterm.viewmodels.EnrollPhase
import wang.yaojia.webterm.viewmodels.EnrollmentUiState
import wang.yaojia.webterm.viewmodels.EnrollmentViewModel
/**
* # EnrollmentScreen (B4) — zero-`.p12` device enrollment (the phone half of zero-touch).
*
* The Compose shell over [EnrollmentViewModel], reached from the session-list host menu "自动获取证书"
* (mirrors iOS `EnrollmentScreen` presented from `SessionListScreen`'s host menu). One operator login
* generates a NON-EXPORTABLE hardware key + CSR and obtains a device cert with no file at all; the cert is
* committed to the shared store and presented automatically on the existing mTLS path (cache-refreshed, so
* no restart). The manual `.p12` path ([ClientCertScreen]) remains available alongside this one.
*
* ### Secrets & trust discipline (plan §8)
* - The operator password is a masked field bound straight to the ViewModel and cleared after every
* attempt — never logged, persisted, or echoed. The private key is generated non-exportably in secure
* hardware inside the library; this screen never sees it.
* - Every cert-derived string (CNs, expiry) and every error message is inert [Text] — no autolink/markdown.
* Error copy is app-authored, never a server/exception string.
*
* The keystore/network I/O it drives is device-QA (plan §7); the state machine is JVM-tested in
* `EnrollmentViewModelTest`.
*/
@Composable
public fun EnrollmentScreen(
viewModel: EnrollmentViewModel,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(viewModel) { viewModel.bind(this) }
EnrollmentScreen(
state = state,
onControlPlaneUrlChange = viewModel::onControlPlaneUrlChange,
onSubdomainChange = viewModel::onSubdomainChange,
onDeviceNameChange = viewModel::onDeviceNameChange,
onPasswordChange = viewModel::onPasswordChange,
onEnroll = viewModel::enroll,
onDismissError = viewModel::clearError,
modifier = modifier,
onBack = onBack,
)
}
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the VM machinery. */
@Composable
public fun EnrollmentScreen(
state: EnrollmentUiState,
onControlPlaneUrlChange: (String) -> Unit,
onSubdomainChange: (String) -> Unit,
onDeviceNameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onEnroll: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
) {
Header(onBack = onBack)
if (state.phase == EnrollPhase.LOADING) {
LoadingRow()
return@Column
}
InstalledSection(summary = state.summary)
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
if (state.didSucceed && state.error == null) SuccessCard()
EnrollForm(
state = state,
onControlPlaneUrlChange = onControlPlaneUrlChange,
onSubdomainChange = onSubdomainChange,
onDeviceNameChange = onDeviceNameChange,
onPasswordChange = onPasswordChange,
onEnroll = onEnroll,
)
Text(
text = EnrollCopy.FOOTER,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun Header(onBack: (() -> Unit)?) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (onBack != null) {
TextButton(onClick = onBack) { Text("返回") }
}
Text(text = EnrollCopy.TITLE, style = MaterialTheme.typography.headlineSmall)
}
}
/** The currently-installed identity summary (or "none" copy) — the "已安装证书" section. */
@Composable
private fun InstalledSection(summary: CertSummaryView?) {
if (summary == null) {
Text(
text = EnrollCopy.NONE_INSTALLED,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
SummaryRow(label = "设备CN", value = summary.subjectCommonName)
SummaryRow(label = "签发方CN", value = summary.issuerCommonName)
SummaryRow(label = "到期", value = summary.expiry)
if (summary.isExpired) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Text(
text = "⚠ 证书已过期,请重新注册。",
style = MaterialTheme.typography.bodyMedium,
color = WebTermColors.statusStuck,
)
}
}
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
// Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8).
Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable
private fun EnrollForm(
state: EnrollmentUiState,
onControlPlaneUrlChange: (String) -> Unit,
onSubdomainChange: (String) -> Unit,
onDeviceNameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onEnroll: () -> Unit,
) {
val enrolling = state.phase == EnrollPhase.ENROLLING
Text(
text = if (state.summary == null) EnrollCopy.ENROLL_HEADER else EnrollCopy.ROTATE_HEADER,
style = MaterialTheme.typography.titleSmall,
)
OutlinedTextField(
value = state.controlPlaneUrl,
onValueChange = onControlPlaneUrlChange,
label = { Text(EnrollCopy.CONTROL_PLANE_URL) },
singleLine = true,
enabled = !enrolling,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.subdomain,
onValueChange = onSubdomainChange,
label = { Text(EnrollCopy.SUBDOMAIN) },
singleLine = true,
enabled = !enrolling,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.deviceName,
onValueChange = onDeviceNameChange,
label = { Text(EnrollCopy.DEVICE_NAME) },
singleLine = true,
enabled = !enrolling,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text(EnrollCopy.PASSWORD) },
singleLine = true,
enabled = !enrolling,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = onEnroll,
enabled = state.canEnroll,
modifier = Modifier.fillMaxWidth(),
) {
if (enrolling) {
CircularProgressIndicator(modifier = Modifier.padding(Spacing.xs4))
} else {
Text(if (state.summary == null) EnrollCopy.ENROLL_ACTION else EnrollCopy.ROTATE_ACTION)
}
}
}
@Composable
private fun ErrorCard(error: EnrollError, onDismiss: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
Text(
text = errorCopy(error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") }
}
}
}
@Composable
private fun SuccessCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Text(
text = EnrollCopy.SUCCESS,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.md12),
)
}
}
@Composable
private fun LoadingRow() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
/** Maps the coarse [EnrollError] to inert, app-authored copy (never an exception/server string, §8). */
private fun errorCopy(error: EnrollError): String = when (error) {
EnrollError.INVALID_URL -> "控制面地址无效,请输入 https:// 开头的完整地址。"
EnrollError.MISSING_FIELDS -> "请填写子域名、设备名称和操作口令。"
EnrollError.BAD_CREDENTIAL -> "操作口令错误,请重试。"
EnrollError.SUBDOMAIN_NOT_OWNED -> "该账号未拥有此子域名,无法签发证书。"
EnrollError.RATE_LIMITED -> "请求过于频繁,请稍后再试。"
EnrollError.REJECTED -> "请求被拒绝:子域名或证书请求无效。"
EnrollError.ENROLL_FAILED -> "注册失败,请稍后重试。"
EnrollError.SERVER -> "服务器返回异常,请稍后重试。"
EnrollError.KEYGEN -> "生成硬件密钥失败(本设备可能不支持安全硬件)。"
EnrollError.UNKNOWN -> "注册失败,请重试。"
}
/** User-facing copy (Chinese), mirroring iOS `EnrollmentCopy`. */
private object EnrollCopy {
const val TITLE = "自动获取证书"
const val NONE_INSTALLED = "尚未安装设备证书。"
const val ENROLL_HEADER = "注册本设备"
const val ROTATE_HEADER = "重新注册"
const val CONTROL_PLANE_URL = "控制面地址"
const val SUBDOMAIN = "子域名(你拥有的隧道名)"
const val DEVICE_NAME = "设备名称"
const val PASSWORD = "操作口令"
const val ENROLL_ACTION = "注册本设备"
const val ROTATE_ACTION = "重新注册"
const val SUCCESS = "已注册,证书已保存到本设备安全硬件,连接隧道主机时将自动出示。"
const val FOOTER =
"首次登录一次即可本设备在安全硬件StrongBox / TEE生成不可导出的私钥向控制面申请证书并自动保存" +
"之后连接隧道主机时自动出示,无需再手动导入 .p12。"
}
@Preview(name = "EnrollmentScreen — none installed")
@Composable
private fun EnrollmentScreenNonePreview() {
WebTermTheme {
EnrollmentScreen(
state = EnrollmentUiState(
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
subdomain = "alice",
deviceName = "Pixel 8",
phase = EnrollPhase.IDLE,
),
onControlPlaneUrlChange = {},
onSubdomainChange = {},
onDeviceNameChange = {},
onPasswordChange = {},
onEnroll = {},
onDismissError = {},
)
}
}
@Preview(name = "EnrollmentScreen — installed + error")
@Composable
private fun EnrollmentScreenInstalledPreview() {
WebTermTheme {
EnrollmentScreen(
state = EnrollmentUiState(
controlPlaneUrl = "https://cp.terminal.yaojia.wang",
subdomain = "alice",
deviceName = "Pixel 8",
summary = CertSummaryView("alice-pixel", "webterm-device-ca", "2027年1月8日", isExpired = false),
phase = EnrollPhase.IDLE,
error = EnrollError.SUBDOMAIN_NOT_OWNED,
),
onControlPlaneUrlChange = {},
onSubdomainChange = {},
onDeviceNameChange = {},
onPasswordChange = {},
onEnroll = {},
onDismissError = {},
)
}
}

View File

@@ -9,23 +9,36 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.CommitLogEntry
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.WorktreeInfo
@@ -36,19 +49,19 @@ import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.ProjectsCopy
import wang.yaojia.webterm.viewmodels.WorktreeViewModel
import java.net.URI
/**
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
* # ProjectDetailScreen (A23 + W5) — one project's detail (branch · worktrees · sessions · CLAUDE.md),
* plus the W5 additions: a **PR + CI chip** (tappable only when the PR url parses as https), a
* **recent-commits** section, and guarded **worktree create / remove / prune** actions.
*
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
*
* @param onBack pop back to the projects grid.
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
* Every server string (name/path/branch/worktree/CLAUDE.md/commit subject/PR title/error) is rendered
* as **inert [Text]** — no autolink/markdown (plan §8). The single exception is the PR chip, which is a
* link ONLY when its url is a valid https URL (scheme-validated before it is made clickable). The
* worktree actions drive [ProjectDetailViewModel.worktree]; a remove force-confirms in a dialog and a
* main worktree is never removable.
*/
@Composable
public fun ProjectDetailScreen(
@@ -56,8 +69,11 @@ public fun ProjectDetailScreen(
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
onViewDiff: (String) -> Unit = {},
) {
val phase by viewModel.phase.collectAsStateWithLifecycle()
val prChip by viewModel.prChip.collectAsStateWithLifecycle()
val recent by viewModel.recentCommits.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LaunchedEffect(viewModel) { viewModel.load() }
@@ -71,7 +87,14 @@ public fun ProjectDetailScreen(
is ProjectDetailViewModel.Phase.Failed ->
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
is ProjectDetailViewModel.Phase.Loaded ->
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude)
DetailBody(
detail = current.detail,
prChip = prChip,
recent = recent,
worktree = viewModel.worktree,
onOpenClaude = onOpenClaude,
onViewDiff = onViewDiff,
)
}
}
}
@@ -90,7 +113,14 @@ private fun DetailHeaderBar(onBack: () -> Unit) {
}
@Composable
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
private fun DetailBody(
detail: ProjectDetail,
prChip: ProjectDetailViewModel.PrChip,
recent: ProjectDetailViewModel.RecentCommits,
worktree: WorktreeViewModel?,
onOpenClaude: (String) -> Unit,
onViewDiff: (String) -> Unit = {},
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -113,37 +143,236 @@ private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
}
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) {
EmptyLine("不是 git 仓库。")
} else if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
PrChipRow(prChip)
if (detail.isGit && worktree != null) {
WorktreeSection(detail = detail, worktree = worktree)
} else {
for (worktree in detail.worktrees) WorktreeRow(worktree)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) EmptyLine("不是 git 仓库。")
else if (detail.worktrees.isEmpty()) EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
else for (w in detail.worktrees) WorktreeRow(w, onRemove = null)
}
val running = detail.sessions.filter { !it.exited }
SectionTitle("运行中的会话(${running.size}")
if (running.isEmpty()) {
EmptyLine("没有运行中的会话 —— 在下方开一个。")
} else {
for (session in running) SessionRow(session)
}
if (running.isEmpty()) EmptyLine("没有运行中的会话 —— 在下方开一个。")
else for (session in running) SessionRow(session)
RecentCommitsSection(recent)
SectionTitle("CLAUDE.md")
val claudeMd = detail.claudeMd
if (detail.hasClaudeMd && claudeMd != null) {
ClaudeMdBlock(claudeMd)
} else {
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
if (detail.hasClaudeMd && claudeMd != null) ClaudeMdBlock(claudeMd)
else EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
if (detail.isGit) TextButton(onClick = { onViewDiff(detail.path) }) { Text("查看改动 (diff)") }
}
}
}
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
// ── PR + CI chip (link only when https) ──────────────────────────────────────────────────────────
@Composable
private fun PrChipRow(prChip: ProjectDetailViewModel.PrChip) {
when (prChip) {
ProjectDetailViewModel.PrChip.Hidden -> Unit
ProjectDetailViewModel.PrChip.Loading -> EmptyLine("正在读取 PR 状态…")
ProjectDetailViewModel.PrChip.Unavailable -> EmptyLine("PR 状态不可用。")
is ProjectDetailViewModel.PrChip.Loaded -> PrChipContent(prChip.status)
}
}
@Composable
private fun WorktreeRow(worktree: WorktreeInfo) {
private fun PrChipContent(pr: PrStatus) {
val uriHandler = LocalUriHandler.current
val httpsUrl = pr.url?.let { if (isHttpsUrl(it)) it else null } // link ONLY when https (plan §Security)
val label = prChipLabel(pr)
val color = prChipColor(pr)
if (httpsUrl != null && pr.availability == PrAvailability.OK) {
AssistChip(
onClick = { runCatching { uriHandler.openUri(httpsUrl) } },
label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors = AssistChipDefaults.assistChipColors(labelColor = color),
)
} else {
// Non-ok / non-https → an INERT, non-clickable line (never make a hostile url tappable).
Text(text = label, style = WebTermType.metaMono, color = color)
}
}
private fun prChipLabel(pr: PrStatus): String = when (pr.availability) {
PrAvailability.OK -> {
val num = pr.number?.let { "#$it " } ?: ""
val checks = pr.checks?.let { " (${it.passing}/${it.total})" } ?: ""
"PR $num${pr.title ?: ""}$checks".trim()
}
PrAvailability.NO_PR -> "当前分支没有 PR"
PrAvailability.NOT_INSTALLED -> "未安装 gh无法读取 PR"
PrAvailability.UNAUTHENTICATED -> "gh 未登录,无法读取 PR"
PrAvailability.DISABLED -> "PR 集成已禁用"
PrAvailability.ERROR -> "PR 状态读取失败"
}
@Composable
private fun prChipColor(pr: PrStatus): androidx.compose.ui.graphics.Color = when {
pr.availability != PrAvailability.OK -> MaterialTheme.colorScheme.onSurfaceVariant
(pr.checks?.failing ?: 0) > 0 -> WebTermColors.statusStuck
(pr.checks?.pending ?: 0) > 0 -> WebTermColors.statusWaiting
else -> WebTermColors.statusWorking
}
private fun isHttpsUrl(url: String): Boolean =
runCatching { URI(url.trim()).scheme?.lowercase() == "https" }.getOrDefault(false)
// ── Worktree section (create / remove / prune) ────────────────────────────────────────────────────
@Composable
private fun WorktreeSection(detail: ProjectDetail, worktree: WorktreeViewModel) {
val scope = rememberCoroutineScope()
val phase by worktree.phase.collectAsStateWithLifecycle()
var branch by remember { mutableStateOf("") }
var base by remember { mutableStateOf("") }
var removeTarget by remember { mutableStateOf<WorktreeInfo?>(null) }
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else {
for (w in detail.worktrees) WorktreeRow(w, onRemove = { if (!w.isMain) removeTarget = w })
}
// New-worktree inline form.
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
OutlinedTextField(
value = branch,
onValueChange = { branch = it },
label = { Text("新工作树分支名") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = base,
onValueChange = { base = it },
label = { Text("基点(可选)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.create(branch, base) } },
) { Text("新建工作树") }
OutlinedButton(
enabled = phase != WorktreeViewModel.Phase.Working,
onClick = { scope.launch { worktree.prune() } },
) { Text("清理") }
}
WorktreePhaseBanner(phase, onDismiss = { worktree.reset() })
}
}
val target = removeTarget
if (target != null) {
RemoveWorktreeDialog(
worktree = target,
onConfirm = { force ->
removeTarget = null
scope.launch { worktree.remove(target, force) }
},
onDismiss = { removeTarget = null },
)
}
}
@Composable
private fun WorktreePhaseBanner(phase: WorktreeViewModel.Phase, onDismiss: () -> Unit) {
when (phase) {
WorktreeViewModel.Phase.Idle -> Unit
WorktreeViewModel.Phase.Working -> Text("处理中…", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
is WorktreeViewModel.Phase.Done -> BannerLine(phase.message, WebTermColors.statusWorking, onDismiss)
is WorktreeViewModel.Phase.Failed -> BannerLine(phase.message, WebTermColors.statusStuck, onDismiss)
}
}
@Composable
private fun BannerLine(message: String, color: androidx.compose.ui.graphics.Color, onDismiss: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(text = message, style = WebTermType.metaMono, color = color, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("知道了") }
}
}
@Composable
private fun RemoveWorktreeDialog(
worktree: WorktreeInfo,
onConfirm: (force: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
var force by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("删除工作树") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = force, onCheckedChange = { force = it })
Text("强制删除(丢弃未提交改动)")
}
}
},
confirmButton = { TextButton(onClick = { onConfirm(force) }) { Text("删除") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
// ── Recent commits ────────────────────────────────────────────────────────────────────────────────
@Composable
private fun RecentCommitsSection(recent: ProjectDetailViewModel.RecentCommits) {
when (recent) {
ProjectDetailViewModel.RecentCommits.Hidden -> Unit
ProjectDetailViewModel.RecentCommits.Loading -> {
SectionTitle("最近提交"); EmptyLine("正在读取提交记录…")
}
ProjectDetailViewModel.RecentCommits.Unavailable -> {
SectionTitle("最近提交"); EmptyLine("提交记录不可用。")
}
is ProjectDetailViewModel.RecentCommits.Loaded -> {
SectionTitle("最近提交")
if (recent.result.commits.isEmpty()) EmptyLine("暂无提交。")
else for (commit in recent.result.commits) CommitRow(commit)
}
}
}
@Composable
private fun CommitRow(commit: CommitLogEntry) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.fillMaxWidth()) {
Text(
text = commit.hash.take(7),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = commit.subject,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
// ── Rows / helpers (reused from A23) ────────────────────────────────────────────────────────────────
@Composable
private fun WorktreeRow(worktree: WorktreeInfo, onRemove: (() -> Unit)?) {
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
@@ -152,6 +381,7 @@ private fun WorktreeRow(worktree: WorktreeInfo) {
if (worktree.isMain) Tag("main")
if (worktree.isCurrent) Tag("current")
if (worktree.locked == true) Tag("locked")
if (onRemove != null && !worktree.isMain) TextButton(onClick = onRemove) { Text("删除") }
}
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@@ -178,7 +408,6 @@ private fun SessionRow(session: ProjectSessionRef) {
@Composable
private fun ClaudeMdBlock(text: String) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
}
}
@@ -238,6 +467,17 @@ private fun ProjectDetailScreenPreview() {
claudeMd = "# CLAUDE.md\n\nProject instructions…",
)
WebTermTheme {
DetailBody(detail = detail, onOpenClaude = {})
DetailBody(
detail = detail,
prChip = ProjectDetailViewModel.PrChip.Loaded(PrStatus(availability = PrAvailability.NO_PR)),
recent = ProjectDetailViewModel.RecentCommits.Loaded(
wang.yaojia.webterm.api.models.GitLogResult(
commits = listOf(CommitLogEntry("abc1234", 1, "Initial commit")),
truncated = false,
),
),
worktree = null,
onOpenClaude = {},
)
}
}

View File

@@ -222,13 +222,21 @@ private fun ProjectCard(
}
}
project.branch?.let { branch ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(
text = branch,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
// W3 sync chip: commits ahead/behind upstream (best-effort; only shown when non-zero).
val ahead = project.ahead ?: 0
val behind = project.behind ?: 0
if (ahead > 0) Text(text = "$ahead", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
if (behind > 0) Text(text = "$behind", style = WebTermType.metaMono, color = WebTermColors.statusWaiting)
}
}
val running = project.sessions.count { !it.exited }
if (running > 0) {

View File

@@ -77,6 +77,7 @@ import java.util.UUID
* @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first).
* @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal).
* @param onPairHost host-menu **配对新主机** → the pairing flow (A19).
* @param onEnroll host-menu **自动获取证书** → the zero-`.p12` enrollment screen (B4).
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
* @param thumbnails off-screen preview seam; production wires it to the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
@@ -87,6 +88,7 @@ public fun SessionListScreen(
onOpenSession: (UUID) -> Unit,
onNewSession: () -> Unit,
onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit,
modifier: Modifier = Modifier,
thumbnails: SessionThumbnails? = null,
@@ -110,6 +112,7 @@ public fun SessionListScreen(
onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } },
onNewSession = onNewSession,
onPairHost = onPairHost,
onEnroll = onEnroll,
onImportCert = onImportCert,
)
},
@@ -138,6 +141,7 @@ private fun SessionListTopBar(
onSelectHost: (String) -> Unit,
onNewSession: () -> Unit,
onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
@@ -156,6 +160,7 @@ private fun SessionListTopBar(
onDismiss = { menuOpen = false },
onSelectHost = { menuOpen = false; onSelectHost(it) },
onPairHost = { menuOpen = false; onPairHost() },
onEnroll = { menuOpen = false; onEnroll() },
onImportCert = { menuOpen = false; onImportCert() },
)
}
@@ -163,7 +168,7 @@ private fun SessionListTopBar(
)
}
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the actions. */
@Composable
private fun HostMenu(
expanded: Boolean,
@@ -171,6 +176,7 @@ private fun HostMenu(
onDismiss: () -> Unit,
onSelectHost: (String) -> Unit,
onPairHost: () -> Unit,
onEnroll: () -> Unit,
onImportCert: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
@@ -184,6 +190,8 @@ private fun HostMenu(
}
if (hosts.isNotEmpty()) HorizontalDivider()
DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost)
// 自动获取证书 (zero-.p12 enroll) sits beside 设备证书 (.p12 import) — same host-menu surface as iOS.
DropdownMenuItem(text = { Text("自动获取证书") }, onClick = onEnroll)
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
}
}

View File

@@ -13,6 +13,11 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
@@ -47,14 +52,17 @@ import java.net.URI
public class DiffViewModel(
private val fetcher: DiffFetcher,
private val path: String,
/** Guarded git-write seam (stage/commit/push). Null → the diff is inert read-only (no buttons). */
private val writer: GitWriteGateway? = null,
) {
private val _uiState = MutableStateFlow(DiffUiState())
private val _uiState = MutableStateFlow(DiffUiState(canWrite = writer != null))
/** The single snapshot `DiffScreen` renders from. */
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
private var job: Job? = null
private var writeJob: Job? = null
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
public fun bind(scope: CoroutineScope) {
@@ -62,27 +70,46 @@ public class DiffViewModel(
reload()
}
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches.
* No-op in base mode (the toggle is hidden there — the server ignores `staged` when `base` is set). */
public fun selectStaged(staged: Boolean) {
if (_uiState.value.base != null) return
if (_uiState.value.staged == staged) return
_uiState.value = _uiState.value.copy(staged = staged)
reload()
}
/**
* Enter/leave base mode: a non-blank [rev] diffs HEAD against that base (staged toggle suppressed,
* git-write disabled — parity with public/diff.ts); null/blank returns to the working/staged view.
*/
public fun setBase(rev: String?) {
val next = rev?.trim()?.takeIf { it.isNotEmpty() }
if (_uiState.value.base == next) return
_uiState.value = _uiState.value.copy(base = next, writeBanner = null)
reload()
}
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
public fun refresh() {
reload()
}
/** Dismiss the git-write result banner. */
public fun clearWriteBanner() {
_uiState.value = _uiState.value.copy(writeBanner = null)
}
private fun reload() {
val scope = scope ?: return
job?.cancel()
val staged = _uiState.value.staged
val base = _uiState.value.base
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
job = scope.launch {
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
val outcome = try {
Result.success(fetcher.fetch(path, staged))
Result.success(fetcher.fetch(path, staged, base))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
@@ -101,6 +128,102 @@ public class DiffViewModel(
)
}
}
// ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────
/** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
public fun toggleStage(newPath: String, staged: Boolean) {
runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
}
/** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
public fun commit(message: String) {
if (message.isBlank()) {
_uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
return
}
runWrite { writer!!.gitCommit(path, message) }
}
/** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
public fun push() {
runWrite { writer!!.gitPush(path) }
}
/**
* Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
* maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
* [writeJob] so a rapid double-tap never races.
*/
private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
val scope = scope ?: return
if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
writeJob?.cancel()
_uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
writeJob = scope.launch {
val outcome = try {
Result.success(op())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
val banner = outcome.fold(
onSuccess = { bannerFor(it) },
onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
)
_uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
if (!banner.isError) reload() // refresh the diff after a successful write
}
}
private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
}
// A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
// their own message — so message is already the display copy.
private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
}
/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
public interface GitWriteGateway {
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
}
/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
api.gitStage(path, files, stage)
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
api.gitCommit(path, message)
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
}
/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
public data class DiffWriteBanner(val message: String, val isError: Boolean)
/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
public object DiffCopy {
public const val COMMIT_EMPTY: String = "请填写提交信息。"
public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun writeFailed(detail: String): String = "Git 操作失败:$detail"
/** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
public fun okBanner(payload: Any?): String = when (payload) {
is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
is PushResult -> "已推送 ${payload.branch ?: "分支"}${payload.remote ?: "远端"}"
else -> "操作完成"
}
}
/** The load phase the screen renders (loading spinner / empty / error / list). */
@@ -108,14 +231,25 @@ public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
/** The immutable snapshot the diff screen renders. */
public data class DiffUiState(
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. Ignored in base mode. */
val staged: Boolean = false,
/** Non-null = base mode: diff HEAD against this revision (staged toggle + git-write suppressed). */
val base: String? = null,
val phase: DiffPhase = DiffPhase.IDLE,
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
val rows: List<DiffRow> = emptyList(),
/** Server capped the diff (too large) — the screen shows a truncation notice. */
val truncated: Boolean = false,
)
/** True once a git-write is in flight — the screen disables the write controls. */
val writing: Boolean = false,
/** The last git-write result (ok/failure), or null. Dismissed via [DiffViewModel.clearWriteBanner]. */
val writeBanner: DiffWriteBanner? = null,
/** Whether git-write controls are offered at all (a writer gateway was supplied). */
val canWrite: Boolean = false,
) {
/** Stage/commit/push are offered only in working/staged mode with a writer bound (never base mode). */
val writeEnabled: Boolean get() = canWrite && base == null
}
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
@@ -125,10 +259,12 @@ public sealed interface DiffRow {
public val id: Long
}
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */
/** A per-file header: the display path plus its `+added/-removed` numstat and status. [stagePath] is the
* file's `newPath` used verbatim for `git add`/`restore` (the display [path] may be an `old → new` rename). */
public data class DiffFileHeaderRow(
override val id: Long,
val path: String,
val stagePath: String,
val status: String,
val added: Int,
val removed: Int,
@@ -153,7 +289,7 @@ public fun flattenDiff(result: DiffResult): List<DiffRow> {
val rows = ArrayList<DiffRow>()
var id = 0L
for (file in result.files) {
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed))
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.newPath, file.status, file.added, file.removed))
if (file.binary) {
rows.add(DiffBinaryRow(id++))
continue
@@ -212,7 +348,13 @@ public data class DiffFile(
val hunks: List<DiffHunk>,
)
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
public data class DiffResult(
val files: List<DiffFile>,
val staged: Boolean,
val truncated: Boolean,
/** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
val base: String? = null,
)
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
private val DiffJson: Json = Json {
@@ -230,7 +372,12 @@ internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
.getOrNull() as? JsonObject
?: return DiffResult(emptyList(), staged = false, truncated = false)
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false))
return DiffResult(
files = files,
staged = root.bool("staged", false),
truncated = root.bool("truncated", false),
base = root.str("base"),
)
}
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
@@ -278,8 +425,12 @@ private fun JsonObject.bool(key: String, default: Boolean): Boolean =
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
public interface DiffFetcher {
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */
public suspend fun fetch(path: String, staged: Boolean): DiffResult
/**
* @param base when non-null, diff HEAD against this base revision — the server IGNORES [staged]
* in base mode (server.ts:831), so callers omit it and the screen hides the Working/Staged toggle.
* @throws DiffUnavailable on a non-200 status; transport errors propagate.
*/
public suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult
}
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
@@ -293,8 +444,8 @@ public class HttpDiffFetcher(
private val endpoint: HostEndpoint,
private val http: HttpTransport,
) : DiffFetcher {
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
return decodeDiffResult(response.body)
@@ -305,20 +456,28 @@ private const val HTTP_OK = 200
private const val HTTP_BAD_REQUEST = 400
/**
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
* Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
* base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
* ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
* `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
* Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
* (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
* the exact query value.
*/
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): 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 stagedValue = if (staged) "1" else "0"
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
val trimmedBase = base?.trim()
return if (!trimmedBase.isNullOrEmpty()) {
"$prefix&base=${percentEncode(trimmedBase)}" // base mode: no staged (server ignores it)
} else {
"$prefix&staged=${if (staged) "1" else "0"}"
}
}
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */

View File

@@ -0,0 +1,265 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.clienttls.CertificateSummary
import java.net.URI
import java.security.GeneralSecurityException
import java.time.Instant
import java.time.ZoneId
/**
* # EnrollmentViewModel (B4) — the zero-`.p12` device-enrollment presenter.
*
* The Android port of iOS `EnrollmentViewModel` (`EnrollmentScreen.swift`). Drives the "自动获取证书"
* screen reached from the session-list host menu: one operator login (password) → a short-lived
* `device:enroll` bearer → a NON-EXPORTABLE hardware key + PKCS#10 CSR → `POST /device/enroll` → the
* returned leaf is committed to the shared cert store and presented AUTOMATICALLY on the existing mTLS
* path (no manual `.p12` import). Cache freshness (FIX 3) means the enrolled cert is live without a restart.
*
* A **plain presenter** (mirrors [ClientCertViewModel] / [PairingViewModel]), NOT an
* `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no `Dispatchers.Main`. The
* enroll flow and the installed-summary read are injected as suspend closures ([enrollOperation] /
* [loadSummary]) so the VM is unit-testable without a keystore or network; production wires them (in the
* enrollment pane) to [wang.yaojia.webterm.wiring.EnrollmentFlowFactory] over a `Dispatchers.IO` hop.
*
* ### Secrets discipline (plan §8)
* The operator password lives only in [EnrollmentUiState] and is **cleared after every attempt** (success
* OR failure) so it never lingers in memory; it is never logged and never placed in error copy. Errors are
* a coarse [EnrollError] enum the screen maps to app-authored, inert copy — never a server/exception string.
*
* @param enrollOperation login → hardware keygen+CSR → enroll → commit, yielding the installed leaf summary.
* @param loadSummary the currently-installed device-cert summary (for the "已安装证书" section), or null.
* @param defaultControlPlaneUrl prefilled control-plane URL (the operator can edit it).
* @param defaultDeviceName prefilled device name (the Android device/model in production).
* @param zone / now formatting + expiry clock for the installed-cert summary (fixed in tests).
*/
public class EnrollmentViewModel(
private val enrollOperation: suspend (password: String, subdomain: String, deviceName: String, controlPlaneUrl: String) -> CertificateSummary,
private val loadSummary: suspend () -> CertificateSummary?,
defaultControlPlaneUrl: String = DEFAULT_CONTROL_PLANE_URL,
defaultDeviceName: String = "",
private val zone: ZoneId = ZoneId.systemDefault(),
private val now: () -> Instant = Instant::now,
) {
private val _uiState = MutableStateFlow(
EnrollmentUiState(controlPlaneUrl = defaultControlPlaneUrl, deviceName = defaultDeviceName),
)
/** The single snapshot the enrollment screen renders from. */
public val uiState: StateFlow<EnrollmentUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
private var job: Job? = null
/** Bind the scope actions launch into (the screen passes a lifecycle scope) and load any installed cert. */
public fun bind(scope: CoroutineScope) {
this.scope = scope
refresh()
}
// ── Field edits (two-way bound from the Compose form) ─────────────────────────────────────────────
public fun onControlPlaneUrlChange(value: String) {
_uiState.value = _uiState.value.copy(controlPlaneUrl = value)
}
public fun onSubdomainChange(value: String) {
_uiState.value = _uiState.value.copy(subdomain = value)
}
public fun onDeviceNameChange(value: String) {
_uiState.value = _uiState.value.copy(deviceName = value)
}
public fun onPasswordChange(value: String) {
_uiState.value = _uiState.value.copy(password = value)
}
/** Dismiss the current error banner. */
public fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
private fun refresh() {
val scope = scope ?: return
job?.cancel()
_uiState.value = _uiState.value.copy(phase = EnrollPhase.LOADING)
job = scope.launch {
// loadSummary is designed to degrade to null on a storage fault (never throw); guard anyway.
val summary = runCatching { loadSummary() }.getOrNull()
_uiState.value = _uiState.value.copy(phase = EnrollPhase.IDLE, summary = summary?.toView())
}
}
/**
* Run enrollment: validate the control-plane URL (must be `https` with a host) and the fields BEFORE
* any network, then drive [enrollOperation]. The password is cleared after every attempt so it never
* lingers. A double-tap while an enroll is in flight is ignored.
*/
public fun enroll() {
val scope = scope ?: return
val snapshot = _uiState.value
if (snapshot.phase == EnrollPhase.ENROLLING) return
val url = validControlPlaneUrl(snapshot.controlPlaneUrl)
if (url == null) {
_uiState.value = snapshot.copy(error = EnrollError.INVALID_URL, didSucceed = false)
return
}
val subdomain = snapshot.subdomain.trim()
val deviceName = snapshot.deviceName.trim()
val password = snapshot.password
if (subdomain.isEmpty() || deviceName.isEmpty() || password.isEmpty()) {
_uiState.value = snapshot.copy(error = EnrollError.MISSING_FIELDS, didSucceed = false)
return
}
_uiState.value = snapshot.copy(phase = EnrollPhase.ENROLLING, error = null, didSucceed = false)
job?.cancel()
job = scope.launch {
val outcome = try {
Result.success(enrollOperation(password, subdomain, deviceName, url))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
// Clear the password whatever the outcome — never linger after an attempt.
_uiState.value = outcome.fold(
onSuccess = { summary ->
_uiState.value.copy(
phase = EnrollPhase.IDLE,
summary = summary.toView(),
password = "",
didSucceed = true,
error = null,
)
},
onFailure = { e ->
_uiState.value.copy(
phase = EnrollPhase.IDLE,
password = "",
didSucceed = false,
error = classifyEnroll(e),
)
},
)
}
}
/** Fields all present + not already enrolling → the button is enabled (deeper URL check on tap). */
private fun CertificateSummary.toView() = toSummaryView(now(), zone)
public companion object {
/** The default control-plane URL (matches iOS `EnrollmentCopy.defaultControlPlaneURL`). */
public const val DEFAULT_CONTROL_PLANE_URL: String = "https://cp.terminal.yaojia.wang"
}
}
/**
* Validate a control-plane URL exactly as iOS does before any network: it must parse, be `https`, and
* carry a non-blank host. Returns the trimmed URL on success, or null (→ [EnrollError.INVALID_URL]).
*/
internal fun validControlPlaneUrl(raw: String): String? {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
if (!"https".equals(uri.scheme, ignoreCase = true)) return null
if (uri.host.isNullOrBlank()) return null
return trimmed
}
/**
* Map an enroll throwable to the coarse, non-secret [EnrollError] the screen renders inertly. Android's
* login + enroll share one [DeviceEnrollmentError.Http] type, so the HTTP status disambiguates (401 login
* vs 403 subdomain-not-owned vs 429 vs 400). Keystore/hardware faults (incl. StrongBox-unavailable, a
* [GeneralSecurityException] subclass) map to KEYGEN. Never carries the password/bearer.
*/
internal fun classifyEnroll(error: Throwable): EnrollError = when (error) {
is DeviceEnrollmentError.Http -> when (error.status) {
401 -> EnrollError.BAD_CREDENTIAL
403 -> EnrollError.SUBDOMAIN_NOT_OWNED
429 -> EnrollError.RATE_LIMITED
400 -> EnrollError.REJECTED
else -> EnrollError.ENROLL_FAILED
}
is DeviceEnrollmentError.MalformedResponse -> EnrollError.SERVER
is DeviceEnrollmentError -> EnrollError.ENROLL_FAILED // InvalidRequest etc. (post-validation, rare)
is GeneralSecurityException -> EnrollError.KEYGEN // hardware key / StrongBox / keystore fault
else -> EnrollError.UNKNOWN
}
/** The load/action phase the enrollment screen renders. */
public enum class EnrollPhase {
/** The initial installed-summary read is in flight. */
LOADING,
/** Idle — the form is editable and [EnrollmentUiState.summary] shows any installed cert. */
IDLE,
/** An enroll is in flight (login → keygen+CSR → enroll → commit). */
ENROLLING,
}
/** The coarse, non-secret enrollment failure taxonomy — the screen maps each to app-authored inert copy (§8). */
public enum class EnrollError {
/** The control-plane URL is not a valid `https://…` URL with a host (set before any network). */
INVALID_URL,
/** A required field (subdomain / device name / password) was empty (set before any network). */
MISSING_FIELDS,
/** Operator login rejected (401) — the password is wrong. */
BAD_CREDENTIAL,
/** The account does not own the requested subdomain (403) — no cert can be issued. */
SUBDOMAIN_NOT_OWNED,
/** Too many requests (429) — back off and retry. */
RATE_LIMITED,
/** The subdomain or CSR was rejected (400). */
REJECTED,
/** Any other enroll failure (server-side or transport). */
ENROLL_FAILED,
/** The server returned a malformed response. */
SERVER,
/** Hardware key generation failed (no StrongBox/TEE, or a keystore fault). */
KEYGEN,
/** An unclassified failure. */
UNKNOWN,
}
/** The immutable snapshot the enrollment screen renders. */
public data class EnrollmentUiState(
val controlPlaneUrl: String = "",
val subdomain: String = "",
val deviceName: String = "",
val password: String = "",
/** The currently-installed device identity's display summary, or `null` when none is installed. */
val summary: CertSummaryView? = null,
val phase: EnrollPhase = EnrollPhase.LOADING,
/** The last enroll failure, or `null`. Surfaced as inert, app-authored copy. */
val error: EnrollError? = null,
/** Whether the most recent enroll succeeded (drives the success affordance). */
val didSucceed: Boolean = false,
) {
/** Every field present and no enroll in flight → the submit button is enabled. */
val canEnroll: Boolean
get() = phase != EnrollPhase.ENROLLING &&
controlPlaneUrl.trim().isNotEmpty() &&
subdomain.trim().isNotEmpty() &&
deviceName.trim().isNotEmpty() &&
password.isNotEmpty()
}

View File

@@ -4,26 +4,32 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.routes.ApiClientError
/**
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
* # ProjectDetailViewModel (A23 + W5) — one project's detail (`GET /projects/detail?path=`), a phase
* state machine, PLUS two failure-ISOLATED side fetches: the PR + CI chip (`GET /projects/pr`) and the
* recent-commits list (`GET /projects/log`). A failure of either side fetch NEVER fails the detail load
* (each has its own StateFlow) — the chip/list simply render an unavailable state.
*
* The [fetch] closure is injected production wraps [ProjectsGateway.projectDetail] (the builder's
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
* fake. This VM only reduces the three user-visible outcomes:
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
* The main [fetch] closure is injected (production wraps [ProjectsGateway.projectDetail]); [fetchPr] /
* [fetchLog] are optional side fetches (null → the chip/list stay [PrChip.Hidden] / [RecentCommits.Hidden]).
* [worktree] (when wired) drives the guarded create/remove/prune actions and re-fetches this detail on
* success (via [load]).
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* [load] in a lifecycle scope; the retry action re-calls it.
*/
public class ProjectDetailViewModel(
public val path: String,
private val fetch: suspend () -> ProjectDetail,
private val fetchPr: (suspend () -> PrStatus)? = null,
private val fetchLog: (suspend () -> GitLogResult)? = null,
/** Guarded worktree actions bound to this project; null when the gateway isn't wired (tests). */
public val worktree: WorktreeViewModel? = null,
) {
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
@@ -35,12 +41,40 @@ public class ProjectDetailViewModel(
public data class Failed(val failure: Failure) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
/** The PR + CI chip's own state (isolated from the detail load). */
public sealed interface PrChip {
public data object Hidden : PrChip
public data object Loading : PrChip
public data class Loaded(val status: PrStatus) : PrChip
public data object Unavailable : PrChip
}
/** The single snapshot `ProjectDetailScreen` renders from. */
/** The recent-commits section's own state (isolated from the detail load). */
public sealed interface RecentCommits {
public data object Hidden : RecentCommits
public data object Loading : RecentCommits
public data class Loaded(val result: GitLogResult) : RecentCommits
public data object Unavailable : RecentCommits
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
private val _prChip = MutableStateFlow<PrChip>(PrChip.Hidden)
private val _recentCommits = MutableStateFlow<RecentCommits>(RecentCommits.Hidden)
/** The main detail snapshot `ProjectDetailScreen` renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
/** The PR chip snapshot (renders one chip from [PrStatus.availability]). */
public val prChip: StateFlow<PrChip> = _prChip.asStateFlow()
/** The recent-commits snapshot. */
public val recentCommits: StateFlow<RecentCommits> = _recentCommits.asStateFlow()
/**
* Fetch and present. Also the retry path: callable again after a [Phase.Failed]. On a successful
* detail load it runs the two side fetches, each failure-isolated (a PR/log failure does not fail
* the detail load nor each other).
*/
public suspend fun load() {
_phase.value = Phase.Loading
_phase.value = try {
@@ -53,6 +87,34 @@ public class ProjectDetailViewModel(
// Transport/decode etc. — a retryable catch-all.
Phase.Failed(Failure.UNAVAILABLE)
}
if (_phase.value is Phase.Loaded) {
loadPr()
loadRecentCommits()
}
}
private suspend fun loadPr() {
val fetcher = fetchPr ?: return
_prChip.value = PrChip.Loading
_prChip.value = try {
PrChip.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
PrChip.Unavailable // isolated: a PR fetch failure never touches the detail phase
}
}
private suspend fun loadRecentCommits() {
val fetcher = fetchLog ?: return
_recentCommits.value = RecentCommits.Loading
_recentCommits.value = try {
RecentCommits.Loaded(fetcher())
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
RecentCommits.Unavailable
}
}
private fun failureFor(error: ApiClientError): Failure = when (error) {
@@ -62,8 +124,22 @@ public class ProjectDetailViewModel(
}
public companion object {
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
/**
* Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). Wires the
* detail + PR + log fetches and a [WorktreeViewModel] whose successes re-fetch this detail.
*/
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel {
var self: ProjectDetailViewModel? = null
val worktree = WorktreeViewModel(gateway, path, onChanged = { self?.load() })
val vm = ProjectDetailViewModel(
path = path,
fetch = { gateway.projectDetail(path) },
fetchPr = { gateway.projectPr(path) },
fetchLog = { gateway.projectLog(path, null) },
worktree = worktree,
)
self = vm
return vm
}
}
}

View File

@@ -5,8 +5,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
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.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.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
@@ -396,12 +402,26 @@ public data class ProjectsUiState(
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */
/**
* Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses.
* The W5 additions (PR / recent commits / worktree create-remove-prune) let the detail page and the
* [WorktreeViewModel] stay JVM-tested against a fake; the guarded worktree writes flow through the
* :api-client Origin-stamping point (plan §Security).
*/
public interface ProjectsGateway {
public suspend fun projects(): List<ProjectInfo>
public suspend fun prefs(): UiPrefs
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
public suspend fun projectDetail(path: String): ProjectDetail
// ── W5: read-only PR + recent commits ──────────────────────────────────────────────────
public suspend fun projectPr(path: String): PrStatus
public suspend fun projectLog(path: String, n: Int? = null): GitLogResult
// ── W5: guarded worktree write ─────────────────────────────────────────────────────────
public suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult>
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult>
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult>
}
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
@@ -410,6 +430,15 @@ public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGate
override suspend fun prefs(): UiPrefs = api.prefs()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
override suspend fun projectPr(path: String): PrStatus = api.projectPr(path)
override suspend fun projectLog(path: String, n: Int?): GitLogResult = api.projectLog(path, n)
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> =
api.createWorktree(path, branch, base)
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> =
api.removeWorktree(path, worktreePath, force)
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> = api.pruneWorktrees(path)
}
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */

View File

@@ -0,0 +1,149 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* # WorktreeViewModel (W5) — the guarded worktree write actions for one project.
*
* A phase machine (`Idle → Working → Done | Failed`) over the three guarded routes
* (`POST /projects/worktree`, `DELETE /projects/worktree`, `POST /projects/worktree/prune`), all
* flowing through the :api-client Origin-stamping point (plan §Security). On a successful op it invokes
* [onChanged] so the detail screen re-fetches and the worktree list refreshes.
*
* ### Defense in depth (UX, not the security boundary)
* The branch name is pre-validated client-side ([isValidBranchName], a mirror of the server's
* `validateBranchName`) so an obviously bad name fails with NO network I/O; a **main** worktree removal
* is blocked client-side ([WorktreeInfo.isMain]) — the server re-validates + realpath-contains
* regardless. Server `error` strings (disabled kill-switch, "uncommitted changes; force required") are
* surfaced INERT (plain text; never linkified).
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest`. The screen calls
* the suspend actions from a lifecycle scope; [reset] clears a settled banner back to [Phase.Idle].
*/
public class WorktreeViewModel(
private val gateway: ProjectsGateway,
private val repoPath: String,
/** Invoked after any successful write so the detail page re-fetches (list refresh). */
private val onChanged: suspend () -> Unit = {},
) {
/** The action phase the screen renders (idle / spinner / success banner / failure banner). */
public sealed interface Phase {
public data object Idle : Phase
public data object Working : Phase
public data class Done(val message: String) : Phase
public data class Failed(val message: String) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Idle)
/** The single snapshot the worktree sheet/dialog renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Create a worktree for [branch] (off optional [base]). Invalid branch → [Phase.Failed], no I/O. */
public suspend fun create(branch: String, base: String? = null) {
val trimmed = branch.trim()
if (!isValidBranchName(trimmed)) {
_phase.value = Phase.Failed(WorktreeCopy.INVALID_BRANCH)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
val cleanBase = base?.trim()?.takeIf { it.isNotEmpty() }
_phase.value = runOp { gateway.createWorktree(repoPath, trimmed, cleanBase) }
}
/** Remove [worktree] ([force] to discard uncommitted changes). A **main** worktree is blocked here. */
public suspend fun remove(worktree: WorktreeInfo, force: Boolean) {
if (worktree.isMain) {
_phase.value = Phase.Failed(WorktreeCopy.CANNOT_REMOVE_MAIN)
return
}
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.removeWorktree(repoPath, worktree.path, force) }
}
/** Reclaim stale worktree admin dirs (idempotent). */
public suspend fun prune() {
if (_phase.value == Phase.Working) return
_phase.value = Phase.Working
_phase.value = runOp { gateway.pruneWorktrees(repoPath) }
}
/** Clear a settled banner (Done/Failed) back to Idle after the user dismisses it. */
public fun reset() {
_phase.value = Phase.Idle
}
/**
* Run one guarded write, mapping its [GitWriteOutcome] to a phase. On success it re-fetches the
* detail (via [onChanged]) BEFORE settling to [Phase.Done] so the list is fresh when the banner shows.
*/
private suspend fun <T> runOp(op: suspend () -> GitWriteOutcome<T>): Phase {
val outcome = try {
op()
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
return Phase.Failed(WorktreeCopy.failed(error.message ?: error.toString()))
}
return when (outcome) {
is GitWriteOutcome.Ok -> {
runCatching { onChanged() } // a refresh failure must not turn a successful write into a failure
Phase.Done(WorktreeCopy.okMessage(outcome.payload))
}
is GitWriteOutcome.Rejected -> Phase.Failed(outcome.message ?: WorktreeCopy.REJECTED)
GitWriteOutcome.RateLimited -> Phase.Failed(WorktreeCopy.RATE_LIMITED)
}
}
public companion object {
/** Longest branch name the server accepts (`src/http/worktrees.ts` `MAX_BRANCH_LEN`). */
private const val MAX_BRANCH_LEN = 250
/** Mirror of the server's `FORBIDDEN_BRANCH_CHARS`: control/DEL, whitespace, `~^:?*[\`. */
private val FORBIDDEN_BRANCH_CHARS = Regex("[\\u0000-\\u001f\\u007f\\s~^:?*\\[\\\\]")
/**
* Client-side mirror of `validateBranchName` (worktrees.ts:95) — a fast UX pre-check ONLY; the
* server re-validates. Rejects empty/overlong, leading `-`, bad slashes, `..`, `.lock`/trailing
* `.`, `@{`, and any forbidden char.
*/
public fun isValidBranchName(branch: String): Boolean {
if (branch.isEmpty() || branch.length > MAX_BRANCH_LEN) return false
if (branch.startsWith("-")) return false
if (branch.startsWith("/") || branch.endsWith("/") || branch.contains("//")) return false
if (branch.contains("..")) return false
if (branch.endsWith(".lock") || branch.endsWith(".")) return false
if (branch.contains("@{")) return false
if (FORBIDDEN_BRANCH_CHARS.containsMatchIn(branch)) return false
return true
}
}
}
/** User-visible worktree-action copy (Chinese named constants; server strings surfaced inert). */
public object WorktreeCopy {
public const val INVALID_BRANCH: String = "分支名不合法(含非法字符或格式)。"
public const val CANNOT_REMOVE_MAIN: String = "不能删除主工作树。"
public const val REJECTED: String = "操作被服务器拒绝。"
public const val RATE_LIMITED: String = "操作过于频繁,服务器已限流,请稍后再试。"
public fun failed(detail: String): String = "工作树操作失败:$detail"
public fun okMessage(payload: Any?): String = when (payload) {
is CreateWorktreeResult -> "已创建工作树 ${payload.branch ?: ""}".trim()
is RemoveWorktreeResult -> "已删除工作树"
is PruneWorktreesResult ->
if (payload.pruned.isEmpty()) "没有可清理的工作树" else "已清理 ${payload.pruned.size} 个工作树"
else -> "操作完成"
}
}

View File

@@ -49,6 +49,11 @@ public class AppEnvironment @Inject constructor(
public val apiClientFactory: ApiClientFactory,
public val sessionEngineFactory: SessionEngineFactory,
public val coldStartPolicy: ColdStartPolicy,
/**
* B4 · Builds a [DeviceEnroller][wang.yaojia.webterm.tlsandroid.DeviceEnroller] per control-plane URL
* for the zero-`.p12` enrollment screen (A-enroll). App-scoped; the screen calls it off `Main`.
*/
public val enrollmentFlowFactory: EnrollmentFlowFactory,
private val identityRepositoryLazy: Lazy<IdentityRepository>,
private val httpTransportLazy: Lazy<HttpTransport>,
private val termTransportLazy: Lazy<TermTransport>,

View File

@@ -0,0 +1,51 @@
package wang.yaojia.webterm.wiring
import dagger.Lazy
import okhttp3.OkHttpClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.DeviceEnroller
import wang.yaojia.webterm.tlsandroid.EnrollmentRecordStore
import wang.yaojia.webterm.tlsandroid.IdentityCacheRefresher
import wang.yaojia.webterm.wire.HttpTransport
import javax.inject.Inject
import javax.inject.Singleton
/**
* B4 · Builds a [DeviceEnroller] bound to a user-supplied control-plane URL, over the app's frozen object
* graph — the Android analogue of iOS `makeDeviceEnrollmentFlow` (`DeviceEnrollmentWiring.swift`).
*
* The control-plane base URL is a RUNTIME value (the operator types it on the enrollment screen), so the
* enroller cannot be a plain singleton; this factory is the singleton and mints one enroller per enroll
* attempt with the typed URL, wiring in the app-scoped collaborators:
* - the SAME shared [HttpTransport] / [OkHttpClient] the mTLS transports use, so the renew ride presents
* the current device cert and the pool eviction drops stale connections,
* - the shared [CertStore] + [EnrollmentRecordStore] the running [IdentityRepository] resolves from, and
* - the [IdentityCacheRefresher] (FIX 3) so a freshly enrolled leaf is presented with no restart.
*
* ### Off-`Main` discipline
* [httpTransport] and [sharedClient] are behind `dagger.Lazy` because resolving either builds the shared
* `OkHttpClient` (mTLS/keystore I/O). [create] therefore MUST be called off the UI thread — the enrollment
* ViewModel invokes it inside a `Dispatchers.IO` hop (mirroring `AppEnvironment.warmUp`).
*/
@Singleton
public class EnrollmentFlowFactory @Inject constructor(
private val httpTransport: Lazy<HttpTransport>,
private val sharedClient: Lazy<OkHttpClient>,
private val certStore: CertStore,
private val recordStore: EnrollmentRecordStore,
private val cacheRefresher: IdentityCacheRefresher,
) {
/**
* Mint a [DeviceEnroller] targeting [controlPlaneBaseUrl] (any trailing slash is trimmed by the
* client). Call OFF `Main` — resolving the lazy transport/client does keystore/TLS I/O.
*/
public fun create(controlPlaneBaseUrl: String): DeviceEnroller =
DeviceEnroller(
client = DeviceEnrollmentClient(controlPlaneBaseUrl, httpTransport.get()),
certStore = certStore,
recordStore = recordStore,
sharedClient = sharedClient.get(),
cacheRefresher = cacheRefresher,
)
}

View File

@@ -6,8 +6,14 @@ import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
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.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
/**
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
@@ -101,8 +107,10 @@ class DiffViewModelTest {
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
val bases = mutableListOf<String?>() // records the base arg of each fetch
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
calls += staged
bases += base
error?.let { throw it }
return result!!
}
@@ -154,4 +162,133 @@ class DiffViewModelTest {
assertTrue(vm.uiState.value.staged)
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
}
// ── diffUrl base mode (Phase B) ───────────────────────────────────────────────────────────────
@Test
fun `diffUrl appends staged in working mode and base (omitting staged) in base mode`() {
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=1",
diffUrl("http://h:3000", "/repo", staged = true, base = null),
)
// base mode: no staged param, base percent-encoded.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&base=feature%2Fx",
diffUrl("http://h:3000", "/repo", staged = true, base = "feature/x"),
)
// a blank base is treated as working mode.
assertEquals(
"http://h:3000/projects/diff?path=%2Frepo&staged=0",
diffUrl("http://h:3000", "/repo", staged = false, base = " "),
)
}
// ── DiffViewModel base mode (Phase B) ─────────────────────────────────────────────────────────
@Test
fun `setBase enters base mode, threads base to the fetcher, and suppresses the staged toggle`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val vm = DiffViewModel(fetcher, "/repo")
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
assertEquals("main", vm.uiState.value.base)
assertEquals(listOf(null, "main"), fetcher.bases) // base threaded on the re-fetch
// In base mode the staged toggle is a no-op (server ignores staged when base is set).
vm.selectStaged(true); advanceUntilIdle()
assertFalse(vm.uiState.value.staged)
assertEquals(2, fetcher.calls.size, "selectStaged must not re-fetch in base mode")
// Leaving base mode returns to the working/staged view.
vm.setBase(null); advanceUntilIdle()
assertNull(vm.uiState.value.base)
assertEquals(listOf(null, "main", null), fetcher.bases)
}
// ── DiffViewModel git-write (Phase C) ─────────────────────────────────────────────────────────
private class FakeWriter(
var stage: GitWriteOutcome<StageResult> = GitWriteOutcome.Ok(StageResult(staged = true, count = 1)),
var commit: GitWriteOutcome<CommitResult> = GitWriteOutcome.Ok(CommitResult(commit = "abc123")),
var push: GitWriteOutcome<PushResult> = GitWriteOutcome.Ok(PushResult(branch = "main", remote = "origin")),
) : GitWriteGateway {
val stageCalls = mutableListOf<Triple<String, List<String>, Boolean>>()
var commitCalls = 0; var pushCalls = 0
override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> {
stageCalls += Triple(path, files, stage); return this.stage
}
override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> { commitCalls++; return commit }
override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> { pushCalls++; return push }
}
@Test
fun `toggleStage posts the file and refreshes the diff`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.toggleStage("src/A.kt", staged = true); advanceUntilIdle()
assertEquals(Triple("/repo", listOf("src/A.kt"), true), writer.stageCalls.single())
assertEquals(2, fetcher.calls.size, "a successful stage must refresh the diff")
assertEquals(false, vm.uiState.value.writeBanner?.isError)
}
@Test
fun `commit surfaces an Ok banner and an empty message is rejected client-side with no I O`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.commit(" "); advanceUntilIdle() // blank → client-side reject
assertEquals(0, writer.commitCalls, "a blank commit message must not hit the network")
assertEquals(true, vm.uiState.value.writeBanner?.isError)
vm.commit("real message"); advanceUntilIdle()
assertEquals(1, writer.commitCalls)
assertEquals(false, vm.uiState.value.writeBanner?.isError)
assertTrue(vm.uiState.value.writeBanner!!.message.contains("abc123"))
}
@Test
fun `push maps a 409 rejection to the inert server message and does not refresh`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter(push = GitWriteOutcome.Rejected(409, "Push rejected: remote has diverged."))
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.push(); advanceUntilIdle()
assertEquals(1, writer.pushCalls)
assertEquals(true, vm.uiState.value.writeBanner?.isError)
assertEquals("Push rejected: remote has diverged.", vm.uiState.value.writeBanner?.message)
assertEquals(1, fetcher.calls.size, "a failed push must NOT refresh the diff")
}
@Test
fun `git-write is disabled in base mode`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val writer = FakeWriter()
val vm = DiffViewModel(fetcher, "/repo", writer)
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle()
vm.setBase("main"); advanceUntilIdle()
vm.toggleStage("a.kt", true); vm.commit("m"); vm.push(); advanceUntilIdle()
assertTrue(writer.stageCalls.isEmpty() && writer.commitCalls == 0 && writer.pushCalls == 0)
assertFalse(vm.uiState.value.writeEnabled)
}
@Test
fun `writeEnabled is false without a writer and true with one in working mode`() {
assertFalse(DiffUiState(canWrite = false).writeEnabled)
assertTrue(DiffUiState(canWrite = true).writeEnabled)
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
}
}

View File

@@ -0,0 +1,213 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.clienttls.CertificateSummary
import java.security.KeyStoreException
import java.time.Instant
import java.time.ZoneId
/**
* [EnrollmentViewModel] / [validControlPlaneUrl] / [classifyEnroll] (B4) — the JVM-tested zero-`.p12`
* enrollment core, mirroring iOS `EnrollmentViewModelTests`. The enroll flow itself is covered headlessly
* in `DeviceEnrollerTest`; these cover the VM's boundary validation, success bookkeeping (summary +
* password-clear), and the error→[EnrollError] mapping — including that the password never lingers after
* an attempt. The keystore / network / Compose shell is device-QA (plan §7); THIS is the pure core.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EnrollmentViewModelTest {
/** Records enroll invocations and replays a scripted result/throwable (mirrors iOS EnrollScript). */
private class EnrollScript(private val result: Result<CertificateSummary>) {
val calls = mutableListOf<Call>()
data class Call(val password: String, val subdomain: String, val deviceName: String, val url: String)
suspend fun run(password: String, subdomain: String, deviceName: String, url: String): CertificateSummary {
calls.add(Call(password, subdomain, deviceName, url))
return result.getOrThrow()
}
}
private val fixedNow = Instant.parse("2026-01-01T00:00:00Z")
private val utc = ZoneId.of("UTC")
private fun summary(
subject: String? = "alice-pixel",
issuer: String? = "webterm-device-ca",
notAfter: Instant? = Instant.parse("2026-10-06T00:00:00Z"),
) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter)
private fun newVm(
script: EnrollScript,
installed: CertificateSummary? = null,
controlPlaneUrl: String = "https://cp.terminal.yaojia.wang",
subdomain: String = "alice",
deviceName: String = "Pixel 8",
): EnrollmentViewModel {
val vm = EnrollmentViewModel(
enrollOperation = { p, s, d, u -> script.run(p, s, d, u) },
loadSummary = { installed },
defaultControlPlaneUrl = controlPlaneUrl,
defaultDeviceName = deviceName,
zone = utc,
now = { fixedNow },
)
vm.onSubdomainChange(subdomain)
return vm
}
// ── Success ─────────────────────────────────────────────────────────────────────────────────────
@Test
fun `a successful enroll sets the summary, flags success and clears the password`() =
runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.success(summary(subject = "alice-pixel")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("operator-secret")
vm.enroll()
val state = vm.uiState.value
assertEquals(1, script.calls.size)
assertEquals("operator-secret", script.calls.single().password, "the entered password reaches the flow")
assertEquals("alice-pixel", state.summary?.subjectCommonName)
assertTrue(state.didSucceed)
assertNull(state.error)
assertEquals("", state.password, "the password must never linger after a successful enroll")
assertEquals(EnrollPhase.IDLE, state.phase)
}
// ── Boundary validation (no network) ──────────────────────────────────────────────────────────────
@Test
fun `a non-https control-plane URL is rejected before any network`() =
runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.success(summary()))
val vm = newVm(script, controlPlaneUrl = "http://cp.terminal.yaojia.wang")
vm.bind(backgroundScope)
vm.onPasswordChange("pw")
vm.enroll()
assertTrue(script.calls.isEmpty(), "an insecure URL must never hit the network")
assertEquals(EnrollError.INVALID_URL, vm.uiState.value.error)
assertFalse(vm.uiState.value.didSucceed)
}
@Test
fun `missing fields are rejected before any network`() = runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.success(summary()))
val vm = newVm(script, subdomain = " ") // whitespace-only subdomain
vm.bind(backgroundScope)
vm.onPasswordChange("pw")
vm.enroll()
assertTrue(script.calls.isEmpty())
assertEquals(EnrollError.MISSING_FIELDS, vm.uiState.value.error)
}
// ── Error → copy mapping (password still cleared) ─────────────────────────────────────────────────
@Test
fun `a 403 subdomain-not-owned maps to actionable copy and clears the password`() =
runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(403, "rejected")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("operator-secret")
vm.enroll()
val state = vm.uiState.value
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, state.error)
assertFalse(state.didSucceed)
assertEquals("", state.password, "the password is cleared even after a failed enroll")
}
@Test
fun `a 401 login maps to the bad-credential copy`() = runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.failure(DeviceEnrollmentError.Http(401, "rejected")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("wrong")
vm.enroll()
assertEquals(EnrollError.BAD_CREDENTIAL, vm.uiState.value.error)
}
@Test
fun `a hardware keystore fault maps to the keygen copy`() = runTest(UnconfinedTestDispatcher()) {
val script = EnrollScript(Result.failure(KeyStoreException("no StrongBox / TEE")))
val vm = newVm(script)
vm.bind(backgroundScope)
vm.onPasswordChange("operator-secret")
vm.enroll()
assertEquals(EnrollError.KEYGEN, vm.uiState.value.error)
}
// ── canEnroll gating ──────────────────────────────────────────────────────────────────────────────
@Test
fun `canEnroll requires every field including a non-empty password`() =
runTest(UnconfinedTestDispatcher()) {
val vm = newVm(EnrollScript(Result.success(summary())))
vm.bind(backgroundScope)
assertFalse(vm.uiState.value.canEnroll, "password empty → disabled")
vm.onPasswordChange("pw")
assertTrue(vm.uiState.value.canEnroll)
vm.onSubdomainChange(" ")
assertFalse(vm.uiState.value.canEnroll, "whitespace-only subdomain → disabled")
}
@Test
fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) {
val vm = newVm(newFailing())
vm.bind(backgroundScope)
vm.onPasswordChange("pw")
vm.enroll()
assertEquals(EnrollError.REJECTED, vm.uiState.value.error)
vm.clearError()
assertNull(vm.uiState.value.error)
}
private fun newFailing() = EnrollScript(Result.failure(DeviceEnrollmentError.Http(400, "rejected")))
// ── Pure helpers ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `validControlPlaneUrl accepts https with a host and rejects everything else`() {
assertEquals("https://cp.terminal.yaojia.wang", validControlPlaneUrl(" https://cp.terminal.yaojia.wang "))
assertNull(validControlPlaneUrl("http://cp.terminal.yaojia.wang"), "http is rejected")
assertNull(validControlPlaneUrl("https://"), "no host is rejected")
assertNull(validControlPlaneUrl("not a url"), "unparseable is rejected")
assertNull(validControlPlaneUrl(""), "empty is rejected")
}
@Test
fun `classifyEnroll maps each throwable to its coarse EnrollError`() {
assertEquals(EnrollError.BAD_CREDENTIAL, classifyEnroll(DeviceEnrollmentError.Http(401, null)))
assertEquals(EnrollError.SUBDOMAIN_NOT_OWNED, classifyEnroll(DeviceEnrollmentError.Http(403, null)))
assertEquals(EnrollError.RATE_LIMITED, classifyEnroll(DeviceEnrollmentError.Http(429, null)))
assertEquals(EnrollError.REJECTED, classifyEnroll(DeviceEnrollmentError.Http(400, null)))
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.Http(500, null)))
assertEquals(EnrollError.SERVER, classifyEnroll(DeviceEnrollmentError.MalformedResponse))
assertEquals(EnrollError.ENROLL_FAILED, classifyEnroll(DeviceEnrollmentError.InvalidRequest))
assertEquals(EnrollError.KEYGEN, classifyEnroll(KeyStoreException("x")))
assertEquals(EnrollError.UNKNOWN, classifyEnroll(IllegalStateException("x")))
}
}

View File

@@ -0,0 +1,68 @@
package wang.yaojia.webterm.viewmodels
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.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.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.UiPrefs
/**
* A configurable [ProjectsGateway] double for the W5 presenter tests (WorktreeViewModel,
* ProjectDetailViewModel PR/log). Records the guarded-write call args and returns canned outcomes;
* PR/log return canned values or throw to exercise failure-isolation. The list-page methods
* (projects/prefs) are unused here and throw if called.
*/
class FakeWorktreeGateway(
private val detail: ProjectDetail? = null,
private val prResult: PrStatus? = null,
private val prThrows: Boolean = false,
private val logResult: GitLogResult? = null,
private val logThrows: Boolean = false,
private val createOutcome: GitWriteOutcome<CreateWorktreeResult> = GitWriteOutcome.Ok(CreateWorktreeResult()),
private val removeOutcome: GitWriteOutcome<RemoveWorktreeResult> = GitWriteOutcome.Ok(RemoveWorktreeResult()),
private val pruneOutcome: GitWriteOutcome<PruneWorktreesResult> = GitWriteOutcome.Ok(PruneWorktreesResult()),
) : ProjectsGateway {
val createCalls = mutableListOf<Triple<String, String, String?>>()
val removeCalls = mutableListOf<Triple<String, String, Boolean>>()
val pruneCalls = mutableListOf<String>()
var detailCalls = 0
private set
override suspend fun projects(): List<ProjectInfo> = throw NotImplementedError()
override suspend fun prefs(): UiPrefs = throw NotImplementedError()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = throw NotImplementedError()
override suspend fun projectDetail(path: String): ProjectDetail {
detailCalls++
return detail ?: throw NotImplementedError("no detail configured")
}
override suspend fun projectPr(path: String): PrStatus {
if (prThrows) throw RuntimeException("pr unavailable")
return prResult ?: throw NotImplementedError("no pr configured")
}
override suspend fun projectLog(path: String, n: Int?): GitLogResult {
if (logThrows) throw RuntimeException("log unavailable")
return logResult ?: throw NotImplementedError("no log configured")
}
override suspend fun createWorktree(path: String, branch: String, base: String?): GitWriteOutcome<CreateWorktreeResult> {
createCalls += Triple(path, branch, base)
return createOutcome
}
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean): GitWriteOutcome<RemoveWorktreeResult> {
removeCalls += Triple(path, worktreePath, force)
return removeOutcome
}
override suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> {
pruneCalls += path
return pruneOutcome
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.viewmodels
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.CommitLogEntry
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.PrAvailability
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
/**
* W5 ProjectDetailViewModel side fetches (JVM). The PR chip and recent-commits list are failure-
* ISOLATED: a failure of either NEVER fails the detail load nor the other; a non-`ok` availability
* renders a degraded (but Loaded) chip; the commit list decodes into its own state.
*/
class ProjectDetailPrLogTest {
private val detail = ProjectDetail(name = "repo", path = "/repo", isGit = true, branch = "main")
@Test
fun `detail plus PR plus log all load`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.OK, number = 7, title = "A PR"),
logResult = GitLogResult(commits = listOf(CommitLogEntry("h", 1, "s")), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.OK, chip.status.availability)
val commits = vm.recentCommits.value as ProjectDetailViewModel.RecentCommits.Loaded
assertEquals(1, commits.result.commits.size)
}
@Test
fun `a PR fetch failure does not fail the detail load nor the log`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prThrows = true,
logResult = GitLogResult(commits = emptyList(), truncated = false),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded, "detail must still load")
assertEquals(ProjectDetailViewModel.PrChip.Unavailable, vm.prChip.value)
assertTrue(vm.recentCommits.value is ProjectDetailViewModel.RecentCommits.Loaded, "log stays isolated")
}
@Test
fun `a log fetch failure isolates to the recent-commits state only`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.NO_PR),
logThrows = true,
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
assertTrue(vm.phase.value is ProjectDetailViewModel.Phase.Loaded)
assertEquals(ProjectDetailViewModel.RecentCommits.Unavailable, vm.recentCommits.value)
// A non-ok availability is still a Loaded chip (degraded copy is a render concern).
val chip = vm.prChip.value as ProjectDetailViewModel.PrChip.Loaded
assertEquals(PrAvailability.NO_PR, chip.status.availability)
}
@Test
fun `the wired worktree VM shares the repo path and refreshes the detail on a successful create`() = runTest {
val gateway = FakeWorktreeGateway(
detail = detail,
prResult = PrStatus(availability = PrAvailability.DISABLED),
logResult = GitLogResult(),
)
val vm = ProjectDetailViewModel.forGateway(gateway, "/repo")
vm.load()
val detailCallsAfterLoad = gateway.detailCalls
vm.worktree!!.create("feat/x")
assertTrue(gateway.detailCalls > detailCallsAfterLoad, "create success re-fetches the detail")
assertEquals("/repo", gateway.createCalls.single().first)
}
}

View File

@@ -202,6 +202,11 @@ class ProjectsViewModelTest {
}
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
override suspend fun projectPr(path: String) = throw NotImplementedError()
override suspend fun projectLog(path: String, n: Int?) = throw NotImplementedError()
override suspend fun createWorktree(path: String, branch: String, base: String?) = throw NotImplementedError()
override suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean) = throw NotImplementedError()
override suspend fun pruneWorktrees(path: String) = throw NotImplementedError()
}
private fun proj(

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.viewmodels
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.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.WorktreeInfo
/**
* W5 WorktreeViewModel (JVM). The guarded worktree write phase machine: client-side branch validation
* (no I/O on a bad name), main-worktree removal blocked client-side, the force flag threaded, and the
* server's SAFE error strings (disabled 403 / 429) surfaced inertly. On success it re-fetches the detail.
*/
class WorktreeViewModelTest {
@Test
fun `an invalid branch name fails with no network I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("bad branch~name") // whitespace + '~' are forbidden
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Failed)
assertEquals(WorktreeCopy.INVALID_BRANCH, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.createCalls.size, "an invalid branch must never hit the network")
}
@Test
fun `a leading dash and dotdot and trailing dot are all rejected client-side`() {
assertTrue(WorktreeViewModel.isValidBranchName("feat/ok-name"))
assertTrue(WorktreeViewModel.isValidBranchName("release/1.2.x"))
listOf("-flag", "a..b", "ends.", "has space", "a~b", "a:b", "@{now}", "", "//x", "/lead", "trail/").forEach {
assertTrue(!WorktreeViewModel.isValidBranchName(it), "should reject '$it'")
}
}
@Test
fun `create success settles Done and re-fetches the detail`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Ok(CreateWorktreeResult(path = "/repo-worktrees/feat", branch = "feat/x")),
)
var refreshes = 0
val vm = WorktreeViewModel(gateway, "/repo", onChanged = { refreshes++ })
vm.create("feat/x", base = "main")
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(1, refreshes, "a successful create must re-fetch the detail")
assertEquals(Triple("/repo", "feat/x", "main"), gateway.createCalls.single())
}
@Test
fun `removing a main worktree is blocked client-side with no I O`() = runTest {
val gateway = FakeWorktreeGateway()
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/repo", branch = "main", isMain = true), force = false)
assertEquals(WorktreeCopy.CANNOT_REMOVE_MAIN, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
assertEquals(0, gateway.removeCalls.size)
}
@Test
fun `remove threads the force flag`() = runTest {
val gateway = FakeWorktreeGateway(removeOutcome = GitWriteOutcome.Ok(RemoveWorktreeResult(path = "/wt/x")))
val vm = WorktreeViewModel(gateway, "/repo")
vm.remove(WorktreeInfo(path = "/wt/x", branch = "feat", isMain = false), force = true)
assertTrue(vm.phase.value is WorktreeViewModel.Phase.Done)
assertEquals(Triple("/repo", "/wt/x", true), gateway.removeCalls.single())
}
@Test
fun `a 403 disabled rejection surfaces the safe server message inertly`() = runTest {
val gateway = FakeWorktreeGateway(
createOutcome = GitWriteOutcome.Rejected(403, "Worktree creation is disabled."),
)
val vm = WorktreeViewModel(gateway, "/repo")
vm.create("feat/x")
assertEquals("Worktree creation is disabled.", (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `a 429 rate-limit surfaces the rate-limited copy`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.RateLimited)
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
assertEquals(WorktreeCopy.RATE_LIMITED, (vm.phase.value as WorktreeViewModel.Phase.Failed).message)
}
@Test
fun `prune with nothing to reclaim reports an empty result`() = runTest {
val gateway = FakeWorktreeGateway(pruneOutcome = GitWriteOutcome.Ok(PruneWorktreesResult(pruned = emptyList())))
val vm = WorktreeViewModel(gateway, "/repo")
vm.prune()
val done = vm.phase.value as WorktreeViewModel.Phase.Done
assertTrue(done.message.contains("没有"))
}
}

View File

@@ -29,17 +29,34 @@ android {
minSdk = 29
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
testOptions {
unitTests {
// The device-enroll orchestration commit logs via android.util.Log — let the JVM unit
// tests stub it (return 0) instead of throwing "not mocked". The security-critical paths
// (commit sequencing, error handling) run on the JVM with a software key double.
isReturnDefaultValues = true
}
}
}
kotlin {
jvmToolchain(17)
}
// JVM (local) unit tests use JUnit 5 (matching the pure modules); AGP's testDebug/ReleaseUnitTest
// tasks are `Test` tasks, so opt them into the JUnit Platform.
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
// Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table),
// CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types.
// (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.)
api(project(":client-tls"))
// B4 device-enroll: the pure CSR encoder + login/enroll/renew client + HttpTransport seam live in
// :api-client (JVM-unit-tested); the framework HardwareBackedKey/DeviceEnroller drive them.
implementation(project(":api-client"))
implementation(libs.tink.android)
implementation(libs.okhttp)
// Mutex serializes the two-store rotation commit (single-commit invariant, A11).
@@ -50,4 +67,10 @@ dependencies {
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators
// Local JVM unit tests (src/test) — the DeviceEnroller enroll/commit orchestration driven with a
// software P-256 key double + the shared FakeHttpTransport (no emulator, no AndroidKeyStore).
testImplementation(project(":test-support"))
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)
}

View File

@@ -0,0 +1,138 @@
package wang.yaojia.webterm.tlsandroid
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.security.Signature
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
/**
* B4 · Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) proof that the generated
* device key is hardware-backed, NON-EXPORTABLE, and produces a self-signed P-256 CSR the
* control-plane accepts. COMPILES in CI here; RUNS on a device/emulator during device QA
* (StrongBox availability is device-dependent — [HardwareKeyStore.generate] falls back to the TEE).
*/
@RunWith(AndroidJUnit4::class)
class HardwareBackedKeyTest {
private val alias = "test-device-enroll-key"
@Before
fun clean() = HardwareKeyStore.delete(alias)
@After
fun tearDown() = HardwareKeyStore.delete(alias)
@Test
fun generate_producesA65ByteX963PublicPoint() {
val key = HardwareKeyStore.generate(alias)
val point = key.publicKeyX963()
assertEquals(65, point.size)
assertEquals(0x04, point[0].toInt() and 0xFF)
}
@Test
fun generatedKeyIsNonExportable() {
HardwareKeyStore.generate(alias)
val loaded = HardwareKeyStore.load(alias)
assertNotNull(loaded)
// AndroidKeyStore private keys have no exportable encoding — the material never leaves HW.
assertNull("AndroidKeyStore key must expose no encoded form", loaded!!.keyHandle.encoded)
}
@Test
fun csrSignedByHardwareKeySelfVerifies() {
val key = HardwareKeyStore.generate(alias)
val der = CertificateSigningRequest.der("t1-android", key)
// Re-parse the CertificationRequestInfo + signature and verify with the embedded public key.
val outer = TestDer.read(der, 0)!!
val parts = TestDer.children(der, outer)
val info = der.copyOfRange(parts[0].start, parts[0].end)
val bitString = parts[2]
val signature = der.copyOfRange(bitString.valueStart + 1, bitString.valueEnd)
// Rebuild a JCA public key from the X9.63 point to run the same crypto check the server does.
val point = key.publicKeyX963()
val pub = X963PublicKeys.p256(point)
val ok = Signature.getInstance("SHA256withECDSA").apply {
initVerify(pub)
update(info)
}.verify(signature)
assertTrue("hardware-signed CSR must self-verify", ok)
}
@Test
fun loadAfterGenerateReturnsAKeyWithTheSamePublicPoint() {
val generated = HardwareKeyStore.generate(alias)
val reloaded = HardwareKeyStore.load(alias)
assertNotNull(reloaded)
assertArrayEquals(generated.publicKeyX963(), reloaded!!.publicKeyX963())
}
@Test
fun loadReturnsNullWhenNoKeyExists() {
assertNull(HardwareKeyStore.load("absent-alias-xyz"))
}
}
/** Reconstruct a P-256 public key from an X9.63 uncompressed point, for on-device signature checks. */
private object X963PublicKeys {
fun p256(point: ByteArray): java.security.PublicKey {
val params = java.security.AlgorithmParameters.getInstance("EC").apply {
init(java.security.spec.ECGenParameterSpec("secp256r1"))
}
val spec = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
val x = java.math.BigInteger(1, point.copyOfRange(1, 33))
val y = java.math.BigInteger(1, point.copyOfRange(33, 65))
val pubSpec = java.security.spec.ECPublicKeySpec(java.security.spec.ECPoint(x, y), spec)
return java.security.KeyFactory.getInstance("EC").generatePublic(pubSpec)
}
}
/** A throwaway canonical-DER reader for structural assertions (device-side mirror of the JVM test). */
private 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, start, index, 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

@@ -100,6 +100,31 @@ class IdentityRepositoryTest {
assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias)
}
/**
* FIX 3 (cache freshness): a device cert committed OUT OF BAND of a running repository (the zero-`.p12`
* [DeviceEnroller] writes the leaf straight into the shared [CertStore] + AndroidKeyStore) is picked up
* by [AndroidIdentityRepository.refreshFromStore] WITHOUT a process restart — the cached "no identity"
* flips to the freshly-committed leaf and is presented on the next handshake.
*/
@Test
fun refreshFromStore_publishesAnOutOfBandCommittedIdentity_withoutRestart() = runBlocking {
val running = newRepository()
// Touch it while nothing is installed — caches the (null) initial identity.
assertFalse(running.hasInstalledIdentity())
// Simulate DeviceEnroller committing an identity out of band (a second repo over the SAME stores).
newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE)
// The running repo still shows its stale cache (no restart yet).
assertFalse("stale cache still reports no identity before a refresh", running.hasInstalledIdentity())
running.refreshFromStore()
// The refresh re-read the committed live-pointer → the enrolled leaf is now live.
assertTrue("refreshFromStore must publish the out-of-band committed identity", running.hasInstalledIdentity())
assertEquals(Fixtures.LEAF_SUBJECT_CN, running.currentSummary()?.subjectCommonName)
}
@Test
fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking {
val repository = newRepository()

View File

@@ -0,0 +1,155 @@
package wang.yaojia.webterm.tlsandroid
import android.util.Log
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import wang.yaojia.webterm.api.enroll.CertificateSigningRequest
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.EnrollmentResult
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.CertificateSummaryReader
/**
* B4 · The Android device-enroll orchestrator — the `.p12`-free path that mirrors iOS
* `KeychainClientIdentityStore.enroll/renew`. It composes the five B4 pieces:
*
* 1. generate a NON-EXPORTABLE hardware key ([HardwareKeyStore]: StrongBox → TEE),
* 2. self-sign a P-256 PKCS#10 CSR with it ([CertificateSigningRequest], `:api-client`),
* 3. run the login → `POST /device/enroll` flow ([DeviceEnrollmentClient], `:api-client`),
* 4. store the returned leaf + issuer chain into the SAME [CertStore] + AndroidKeyStore slot the
* existing [AndroidIdentityRepository] resolves from — so it is presented on the EXISTING
* re-reading `X509KeyManager` mTLS path with no change to that module, and
* 5. expose a silent [renew] against `/device/:id/renew` using the SAME hardware key.
*
* The mutating methods are serialized by a [Mutex] so an enroll and a rotation can never interleave
* the two-store commit (cert live-pointer + enrollment record).
*
* ### The commit
* The cert-store save is THE durable live-pointer flip (identical to the import/rotation path). It is
* written LAST, after the enrollment record, so a successful cert-store save always means the mTLS
* identity is fully live; the pool is then evicted so the next handshake presents the new leaf.
*/
public class DeviceEnroller(
private val client: DeviceEnrollmentClient,
private val certStore: CertStore,
private val recordStore: EnrollmentRecordStore,
private val sharedClient: OkHttpClient,
private val keyAlias: String = AndroidKeyStoreImporter.DEFAULT_ALIAS,
private val keyProvider: DeviceKeyProvider = HardwareDeviceKeyProvider,
// FIX 3 (cache freshness): the in-memory identity cache (AndroidIdentityRepository) is refreshed
// AFTER each commit so a mid-session enroll/renew is presented on the NEXT handshake with no
// process restart. Optional so the JVM orchestration tests can construct the enroller without it.
private val cacheRefresher: IdentityCacheRefresher? = null,
) {
private val commitMutex = Mutex()
/** Raised when a state-changing enroll/renew precondition is not met. Never leaks a secret. */
public class EnrollmentStateException(message: String) : Exception(message)
/**
* One-time enrollment: login (operator password → short-lived `device:enroll` bearer) → generate
* a non-exportable hardware key → CSR → `POST /device/enroll` → store the leaf + present it.
* Returns the installed leaf's display summary. The bearer is held only for this call, never
* persisted or logged.
*/
public suspend fun enroll(
password: String,
subdomain: String,
deviceName: String,
): CertificateSummary = commitMutex.withLock {
val login = client.login(password)
// Generate the hardware key ONLY after a successful login, so a rejected credential never
// burns a fresh key slot; overwrites any stale key at the alias.
val key = keyProvider.generate(keyAlias)
try {
val csr = CertificateSigningRequest.der(deviceName, key)
val result = client.enroll(login.enrollToken, csr, subdomain, deviceName)
commitIdentity(result, deviceName, key.alias)
summaryOf(result)
} catch (e: Exception) {
// The enroll failed AFTER keygen: drop the orphan key so a retry starts clean and no
// unreferenced key lingers in secure hardware.
runCatching { keyProvider.delete(key.alias) }
throw e
}
}
/**
* Silent rotation: re-CSR from the SAME hardware key and replace the leaf via
* `POST /device/:id/renew`. The renew endpoint authenticates by the CURRENT device certificate over
* mTLS (the presented client cert), so [bearerToken] is OPTIONAL and defaults to absent — the
* production caller passes none (mirrors iOS, which renews with `bearerToken: nil`). The seam still
* accepts a bearer for a hypothetical bearer-authenticated renew, but bakes in no credential policy;
* it only re-signs and re-commits. Throws [EnrollmentStateException] if there is nothing enrolled to
* renew or the key is gone.
*/
public suspend fun renew(bearerToken: String? = null): CertificateSummary = commitMutex.withLock {
val record = recordStore.load()
?: throw EnrollmentStateException("no enrollment record — nothing to renew")
val key = keyProvider.load(record.keyStoreAlias)
?: throw EnrollmentStateException("device key missing — a fresh enroll is required")
val csr = CertificateSigningRequest.der(record.deviceName, key)
val result = client.renew(record.deviceId, csr, bearerToken)
commitIdentity(result, record.deviceName, record.keyStoreAlias)
summaryOf(result)
}
/** Remove the enrolled identity: cert pointer, enrollment record, and the hardware key. */
public suspend fun remove(): Unit = commitMutex.withLock {
certStore.clear()
recordStore.clear()
keyProvider.delete(keyAlias)
sharedClient.connectionPool.evictAll()
}
/**
* Persist the enrollment record (deviceId → renew), THEN commit the cert live-pointer (the mTLS
* flip), THEN evict pooled/resumed connections so the next handshake presents the new leaf via
* the existing re-reading `X509KeyManager`. The key already lives in AndroidKeyStore at [alias];
* the private key never enters storage.
*/
private fun commitIdentity(result: EnrollmentResult, deviceName: String, alias: String) {
val leaf = parseCertificate(result.certificate)
val issuers = result.caChain.map { parseCertificate(it) }
recordStore.save(
EnrollmentRecord(
deviceId = result.deviceId,
deviceName = deviceName,
keyStoreAlias = alias,
renewAfterEpochSeconds = result.renewAfter?.epochSecond ?: 0L,
),
)
certStore.save(
StoredIdentityMetadata(
alias = alias,
keyAlgorithm = KEY_ALGORITHM_EC,
keyStoreAlias = alias,
certificateChain = listOf(leaf) + issuers,
),
)
sharedClient.connectionPool.evictAll()
// FIX 3: re-read the just-committed live-pointer into the in-memory identity cache so the
// newly enrolled/renewed leaf is presented on the NEXT mTLS handshake without a restart. Done
// AFTER the durable commit + pool eviction so the cache can never publish an un-committed leaf.
cacheRefresher?.refreshFromStore()
Log.i(TAG, "Device identity enrolled/renewed and committed for alias '$alias'")
}
private fun summaryOf(result: EnrollmentResult): CertificateSummary =
CertificateSummaryReader.summarize(parseCertificate(result.certificate))
private fun parseCertificate(der: ByteArray): X509Certificate =
CertificateFactory.getInstance(X509).generateCertificate(der.inputStream()) as X509Certificate
private companion object {
const val TAG = "DeviceEnroller"
const val X509 = "X.509"
/** AndroidKeyStore EC keys report algorithm "EC" — matched by `ClientKeyManagerLogic`. */
const val KEY_ALGORITHM_EC = "EC"
}
}

View File

@@ -0,0 +1,33 @@
package wang.yaojia.webterm.tlsandroid
/**
* B4 · A seam over the three non-exportable hardware-key operations [DeviceEnroller]'s
* enroll/renew orchestration needs. Production wires the real AndroidKeyStore-backed
* [HardwareKeyStore] (StrongBox → TEE); a JVM unit test wires a software P-256 double, so the
* enroll/commit orchestration (request shaping, error handling, the two-store commit sequencing)
* can be exercised without an emulator. NOTHING about the hardware-key policy leaks through this
* seam beyond generate/load/delete — the key stays non-exportable in the real implementation.
*/
public interface DeviceKeyProvider {
/** Generate a fresh non-exportable key at [alias], overwriting any prior entry there. */
public fun generate(alias: String): HardwareBackedKey
/** Load a previously-generated key by [alias], or null if no entry exists (pre-enroll state). */
public fun load(alias: String): HardwareBackedKey?
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
public fun delete(alias: String)
}
/**
* The production [DeviceKeyProvider] — a thin delegate to the AndroidKeyStore-backed
* [HardwareKeyStore]. Kept as a stateless object so it can be the [DeviceEnroller] constructor
* default without any wiring.
*/
public object HardwareDeviceKeyProvider : DeviceKeyProvider {
override fun generate(alias: String): HardwareBackedKey = HardwareKeyStore.generate(alias)
override fun load(alias: String): HardwareBackedKey? = HardwareKeyStore.load(alias)
override fun delete(alias: String): Unit = HardwareKeyStore.delete(alias)
}

View File

@@ -0,0 +1,143 @@
package wang.yaojia.webterm.tlsandroid
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
/**
* B4 · The auxiliary enrollment record needed to drive silent rotation: the server-minted
* [deviceId] (the `/device/:id/renew` path segment), the [deviceName] re-used as the renew CSR
* subject CN, the AndroidKeyStore [keyStoreAlias] holding the SAME non-exportable key to re-sign
* with, and [renewAfterEpochSeconds] (0 = unknown) for the rotation scheduler.
*
* This is deliberately SEPARATE from [StoredIdentityMetadata] (the mTLS live-pointer): the cert
* identity is what the handshake presents; this record only exists so renew can find the device and
* its key. The private key is never here — it stays non-exportable in AndroidKeyStore.
*/
public data class EnrollmentRecord(
val deviceId: String,
val deviceName: String,
val keyStoreAlias: String,
val renewAfterEpochSeconds: Long,
) {
init {
require(deviceId.isNotBlank()) { "deviceId must not be blank" }
require(keyStoreAlias.isNotBlank()) { "keyStoreAlias must not be blank" }
}
}
/** Length-prefixed binary codec for [EnrollmentRecord] (KISS — three UTF strings + one long). */
public object EnrollmentRecordCodec {
public fun encode(record: EnrollmentRecord): ByteArray {
val out = ByteArrayOutputStream()
DataOutputStream(out).use { data ->
data.writeUTF(record.deviceId)
data.writeUTF(record.deviceName)
data.writeUTF(record.keyStoreAlias)
data.writeLong(record.renewAfterEpochSeconds)
}
return out.toByteArray()
}
/** Decode [bytes]; any structural failure → [CorruptStoredIdentityException]. */
public fun decode(bytes: ByteArray): EnrollmentRecord =
try {
DataInputStream(ByteArrayInputStream(bytes)).use { data ->
EnrollmentRecord(
deviceId = data.readUTF(),
deviceName = data.readUTF(),
keyStoreAlias = data.readUTF(),
renewAfterEpochSeconds = data.readLong(),
)
}
} catch (e: Exception) {
throw CorruptStoredIdentityException("Stored enrollment record was truncated/malformed", e)
}
}
/**
* Storage contract for the [EnrollmentRecord] — repository pattern so [DeviceEnroller] depends on
* the operation set and a fault/blank can be injected in tests. Idempotent [clear].
*/
public interface EnrollmentRecordStore {
public fun save(record: EnrollmentRecord)
public fun load(): EnrollmentRecord?
public fun clear()
}
/**
* Tink-AEAD-encrypted [EnrollmentRecordStore] over an app-private `SharedPreferences` file, mirroring
* [TinkCertStore]'s custody model (AndroidKeystore-wrapped master key; uninstall-wiped; useless off
* this device). Kept in its own key/file namespace so it never collides with the cert live-pointer.
*/
public class TinkEnrollmentRecordStore(
context: Context,
private val keysetName: String = DEFAULT_KEYSET_NAME,
private val prefFileName: String = DEFAULT_PREF_FILE,
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
) : EnrollmentRecordStore {
private val appContext: Context = context.applicationContext
private val aead: Aead by lazy { buildAead() }
override fun save(record: EnrollmentRecord) {
val ciphertext = aead.encrypt(EnrollmentRecordCodec.encode(record), ASSOCIATED_DATA)
val committed = prefs().edit()
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
.commit()
if (!committed) throw java.io.IOException("Failed to durably persist the device enrollment record")
}
override fun load(): EnrollmentRecord? {
val encoded = prefs().getString(BLOB_KEY, null) ?: return null
val ciphertext = try {
Base64.decode(encoded, Base64.NO_WRAP)
} catch (e: IllegalArgumentException) {
throw CorruptStoredIdentityException("Enrollment record blob was not valid base64", e)
}
val plaintext = try {
aead.decrypt(ciphertext, ASSOCIATED_DATA)
} catch (e: java.security.GeneralSecurityException) {
throw CorruptStoredIdentityException("Enrollment record blob failed AEAD decryption", e)
}
return EnrollmentRecordCodec.decode(plaintext)
}
override fun clear() {
val committed = prefs().edit().remove(BLOB_KEY).commit()
if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record")
}
private fun buildAead(): Aead {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(appContext, keysetName, prefFileName)
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
.withMasterKeyUri(masterKeyUri)
.build()
.keysetHandle
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
private fun prefs(): SharedPreferences =
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
public companion object {
private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset"
private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key"
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "enrollment_record_blob"
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray()
}
}

View File

@@ -0,0 +1,122 @@
package wang.yaojia.webterm.tlsandroid
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import android.util.Log
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.Signature
import java.security.cert.X509Certificate
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
import wang.yaojia.webterm.api.enroll.CsrSigner
import wang.yaojia.webterm.api.enroll.EcPointEncoding
/**
* B4 · A P-256 signing key that lives ENTIRELY inside AndroidKeyStore and is NON-EXPORTABLE by
* construction (AndroidKeyStore has no key-material getter). It is the Android analogue of the iOS
* `SecureEnclaveKey`: `sign` runs inside secure hardware (StrongBox → TEE) and drives the same
* `Signature("SHA256withECDSA")` path the JVM-unit-test software key uses, so [CsrSigner] callers
* (`CertificateSigningRequest`) are exercised identically.
*
* The wrapped [privateKey] is the opaque AndroidKeyStore handle — presented to the re-reading
* `X509KeyManager` for the TLS `CertificateVerify` and never exported. [publicKey] is only used to
* emit the CSR's `SubjectPublicKeyInfo`.
*/
public class HardwareBackedKey internal constructor(
public val alias: String,
private val privateKey: PrivateKey,
private val publicKey: ECPublicKey,
) : CsrSigner {
override fun publicKeyX963(): ByteArray = EcPointEncoding.x963(publicKey)
override fun sign(message: ByteArray): ByteArray =
Signature.getInstance(SIGNATURE_ALGORITHM).apply {
initSign(privateKey)
update(message)
}.sign()
/** The opaque, non-exportable AndroidKeyStore private-key handle presented on the mTLS path. */
public val keyHandle: PrivateKey get() = privateKey
public companion object {
private const val SIGNATURE_ALGORITHM = "SHA256withECDSA"
}
}
/**
* Creates / loads / deletes the device's non-exportable P-256 key in AndroidKeyStore.
*
* Generation prefers **StrongBox** (dedicated secure element) and falls back to the **TEE** when the
* device has no StrongBox — the security posture (non-exportable, hardware-backed, silent-signing)
* is identical either way; StrongBox is a hardening bonus, not a requirement. The key is
* `PURPOSE_SIGN` only with a broad digest set so TLS 1.2/1.3 signature negotiation for the client
* `CertificateVerify` works, and NO user-authentication is required so silent enroll/renew never
* blocks on a biometric prompt.
*/
public object HardwareKeyStore {
private const val TAG = "HardwareKeyStore"
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val CURVE = "secp256r1"
/**
* Generate a fresh non-exportable P-256 key at [alias], overwriting any prior entry there.
* StrongBox-backed when available, else TEE-backed. Throws the underlying keystore exception if
* BOTH paths fail (never returns a half-generated key).
*/
public fun generate(alias: String): HardwareBackedKey {
val keyPair = try {
generateKeyPair(alias, strongBox = true)
} catch (_: StrongBoxUnavailableException) {
Log.i(TAG, "StrongBox unavailable; generating a TEE-backed device key (non-exportable)")
delete(alias) // clear any partial StrongBox entry before the TEE retry
generateKeyPair(alias, strongBox = false)
}
return HardwareBackedKey(alias, keyPair.private, keyPair.public as ECPublicKey)
}
/**
* Load a previously-generated key by [alias] (renew path, after relaunch). The public key is
* recovered from the self-signed placeholder certificate AndroidKeyStore stored at generation.
* Returns null if no key entry exists (the normal pre-enroll state).
*/
public fun load(alias: String): HardwareBackedKey? {
val keyStore = androidKeyStore()
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: return null
val publicKey = (keyStore.getCertificate(alias) as? X509Certificate)?.publicKey as? ECPublicKey
?: return null
return HardwareBackedKey(alias, privateKey, publicKey)
}
/** Delete the key entry at [alias]. Idempotent (a missing alias is a no-op). */
public fun delete(alias: String) {
val keyStore = androidKeyStore()
if (keyStore.containsAlias(alias)) keyStore.deleteEntry(alias)
}
/** Cheap existence check (does NOT read key material). */
public fun exists(alias: String): Boolean = androidKeyStore().containsAlias(alias)
private fun generateKeyPair(alias: String, strongBox: Boolean): KeyPair {
val spec = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
.setAlgorithmParameterSpec(ECGenParameterSpec(CURVE))
.setDigests(
KeyProperties.DIGEST_NONE,
KeyProperties.DIGEST_SHA256,
KeyProperties.DIGEST_SHA384,
KeyProperties.DIGEST_SHA512,
)
.setIsStrongBoxBacked(strongBox)
.build()
val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEYSTORE)
generator.initialize(spec)
return generator.generateKeyPair()
}
private fun androidKeyStore(): KeyStore =
KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
}

View File

@@ -56,6 +56,18 @@ public interface IdentityRepository {
public suspend fun remove()
}
/**
* B4 · A narrow seam the zero-`.p12` enroll/renew commit ([DeviceEnroller]) fires so an in-memory
* identity cache re-reads the freshly-committed live-pointer and presents the new leaf on the NEXT
* mTLS handshake WITHOUT a process restart. Kept separate from [IdentityRepository] so the enroller
* depends only on this one operation (it never needs the import/rotate/remove surface). The production
* implementation is [AndroidIdentityRepository]; a JVM test uses a recording double.
*/
public fun interface IdentityCacheRefresher {
/** Reload the persisted live identity into the in-memory cache and drop stale pooled connections. */
public fun refreshFromStore()
}
/**
* Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted
* live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`).
@@ -90,7 +102,7 @@ public class AndroidIdentityRepository(
private val importer: AndroidKeyStoreImporter,
private val certStore: CertStore,
private val sharedClient: OkHttpClient,
) : IdentityRepository {
) : IdentityRepository, IdentityCacheRefresher {
/** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */
private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String)
@@ -150,6 +162,20 @@ public class AndroidIdentityRepository(
sharedClient.connectionPool.evictAll()
}
/**
* FIX 3 (cache freshness) · Re-read the persisted live-pointer into the in-memory cache. Used when a
* device certificate is committed OUT OF BAND of this repository — the zero-`.p12` [DeviceEnroller]
* writes the leaf straight into the shared [CertStore] + AndroidKeyStore, so without this the running
* repo would keep presenting its cached (pre-enroll) identity until process restart. Publishing the
* freshly-loaded snapshot as [liveOverride] and evicting pooled connections makes the enrolled leaf
* present on the NEXT handshake. Reloading to `null` (a fault/absent pointer) is a valid outcome and
* simply reports "no identity". Not `suspend` — the enroller already runs this off the UI thread.
*/
override fun refreshFromStore() {
liveOverride = Box(loadInstalledOrNull())
sharedClient.connectionPool.evictAll()
}
/**
* Single-commit install/rotation (see the class KDoc). Validation throws before any mutation;
* the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that

View File

@@ -0,0 +1,313 @@
package wang.yaojia.webterm.tlsandroid
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
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.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HttpMethod
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec
/**
* B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
* the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider]
* double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most
* importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore.
*
* The security-critical invariant under test: the enrollment record is persisted BEFORE the cert
* live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is
* fully live (see [DeviceEnroller.commitIdentity]).
*/
class DeviceEnrollerTest {
private companion object {
const val BASE = "https://cp.terminal.yaojia.wang"
const val ALIAS = "test-device-key"
// Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory /
// CertificateSummaryReader parse them exactly as they parse a server-issued leaf.
const val LEAF_CN = "t1-device"
const val CA_CN = "webterm-device-ca"
const val LEAF_B64 =
"MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
"ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
"WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
"cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
"HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
"ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
"cdoleWDlqcMKU"
const val CA_B64 =
"MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
"dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
"YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
"e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
"4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
"Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
"YloHuEAg+kngzA33m52aWtublai4L+eybg=="
fun loginBody(): ByteArray =
"""{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()
fun enrollBody(deviceId: String = "dev-1"): ByteArray =
"""
{"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
"notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
"renewAfter":"2026-09-05T00:00:00.000Z"}
""".trimIndent().toByteArray()
fun softwareKey(alias: String): HardwareBackedKey {
val kpg = KeyPairGenerator.getInstance("EC")
kpg.initialize(ECGenParameterSpec("secp256r1"))
val kp = kpg.generateKeyPair()
return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
}
}
private val events = mutableListOf<String>()
private val transport = FakeHttpTransport()
private val certStore = RecordingCertStore(events)
private val recordStore = RecordingRecordStore(events)
private val keyProvider = RecordingKeyProvider(events)
private val refresher = RecordingRefresher(events)
private fun enroller(): DeviceEnroller =
DeviceEnroller(
client = DeviceEnrollmentClient(BASE, transport),
certStore = certStore,
recordStore = recordStore,
sharedClient = OkHttpClient(),
keyAlias = ALIAS,
keyProvider = keyProvider,
cacheRefresher = refresher,
)
// ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────
@Test
fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
// Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST.
assertTrue(events.contains("record.save") && events.contains("cert.save"))
assertTrue(
events.indexOf("record.save") < events.indexOf("cert.save"),
"the enrollment record must be committed BEFORE the cert live-pointer flip",
)
// Both stores received the issued identity; the stored chain is leaf + issuer (from caChain).
assertEquals("dev-1", recordStore.saved!!.deviceId)
assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias)
val chain = certStore.saved!!.certificateChain
assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer")
assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf")
assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer")
// The install summary is read off the leaf via the production CertificateSummaryReader.
assertEquals(LEAF_CN, summary.subjectCommonName)
}
@Test
fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
// FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so
// the newly enrolled leaf is presented on the next handshake with no restart.
assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll")
assertTrue(
events.indexOf("cert.save") < events.indexOf("cache.refresh"),
"the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)",
)
}
@Test
fun enrollShapesTheLoginAndEnrollRequests() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())
enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")
val login = transport.recordedRequests[0]
assertEquals("$BASE/auth/login", login.url)
assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\""))
val enroll = transport.recordedRequests[1]
assertEquals("$BASE/device/enroll", enroll.url)
assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer")
val enrollBodyStr = enroll.body!!.decodeToString()
assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\""))
assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\""))
}
// ── enroll: error handling ─────────────────────────────────────────────────────────────────
@Test
fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray())
val error = runCatching {
enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel")
}.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error)
assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login")
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure")
assertNull(recordStore.saved, "no record is committed when enroll fails")
assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails")
}
@Test
fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray())
val error = runCatching {
enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel")
}.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot")
assertNull(recordStore.saved)
assertNull(certStore.saved)
}
// ── renew: preconditions + request shaping + commit ────────────────────────────────────────
@Test
fun renewThrowsWhenNothingIsEnrolled() = runTest {
val error = runCatching { enroller().renew() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew")
}
@Test
fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
// keyProvider has no key at ALIAS → load() returns null.
val error = runCatching { enroller().renew() }.exceptionOrNull()
assertTrue(error is DeviceEnroller.EnrollmentStateException)
assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone")
}
@Test
fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody())
// FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS).
enroller().renew()
val renew = transport.recordedRequests.single()
assertEquals("$BASE/device/dev-1/renew", renew.url)
assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
val body = renew.body!!.decodeToString()
// The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra.
assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR")
assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg")
assertFalse(body.contains("subdomain"), "renew must not send subdomain")
assertFalse(body.contains("deviceName"), "renew must not send deviceName")
// Same commit sequencing on the rotation path: record before cert, then cache refresh last.
assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"))
assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
}
// ── remove: full teardown ──────────────────────────────────────────────────────────────────
@Test
fun removeClearsBothStoresAndDeletesTheKey() = runTest {
recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
keyProvider.seed(ALIAS, softwareKey(ALIAS))
enroller().remove()
assertTrue(certStore.cleared)
assertTrue(recordStore.cleared)
assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove")
}
// ── recording doubles ──────────────────────────────────────────────────────────────────────
private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
var saved: StoredIdentityMetadata? = null
var cleared = false
override fun save(metadata: StoredIdentityMetadata) {
saved = metadata
events += "cert.save"
}
override fun load(): StoredIdentityMetadata? = saved
override fun clear() {
cleared = true
saved = null
events += "cert.clear"
}
}
private class RecordingRecordStore(private val events: MutableList<String>) : EnrollmentRecordStore {
var saved: EnrollmentRecord? = null
var cleared = false
private var current: EnrollmentRecord? = null
fun seed(record: EnrollmentRecord) {
current = record
}
override fun save(record: EnrollmentRecord) {
saved = record
current = record
events += "record.save"
}
override fun load(): EnrollmentRecord? = current
override fun clear() {
cleared = true
current = null
events += "record.clear"
}
}
private class RecordingRefresher(private val events: MutableList<String>) : IdentityCacheRefresher {
override fun refreshFromStore() {
events += "cache.refresh"
}
}
private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
private val keys = mutableMapOf<String, HardwareBackedKey>()
val generatedAliases = mutableListOf<String>()
val deletedAliases = mutableListOf<String>()
fun seed(alias: String, key: HardwareBackedKey) {
keys[alias] = key
}
override fun generate(alias: String): HardwareBackedKey {
val key = softwareKey(alias)
keys[alias] = key
generatedAliases += alias
events += "generate:$alias"
return key
}
override fun load(alias: String): HardwareBackedKey? = keys[alias]
override fun delete(alias: String) {
keys.remove(alias)
deletedAliases += alias
events += "delete:$alias"
}
}
}

View File

@@ -0,0 +1,142 @@
/**
* B1 — operator login → `device:enroll` bearer mint (registerable Fastify plugin; wired in `main.ts`).
*
* POST /auth/login { "password": "<operator secret>" }
* -> 201 { "enrollToken": "<v4.public PASETO>", "accountId": "<uuid>", "expiresIn": <seconds> }
*
* The `enrollToken` is the short-lived §4.3 `device:enroll` capability token that `POST /device/enroll`
* requires as `Authorization: Bearer <enrollToken>`. This is the ONE authenticated human action that
* bootstraps the phone track (the honest "one bootstrap tap" constraint) — everything after (CSR →
* cert → silent renew) is certificate-authenticated.
*
* SECURITY (this path ultimately mints device certs):
* - credential compare is CONSTANT-TIME (delegated to `loginToAccountId` → `timingSafeEqualBytes`);
* - login attempts are RATE-LIMITED per client (sliding window, mirrors registry/devices.ts);
* - DENY-BY-DEFAULT + FAIL-CLOSED: unset operator credential ⇒ 503 (never mints); wrong ⇒ 401;
* - the operator secret and the minted token are NEVER logged (INV9);
* - input is Zod-validated at the boundary; `accountId` is server-config, never a client field.
*/
import { z } from 'zod'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import {
loginToAccountId,
mintDeviceEnrollToken,
DeviceEnrollAuthError,
DEFAULT_DEVICE_ENROLL_TTL_SEC,
MIN_DEVICE_ENROLL_TTL_SEC,
MAX_DEVICE_ENROLL_TTL_SEC,
type LoginSeamConfig,
} from '../auth/session.js'
/** Per-client login attempts allowed within the window (brute-force throttle). */
export const DEFAULT_LOGIN_RATE_MAX = 10
/** Login rate window (ms): 15 minutes. */
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
export interface AuthLoginDeps {
/** Ed25519 signing key for the enroll bearer (PRIVATE half of `CAPABILITY_SIGN_PUBKEY_B64`). */
readonly signingKey: CryptoKey | null
/** How a credential resolves to an accountId (single-operator MVP or an injected resolver). */
readonly loginConfig: LoginSeamConfig
/** Minted bearer TTL (seconds); clamped to the enroll-token bounds. Defaults to 10 min. */
readonly enrollTtlSec?: number
readonly rateMax?: number
readonly rateWindowMs?: number
/** Clock (ms) — injectable for tests. */
readonly now?: () => number
/** Client-bucket key for rate-limiting (defaults to the socket IP). Injectable for tests. */
readonly clientKey?: (req: FastifyRequest) => string
}
const LoginBodySchema = z.object({ password: z.string().min(1).max(512) }).strict()
/** Uniform rate-limit reject → 429. */
class LoginRateError extends Error {
constructor() {
super('login rate limited')
this.name = 'LoginRateError'
}
}
/** In-process sliding-window limiter keyed by client bucket (mirrors registry/devices.ts). */
function createLoginLimiter(max: number, windowMs: number, now: () => number) {
const hits = new Map<string, number[]>()
return {
check(key: string): void {
const ts = now()
const cutoff = ts - windowMs
const arr = (hits.get(key) ?? []).filter((t) => t > cutoff)
if (arr.length >= max) {
hits.set(key, arr) // persist the pruned window; do NOT record this rejected attempt
throw new LoginRateError()
}
arr.push(ts)
hits.set(key, arr)
},
}
}
function clampEnrollTtl(ttl: number): number {
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
}
/** The feature is live only when a signing key AND a resolvable credential seam are BOTH present. */
function isConfigured(deps: AuthLoginDeps): deps is AuthLoginDeps & { signingKey: CryptoKey } {
if (deps.signingKey === null) return false
const c = deps.loginConfig
return c.resolve !== undefined || (c.operatorCredential !== undefined && c.accountId !== undefined)
}
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed (no oracle). */
function sendLoginError(reply: FastifyReply, err: unknown): void {
if (err instanceof LoginRateError) {
void reply.code(429).send({ error: 'rate_limited' })
return
}
if (err instanceof DeviceEnrollAuthError) {
void reply.code(err.status).send({ error: 'rejected' })
return
}
if (err instanceof z.ZodError) {
void reply.code(400).send({ error: 'invalid request' })
return
}
void reply.code(400).send({ error: 'rejected' })
}
export function buildAuthLoginRouter(deps: AuthLoginDeps): FastifyPluginAsync {
const now = deps.now ?? (() => Date.now())
const clientKey = deps.clientKey ?? ((req: FastifyRequest) => req.ip || 'unknown')
const limiter = createLoginLimiter(deps.rateMax ?? DEFAULT_LOGIN_RATE_MAX, deps.rateWindowMs ?? DEFAULT_LOGIN_RATE_WINDOW_MS, now)
const ttl = clampEnrollTtl(deps.enrollTtlSec ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
return async (app) => {
app.post('/auth/login', async (req, reply) => {
try {
// Rate-limit BEFORE any credential work so brute force is throttled regardless of outcome.
limiter.check(clientKey(req))
const { password } = LoginBodySchema.parse(req.body)
// Fail-closed: an unconfigured login mints nothing (503, distinct from a wrong-credential 401).
if (!isConfigured(deps)) {
void reply.code(503).send({ error: 'login unavailable' })
return
}
// Constant-time credential → accountId (deny-by-default inside loginToAccountId). Any failure
// becomes a uniform 401 — never distinguish wrong-password from unknown-credential.
let accountId: string
try {
accountId = loginToAccountId(password, deps.loginConfig)
} catch {
throw new DeviceEnrollAuthError(401, 'login rejected')
}
const enrollToken = await mintDeviceEnrollToken(accountId, { signingKey: deps.signingKey, ttlSeconds: ttl })
await reply.code(201).send({ enrollToken, accountId, expiresIn: ttl })
} catch (err) {
sendLoginError(reply, err)
}
})
}
}

View File

@@ -171,7 +171,15 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA
const value = Array.isArray(raw) ? raw[0] : raw
if (typeof value !== 'string' || value.length === 0) return null
try {
return new Uint8Array(Buffer.from(value, 'base64'))
// The terminator may forward the verified cert as base64 DER, OR as PEM — including nginx's
// header-safe `$ssl_client_escaped_cert` (URL-encoded PEM). Normalize all three to raw DER:
// URL-decode if escaped, then strip PEM armor + whitespace to recover the base64 DER body.
let s = value.includes('%') ? safeDecodeUri(value) : value
if (s.includes('BEGIN CERTIFICATE')) {
s = s.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
}
const der = Buffer.from(s, 'base64')
return der.length > 0 ? new Uint8Array(der) : null
} catch {
return null
}
@@ -179,6 +187,15 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA
}
}
/** URL-decode `value`, returning it unchanged if it is not valid percent-encoding. */
function safeDecodeUri(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
/**
* Trust-anchor verification of the presented current cert (defense in depth: the mTLS terminator has
* already verified it, but this route must not trust a cert the terminator would never have accepted).

View File

@@ -22,7 +22,7 @@
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
import { verifyCapabilityToken } from 'relay-auth'
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
import { randomBytes, randomUUID } from 'node:crypto'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { timingSafeEqualBytes } from '../util/bytes.js'
/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
@@ -157,8 +157,11 @@ export function loginToAccountId(credential: string, config: LoginSeamConfig): s
return acct
}
if (config.operatorCredential !== undefined && config.accountId !== undefined) {
const a = new TextEncoder().encode(credential)
const b = new TextEncoder().encode(config.operatorCredential)
// SHA-256 both to a fixed 32 bytes BEFORE comparing, so the compare is
// length-independent — timingSafeEqualBytes short-circuits on unequal length,
// which would otherwise leak the operator credential's length via timing.
const a = createHash('sha256').update(credential, 'utf8').digest()
const b = createHash('sha256').update(config.operatorCredential, 'utf8').digest()
if (timingSafeEqualBytes(a, b)) return config.accountId
throw new DeviceEnrollAuthError(401, 'login rejected')
}

View File

@@ -13,8 +13,9 @@
* `x509-assembler` normalizes to DER; both expose the same `sign()` so callers stay KMS-shaped.
*/
import { createPublicKey, generateKeyPairSync, sign as nodeSign } from 'node:crypto'
import { readFileSync } from 'node:fs'
import type { ControlPlaneEnv } from '../env.js'
import { ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
import { createPrivateKey, ed25519Sign, generateEd25519, type KeyObject } from '../util/crypto.js'
/** Wraps KMS `sign()`; no raw key ever crosses this boundary. */
export interface CaSigner {
@@ -98,3 +99,56 @@ export function inProcessP256CaSigner(privateKey?: KeyObject): CaSigner {
},
}
}
/** Prefix marking a KMS key ref that is actually an on-disk PEM key path (`file:<path>`). */
const FILE_KEY_REF_PREFIX = 'file:'
/** OpenSSL's name for the P-256 (secp256r1) curve — the ONLY curve the native-tunnel CAs use. */
const P256_CURVE = 'prime256v1'
/** Build a `file:<path>` KMS key ref from an on-disk native-CA private key path. */
export function fileKeyRef(path: string): string {
return `${FILE_KEY_REF_PREFIX}${path}`
}
/**
* Load a PKCS#8 PEM P-256 private key from disk and wrap it in the SAME `CaSigner` surface as
* `inProcessP256CaSigner` (raw P1363 `r||s` over the DER TBS). FAIL-FAST on an unreadable file,
* malformed PEM, or a non-P-256 key. The raw key stays in-process — NEVER logged/serialised (INV9).
*/
function loadP256FileSigner(path: string): CaSigner {
let pem: string
try {
pem = readFileSync(path, 'utf8')
} catch {
// Never echo the path contents — only that the file was unreadable (INV9).
throw new Error('native-CA private key file is unreadable')
}
let key: KeyObject
try {
key = createPrivateKey(pem)
} catch {
throw new Error('native-CA private key is not a valid PEM private key')
}
if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== P256_CURVE) {
throw new Error('native-CA private key must be a P-256 (prime256v1) EC key')
}
return inProcessP256CaSigner(key)
}
/**
* FILE-BACKED KmsResolver — the production native-tunnel key custody for the single-owner VPS deploy.
* Resolves a key ref of the form `file:<path>` (or a bare path) by loading the on-disk PEM P-256 key
* into an in-process `CaSigner`. `policyRestrictedToServicePrincipal` is TRUE because the on-disk key
* IS the control-plane's sole custody (documented file-custody reality; a real non-exportable KMS —
* `KmsResolver` in front of KMS/HSM — is the future upgrade). Throws if the ref cannot be resolved so
* `buildCaSigner` fails fast at boot. NEVER logs key material (INV9).
*/
export function fileKmsResolver(): KmsResolver {
return {
async resolve(keyRef: string) {
const path = keyRef.startsWith(FILE_KEY_REF_PREFIX) ? keyRef.slice(FILE_KEY_REF_PREFIX.length) : keyRef
if (path.length === 0) throw new Error('file KMS key ref has an empty path')
return { signer: loadP256FileSigner(path), policyRestrictedToServicePrincipal: true }
},
}
}

View File

@@ -21,9 +21,17 @@
import 'reflect-metadata'
import * as x509 from '@peculiar/x509'
import { webcrypto, generateKeyPairSync } from 'node:crypto'
import type { ControlPlaneEnv } from '../env.js'
import { readFileSync } from 'node:fs'
import type { ControlPlaneEnv, NativeCaEnvConfig } from '../env.js'
import { assembleCertificate } from '../ca/x509-assembler.js'
import { buildCaSigner, inProcessP256CaSigner, type CaSigner, type KmsResolver } from './ca-wiring.js'
import {
buildCaSigner,
fileKeyRef,
fileKmsResolver,
inProcessP256CaSigner,
type CaSigner,
type KmsResolver,
} from './ca-wiring.js'
x509.cryptoProvider.set(webcrypto)
@@ -153,3 +161,50 @@ export async function buildNativeCas(env: ControlPlaneEnv, opts: BuildNativeCasO
])
return { frpClientCa, deviceCa }
}
/** Load one CA's (public) cert PEM from disk as anchor DER, failing loud on unreadable/unparseable material (INV9). */
function loadCaCertDer(certPath: string): Uint8Array {
let pem: string
try {
pem = readFileSync(certPath, 'utf8')
} catch {
// Never echo the file contents — only that it was unreadable (INV9).
throw new Error('native-CA certificate file is unreadable — refusing to boot')
}
try {
return new Uint8Array(new x509.X509Certificate(pem).rawData)
} catch {
throw new Error('native-CA certificate material is not a parseable X.509 certificate — refusing to boot')
}
}
/**
* PRODUCTION wiring from ON-DISK material — the A2-prep prerequisite for deploying the control-plane.
* Builds both native-tunnel CAs from the VPS `gen-device-ca.sh` output: each CA's (public) cert PEM
* becomes its trust-anchor DER and its PKCS#8 PEM private key path becomes a `file:` KMS ref resolved
* by `fileKmsResolver` (single-owner file-custody; a non-exportable KMS is the upgrade). Delegates to
* `buildNativeCas` in production mode so the SAME INV9 fail-fast applies — any unreadable/unparseable
* cert or non-P-256 key refuses to boot. Raw key bytes are never loaded/logged at this layer.
*/
export async function buildFileBackedNativeCas(
env: ControlPlaneEnv,
config: NativeCaEnvConfig,
opts: { readonly now?: () => number } = {},
): Promise<NativeCas> {
const material: NativeCaMaterial = {
frpClientCa: {
kmsKeyRef: fileKeyRef(config.frpClientCaKeyPath),
caCertDer: loadCaCertDer(config.frpClientCaCertPath),
},
deviceCa: {
kmsKeyRef: fileKeyRef(config.deviceCaKeyPath),
caCertDer: loadCaCertDer(config.deviceCaCertPath),
},
}
return buildNativeCas(env, {
production: true,
kmsResolver: fileKmsResolver(),
material,
...(opts.now !== undefined ? { now: opts.now } : {}),
})
}

View File

@@ -0,0 +1,26 @@
/**
* B1 — load the `device:enroll` bearer SIGNING key (Ed25519) from the CP env's PKCS#8 DER.
*
* The enroll bearer must verify on the SAME §4.3 path the admin API uses (boot/verifier.ts, keyed off
* `CAPABILITY_SIGN_PUBKEY_B64`), so the login route signs with the PRIVATE half of that same keypair.
* The key is imported NON-EXPORTABLE + `sign`-only (INV9: raw key material is never held as bytes and
* never logged). A malformed / non-Ed25519 key FAILS CLOSED (throws) — the CP refuses to serve a login
* route it cannot mint from.
*/
/**
* Import the Ed25519 PKCS#8 private key as a non-exportable, sign-only `CryptoKey`. Throws (fail-closed)
* on any malformed key; the error message never echoes key material (INV9).
*/
export async function loadEnrollSigningKey(pkcs8Der: Uint8Array): Promise<CryptoKey> {
// Copy into a fresh ArrayBuffer-backed view (WebCrypto BufferSource typing / no shared pool).
const bytes = new Uint8Array(pkcs8Der.length)
bytes.set(pkcs8Der)
try {
return await globalThis.crypto.subtle.importKey('pkcs8', bytes, { name: 'Ed25519' }, false, ['sign'])
} catch (err: unknown) {
throw new Error(
`failed to load device:enroll signing key: ${err instanceof Error ? err.message : 'unknown'}`,
)
}
}

View File

@@ -42,6 +42,41 @@ export interface ControlPlaneEnv {
readonly heartbeatTtlSec: number
readonly pairingTtlSec: number
readonly pairingMaxRedeemAttempts: number
/**
* B1 operator-login seam (single-tenant MVP). ALL THREE are set together or NONE — a half-configured
* login is a boot error (fail-fast). Unset ⇒ the `/auth/login` route is fail-closed (503). When set:
* - `operatorPassword` — the operator login secret (mirrors WEBTERM_TOKEN / relay OPERATOR_PASSWORD;
* 16512 URL/cookie-safe chars). Constant-time compared, NEVER logged (INV9).
* - `operatorAccountId` — the account the minted `device:enroll` bearer is scoped to (`sub`).
* - `capabilitySignPrivkey` — Ed25519 PKCS#8 DER private key whose PUBLIC half is
* `capabilitySignPubkey`; used ONLY to sign the enroll bearer so it verifies on the same §4.3 path.
*/
readonly operatorPassword?: string
readonly operatorAccountId?: string
readonly capabilitySignPrivkey?: Uint8Array
/**
* A2-prep — the native-tunnel PKI's on-disk P-256 CA material (the VPS `gen-device-ca.sh` output:
* `frp-client-CA` for host frp-client leaves + `device-CA` for device leaves). Set together or NOT
* at all — a partial set is a boot error (fail-closed). Undefined ⇒ the dev/test injection path
* (self-signed in-process CAs) is used. The private KEY paths mirror `CA_INTERMEDIATE_KEY_PATH`
* (optional; derived from the cert path when unset). The raw keys are custody-on-disk (single-owner
* file-custody reality; a non-exportable KMS is the future upgrade).
*/
readonly nativeCa?: NativeCaEnvConfig
}
/** Resolved on-disk material for the two native-tunnel P-256 CAs + the DNS zone their leaves live under. */
export interface NativeCaEnvConfig {
/** frp-client-CA (public) cert PEM path — the host-leaf trust anchor. */
readonly frpClientCaCertPath: string
/** frp-client-CA PKCS#8 PEM private key path (derived from the cert path when unset). */
readonly frpClientCaKeyPath: string
/** device-CA (public) cert PEM path — the device-leaf trust anchor. */
readonly deviceCaCertPath: string
/** device-CA PKCS#8 PEM private key path (derived from the cert path when unset). */
readonly deviceCaKeyPath: string
/** DNS zone the leaves' `dNSName <sub>.<zone>` SAN is stamped under (the nginx :8470 tenant key). */
readonly dnsZone: string
}
/** A positive integer parsed from an env string, or a default when unset/empty. */
@@ -77,8 +112,101 @@ const EnvSchema = z.object({
HEARTBEAT_TTL_SEC: intWithDefault(15),
PAIRING_TTL_SEC: intWithDefault(DEFAULT_PAIRING_TTL_SEC),
PAIRING_MAX_REDEEM_ATTEMPTS: intWithDefault(DEFAULT_PAIRING_MAX_REDEEM_ATTEMPTS),
// B1 operator-login seam — all optional; cross-validated below (set together or not at all).
OPERATOR_PASSWORD: z
.string()
.trim()
.regex(/^[A-Za-z0-9._~+/=-]{16,512}$/, 'OPERATOR_PASSWORD must be 16512 URL/cookie-safe chars')
.optional(),
OPERATOR_ACCOUNT_ID: z.string().trim().uuid('OPERATOR_ACCOUNT_ID must be a UUID').optional(),
CAPABILITY_SIGN_PRIVKEY_B64: z.string().trim().min(1).optional(),
// A2-prep native-tunnel CA material — all optional; cross-validated below (set together or not at all).
NATIVE_FRP_CLIENT_CA_CERT_PATH: z.string().trim().optional(),
NATIVE_FRP_CLIENT_CA_KEY_PATH: z.string().trim().optional(),
NATIVE_DEVICE_CA_CERT_PATH: z.string().trim().optional(),
NATIVE_DEVICE_CA_KEY_PATH: z.string().trim().optional(),
NATIVE_DNS_ZONE: z.string().trim().optional(),
})
/**
* Resolve the optional operator-login triplet. Deny-by-default + fail-fast: if the operator password
* is set, the account id and the Ed25519 signing key MUST also be set (a half-configured login is a
* boot error, never a silent half-open). Returns `{}` when the feature is unconfigured. NEVER echoes
* secret VALUES — only key names on failure (INV9).
*/
function resolveOperatorLogin(e: {
OPERATOR_PASSWORD: string | undefined
OPERATOR_ACCOUNT_ID: string | undefined
CAPABILITY_SIGN_PRIVKEY_B64: string | undefined
}): { operatorPassword?: string; operatorAccountId?: string; capabilitySignPrivkey?: Uint8Array } {
const has = (v: string | undefined): v is string => v !== undefined && v.length > 0
const pw = e.OPERATOR_PASSWORD
const acct = e.OPERATOR_ACCOUNT_ID
const priv = e.CAPABILITY_SIGN_PRIVKEY_B64
if (!has(pw) && !has(acct) && !has(priv)) {
return {} // feature off — /auth/login is fail-closed at runtime
}
if (!has(pw) || !has(acct) || !has(priv)) {
const missing = [
has(pw) ? null : 'OPERATOR_PASSWORD',
has(acct) ? null : 'OPERATOR_ACCOUNT_ID',
has(priv) ? null : 'CAPABILITY_SIGN_PRIVKEY_B64',
].filter((x): x is string => x !== null)
throw new Error(`Invalid control-plane env: operator login requires all of ${missing.join(', ')} to be set`)
}
let capabilitySignPrivkey: Uint8Array
try {
capabilitySignPrivkey = base64ToBytes(priv)
} catch {
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PRIVKEY_B64 is not valid base64')
}
return { operatorPassword: pw, operatorAccountId: acct, capabilitySignPrivkey }
}
/**
* Resolve the optional native-tunnel CA material. Deny-by-default + fail-fast: the two (public) CA
* cert paths and the DNS zone are the mandatory triad; if ANY native field is supplied (including a
* lone KEY_PATH), all three MUST be present or boot fails (a partial set is never a silent half-open,
* mirroring `resolveOperatorLogin`). The private KEY paths are optional and derived from their cert
* path when unset (mirrors `CA_INTERMEDIATE_KEY_PATH`). Returns `{}` when the feature is unconfigured.
* NEVER echoes path VALUES — only field NAMES on failure (INV9).
*/
function resolveNativeCa(e: {
NATIVE_FRP_CLIENT_CA_CERT_PATH: string | undefined
NATIVE_FRP_CLIENT_CA_KEY_PATH: string | undefined
NATIVE_DEVICE_CA_CERT_PATH: string | undefined
NATIVE_DEVICE_CA_KEY_PATH: string | undefined
NATIVE_DNS_ZONE: string | undefined
}): { nativeCa?: NativeCaEnvConfig } {
const has = (v: string | undefined): v is string => v !== undefined && v.length > 0
const frpCert = e.NATIVE_FRP_CLIENT_CA_CERT_PATH
const frpKey = e.NATIVE_FRP_CLIENT_CA_KEY_PATH
const devCert = e.NATIVE_DEVICE_CA_CERT_PATH
const devKey = e.NATIVE_DEVICE_CA_KEY_PATH
const zone = e.NATIVE_DNS_ZONE
const anyPresent = [frpCert, frpKey, devCert, devKey, zone].some(has)
if (!anyPresent) return {} // feature off — dev/test injection path (self-signed in-process CAs)
if (!has(frpCert) || !has(devCert) || !has(zone)) {
const missing = [
has(frpCert) ? null : 'NATIVE_FRP_CLIENT_CA_CERT_PATH',
has(devCert) ? null : 'NATIVE_DEVICE_CA_CERT_PATH',
has(zone) ? null : 'NATIVE_DNS_ZONE',
].filter((x): x is string => x !== null)
throw new Error(
`Invalid control-plane env: native-tunnel CA material requires all of ${missing.join(', ')} to be set`,
)
}
return {
nativeCa: {
frpClientCaCertPath: frpCert,
frpClientCaKeyPath: has(frpKey) ? frpKey : deriveKeyPath(frpCert),
deviceCaCertPath: devCert,
deviceCaKeyPath: has(devKey) ? devKey : deriveKeyPath(devCert),
dnsZone: zone,
},
}
}
/**
* Parse + validate control-plane config. THROWS (fail-fast) listing every missing/invalid
* key by NAME. Never echoes secret VALUES (INV9).
@@ -125,5 +253,17 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
pairingTtlSec: e.PAIRING_TTL_SEC,
pairingMaxRedeemAttempts: e.PAIRING_MAX_REDEEM_ATTEMPTS,
...resolveOperatorLogin({
OPERATOR_PASSWORD: e.OPERATOR_PASSWORD,
OPERATOR_ACCOUNT_ID: e.OPERATOR_ACCOUNT_ID,
CAPABILITY_SIGN_PRIVKEY_B64: e.CAPABILITY_SIGN_PRIVKEY_B64,
}),
...resolveNativeCa({
NATIVE_FRP_CLIENT_CA_CERT_PATH: e.NATIVE_FRP_CLIENT_CA_CERT_PATH,
NATIVE_FRP_CLIENT_CA_KEY_PATH: e.NATIVE_FRP_CLIENT_CA_KEY_PATH,
NATIVE_DEVICE_CA_CERT_PATH: e.NATIVE_DEVICE_CA_CERT_PATH,
NATIVE_DEVICE_CA_KEY_PATH: e.NATIVE_DEVICE_CA_KEY_PATH,
NATIVE_DNS_ZONE: e.NATIVE_DNS_ZONE,
}),
}
}

View File

@@ -43,6 +43,9 @@ import { createFrpClientLeafSigner } from './ca/frpclient-issue.js'
import { createDeviceLeafSigner, type DeviceLeafSigner } from './ca/device-issue.js'
import { createLeafRenewer } from './ca/rotate.js'
import { buildDeviceEnrollRouter, type SubdomainOwnershipResolver } from './api/device-enroll.js'
import { buildAuthLoginRouter } from './api/auth-login.js'
import { loadEnrollSigningKey } from './boot/session-signing.js'
import type { LoginSeamConfig } from './auth/session.js'
import { buildRenewRouter } from './api/renew.js'
import { buildNativeCas, DEFAULT_NATIVE_DNS_ZONE, type NativeCas, type NativeCaMaterial } from './boot/native-ca.js'
import type { RevocationBus } from 'relay-contracts'
@@ -55,6 +58,13 @@ export interface ControlPlaneOverrides {
readonly caChainDer?: readonly Uint8Array[]
/** Production mode → native-CA + renew-anchor material is fail-closed (INV9). Defaults to NODE_ENV. */
readonly production?: boolean
/**
* Pre-built native-tunnel CAs (A2-prep production path). When supplied, `buildControlPlane` uses
* these verbatim and does NOT call `buildNativeCas` — this is how `server.ts` threads the two
* on-disk P-256 CAs (`buildFileBackedNativeCas`) in without coupling them to the intermediate
* `kmsResolver`. Takes precedence over `nativeCaMaterial`.
*/
readonly nativeCas?: NativeCas
/** Production-loaded native-tunnel CA material (per-CA KMS ref + public cert DER). */
readonly nativeCaMaterial?: NativeCaMaterial
/** DNS zone the native-tunnel leaves are stamped under (defaults to `terminal.yaojia.wang`). */
@@ -182,11 +192,15 @@ export async function buildControlPlane(
// the renew route MUST validate presented certs against non-empty anchors (renew.ts). DEV generates
// self-signed in-process P-256 CAs so leaves chain to a real, re-parseable CA.
const production = overrides.production ?? process.env.NODE_ENV === 'production'
const nativeCas = await buildNativeCas(env, {
// `server.ts` builds the two on-disk P-256 CAs itself (buildFileBackedNativeCas, with the file
// resolver) and threads them in as `nativeCas` — decoupled from the intermediate `kmsResolver`.
const nativeCas =
overrides.nativeCas ??
(await buildNativeCas(env, {
production,
...(overrides.kmsResolver !== undefined ? { kmsResolver: overrides.kmsResolver } : {}),
...(overrides.nativeCaMaterial !== undefined ? { material: overrides.nativeCaMaterial } : {}),
})
}))
const nativeDnsZone = overrides.nativeDnsZone ?? DEFAULT_NATIVE_DNS_ZONE
// ONE DeviceStore shared across the device registry, the device signer, and the renew path so the
@@ -243,6 +257,18 @@ export async function buildControlPlane(
await app.register(buildRouter({ authorizer, accounts, hosts, pairingIssuer, redeemer, deprovisioner, nativeEnroller }))
// Device enrollment is bearer-gated by the SAME capability verifier seam the admin API uses.
await app.register(buildDeviceEnrollRouter({ verifier, devices: deviceRegistry, signer: deviceSigner, ownership }))
// B1 — operator login → device:enroll bearer mint. The route ALWAYS registers (so a phone client
// gets a coherent response), but is FAIL-CLOSED (503) unless the operator triplet is env-configured
// (env.ts cross-validates set-together-or-none). The bearer is signed with the PRIVATE half of
// `CAPABILITY_SIGN_PUBKEY_B64` so it verifies on the same §4.3 path /device/enroll checks. INV9: the
// signing key is imported non-exportable + sign-only; the operator secret is never logged.
const enrollSigningKey =
env.capabilitySignPrivkey !== undefined ? await loadEnrollSigningKey(env.capabilitySignPrivkey) : null
const loginConfig: LoginSeamConfig =
env.operatorPassword !== undefined && env.operatorAccountId !== undefined
? { operatorCredential: env.operatorPassword, accountId: env.operatorAccountId }
: {}
await app.register(buildAuthLoginRouter({ signingKey: enrollSigningKey, loginConfig }))
// Leaf renewal is mTLS-authenticated (current client cert) — anchors chain-validate the presented cert.
await app.register(
buildRenewRouter({

View File

@@ -16,7 +16,8 @@ import { runMigrations } from './db/migrate.js'
import { createRedisRevocationBus } from './routing/bus.js'
import { createCapabilityVerifier, configureCapabilityVerifyKey } from './boot/verifier.js'
import { createRedisClient, createRedisPublisher } from './boot/redis.js'
import { buildControlPlane } from './main.js'
import { buildControlPlane, type ControlPlaneOverrides } from './main.js'
import { buildFileBackedNativeCas } from './boot/native-ca.js'
/** Admin API binds to loopback by default — never expose the provisioning API on a public interface. */
const DEFAULT_CP_BIND_HOST = '127.0.0.1'
@@ -57,7 +58,21 @@ async function main(): Promise<void> {
await configureCapabilityVerifyKey(env.capabilitySignPubkey)
const verifier = createCapabilityVerifier()
const { app } = await buildControlPlane(env, { stores, bus, verifier })
// A2-prep: when NATIVE_* on-disk CA material is configured, load the two REAL P-256 CAs (frp-client
// + device) from disk and thread them into the app so /enroll + /device/enroll issue leaves from the
// production CAs. `buildFileBackedNativeCas` fails fast (INV9) on unreadable/non-P-256 material. When
// unset, `buildControlPlane` keeps its existing behaviour (dev self-signed CAs, or fail-closed if
// NODE_ENV=production) — the dev/test injection path is untouched.
const nativeOverrides: Pick<ControlPlaneOverrides, 'production' | 'nativeCas' | 'nativeDnsZone'> =
env.nativeCa !== undefined
? {
production: true,
nativeCas: await buildFileBackedNativeCas(env, env.nativeCa),
nativeDnsZone: env.nativeCa.dnsZone,
}
: {}
const { app } = await buildControlPlane(env, { stores, bus, verifier, ...nativeOverrides })
const host = resolveBindHost(process.env)
const port = resolveBindPort(process.env)

View File

@@ -0,0 +1,184 @@
/**
* B1 — operator login → device:enroll bearer mint. Proves the PINNED contract:
* POST /auth/login { password } -> 201 { enrollToken, accountId, expiresIn }
* then POST /device/enroll Authorization: Bearer <enrollToken> is reachable (201) and rejected
* WITHOUT the bearer (401).
*
* Security surface under test: constant-time credential compare (via loginToAccountId), rate-limited
* login attempts, deny-by-default / fail-closed when the operator credential is unset, and that the
* minted bearer carries the correct right ('enroll') + audience ('device-enroll') + TTL — verified
* through relay-auth's REAL §4.3 verify path (verifyDeviceEnrollToken), the same one production uses.
*/
import 'reflect-metadata'
import { describe, test, expect, beforeEach } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto } from 'node:crypto'
import Fastify, { type FastifyInstance } from 'fastify'
import { configureVerifyKey } from 'relay-auth'
import { resetVerifyKeyForTest } from 'relay-auth/src/config/keys.js'
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
import { buildAuthLoginRouter, type AuthLoginDeps } from '../src/api/auth-login.js'
import {
verifyDeviceEnrollToken,
DEVICE_ENROLL_AUD,
DEFAULT_DEVICE_ENROLL_TTL_SEC,
} from '../src/auth/session.js'
import { buildControlPlane } from '../src/main.js'
import { createCapabilityVerifier } from '../src/boot/verifier.js'
import { loadEnv } from '../src/env.js'
import { createMemoryStores } from '../src/store/memory.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { generateEd25519 } from '../src/util/crypto.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { bytesToBase64 } from '../src/util/bytes.js'
x509.cryptoProvider.set(webcrypto)
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const OPERATOR_PASSWORD = 'operator-secret-abcdef123456'
// ───────────────────────────────────────────────────────────────────────────────────────────────
// Block A — the login route in isolation (fast; verify-key configured so the mint round-trips)
// ───────────────────────────────────────────────────────────────────────────────────────────────
describe('B1 POST /auth/login (route in isolation)', () => {
let signingKey: CryptoKey
beforeEach(async () => {
resetVerifyKeyForTest()
const pair = await generateEd25519KeyPair()
await configureVerifyKey(pair.publicKey)
signingKey = pair.privateKey
})
function buildApp(overrides: Partial<AuthLoginDeps> = {}): FastifyInstance {
const deps: AuthLoginDeps = {
signingKey,
loginConfig: { operatorCredential: OPERATOR_PASSWORD, accountId: ACCOUNT_A },
clientKey: () => 'test-client', // deterministic rate-limit bucket
...overrides,
}
const app = Fastify({ logger: false })
void app.register(buildAuthLoginRouter(deps))
return app
}
test('valid password → 201 { enrollToken, accountId, expiresIn }', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(body.accountId).toBe(ACCOUNT_A)
expect(body.expiresIn).toBe(DEFAULT_DEVICE_ENROLL_TTL_SEC)
expect(typeof body.enrollToken).toBe('string')
expect(body.enrollToken.startsWith('v4.public.')).toBe(true)
})
test('the minted bearer carries the enroll right + device-enroll aud + minutes TTL', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
const { enrollToken, expiresIn } = JSON.parse(res.body)
// A successful verify through the REAL §4.3 path asserts aud === device-enroll AND the enroll right.
const { accountId } = await verifyDeviceEnrollToken(enrollToken, { aud: DEVICE_ENROLL_AUD })
expect(accountId).toBe(ACCOUNT_A)
expect(expiresIn).toBeGreaterThanOrEqual(60) // minutes-scale, separate from the 3060s connect clamp
})
test('wrong password → 401, no token minted', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
expect(res.statusCode).toBe(401)
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
})
test('missing password field → 400 (Zod at the boundary)', async () => {
const app = buildApp()
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: {} })
expect(res.statusCode).toBe(400)
})
test('fail-closed when the operator credential is unset → 503, never mints', async () => {
const app = buildApp({ signingKey: null, loginConfig: {} })
await app.ready()
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
expect(res.statusCode).toBe(503)
expect(JSON.parse(res.body).enrollToken).toBeUndefined()
})
test('login attempts are rate-limited → 429 after the threshold', async () => {
const app = buildApp({ rateMax: 2 })
await app.ready()
const attempt = () =>
app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'wrong-but-long-enough' } })
expect((await attempt()).statusCode).toBe(401)
expect((await attempt()).statusCode).toBe(401)
expect((await attempt()).statusCode).toBe(429) // over the per-client window
})
})
// ───────────────────────────────────────────────────────────────────────────────────────────────
// Block B — end-to-end through the WIRED control-plane (login → bearer → /device/enroll)
// ───────────────────────────────────────────────────────────────────────────────────────────────
describe('B1 login → device:enroll e2e (wired app)', () => {
let app: FastifyInstance
beforeEach(async () => {
resetVerifyKeyForTest()
// Capability keypair: raw public → boot verify key; PKCS#8 private → the enroll signing key env.
const pair = await generateEd25519KeyPair()
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
const pkcs8 = new Uint8Array(await webcrypto.subtle.exportKey('pkcs8', pair.privateKey))
const env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
CAPABILITY_SIGN_PRIVKEY_B64: bytesToBase64(pkcs8),
OPERATOR_PASSWORD,
OPERATOR_ACCOUNT_ID: ACCOUNT_A,
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
RELAY_TRUST_DOMAIN: 'terminal.yaojia.wang',
BASE_DOMAIN: 'term.example.com',
})
const stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
app = built.app
await app.ready()
// Onboard a host so ACCOUNT_A OWNS 'alice' (device-enroll ownership gate reads this registry).
const hosts = createHostRegistry({ hosts: stores.hosts })
const { publicKeyRaw } = generateEd25519()
await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
})
test('login mints a bearer that /device/enroll accepts (201); without it → 401', async () => {
const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: OPERATOR_PASSWORD } })
expect(login.statusCode).toBe(201)
const { enrollToken, accountId } = JSON.parse(login.body)
expect(accountId).toBe(ACCOUNT_A)
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
const payload = { csr: Buffer.from(csr).toString('base64'), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' }
const enrolled = await app.inject({
method: 'POST',
url: '/device/enroll',
headers: { authorization: `Bearer ${enrollToken}` },
payload,
})
expect(enrolled.statusCode).toBe(201)
expect(JSON.parse(enrolled.body).deviceId.length).toBeGreaterThan(0)
const noBearer = await app.inject({ method: 'POST', url: '/device/enroll', payload })
expect(noBearer.statusCode).toBe(401)
})
test('wrong operator password on the wired app → 401', async () => {
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { password: 'definitely-the-wrong-secret' } })
expect(res.statusCode).toBe(401)
})
})

View File

@@ -0,0 +1,99 @@
/**
* A2-prep — file-backed KMS resolver unit tests. Proves `fileKmsResolver` loads an on-disk PKCS#8
* PEM P-256 CA key and exposes it as a `CaSigner` whose signature verifies against the key's public
* half (the single-owner file-custody reality of the VPS deploy), and fails loud on bad material
* (missing file, malformed PEM, wrong curve) — NEVER leaking key bytes (INV9).
*/
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
import { generateKeyPairSync, createPublicKey, verify as nodeVerify, randomBytes } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileKmsResolver, fileKeyRef } from '../src/boot/ca-wiring.js'
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cp-cawiring-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
/** Write a fresh P-256 PKCS#8 PEM key to disk; return its path + the matching public KeyObject. */
function writeP256Key(name: string): { path: string; publicKey: ReturnType<typeof createPublicKey> } {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
const path = join(dir, name)
writeFileSync(path, pem)
return { path, publicKey }
}
describe('fileKmsResolver — P-256 on-disk key custody', () => {
test('resolves a file: ref, loads the P-256 key, and signs verifiably', async () => {
const { path, publicKey } = writeP256Key('frp.key.pem')
const resolver = fileKmsResolver()
const { signer, policyRestrictedToServicePrincipal } = await resolver.resolve(fileKeyRef(path))
expect(policyRestrictedToServicePrincipal).toBe(true)
const tbs = randomBytes(48)
const sig = await signer.sign(new Uint8Array(tbs))
// signer emits raw P1363 r||s (64 bytes) — the same shape hardware/`inProcessP256CaSigner` produce.
expect(sig.length).toBe(64)
const ok = nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig))
expect(ok).toBe(true)
// the signer's public key equals the on-disk key's SPKI DER.
const spki = new Uint8Array(publicKey.export({ format: 'der', type: 'spki' }) as Buffer)
expect(Buffer.from(signer.publicKeyRaw).equals(Buffer.from(spki))).toBe(true)
})
test('resolves a bare path (no file: prefix)', async () => {
const { path, publicKey } = writeP256Key('bare.key.pem')
const { signer } = await fileKmsResolver().resolve(path)
const tbs = randomBytes(32)
const sig = await signer.sign(new Uint8Array(tbs))
expect(nodeVerify('sha256', tbs, { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(sig))).toBe(true)
})
test('missing file → rejects, never echoing the path contents', async () => {
await expect(fileKmsResolver().resolve(fileKeyRef(join(dir, 'nope.pem')))).rejects.toThrow(/unreadable/)
})
test('malformed PEM → rejects', async () => {
const path = join(dir, 'bad.pem')
writeFileSync(path, 'not a pem key')
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/not a valid PEM private key/)
})
test('non-P-256 key (Ed25519) → rejected (curve mismatch, fail-closed)', async () => {
const { privateKey } = generateKeyPairSync('ed25519')
const path = join(dir, 'ed.key.pem')
writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/)
})
test('wrong-curve EC key (P-384) → rejected', async () => {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' })
const path = join(dir, 'p384.key.pem')
writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
await expect(fileKmsResolver().resolve(fileKeyRef(path))).rejects.toThrow(/P-256/)
})
test('empty file: ref path → rejected', async () => {
await expect(fileKmsResolver().resolve('file:')).rejects.toThrow(/empty path/)
})
test('never leaks key material in the error message (INV9)', async () => {
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-384' })
const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
const path = join(dir, 'secret.key.pem')
writeFileSync(path, pem)
try {
await fileKmsResolver().resolve(fileKeyRef(path))
throw new Error('expected rejection')
} catch (e) {
expect(e instanceof Error ? e.message : '').not.toContain(pem.slice(40, 80))
}
})
})

View File

@@ -66,3 +66,110 @@ describe('T1 loadEnv (INV9 fail-fast)', () => {
expect(() => loadEnv({ ...base(), CAPABILITY_SIGN_PUBKEY_B64: short })).toThrow(/32 bytes/)
})
})
describe('B1 operator-login env (set-together-or-none, fail-closed)', () => {
const OPERATOR_PASSWORD = 'operator-secret-abcdef123456'
const ACCOUNT = '11111111-1111-4111-8111-111111111111'
const PRIVKEY = bytesToBase64(new Uint8Array(48).fill(3)) // shape-valid base64; import validated at boot
test('none set → login fields undefined (feature off, route fail-closed at runtime)', () => {
const env = loadEnv(base())
expect(env.operatorPassword).toBeUndefined()
expect(env.operatorAccountId).toBeUndefined()
expect(env.capabilitySignPrivkey).toBeUndefined()
})
test('all three set → parsed together', () => {
const env = loadEnv({
...base(),
OPERATOR_PASSWORD,
OPERATOR_ACCOUNT_ID: ACCOUNT,
CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY,
})
expect(env.operatorPassword).toBe(OPERATOR_PASSWORD)
expect(env.operatorAccountId).toBe(ACCOUNT)
expect(env.capabilitySignPrivkey?.length).toBeGreaterThan(0)
})
test('password without account/key → fail-fast (no silent half-open)', () => {
expect(() => loadEnv({ ...base(), OPERATOR_PASSWORD })).toThrow(/OPERATOR_ACCOUNT_ID|CAPABILITY_SIGN_PRIVKEY_B64/)
})
test('too-short operator password rejected (16512 charset rule)', () => {
expect(() =>
loadEnv({ ...base(), OPERATOR_PASSWORD: 'short', OPERATOR_ACCOUNT_ID: ACCOUNT, CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
).toThrow(/OPERATOR_PASSWORD/)
})
test('non-UUID operator account rejected', () => {
expect(() =>
loadEnv({ ...base(), OPERATOR_PASSWORD, OPERATOR_ACCOUNT_ID: 'not-a-uuid', CAPABILITY_SIGN_PRIVKEY_B64: PRIVKEY }),
).toThrow(/OPERATOR_ACCOUNT_ID/)
})
test('never echoes the operator secret on a partial-config failure (INV9)', () => {
try {
loadEnv({ ...base(), OPERATOR_PASSWORD })
} catch (e) {
expect(e instanceof Error ? e.message : '').not.toContain(OPERATOR_PASSWORD)
}
})
})
describe('A2-prep native-tunnel CA env (set-together-or-none, fail-closed)', () => {
const FRP_CERT = '/etc/cp/frp-client-ca.cert.pem'
const DEV_CERT = '/etc/cp/device-ca.cert.pem'
const ZONE = 'native.example.test'
test('none set → nativeCa undefined (injection/dev path)', () => {
const env = loadEnv(base())
expect(env.nativeCa).toBeUndefined()
})
test('cert paths + dns zone set → parsed, key paths DERIVED from cert paths', () => {
const env = loadEnv({
...base(),
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
NATIVE_DNS_ZONE: ZONE,
})
expect(env.nativeCa).toEqual({
frpClientCaCertPath: FRP_CERT,
frpClientCaKeyPath: '/etc/cp/frp-client-ca.key.pem',
deviceCaCertPath: DEV_CERT,
deviceCaKeyPath: '/etc/cp/device-ca.key.pem',
dnsZone: ZONE,
})
})
test('explicit key paths are used verbatim (not derived)', () => {
const env = loadEnv({
...base(),
NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT,
NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem',
NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT,
NATIVE_DEVICE_CA_KEY_PATH: '/keys/dev.pem',
NATIVE_DNS_ZONE: ZONE,
})
expect(env.nativeCa?.frpClientCaKeyPath).toBe('/keys/frp.pem')
expect(env.nativeCa?.deviceCaKeyPath).toBe('/keys/dev.pem')
})
test('partial set — only frp cert path → fail-closed listing the missing keys', () => {
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT })).toThrow(
/NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
)
})
test('partial set — cert paths without a dns zone → fail-closed', () => {
expect(() =>
loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_CERT_PATH: FRP_CERT, NATIVE_DEVICE_CA_CERT_PATH: DEV_CERT }),
).toThrow(/NATIVE_DNS_ZONE/)
})
test('partial set — a key path supplied WITHOUT its cert path → fail-closed', () => {
expect(() => loadEnv({ ...base(), NATIVE_FRP_CLIENT_CA_KEY_PATH: '/keys/frp.pem' })).toThrow(
/NATIVE_FRP_CLIENT_CA_CERT_PATH|NATIVE_DEVICE_CA_CERT_PATH|NATIVE_DNS_ZONE/,
)
})
})

View File

@@ -0,0 +1,215 @@
/**
* A2-prep — PRODUCTION native-tunnel CA wiring from ON-DISK P-256 material. Generates throwaway
* P-256 CAs (key + cert PEM) in a tmp dir — standing in for the VPS `gen-device-ca.sh` output — then
* proves `buildFileBackedNativeCas` binds each CA's signer to its on-disk key and its anchor to its
* on-disk cert, that a leaf issued by the wired signer CHAINS to that on-disk cert, that a WIRED
* control-plane `/enroll` mints an frp-client leaf whose dNSName SAN = `<sub>.<zone>` from the real
* on-disk CA, and that partial/missing/malformed material FAILS CLOSED (INV9).
*/
import 'reflect-metadata'
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
import * as x509 from '@peculiar/x509'
import { webcrypto, generateKeyPairSync } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { buildNativeCas, buildFileBackedNativeCas } from '../src/boot/native-ca.js'
import { inProcessP256CaSigner } from '../src/boot/ca-wiring.js'
import { assembleCertificate } from '../src/ca/x509-assembler.js'
import { createFrpClientLeafSigner } from '../src/ca/frpclient-issue.js'
import { createHostRegistry } from '../src/registry/hosts.js'
import { createMemoryStores } from '../src/store/memory.js'
import { fingerprint } from '../src/ca/fingerprint.js'
import { buildCsrEc } from '../src/ca/csr-ec.js'
import { createPairingIssuer } from '../src/pairing/issue.js'
import { buildControlPlane } from '../src/main.js'
import { loadEnv, type ControlPlaneEnv, type NativeCaEnvConfig } from '../src/env.js'
import { bytesToBase64 } from '../src/util/bytes.js'
import type { Stores } from '../src/store/ports.js'
x509.cryptoProvider.set(webcrypto)
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
const ZONE = 'native.example.test'
const DAY_MS = 24 * 60 * 60 * 1000
let dir: string
let env: ControlPlaneEnv
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'cp-nativeca-'))
env = loadEnv({
PG_URL: 'postgres://u:p@localhost:5432/cp',
REDIS_URL: 'redis://localhost:6379',
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(new Uint8Array(32).fill(7)),
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
CA_INTERMEDIATE_CERT_PATH: join(dir, 'int.cert.pem'),
NODE_MTLS_TRUST_BUNDLE_PATH: join(dir, 'node-ca.pem'),
RELAY_TRUST_DOMAIN: ZONE,
BASE_DOMAIN: 'term.example.com',
})
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
/** Write a self-signed throwaway P-256 CA (key + cert PEM) to disk — mirrors the VPS gen-device-ca output. */
async function writeThrowawayP256Ca(cn: string): Promise<{ keyPath: string; certPath: string; caDer: Uint8Array }> {
const caKeys = generateKeyPairSync('ec', { namedCurve: 'P-256' })
const keyPem = caKeys.privateKey.export({ format: 'pem', type: 'pkcs8' }) as string
const caSpki = new Uint8Array(caKeys.publicKey.export({ format: 'der', type: 'spki' }))
const signer = inProcessP256CaSigner(caKeys.privateKey)
const now = Date.now()
const caDer = await assembleCertificate({
subjectPublicKey: caSpki,
subject: `CN=${cn}`,
issuer: `CN=${cn}`,
serialNumber: Uint8Array.from([0x01]),
notBefore: new Date(now - DAY_MS),
notAfter: new Date(now + 365 * DAY_MS),
extensions: [
new x509.BasicConstraintsExtension(true, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
],
signer,
sigAlg: 'ecdsa-p256',
})
const certPem = new x509.X509Certificate(caDer).toString('pem')
const keyPath = join(dir, `${cn}.key.pem`)
const certPath = join(dir, `${cn}.cert.pem`)
writeFileSync(keyPath, keyPem)
writeFileSync(certPath, certPem)
return { keyPath, certPath, caDer }
}
async function writeNativeCaConfig(): Promise<{ config: NativeCaEnvConfig; frpCaDer: Uint8Array; deviceCaDer: Uint8Array }> {
const frp = await writeThrowawayP256Ca('frp-client-CA')
const dev = await writeThrowawayP256Ca('device-CA')
return {
config: {
frpClientCaCertPath: frp.certPath,
frpClientCaKeyPath: frp.keyPath,
deviceCaCertPath: dev.certPath,
deviceCaKeyPath: dev.keyPath,
dnsZone: ZONE,
},
frpCaDer: frp.caDer,
deviceCaDer: dev.caDer,
}
}
function b64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64')
}
function sanNames(der: Uint8Array): ReturnType<x509.GeneralNames['toJSON']> {
return new x509.X509Certificate(der).getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
}
describe('buildFileBackedNativeCas — anchors + signer from on-disk P-256 material', () => {
test('each CA anchor DER == its on-disk cert DER; issuer names parse', async () => {
const { config, frpCaDer, deviceCaDer } = await writeNativeCaConfig()
const cas = await buildFileBackedNativeCas(env, config)
expect(b64(cas.frpClientCa.caCertDer)).toBe(b64(frpCaDer))
expect(b64(cas.deviceCa.caCertDer)).toBe(b64(deviceCaDer))
expect(cas.frpClientCa.anchorsDer).toEqual([cas.frpClientCa.caCertDer])
expect(cas.deviceCa.anchorsDer).toEqual([cas.deviceCa.caCertDer])
expect(cas.frpClientCa.issuerName.toString()).toContain('frp-client-CA')
expect(cas.deviceCa.issuerName.toString()).toContain('device-CA')
})
test('a leaf minted by the wired frp-client signer CHAINS to the on-disk CA cert', async () => {
const { config, frpCaDer } = await writeNativeCaConfig()
const cas = await buildFileBackedNativeCas(env, config)
// Bind a P-256 host so the signer will issue for it.
const stores = createMemoryStores()
const hosts = createHostRegistry({ hosts: stores.hosts })
const { der: csr, keys } = await buildCsrEc('CN=alice')
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
const host = await hosts.bindHost({ accountId: ACCOUNT_A, subdomain: 'alice', agentPubkey: spki, enrollFpr: fingerprint(spki) })
const signer = createFrpClientLeafSigner({
hosts: stores.hosts,
signer: cas.frpClientCa.signer,
issuerName: cas.frpClientCa.issuerName,
caChainDer: cas.frpClientCa.anchorsDer,
trustDomain: ZONE,
dnsZone: ZONE,
})
const { cert } = await signer.signHostLeaf(host.hostId, spki, csr)
// The leaf verifies against the ON-DISK CA cert's public key (real chain) …
const onDiskCa = new x509.X509Certificate(frpCaDer)
expect(await new x509.X509Certificate(cert).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true)
// … and carries the enforcement dNSName SAN under the configured zone.
expect(sanNames(cert)).toContainEqual({ type: 'dns', value: `alice.${ZONE}` })
})
})
describe('WIRED control-plane /enroll from on-disk native CAs', () => {
async function ownedApp(): Promise<{ app: Awaited<ReturnType<typeof buildControlPlane>>['app']; stores: Stores; frpCaDer: Uint8Array }> {
const { config, frpCaDer } = await writeNativeCaConfig()
const nativeCas = await buildFileBackedNativeCas(env, config)
const stores = createMemoryStores()
const built = await buildControlPlane(env, { stores, production: true, nativeCas, nativeDnsZone: ZONE })
await built.app.ready()
return { app: built.app, stores, frpCaDer }
}
test('POST /enroll (native EC arm) → 201 frp-client leaf that chains to the on-disk CA + dNSName SAN <sub>.<zone>', async () => {
const { app, stores, frpCaDer } = await ownedApp()
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 })
const { code } = await issuer.issuePairingCode(ACCOUNT_A)
const { der: csr, keys } = await buildCsrEc('CN=web-terminal-host')
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
const res = await app.inject({
method: 'POST',
url: '/enroll',
payload: { code, machineId: 'MB-1', agentPubkey: b64(spki), csr: b64(csr) },
})
expect(res.statusCode).toBe(201)
const body = JSON.parse(res.body)
expect(typeof body.subdomain).toBe('string')
expect(body.hostContentSecret).toBeNull()
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
// chains to the REAL on-disk frp-client-CA …
const onDiskCa = new x509.X509Certificate(frpCaDer)
expect(await new x509.X509Certificate(leafDer).verify({ publicKey: onDiskCa.publicKey, signatureOnly: true })).toBe(true)
// … the caChain the server returned IS that on-disk anchor …
expect(b64(new Uint8Array(Buffer.from(body.caChain[0], 'base64')))).toBe(b64(frpCaDer))
// … and the leaf carries the server-assigned dNSName SAN under the env-configured zone.
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${ZONE}` })
})
})
describe('native-tunnel CA fail-closed (INV9)', () => {
test('buildNativeCas production without material → refuses to boot', async () => {
await expect(buildNativeCas(env, { production: true })).rejects.toThrow(/unresolvable in production/)
})
test('buildFileBackedNativeCas with an unreadable cert path → refuses to boot', async () => {
const { config } = await writeNativeCaConfig()
const broken: NativeCaEnvConfig = { ...config, frpClientCaCertPath: join(dir, 'missing.cert.pem') }
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/certificate file is unreadable/)
})
test('buildFileBackedNativeCas with a NON-P256 key file → refuses to boot (fail-closed)', async () => {
const { config } = await writeNativeCaConfig()
const edPath = join(dir, 'ed.key.pem')
writeFileSync(edPath, generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }) as string)
const broken: NativeCaEnvConfig = { ...config, frpClientCaKeyPath: edPath }
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/P-256|could not be resolved/)
})
test('buildFileBackedNativeCas with a malformed cert file → refuses to boot', async () => {
const { config } = await writeNativeCaConfig()
const badCert = join(dir, 'bad.cert.pem')
writeFileSync(badCert, 'not a certificate')
const broken: NativeCaEnvConfig = { ...config, deviceCaCertPath: badCert }
await expect(buildFileBackedNativeCas(env, broken)).rejects.toThrow(/parseable X.509 certificate/)
})
})

View File

@@ -41,6 +41,10 @@ extraResources:
to: node_modules
filter:
- "**/*"
# .bin holds dev-tool shims (asar/tsc/esbuild/…) symlinked into packages we
# exclude below — copying them leaves DANGLING symlinks that electron-builder
# stat()s and dies on (ENOENT .bin/asar). Runtime deps load by path, not .bin.
- "!.bin/**"
# build-only tooling — never needed at runtime by express/ws/web-push/node-pty:
- "!electron/**"
- "!electron-builder/**"

View File

@@ -9,7 +9,9 @@
"version": "0.1.0",
"dependencies": {
"express": "^5.2.1",
"google-auth-library": "^10.9.0",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"web-push": "3.6.7",
"ws": "^8.21.0"
},
@@ -1093,7 +1095,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1103,7 +1104,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -1367,7 +1367,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -1384,6 +1383,15 @@
],
"license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -1586,6 +1594,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1668,7 +1685,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -1681,7 +1697,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -1818,6 +1833,15 @@
"node": ">= 8"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1835,6 +1859,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -1939,6 +1972,12 @@
"license": "MIT",
"optional": true
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@@ -2218,7 +2257,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -2470,6 +2508,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2512,6 +2556,29 @@
}
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -2573,6 +2640,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/form-data": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
@@ -2590,6 +2670,18 @@
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2639,11 +2731,38 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.2.0.tgz",
"integrity": "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -2792,6 +2911,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/google-auth-library": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
"integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3038,7 +3183,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3131,6 +3275,15 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -3217,6 +3370,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -3449,6 +3614,44 @@
"semver": "^7.3.5"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-gyp": {
"version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
@@ -3636,6 +3839,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3645,6 +3884,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -3756,6 +4004,15 @@
"node": ">=10.4.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
@@ -3883,6 +4140,89 @@
"node": ">=16.0.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -3973,7 +4313,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3989,6 +4328,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resedit": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@@ -4227,6 +4572,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -4410,7 +4761,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -4425,7 +4775,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -4788,6 +5137,15 @@
"node": ">= 16"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webcrypto-core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
@@ -4818,6 +5176,12 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -20,7 +20,9 @@
},
"dependencies": {
"express": "^5.2.1",
"google-auth-library": "^10.9.0",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"web-push": "3.6.7",
"ws": "^8.21.0"
},

View File

@@ -23,6 +23,8 @@ import { buildServerEnv } from './server-config.js'
/** Default listen port when the user hasn't pinned one (free-port fallback still applies). */
const DEFAULT_PORT = 3000
/** How long to wait for the probe of an already-running base app before giving up. */
const PROBE_TIMEOUT_MS = 1500
export interface EmbeddedServerDeps {
readonly prefs: DesktopPrefs
@@ -34,6 +36,26 @@ interface ServerHandle {
close(): Promise<void>
}
/**
* Probe whether a web-terminal base app is ALREADY serving on `port` (typically
* the always-on tunnel daemon that owns :3000 + frpc). `GET /config/ui` returns a
* small JSON object with `allowAutoMode` only from our backend, so it doubles as a
* cheap "is this one of ours?" fingerprint. Any failure (nothing listening, wrong
* shape, timeout) → false, and we spawn our own server as before.
*/
async function probeExistingBaseApp(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/config/ui`, {
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
})
if (!res.ok) return false
const body: unknown = await res.json()
return typeof body === 'object' && body !== null && 'allowAutoMode' in body
} catch {
return false
}
}
/**
* Start the embedded backend and return a lifecycle handle. Throws (after
* logging) if the compiled server can't be imported or fails to start — the app
@@ -41,7 +63,23 @@ interface ServerHandle {
*/
export async function startEmbeddedServer(deps: EmbeddedServerDeps): Promise<EmbeddedServer> {
const { prefs, logger } = deps
const port = await pickFreePort(prefs.port ?? DEFAULT_PORT)
const preferred = prefs.port ?? DEFAULT_PORT
// If a base app is already up on the preferred port (the always-on tunnel
// daemon), RIDE it instead of spawning a duplicate on another port: one base app
// then serves the desktop UI, LAN, and the frpc tunnel alike. close() is a no-op
// because we don't own that process — quitting the window must not kill it.
if (await probeExistingBaseApp(preferred)) {
logger.info(`reusing base app already on http://127.0.0.1:${preferred} (tunnel daemon) — not spawning a duplicate`)
return {
port: preferred,
bindHost: '127.0.0.1',
allowedOrigins: [],
close: async () => {},
}
}
const port = await pickFreePort(preferred)
const env = buildServerEnv({ prefs, port, platform: process.platform, env: process.env })
// dist/ is a sibling of the desktop dir in dev; under resourcesPath when packaged.

View File

@@ -0,0 +1,165 @@
# Zero-Touch Enrollment — Rollout & Integration Plan
> **This is the EXECUTION plan** (deploy + wire + fill the gaps), grounded in the *current*
> deployment reality. The DESIGN blueprint is `docs/PLAN_TUNNEL_AUTOMATION.md`; this doc says
> what to actually do next, in order, to reach the product requirement.
>
> **Product requirement (operator, 2026-07-18):** ① 主机端启动即自动连服务器 + 自动管理证书(零手动装证书);② 客户端(手机)连接时自动处理好证书(零手动 .p12)。
---
## 0. The honest constraint (read first)
The **first** enrollment of each identity cannot be zero *human* actions: issuing a tunnel cert to
an unknown machine/phone with no auth = anyone who reaches the endpoint gets a cert. So exactly **one
authenticated human action at bootstrap is unavoidable**: paste a one-time pairing code (host) / log
in once (phone). **Everything after** — renew, rotate, reboot-survival — is genuinely silent.
> "Zero-touch" here honestly means **"one bootstrap tap, then never again"**, not "no human step ever."
> Every step below is designed so that single tap is the *only* manual action for the product lifetime.
---
## 1. Current state (verified 2026-07-18)
| Piece | State |
|---|---|
| `control-plane/` (PKI/CA + `/enroll` + `/device/enroll` + `/renew`) | **Built, 246 tests pass.** Loopback `127.0.0.1:8080`, fail-closed prod boot (needs real KMS CA material). |
| `agent/` host agent (`pair <CODE> --install`) | **Built, 267 tests pass.** launchd user-agent; keygen→CSR→enroll→frpc.toml→2 units→supervised frpc. |
| iOS device-enroll library (SecureEnclave→CSR→cert) | **Built + unit-tested**, but **zero app callers** (no UI). |
| Android / Desktop device-enroll | **None** (Android from scratch; Desktop deferred). |
| Server **login route + `device:enroll` bearer mint** | **MISSING — hard blocker for the phone track.** `loginToAccountId` is a deny-all stub. |
| VPS control-plane | **NOT running** (`:8080` down); code on VPS is the **older `feat/relay-phase1`** branch. |
| VPS tunnel data path (frps + nginx:8470 mTLS + LE wildcard cert) | **UP** (from M1). LE `*.terminal.yaojia.wang` valid → 2026-10-05. |
| My manual `mac1` host tunnel | **Live**, but static hand-issued certs — bypasses the control-plane. Control cert correctly from **frp-client-CA**, so CA lineage already matches the agent path (low migration risk). |
**Bottom line:** the automated system is ~built but **not deployed and not wired**; the phone track has a
real server-code hole (login/bearer). My `mac1` is a manual stand-in to be retired.
---
## 2. Architecture (target flows)
**Host** (`web-terminal-agent pair <CODE> --install`): generate P-256 key (never leaves host, 0600) →
PKCS#10 CSR → `POST /enroll {code, machineId, agentPubkey, csr}` → CA returns frp-client leaf
(`dNSName SAN = <sub>.terminal.yaojia.wang`) + caChain → SHA-256-verify-download pinned `frpc` v0.61.1 →
write `frpc.toml` (mTLS control channel, `serverName=frp.terminal.yaojia.wang`, one http proxy →
`127.0.0.1:PORT`) + base-app `ALLOWED_ORIGINS` → install **two launchd units** (base app + supervised
agent) → reboot-durable. **Renew** silently at ~2/3 TTL via `POST /renew` (mTLS w/ current cert).
**Phone**: one-time **account login** → short-lived `device:enroll` bearer → app generates a
**non-exportable hardware key** (Secure Enclave / StrongBox) → hardware-signed CSR →
`POST /device/enroll {csr, subdomain, deviceName, attestation?}` (Bearer) → CA verifies token +
account-owns-subdomain (deny-by-default) + PoP + cap/rate-limit → returns device leaf + caChain →
app stores identity against the hardware key, presents on the **already-wired mTLS path**. Silent renew
via `POST /device/:id/renew`.
**Three PKI roots (do not conflate):** frp-client-CA (P-256, host control channel, frps:7000) ·
device-CA (P-256, device data path, nginx:8470 `ssl_verify_client`) · relay agent-CA (Ed25519, legacy).
---
## 3. PRE-FLIGHT: deployment unknowns to verify (blockers before any build)
Run against VPS `8.138.1.192`. **These gate feasibility — resolve before committing to the tracks.**
1. **CP CA material** — does production KMS/CA material exist for the *native* CAs the new CP needs
(`NATIVE_FRP_CLIENT_CA_*`, `NATIVE_DEVICE_CA_*`, `CA_INTERMEDIATE_*`)? CP is **fail-closed**: no real
material ⇒ it won't boot (or falls back to dev placeholder leaves = unusable). **This is the #1 risk.**
The relay-phase1 deploy had `/etc/relay/ca/{root,intermediate}` + a P-256 device-CA + frp-client-CA
on disk — confirm these can back the new CP's signer (KMS refs vs on-disk keys).
2. **CP branch/deploy** — the newer CP (with the enroll/renew endpoints as-tested) must be checked out
& built on the VPS (currently `feat/relay-phase1`). Confirm PG + Redis reachable + migrated.
3. **Public `/enroll` reachability** — CP is loopback; an nginx vhost must proxy `/enroll`,`/device/enroll`,
`/renew` over public TLS. `curl -i https://<enroll-host>/enroll` should give a 4xx (validation), not 502.
4. **frps trusts `frp-client-CA`** on the `:7000` control channel (NOT device-CA). Inspect frps `trustedCaFile`.
5. **`manage` bearer + `CAPABILITY_SIGN_PUBKEY_B64`** set (else the verifier is `refuseAll` and no pairing
code can be minted).
6. **`FRP_AUTH_TOKEN`** identical on agent side and frps.
7. **nginx :8470 dNSName→Host binding** deployed (njs `getCertSub` + `map` → 403 on mismatch) — the
load-bearing isolation control for the device path.
8. **Login/`device:enroll` route** — expected: **absent today**; confirm (defines phone-track start).
---
## 4. TRACK A — Host auto-enroll (smallmedium; mostly deploy + wire)
**Goal:** fresh Mac → one pairing code → `pair --install` → auto-connect + self-managing cert, reboot-durable.
| # | Task | Where | Effort | Notes / risk |
|---|---|---|---|---|
| A1 | Resolve pre-flight §3.13.6 (CA material, CP build, public `/enroll`, frps CA, manage bearer, token) | VPS | SM | Gated by §3.1 KMS/CA material — the real unknown. |
| A2 | Deploy + run the new control-plane in **production mode** (real CA material, PG/Redis, systemd unit) | VPS `control-plane/` | M | Replace the old relay-phase1 CP; keep loopback:8080. |
| A3 | nginx vhost proxying `/enroll` `/device/enroll` `/renew` → 127.0.0.1:8080 over the LE wildcard TLS | `deploy/nginx` + VPS | S | Add to existing :443 SNI / a dedicated enroll host. |
| A4 | Mint one **pairing code** (`POST /accounts/:id/pairing-codes`, `manage` bearer) | VPS | S | MVP = manually pasted code (RFC 8628 device-grant deferred). |
| A5 | **Wire auto-renew into the native run-loop** — call `createCertRotator`/`renewCert` from `superviseNative` | `agent/src` (**only real host code gap**) | S | Logic already built+tested; today `superviseNative` only *monitors* freshness → cert dies at 24h. TDD it. |
| A6 | Build the agent (`dist/cli.js`) + set env (`ENROLL_URL`,`TUNNEL_DOMAIN`,`TUNNEL_ZONE=terminal`,`FRP_AUTH_TOKEN`,`PORT`,`BIND_HOST=127.0.0.1`, dummy `RELAY_URL`) | Mac | S | |
| A7 | Run `web-terminal-agent pair <CODE> --install`; verify 2 launchd units + `https://<sub>.terminal.yaojia.wang` live via device cert | Mac | S | |
| A8 | **Retire manual `mac1`** — bootout `com.webterm.tunnel.{baseapp,frpc}`, remove `frpc.toml`/certs | Mac | S | Only after A7 green. |
| A9 | (optional) `machineId` dedup so reinstall keeps the same subdomain | `agent/src`, `control-plane/registry` | S | Deferred; cosmetic (`status` output). |
**Deliverable A:** computer start → auto-connect + auto-managed/renewing cert; one pairing code at first
setup, silent thereafter. **Blocking risk = §3.1 (production CA material).**
---
## 5. TRACK B — Phone auto-enroll (build-heavy; server hole first)
**Goal:** phone → one login → device cert auto-issued to hardware key → connect, no `.p12`, ever.
| # | Task | Where | Effort | Notes / risk |
|---|---|---|---|---|
| B1 | **Build + deploy the CP login route + `device:enroll` bearer mint** (`auth/session.ts mintDeviceEnrollToken`, add `/login` or `/auth`, add `enroll` to `CapabilityRightSchema`) | `control-plane/src/auth` | M | **HARD BLOCKER — do first; nothing in the phone flow is reachable without a bearer.** Even a single-operator credential seam unblocks it (full OIDC later). |
| B2 | Bridge: after login, mint bearer + scope to the account's subdomain(s) (deny-by-default already enforced by CP) | `control-plane` | S | |
| B3 | **iOS**: wire `KeychainClientIdentityStore.enroll` into an enrollment screen (login → bearer → CSR → `/device/enroll` → store SE identity → present on existing mTLS path) + rotation scheduler | `ios/` | M | Library exists + unit-tested; this is UI + flow wiring. |
| B4 | **Android**: build the enroll library from scratch (StrongBox keygen + CSR + `/device/enroll` client) + present via existing `X509KeyManager` | `android/` | L | Mirror the iOS ClientTLS package; no existing code. |
| B5 | (optional) scan-to-enroll: make the pairing QR carry a `device:enroll` credential (QR & device-enroll are disjoint today) | app + CP `pairing` | M | UX nicety; not required for correctness. |
| B6 | Desktop (Electron) programmatic OS-keychain install | `desktop/src` | M | **Deferred** — Chromium only reads certs from the OS store; today assumes pre-install. |
**Deliverable B:** phone → login once → connects with an auto-issued, hardware-bound, auto-renewing cert.
---
## 6. Deliberately deferred (layer on later, no rework)
Attestation verifiers (Apple App Attest / Android Key-Attestation) — mandatory only before a 2nd
distrusting tenant · signed X.509 **CRL** + VPS reload webhook (MVP relies on 24h passive revocation) ·
**B2** per-tenant name-constrained intermediates (gate for a 2nd customer) · RFC 8628 device-grant
(approve-in-app) wrapping the same `/enroll` · Windows service · agent autoupdate · legacy `.p12`
dual-trust migration window.
---
## 7. Suggested order & milestones
1. **Pre-flight §3** (esp. §3.1 CA material) — a spike; decides whether Track A is "deploy" or "also build a signer."
2. **Track A (A1A8)****M-Host**: fresh Mac, one command, reboot-durable, self-renewing. *Ships the host half of the requirement.*
3. **B1 (login/bearer)** → unblocks everything phone-side.
4. **B3 (iOS)****M-Device-iOS**: iPhone auto-enrolls, no `.p12`.
5. **B4 (Android)****M-Device-Android**.
6. Later: attestation → CRL → B2 (per-tenant) = gate for a 2nd distrusting customer.
## 8. Top risks
- **[R1] Production native-CA wiring in the new CP (§3.1)** — **RE-ASSESSED 2026-07-18: a real (smallmedium)
CODE task, the hard prerequisite for the VPS deploy.** The P-256 CA keys + certs exist on the VPS
(`/etc/relay/{device-ca,frp-client-ca}/*.key.pem`), BUT: (1) `env.ts` does **not** define
`NATIVE_FRP_CLIENT_CA_*` / `NATIVE_DEVICE_CA_*` (native-ca.ts:16 flags this as the "concrete follow-up");
(2) `buildNativeCas` exists but is **never called from `server.ts`** — the prod boot doesn't wire the
native CAs at all (the 246 tests inject dev `inProcessP256CaSigner`s); (3) production wants a KMS resolver
and there is no file-backed signer to load the on-disk PEM keys. **Task A2-prep (do before deploy):** add
the `NATIVE_*` env vars + a file-backed `KmsResolver` (load PEM P-256 key → sign) + call `buildNativeCas`
in `server.ts` and thread the CAs into the enroll services, TDD + reviewed. File-key custody (not KMS) is
acceptable for the single-owner VPS (mirrors the existing `CA_INTERMEDIATE_KEY_PATH` file fallback);
document the KMS upgrade as future.
- **[R2] frps control-channel CA mismatch** — must be `frp-client-CA`, not `device-CA`; crossed = silent
1006/handshake failure (this class of bug already cost days in M1).
- **[R3] nginx dNSName→Host binding** — the load-bearing isolation control; needs positive **and**
negative tests (leaf-A must be refused for subdomain-B) even in MVP.
- **[R4] Phone login trust seam (B1)** — the whole phone track hinges on a credential mechanism; a weak
seam here is a security hole (it mints certs). Scope it deliberately even for single-operator.
- **[R5] BIND_HOST loopback** — mandatory on every host; `0.0.0.0` re-opens an unauth shell on the LAN
bypassing mTLS. The agent enforces it (S-GATE) — keep it enforced.
---
_Grounded against `develop` + VPS `8.138.1.192` as of 2026-07-18. Design source: `docs/PLAN_TUNNEL_AUTOMATION.md`._

View File

@@ -24,6 +24,40 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🗺️ ROADMAP 落地 — Wave 1-4 八个功能(2026-07-12,当前活跃;多 agent 并行计划 + 逐个 builder 实施)
- **产物**: [docs/ROADMAP.md](./ROADMAP.md)(分层 backlog)+ [docs/plans/](./plans/)(8 份可直接照写的实施计划,并行生成)。全在 `develop`
- **编排**: 主线洞察"产品把一切都**采集**了却**没拿去行动**",两个原语(PTY-inject、审批预览)解锁一片。流程 = **并行生成 8 份计划**(1 个 workflow / 8 agent)→ **逐个功能派 builder 在主工作树实现**(一次一个,避免踩 server.ts 等共享文件)→ **orchestrator 独立复验**(typecheck 两 config + full suite + build:web + 安全审)→ 提交 → 下一个。
- **[x] 八个全绿(独立复验,非仅采信 builder)**,每个单独 commit:
1. **W1 可点链接/文件路径** `debf47d``public/link-paths.ts` 纯 matcher + xterm link provider;URL scheme 白名单 + noopener;附加 `openFileInEditor`(`--goto file:line`,现有路由只能开目录)。
2. **W1 审批预览** `e062065` — 手机上 Approve 前先看到 Bash 命令/Edit diff。`src/http/approval-preview.ts` 有界 sanitize(40 行/200 字/4KB),走 gate 同款晚加入者重发;渲染只走 textContent/renderDiffFile。
3. **W2 PTY-inject + idle 队列** `3076843` — 地基原语。`POST /live-sessions/:id/queue`(Origin+限流+SESSION_ID_RE),idle 时 drain 一条(去抖 timer + pop-one + settle 复检 三重防重复),注入复用 writeInput 字节原样进 PTY。
4. **W3 diff-vs-base** `b119c31``?base=<rev>` 审整条分支。三层防选项注入:isPlausibleRev → `rev-parse --verify --end-of-options` → 只有解析出的 sha 进 `git diff <sha>... --`
5. **W3 PR/CI chip** `7551f8a``src/http/gh.ts` 单次 `gh pr view --json`;缺 gh/未登录/无 PR 全降级不抛;PR title 走 textContent。
6. **W3 quick wins** `1dd12b0` — 项目卡 ahead/behind + 最近提交时间;成本预算告警(`COST_BUDGET_USD` 单次 latch + push);`/digest` 重连摘要;`/projects/log` 最近提交。
7. **W4 worktree 删除/prune** `552f35c` — 破坏性,护栏:必须在 `git worktree list`(realpath 匹配)、拒主 worktree、容器内、脏树要 force、locked 拒、错误归类、`git worktree remove` 不用 rm -rf。
8. **W4 stage/commit/push** `19f241d` — 手机审完直接落地。MVP 只 stage/commit/push 当前分支(砍 discard/checkout);push 的 remote+branch 从 repo 读、绝不 `--force`/`+refspec`;三路由 Origin+GIT_OPS_ENABLED+限流;路径 realpath 容器内 + `--` 后作 argv;错误归类不泄露。
- **已知测试抖动(非回归)**: 两个真-PTY/tmux 集成测试(`ring buffer` 重放、`H1 tmux`)在沙箱满负载下撞默认 5s / 自身 20s 超时;**单独跑或 `--test-timeout=30000` 全绿**(full suite @30s = **2005/2006**,唯一 red 是 H1 tmux 撞自身上限)。逻辑无回归。
- **未 push**: 全部本地 `develop`,领先 origin/develop 一批。
### 🖥️ SPLIT-GRID 看板 — 桌面多 session 分屏(2026-07-11)
- **需求**: web/Mac 大屏、开多个 tab 时,把 `#term` 大窗切成 1×2 / 2×2 宫格,多个 **live 可交互**终端同屏,方便"vibe coding"时盯多个 Claude session。手机不做(<1024px 强制 single)。
- **分支**: `feat/split-grid-view`( `feat/tunnel-automation`)。**用户决策(AskUserQuestion)**:全部阶段(v1v2v3)用多 agent + loop 完成;审批用**每格内联 ✓/✗**;成员=**前 N tab(拖拽换序控制)**;布局=**single + 1×2 + 2×2**。
- **编排**: orchestrator 亲写互锁的 5 文件(并行 builder 会互相踩),每阶段后**并行对抗式 review workflow**(4 lens 逐条 verify)→ confirmed 复验绿 commit loop 下一阶段
- **[x] v1 DONE(2026-07-11)** orchestrator 独立复验全绿:`npm run typecheck`(前后端两 config)干净`build:web` 干净、**全套 1566 测试通过**(+33 新增),覆盖率 grid-layout.ts 95%/tabs.ts 94%(≥80 门槛)。
- **核心设计**: `activeIndex` 语义**不变**=聚焦格(keybar/voice/approval 全部零改动);新增 `gridLayout` + 派生 `visibleIndices`;每个 pane 包进 `.term-cell`(header + 终端 + 可选内联审批 footer);`applyLayout()` 独占 pane 显隐/grid class/cell 排序/占位符;`#term` CSS grid。**服务端/WS 协议零改动**。
- **文件**: `public/grid-layout.ts`(纯逻辑 + matchMedia + 持久化 + toolbar 分段控件)、`tabs.ts`(applyLayout/renderCell/renderInlineApprove/setFocused/setGridLayout/refitVisible + `activate()` 改为**board-aware**)、`terminal-session.ts`(`show({focus})` 4 格抢焦点 + `onFocus` 回调)、`main.ts`( toggle + refitVisible)、`style.css`(cell/grid/焦点环/pending 脉冲/内联审批/占位符/toggle + `.term-pane` position:relative flex )。新测 `test/grid-layout.test.ts` + `tabs.test.ts` 追加 split-grid
- **交叉验证抓修 3 真缺陷**:**HIGH** `activate()` 原先非 board-aware:满格(4/4)时点 "+" 会让新 tab 成为 activeIndex `display:none`,用户对着看不见的 session 打字(空最常见路径触发)。修:把 off-boardmoveTab 上板逻辑折进 `activate()`,`setFocused`/`setGridLayout` 委托之。**LOW** 通知抑制未算 `homeForced`(⌂ 覆盖时 pane 实际不在屏);修:`onScreen = !homeForced && isVisible`。**LOW** toggle 触控尺寸(coarse-pointer 平板);修:`@media (pointer:coarse)` 加大全部补了回归测试
- **[x] v2 DONE(2026-07-11)** orchestrator 独立复验全绿:typecheck config 干净build:web 干净、**全套 1579 测试**(+13),覆盖率 grid-layout 95%/tabs 93%。加了:**1×3(row-3)/2×3(grid-6)布局**、**Ctrl+`/Ctrl+Shift+` 循环焦点**(main.ts capture keydown cycleFocus,单格模式不吞键)、**每格最大化 ⛶**(`.maximized` 覆盖层)、** tab 到格**(wireCellDropTarget 复用 dragIndex)。用单个 `grid` 标记类承载共享 cell 样式(不再枚举每个 lay-*)。
- **交叉验证抓修 3 真缺陷**:**HIGH** 最大化原用 `grid-column/row: 1/-1` **span 网格**是错的:CSS Grid 把兄弟格挤进隐式行→"最大化"格变成细条(非全屏)+ 兄弟格 box 变化触发 ResizeObserver→**给后台 live PTY 发错误 resize(伪 SIGWINCH)**评审在** headless Chrome 里实测复现**。修:`.maximized` `position:absolute; inset:0; z-index:4` 脱离网格流做覆盖层。**独立 Playwright 复验**:最大化格填满 #term(1000×700)、兄弟格尺寸 0px 变化,对照组证实旧规则确会 891×572 细条+兄弟塌成 52px。**MED** 最大化时被盖住的 pending amber 脉冲外溢;修:`(!maximized || focused)` 。**LOW** `.cell-max` coarse-pointer 触控尺寸均补回归测试;并把 Ctrl+` 键匹配抽成 `matchFocusCycleKey` 纯函数单测
- **[x] v3 DONE(2026-07-11)** 三个特性,orchestrator 独立复验全绿(typecheck config + build:web 干净、**全套 1615 测试**、全局覆盖率 89% stmts/82% branch 80 门槛)。分三 commit:
- **v3a 只读 monitor (cd97114)**:每格 👁 切换 liveread-only`public/cell-monitor.ts` 轮询 `GET /live-sessions/:id/preview` 写只读 xterm,** attach WS不发 resize**,故不驱动共享 PTY 尺寸——解决"小格盯 session 会缩掉别的设备全屏"的跨设备 shrinkmonitor live pane 保持 hidden;toggle-off/ tab/离开 grid 皆清理
- **v3b 可拖拽分隔条(007e598)**:格间 gutter 拖拽调每布局 col/row fr 比例(`adjustSplit` 纯函数:相邻轨道互让clamp 0.3守恒),内联 grid-template + 持久化 `web-terminal:grid-splits`;拖拽中 reposition 不重建 handle
- **v3c 布局预设(007e598)**:`public/grid-presets.ts` toolbar 下拉,/应用/删命名的 布局+split(`web-terminal:grid-presets`);外点/Esc 同名替换XSS-safe(textContent)。
- **交叉验证抓修 4 类真缺陷(6 confirmed,2 dup)**:**HIGH** `splitForLayout({'grid-4':null})` TypeError(`null!==undefined` 过守卫后读 `null.cols`),每次渲染都炸整个 tab UI 砖掉;修:守卫加 `!==null && typeof==='object'`。**MED** monitor attach (id=null)切换:按钮显 active 但格仍 live 且发 resize,且不自愈;修:`onSessionId` 到达时 reconcile(re-render 使 startMonitor 生效)。**MED** `renderGutters` 拖拽中被并发 applyLayout 销毁重建丢拖拽监听;修:`draggingGutter` 标志跳过重建。**LOW** grid-presets 硬编码 1024改用 `GRID_MIN_WIDTH`均补回归测试( null-split 不抛reconnect-reconcile拖拽中重渲染 handle 存活)。
- **✅ 全部阶段(v1+v2+v3)完成**:5 commit(06814ba/5475b66/cd97114/007e598 + fix)。每阶段 = 实现→typecheck/test/build 绿→**并行多 lens 对抗式 review workflow + 逐条 verify**→ confirmed复验commitmaximize HIGH 几何缺陷经** headless Chrome(Playwright)实测**证实修复服务端/WS 协议零改动;单格模式行为不变
- **探索产物**: 交互式原型 artifact(single/1×2/2×2 切换点击移焦内联审批)已给用户看过并据此拍板方向
### 🔐 TUNNEL AUTOMATION — 零接触隧道注入(客户永不碰证书/密钥;2026-07-08)
- **计划**: [PLAN_TUNNEL_AUTOMATION.md](./PLAN_TUNNEL_AUTOMATION.md)(design-locked)。目标:host 一条命令 onboarddevice 登录一次即在硬件里生成不可导出密钥CSR拿证书,** .p12/AirDrop,私钥永不离设备**。三轨:A 控制面/PKIB host agentC 原生客户端(iOS/Android/desktop)。基础设施(frps/device-CA/frp-client-CA/nginx:8470 mTLS)已在 VPS M1 上线;本工作流建的是**自动化**(签发端点njs certHost 绑定硬件 keygenhost onboard)。
- **分支**: `feat/tunnel-automation`( develop)。 **MVP fast-path §7**(10 tracked task,见任务表 #1#10),依赖图强制:A1 crypto A2 契约 A3 nginx 绑定为顺序地基,之后按职责(backend/host/iOS)扇出

160
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,160 @@
# Roadmap
> **Status (2026-07-13):** ALL of Wave 1-5 (11 items) implemented + committed on `develop` — see docs/PROGRESS_LOG.md for per-feature commits. Remaining: only the deferred backlog table (smaller, several now cheap on the shipped PTY-inject + approval-preview primitives).
Prioritized backlog of features to build next, derived from a grounded analysis of
the codebase + the existing planning docs (5 exploration lenses → 24 candidates →
this synthesis). Each item keeps **what it touches** (real files/subsystems) and a
rough **effort** so it's actionable, not aspirational.
**The throughline:** the product already *captures* everything — Claude Code hooks,
statusLine telemetry, the activity timeline, git diff, live-sessions — but under-*acts*
on it. The highest-leverage work turns passive capture into a **trustworthy remote
review-and-drive surface**, without touching the byte-shuttle. Two small primitives
unlock a whole family of features: a **server-side PTY-inject** call and a **preview on
the approval bar**.
Effort key: **S** ≈ 12 days · **M** ≈ 34 days · **L** ≈ 12 weeks. Order = suggested
build sequence (dependencies noted).
---
## Recently shipped (context)
- **Split-grid watch board (v0.8, desktop)** — 1×2/1×3/2×2/2×3 layouts, click-to-focus,
per-quadrant inline approve / maximize / read-only monitor, drag-to-quadrant,
resizable splitters, saved presets. (`public/grid-layout.ts`, `grid-presets.ts`,
`cell-monitor.ts`, `tabs.ts`.)
- **Fixed:** browser worktree-create was 400ing (frontend sent `repoPath`, server reads
`path`); now aligned + regression-tested (`public/projects.ts`, `test/worktree-form.test.ts`).
---
## Wave 1 — ship first (high-trust / high-delight, independent)
- [x] **Approval preview — see-what-you-approve**_(strongest)_
Show the pending `Bash` command or `Edit`/`Write` diff *above* Approve/Reject, so remote
one-tap approval stops being blind (today you could tap Approve on `rm -rf build`).
*The core walk-away trust gap.*
**Touches:** `src/http/hook.ts` (`tool_input` is already parsed at `:104` — derive a
bounded preview for Bash/Edit/Write/MultiEdit) → `/hook/permission` + `pendingApprovals`
in `src/server.ts:423` (attach + re-send to late joiners, like `gate` already does) →
optional bounded `preview` on the `status` ServerMessage in `src/types.ts``manager`
→ approval bar in `public/tabs.ts` (reuse `public/diff.ts` renderer + `sanitizeField`).
Pure side-channel. **Effort: M.** Risk: truncate + strip control chars + cap bytes,
`textContent`/diff-render only, unknown tools fall back to today's name-only bar.
- [x] **Clickable URLs & file paths in the terminal**
Tap the dev-server URL or file path Claude prints instead of soft-keyboard copy gymnastics
(paths reuse `POST /open-in-editor`). TECH_DOC named `@xterm/addon-web-links` in v0.2; never built.
**Touches:** frontend only — `public/terminal-session.ts` (add the addon + a path-matcher
regex → `/open-in-editor`). Zero server change. **Effort: S.**
---
## Wave 2 — the unlocking primitive
- [x] **Server-side PTY-inject + queued follow-up prompt** _(unlocks a family)_
A thin Origin/loopback-guarded route that writes text into a session's PTY, plus a
per-session queue that fires one entry when Claude goes idle ("now run the tests", then
"now open a PR"). Walk-away = give a task and leave; queuing lets the session advance itself.
**Touches:** new `POST /live-sessions/:id/queue` calling the existing `writeInput`
(`src/session/session.ts:201`); a small queue in `manager.ts`; dequeue in the existing
Stop/SessionEnd hook branch (`src/server.ts:414`); FE near `public/quick-reply.ts`.
Injection is identical to a keystroke → byte-shuttle preserved, broadcasts to mirrors.
**Effort: M.** Risk: gate firing on `claudeStatus==='idle'` + settle delay; surface the
queue in UI. **Foundation for** templated launches, auto-continue, issue-intake (backlog).
---
## Wave 3 — read-only side-channel batch (review from the phone)
- [x] **Diff against a base branch** (`?base=<rev>`)
Review a whole agent branch vs `main` before landing, not just uncommitted changes.
`src/http/diff.ts` already deferred this (FR-B1.9) and named its mitigation:
`git rev-parse --verify` allow-list before any revision reaches the CLI.
**Touches:** optional `base` on `getDiff()` (guarded `git rev-parse --verify <base>` +
trailing `--`, then `git diff <base>...`); branch-picker in `public/diff.ts`. **Effort: S/M.**
- [x] **PR + CI/checks status via `gh`** (read-only)
Per-project/session chip: PR state · checks passing · mergeable — glance from the phone,
re-engage only when red. No `gh` usage exists in `src/` yet; `gh` emits JSON (no parsing pain).
**Touches:** new `src/http/gh.ts` (mirror `diff.ts`'s `runGit`); `GET /projects/pr?path=`
guarded by `isValidGitDir`; `PrStatus` in `src/types.ts`; FE chip in project detail.
Capability-probe + empty-degrade when `gh` absent/unauthed. **Effort: M.**
- [x] **Quick wins** (small, cheap, high-delight)
- [ ] **Sync chip on project cards** — ahead/behind + last-commit, folded into the existing
per-repo metadata pass in `src/http/projects.ts` (no new route). **S.**
- [ ] **Cost budget guard + push alert**`costUsd` already flows via statusLine
(`handleStatusLine`); add `COST_BUDGET_USD` + a one-shot latch (like `stuckNotified`) +
warn styling in `public/preview-grid.ts`. The rail that makes unattended auto-continue safe. **SM.**
- [ ] **"While you were away" reconnect digest** — read-side aggregate over `manager.list()`
+ telemetry/timeline/status → "3 done, 1 waiting, $6, 2 PRs" on reconnect. **SM.**
- [ ] **Recent-commits log per project**`git log --oneline -n N` (NUL-delimited) via a
guarded `GET /projects/log`; inert-text render. **SM.**
---
## Wave 4 — close the git loop
- [x] **Worktree lifecycle: remove / prune** (create is now fixed)
Delete losing worktrees + land the winner from any device — closes the create-only loop.
**Touches:** `removeWorktree`/`pruneWorktrees` in `src/http/worktrees.ts` (same execFile
no-shell + timeout, validate target in `git worktree list` & not main, reuse realpath
containment); `DELETE /projects/worktree` + `POST /projects/worktree/prune` (Origin-guarded);
make the existing `locked`/`prunable` tags actionable. **Effort: SM.** Risk: destructive —
require `--force` + confirm for dirty trees, reject the main worktree, safe error messages.
- [x] **Stage / commit / push from the diff viewer**
Claude's done, you reviewed on the phone — now commit + push without typing git into a
mobile terminal. Highest-risk git write; bound the MVP to per-file stage-toggle + commit +
push-current-branch only; **defer discard/checkout**; realpath-contain paths, cap msg length,
push only to existing upstream or `-u`, CSRF-guarded. **Effort: ML.**
---
## Wave 5 — bigger bets
- [x] **Worktree fan-out board** _(the north star: 真并行不互踩)_
Fan one task across N branch/agent lanes of one repo, watch them race, approve/kill per lane,
keep the winner. **Mostly composition of shipped parts**`createWorktree` + live-sessions +
the split-grid watch board + statusLine gauges + per-quadrant inline approve; server side is a
thin "sessions grouped by repo/worktree" endpoint. Cost is UI. **Effort: L.** Depends on Wave 4.
- [x] **App-level access token** (leave-the-LAN bar-raiser) — shipped on `develop`
A constant-time-compared (`crypto.timingSafeEqual` over SHA-256, fixed-length guard)
`WEBTERM_TOKEN` checked on the WS handshake (alongside, not replacing, Origin) + a central
gate over every remote HTTP route, set as an `HttpOnly; SameSite=Strict; Secure-when-https`
cookie after one-time `GET /?token=` or `POST /auth` (rate-limited 10/min/IP), disabled when
unset (keeps LAN zero-config byte-identical). Charset/length-validated at load; loopback
`/hook*` ingest exempt. `src/http/auth.ts` + wiring in `src/server.ts`.
**Honest tradeoff:** a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the
token is cleartext and replayable; only hardens the TLS-terminated relay/tunnel path. **Effort: M.**
- [x] **Android Projects / Diff / Worktree screens** (client parity)
Android's whole v0.6/v0.7 projects-git UI is SDK-gated/off in `settings.gradle.kts`.
Zero server change (the `:api-client` module already speaks the endpoints), but the largest
scope. Sequence *after* the server-side git features so it's one parity pass. **Effort: L.**
---
## Deferred backlog (your own recorded intent, surfaced from the docs)
Consciously punted in the planning docs; several become cheap once the two unlocking primitives
(PTY-inject, approval-preview) land.
| Feature | Deferred in | Note |
|---|---|---|
| Line-level review comments → agent | `FEATURE_WALKAWAY_WORKBENCH.md §B1.6 / US-B1.4` | Tap a diff line, type feedback, composed with `file:line` and sent via `TerminalSession.send`. No new route. Pairs with diff-vs-base. **M.** |
| GitHub issue → new session/worktree | `FEATURE_PROJECT_MANAGER.md §9` | Pick an issue → spawn a session (optionally fresh worktree) with title+body as the prompt. Rides PTY-inject + host `gh`. Treat issue text as untrusted bytes. **M.** |
| Templated one-tap launches (repo + prompt + mode) | implied by `attach.cwd` + Projects launchers | "Triage repo X in plan mode" as one tap. Rides PTY-inject + a template store. **M.** |
| Auto-continue on idle (bounded) | no idle automation today | Opt-in auto-inject "continue" up to N times under a cost ceiling. Rides PTY-inject + budget guard. Build last, low default cap, never auto-approve. **SM.** |
| Cross-session cost rollup / trends | `FEATURE_WALKAWAY_WORKBENCH.md §B2.6` | Bounded ring beside the latest-only field + `GET /telemetry/summary` + dashboard panel. Overlaps the budget-guard quick win. **M.** |
| Mission Control: cross-session feed + durable run log | (no cockpit persistence today) | Merged reverse-chron feed + append-only on-disk tail (reuse `subscription-store.ts` JSONL/atomic write, byte-capped) so overnight runs survive restart. The "while you were away" digest is its first slice. **ML.** |
| Per-project task backlog | task tracking unbuilt | Per-repo TODO store mirroring `prefs-store.ts`; one tap spawns Claude pre-filled. Local state, **not** a GitHub Issues sync (YAGNI). **M.** |
| Host tmux session discovery + attach | user request (this is not raw-iTerm; needs tmux) | We already run as a tmux client on the user's default tmux server (sessions `web_<id>`), so `tmux ls` already sees manual/iTerm tmux sessions. Add a discovery list + "attach external tmux session" entry in the launcher → spawn a client PTY `tmux attach -t <name>`, wrapped in the session model. **SM.** |
---
_Kept in sync by the maintainer. Ideas are grounded against the code as of `develop` — verify
file/line references before starting (they drift)._

View File

@@ -0,0 +1,155 @@
# Approval preview — command/diff on the approval bar
## Summary & grounding
Today `/hook/permission` (`src/server.ts:423`) reads the hook body, extracts only `tool_name` (`server.ts:430`), derives a `gate` (`server.ts:454`), parks the held `res` in `pendingApprovals` (`server.ts:464`), and broadcasts a bare `waiting` status via `manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)` (`server.ts:471`). The rich `tool_input` — which `parseHookEvent` already knows how to pass through verbatim (`src/http/hook.ts:104-105`) — is dropped on this route. The status `ServerMessage` (`src/types.ts:113-119`) has no field to carry a preview, so the approval bar in `public/tabs.ts:360-377` can only say *"Claude wants to use Bash"*.
This feature derives a **bounded, sanitized preview** from `tool_input` server-side, threads it through the same broadcast + late-joiner re-send paths the `gate` already uses, and renders it above the Approve/Reject buttons — reusing the render-only diff renderer (`public/diff.ts:139` `renderDiffFile`) and the `sanitizeField` pattern (`src/session/timeline.ts:50`).
Design decision: the derive logic lives in a **new pure module** `src/http/approval-preview.ts` (not inlined in `hook.ts`) so it is unit-testable in isolation and keeps `hook.ts` focused; `hook.ts` is cited only as the proof that `tool_input` is already available on the hook body. The `/hook/permission` route calls it directly (it does not go through `parseHookEvent`).
---
## Contract
### New message field — `src/types.ts` (coordination edit)
Add a new exported type and extend the `status` variant of `ServerMessage` (currently `src/types.ts:113-119`). Additive + optional → older clients ignore it; the frontend exhaustiveness check (`terminal-session.ts:349-355`) is unaffected.
```ts
/** A5-adjacent: compact, BOUNDED preview of what a held approval will run.
* Derived server-side from the hook tool_input, sanitized + byte-capped.
* Discriminated on `kind`:
* - 'command' → a shell command string (Bash). Newlines PRESERVED; all other
* control/ANSI chars stripped. Rendered in a <pre> via textContent.
* - 'diff' → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
* rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
* `truncated` = the source exceeded the line/byte cap and was clipped. */
export type ApprovalPreview =
| { kind: 'command'; text: string; truncated?: boolean }
| { kind: 'diff'; file: DiffFile; truncated?: boolean };
```
Extend the status variant:
```ts
| {
type: 'status';
status: ClaudeStatus;
detail?: string;
pending?: boolean;
gate?: PermissionGate;
preview?: ApprovalPreview; // NEW — present only on a held (pending) waiting status
}
```
Extend `SessionManager.handleHookEvent` (currently `src/types.ts:331-339`) with a trailing optional param (appended last so the existing positional `/hook` call at `server.ts:412` is untouched):
```ts
handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
preview?: ApprovalPreview, // NEW
): void;
```
### New route behavior
No new route. `POST /hook/permission` (`server.ts:423`) gains: derive a preview from `body['tool_input']` + `tool`, store it on the `PendingApproval`, pass it into `handleHookEvent`. `GET`/other routes unchanged.
### New env vars — `src/config.ts`
**None.** The bounds are security limits, not user knobs (loosening them is a DoS/broadcast-bloat vector), so they are module constants in `approval-preview.ts`, not config. (Documented as a deliberate choice; if a knob is later wanted, `APPROVAL_PREVIEW_BYTES` slots into `Config` next to the existing `previewBytes` field.)
### Bounds (module constants in `src/http/approval-preview.ts`)
| Constant | Value | Purpose |
|---|---|---|
| `PREVIEW_MAX_LINES` | 40 | max diff/command lines emitted |
| `PREVIEW_MAX_LINE_LEN` | 200 | per-line char cap (= `sanitizeField` default) |
| `PREVIEW_MAX_BYTES` | 4096 | hard total-byte cap on the serialized preview payload |
| `EDIT_TOOLS` | `Edit`,`Write`,`MultiEdit`,`NotebookEdit` | reuse the set semantics from `timeline.ts:17` |
---
## Files to change
| Path | Change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `ApprovalPreview` type; add `preview?` to the `status` `ServerMessage` variant (`:113-119`); add trailing `preview?` param to `SessionManager.handleHookEvent` (`:331-339`). |
| `src/http/approval-preview.ts` | **New file (pure, no DOM, never throws).** `deriveApprovalPreview(toolName: string \| undefined, toolInput: unknown): ApprovalPreview \| null`. Bash→`{kind:'command'}`; Edit/Write/MultiEdit/NotebookEdit→`{kind:'diff', file}`; unknown tool / malformed input→`null`. Imports `sanitizeField` from `../session/timeline.js`; defines a `sanitizeLine` wrapper and per-line splitting so `\n`/`\t` survive but ANSI/control chars don't. |
| `src/server.ts` | In `/hook/permission` (`:423`): after computing `tool` (`:430`), call `deriveApprovalPreview(tool, body['tool_input'])`. Add `preview?: ApprovalPreview` to the `PendingApproval` interface (`:225-231`); store it at creation (`:464`). Pass `preview` into `handleHookEvent(...)` (`:471`). In the late-joiner re-send (`:858-864`) include `preview: heldApproval.preview`. Import `deriveApprovalPreview` + `ApprovalPreview`. |
| `src/session/manager.ts` | `handleHookEvent` (`:223-249`): accept trailing `preview?` param; set `msg.preview = preview` when defined, before `broadcast(session, msg)` (`:245-249`). (The Case-2 late-join re-send at `:142` stays bare — the server layer owns pending/preview re-send, per the comment at `:137-138`.) |
| `public/terminal-session.ts` | Add `private pendingPreviewValue: ApprovalPreview \| null = null` near `:98-106`; getter `get pendingPreview()` near `:185-207`; in `case 'status'` (`:313-322`) set `this.pendingPreviewValue = nextPending ? (msg.preview ?? null) : null`; clear it in the two disconnect resets near `:385-389`. Import `ApprovalPreview` from `../src/types.js`. |
| `public/tabs.ts` | In `updateApprovalBar` (`:360-377`): after the `label`, if `session.pendingPreview` is set, build and insert a preview node **before** the buttons. Add `private renderApprovalPreview(p: ApprovalPreview): HTMLElement``kind:'command'``<pre class="approval-cmd">` via `textContent`; `kind:'diff'``renderDiffFile(p.file)` wrapped in a scroll container; append a "… truncated" note when `p.truncated`. Import `renderDiffFile` from `./diff.js` and `ApprovalPreview` from `../src/types.js`. |
| `public/style.css` | Add `.approval-preview` (scroll container: `max-height`, `overflow:auto`, `overflow-x:auto`), `.approval-cmd` (`white-space:pre-wrap`, monospace), `.approval-truncated` under the existing `#approvalbar` block (`:1084`). Reuse existing `.df-*` styling for the diff. |
| `test/http/approval-preview.test.ts` | **New (node).** Unit tests for `deriveApprovalPreview`. |
| `test/manager.test.ts` | Extend: `handleHookEvent` with a `preview` arg puts it on the broadcast status msg. |
| `test/terminal-session.test.ts` | Extend (jsdom): status frame with `preview` sets `pendingPreview`; cleared when `pending` false / on disconnect. |
| `test/tabs.test.ts` | Extend (jsdom): `FakeTerminalSession` gains `pendingPreview`; `updateApprovalBar` renders command / diff / truncated note; no-preview → name-only bar (regression). |
| `test/integration/server.test.ts` | Extend: `POST /hook/permission` with `tool_input` → broadcast `waiting` status carries `preview`; a late-joining WS gets the preview on attach. |
---
## TDD steps (ordered)
Repo style: pure/back-end tests are plain vitest (node) — see `test/hook.test.ts` (`import { describe, it, expect }`, `expect.objectContaining`). Front-end tests carry `// @vitest-environment jsdom` and mock `TerminalSession` (`test/tabs.test.ts`) or mock `WebSocket` + stub xterm (`test/terminal-session.test.ts`). Diff render tests run in jsdom asserting `textContent` (`test/diff.test.ts`).
1. **RED — `test/http/approval-preview.test.ts` (node).** Write, before any impl:
- Bash: `deriveApprovalPreview('Bash', { command: 'ls -la', description: 'x' })``{ kind:'command', text:'ls -la' }`.
- Bash multi-line: `command:'a\nb'``text` still contains the `\n` (newlines preserved).
- Bash with ANSI/control injection: `command:'\x1b[31mrm\x1b[0m\x07'` → ESC/BEL stripped, no `\x1b`/`\x07` in `text`.
- Edit: `{ file_path:'/p/f.ts', old_string:'a\nb', new_string:'c' }``{ kind:'diff', file }` where `file.newPath` sanitized, hunk has `removed` lines `a`,`b` and `added` line `c`, `file.removed===2`, `file.added===1`.
- Write: `{ file_path, content:'x\ny' }` → all-added hunk, `removed===0`.
- MultiEdit: `{ file_path, edits:[{old_string,new_string},{...}] }` → one hunk per edit.
- Truncation: `content` with >`PREVIEW_MAX_LINES` lines → `truncated:true`, ≤ cap lines emitted; a >`PREVIEW_MAX_BYTES` blob → `truncated:true` and serialized size ≤ cap.
- Unknown tool (`'WebFetch'`) → `null`; missing/`null`/array/number `toolInput``null` and **never throws** (mirrors `hook.ts` SEC-M7 style).
2. **GREEN — implement `src/http/approval-preview.ts`.** Pure, `unknown`-narrowed, `sanitizeField`-per-line, byte-clamped. Run the suite to green.
3. **RED — `test/manager.test.ts`.** Add: calling `handleHookEvent(id,'waiting','Bash',true,'tool',undefined,undefined,{kind:'command',text:'ls'})` broadcasts a status msg whose `preview` deep-equals the arg (assert via the fake WS `send` capture already used in this file). Add: omitting `preview` → no `preview` key on the msg.
4. **GREEN — `src/types.ts` param + `src/session/manager.ts`.** Add the trailing param and `msg.preview` assignment.
5. **RED — `test/integration/server.test.ts`.** Add a test (follow the `itPty` pattern of ⑧ at `:608-655`): attach a WS, `POST /hook/permission` with `{ tool_name:'Bash', tool_input:{ command:'echo hi' } }`, assert the broadcast `pending===true` status has `preview.kind==='command'` and `preview.text` containing `echo hi`. Add a **late-joiner** assertion: open a 2nd WS to the same `sessionId` while held → its first `waiting/pending` status includes `preview`.
6. **GREEN — `src/server.ts`.** Wire `deriveApprovalPreview` into `/hook/permission`, store on `PendingApproval`, pass to `handleHookEvent`, include in the `:858-864` re-send.
7. **RED — `test/terminal-session.test.ts` (jsdom).** Feed a `status` frame with `pending:true, gate:'tool', preview:{kind:'command',text:'ls'}``session.pendingPreview` set; a follow-up `status` with `pending:false` clears it to `null`; disconnect clears it.
8. **GREEN — `public/terminal-session.ts`.** Add field, getter, set/clear.
9. **RED — `test/tabs.test.ts` (jsdom).** Extend `FakeTerminalSession` with `pendingPreview`. Assert `updateApprovalBar`: command preview → a `.approval-cmd` node whose `textContent` equals the command; diff preview → a `.df-file` present (renderDiffFile output); `truncated:true``.approval-truncated` present; `pendingPreview:null` → bar shows label + buttons only (regression, matches `:373-374`). Assert **zero `innerHTML`** — content via `textContent` only.
10. **GREEN — `public/tabs.ts` + `public/style.css`.** Add `renderApprovalPreview`, insert before buttons, style the container.
11. **Coverage gate.** The new pure module is branch-dense and fully unit-covered (helps the 80% gate); FE branches covered by jsdom tests. Run full `npm test` + coverage; confirm no regression in `hook.test.ts`/`diff.test.ts`/`manager.test.ts`.
---
## Edge cases & failure modes
- **Unknown / non-preview tool** (WebSearch, Task, MCP tools, `ExitPlanMode`): `deriveApprovalPreview` returns `null` → status carries no `preview``updateApprovalBar` falls back to today's name-only bar (`:373`). Plan-gate (`gate:'plan'`, `:369-371`) also gets no preview (ExitPlanMode has no reviewable command/diff) — unchanged.
- **Missing/partial `tool_input`**: Bash without `command`, Edit without `old_string`, `tool_input` present but `null` (the `'tool_input' in b` passthrough at `hook.ts:104` can yield `null`) → return `null`, never throw.
- **Huge command / whole-file Write**: clipped at `PREVIEW_MAX_LINES` then `PREVIEW_MAX_BYTES`; `truncated:true` shows the "… truncated" note. Prevents a multi-MB status frame from being broadcast to every client and retained in `pendingApprovals`.
- **Newlines vs control chars**: `sanitizeField` (`timeline.ts:50`) strips `\x00-\x1f` which includes `\n`/`\t`/`\r` — so it is applied **per line after splitting**, never to the whole multi-line blob, preserving structure while still killing ANSI ESC (`\x1b`) and BEL.
- **MultiEdit with many edits**: hunks accumulate until the line/byte cap, then stop + `truncated:true`.
- **Late joiner after approval already resolved**: `pendingApprovals.get()` returns `undefined` (`server.ts:858`) → no preview re-sent (correct; nothing is held).
- **Approve/reject clears preview**: `handleHookEvent(...,'working',...,false)` (`server.ts:887,890`) broadcasts a non-pending status → `terminal-session` sets `pendingPreview = null` → bar hides (existing `:362` guard).
- **Multi-device**: preview broadcasts to all clients via `broadcast` and re-sends to each new attach — every mirror shows the same preview.
- **Binary/odd content in Write**: rendered as literal text via `textContent` (no interpretation), `binary:false` on the synthetic file (we don't attempt binary detection — out of scope).
---
## Security
- **Trust boundary**: `tool_input` is Claude-controlled content arriving on the loopback-only `/hook/permission` route (`isLoopback` guard at `server.ts:424`). It is untrusted for *content*: treat as `unknown`, narrow every field (`typeof x === 'string'`), never index without a guard. `deriveApprovalPreview` must **never throw** (SEC-M7 discipline, mirroring `parseHookEvent`).
- **Sanitization (SEC-H6 reuse)**: every emitted string passes through `sanitizeField`/`sanitizeLine` — strips `\x00-\x1f` (incl. ANSI ESC `\x1b`), truncates to `PREVIEW_MAX_LINE_LEN`. This neutralizes terminal-escape / cursor-hijack payloads in a filename or command before they reach the DOM.
- **XSS (SEC-H4 reuse)**: the frontend renders **only** via `textContent` / `el()` / `renderDiffFile` (which is already innerHTML-free — `diff.ts:9`, `:191-194`). No `innerHTML` anywhere in the new FE code; asserted in tests. `<script>`, `&`, `<img onerror>` appear as literal characters.
- **DoS / resource containment**: `PREVIEW_MAX_LINES` + `PREVIEW_MAX_BYTES` cap the payload that is (a) broadcast to N clients and (b) retained in `pendingApprovals` for the held-decision lifetime. No unbounded growth from a hostile/huge `tool_input`.
- **No new route, no new capability token**: the existing per-decision token (`server.ts:457`), Origin/loopback guards, and rate limiters are untouched. Preview is pure display data attached to an already-authorized held decision.
- **No path egress / no argv**: file paths from `tool_input` are only sanitized + displayed as text; they are never passed to `execFile`, `fs`, or a shell. No path-traversal surface is added.
---
## Effort & dependencies
- **Effort**: ~1.52 days. Breakdown: pure `approval-preview.ts` + its tests ~0.5d (the bulk of logic + coverage); server/manager/types threading ~0.25d; FE (terminal-session field + tabs render + CSS) + jsdom tests ~0.5d; integration test + polish ~0.25d.
- **Depends on** (all already shipped): H3 held-approval gate + `pendingApprovals` (`server.ts:423-472`); B4 `gate` re-send plumbing to late joiners (`server.ts:858-864`, `manager.ts:137-142`) — this feature rides the exact same rails; B1 `public/diff.ts` `renderDiffFile` + the `DiffFile`/`DiffLine` types (reused, not modified); A4 `sanitizeField` (`timeline.ts:50`, reused).
- **Unlocks / synergizes**: the diff-render path here is the same one W3 "Diff against a base branch" and W4 "Stage/commit/push from the diff viewer" extend — a shared, security-reviewed `DiffFile` render surface. Also complements A1 lock-screen approvals: a future enhancement can put a one-line preview summary into the push `detail` (`PushPayload.detail`, `types.ts:381`) so the phone shows *what* is being approved (not planned here, but the derive function is the reusable source).
- **Isolation**: `src/http/approval-preview.ts` is a new owned file (no conflict); `src/types.ts` is the one coordination edit (additive-optional, low collision risk); `server.ts`/`manager.ts`/`terminal-session.ts`/`tabs.ts` edits are localized to the cited line ranges.

View File

@@ -0,0 +1,143 @@
# Clickable URLs & file paths in the terminal
**Status of premise (read first).** Two independent capabilities are bundled here:
1. **URLs** — already *90% shipped.* `public/terminal-session.ts:148` already does `this.term.loadAddon(new WebLinksAddon())`, and `@xterm/addon-web-links@^0.12.0` is already in `package.json`. Remaining work is a security hardening pass on link activation (scheme allowlist + `noopener`). Pure frontend.
2. **File paths → editor** — needs a **custom link provider** *and* a **small, additive server change.** The task brief says "POST to the existing `/open-in-editor` … no server change," but the existing route **cannot** open a file at a line: `openInEditor` (`src/http/editor.ts:31`) rejects anything that is not an **absolute existing _directory_** (lines 35, 4547) and spawns `code <dir>` with **no `--goto`/line** (line 51). Feeding it `src/app.ts:42` fails three validators at once. See the **Decision** callout below — I recommend a tiny additive `openFileInEditor` alongside the untouched `openInEditor`. A strict "frontend-only" fallback exists but degrades to "open the containing folder, no line jump."
`src/types.ts` is **not** touched — this is an HTTP JSON body, not a WS protocol message; no shared-contract change. The byte-shuttle terminal stream is untouched.
---
## Decision: how file-path clicks reach the editor
| Option | What clicking `src/app.ts:42` does | Server change | Recommendation |
|---|---|---|---|
| **A — additive `openFileInEditor` (recommended)** | Opens the file at line 42 (`code --goto /abs/src/app.ts:42`) | +~35 lines in `src/http/editor.ts`, +1 branch in the `server.ts:384` route. Backward-compatible; `openInEditor` + its tests untouched | **Yes.** Only option that delivers the actual feature (jump to file:line). Additive and low-risk. |
| **B — strict frontend-only** | Resolves to the file's parent dir and calls existing `openInEditor` → opens the *repo/folder*, no file, no line | None | Fallback only. Poor UX; loses the whole point (line jump). Document but don't ship as primary. |
The rest of this plan assumes **Option A**. The frontend work is identical either way; only the POST body and server branch differ.
---
## Contract
### Route (extended, backward-compatible)
`POST /open-in-editor` (`src/server.ts:384`) — unchanged guards: `express.json({ limit: '4kb' })`, `requireAllowedOrigin` (CSRF, `src/server.ts:352`). New branch on body shape:
- **Existing (directory)** — `{ "path": "<abs dir>" }``openInEditor(cfg, body.path)` (unchanged; Projects panel keeps working).
- **New (file+line)** — `{ "file": "<abs file>", "line": <int?>, "column": <int?> }``openFileInEditor(cfg, body.file, body.line)`.
- Response envelope unchanged: `204` on success; `{ error }` + `4xx/5xx` on failure (mirrors `src/server.ts:388392`).
Body is a discriminated request: **`file` present ⇒ file mode; else path mode.** If neither present → `400 { error: 'path or file is required' }`.
### New server function (`src/http/editor.ts`)
```
export async function openFileInEditor(
cfg: Config, rawFile: unknown, rawLine?: unknown
): Promise<OpenEditorResult>
```
Reuses the existing `OpenEditorResult` type (`editor.ts:20`). Validates: string + non-empty (`400`), `path.isAbsolute` (`400`), `fs.stat` exists (`404`), `stat.isFile()` (`400 'path is not a file'`), and `line` (when present) is an integer in `1..1_000_000` else `400`. Spawns via `execFile(cfg.editorCmd, args)` (no shell, same as line 51) where:
- `args = isGotoEditor(cfg.editorCmd) && line !== undefined ? ['--goto', `${file}:${line}`] : [file]`
- `isGotoEditor` = basename ∈ `{code, code-insiders, codium, cursor, windsurf}` (the editors that accept `--goto`). Unknown editors open the bare file (never pass a bogus `--goto` argv).
### New frontend module (`public/link-paths.ts`) — pure, node-testable
```
export interface PathMatch {
text: string // exact matched substring (e.g. "src/app.ts:42")
path: string // "src/app.ts"
line?: number
column?: number
startX: number // 1-based column of first char (xterm range.start.x)
endX: number // 1-based column of last char (xterm range.end.x, inclusive)
}
export function findPathMatches(lineText: string): PathMatch[]
```
Matching rules (concrete): a token is a path candidate iff it has a filename with a dot-extension, optionally preceded by `./`, `../`, or `dir/…/` segments, optionally suffixed `:line` and `:line:col`. A candidate becomes a match iff **(has a `/` separator) OR (has a `:line` suffix) OR (extension ∈ `CODE_EXT` allowlist)** — this links `src/app.ts:42`, `README.md`, `main.rs:10` while rejecting `example.com`, `v1.2`, `foo.bar`. Reject candidates immediately preceded by `/` or `:` (avoids grabbing the tail of a `https://host/path.html` URL, which `WebLinksAddon` owns). `CODE_EXT` = a named const set (`ts tsx js jsx mjs cjs py go rs rb java kt c h cpp hpp cc cs php swift css scss html json yaml yml toml md txt sh sql vue svelte` …).
### Env vars
**None new.** `EDITOR_CMD` (default `'code'`) already exists (`src/config.ts:49`, `src/types.ts:42`) and is reused.
### WS protocol / `src/types.ts`
**No change.** No new client→server or server→client message types.
---
## Files to change
| Path | Change |
|---|---|
| `public/link-paths.ts` | **New.** Pure `findPathMatches` + `CODE_EXT` const + `PathMatch` type. No DOM. (Keeps matcher unit-testable in node and file <150 lines.) |
| `public/terminal-session.ts` | Replace bare `new WebLinksAddon()` at **line 148** with a hardened handler (scheme allowlist + `window.open(uri,'_blank','noopener,noreferrer')`). After `term.open` (line 149) register a path link provider via `this.term.registerLinkProvider(...)`; store the returned `IDisposable`. Add `private openPath(m: PathMatch)` (resolve relabs via `this.cwdValue`, in-flight guard, `fetch('/open-in-editor', …)`, error`statusLine` toast). Dispose the provider in `dispose()` (line 452). Small helpers `openWebLink`, `makePathLinkProvider`. |
| `src/http/editor.ts` | **Add** `openFileInEditor` + `isGotoEditor` helper. **`openInEditor` unchanged** (Projects panel + its tests keep passing). |
| `src/server.ts` | In the `/open-in-editor` handler (**line 384393**) branch: `body.file` present `openFileInEditor(cfg, body.file, body.line)`; else existing `openInEditor(cfg, body.path)`. |
| `src/types.ts` | **Not touched** noted here only to confirm no shared-contract edit is required. |
---
## TDD steps (ordered)
> Run with `npm test` (vitest). New frontend-logic tests are **node** (pure matcher); DOM-wiring tests reuse the existing **jsdom** harness in `test/terminal-session.test.ts`.
1. **`test/link-paths.test.ts` (new, node).** REDGREEN for `findPathMatches`:
- `'see src/app.ts:42 for'` one match, `path:'src/app.ts', line:42`, `startX/endX` correct (1-based, `startX = index+1`, `endX = index+len`).
- `'./a/b.tsx:10:5'` `line:10, column:5`.
- `'README.md'` (allowlisted ext, no slash) matched; `'example.com'` and `'v1.2.3'` **no** match.
- URL guard: `'https://host/path.html'` **no** path match (preceded-by-`/` rule).
- Two paths on one line two matches with disjoint ranges.
Then implement `public/link-paths.ts` to green.
2. **`test/editor.test.ts` (extend, node mirror existing spawn-a-harmless-process style, `editorCmd:'true'`).** Add a `describe('openFileInEditor')`:
- relative `400`; missing `404`; **directory** `400 'is not a file'`; non-int/`0`/`1e9+` line `400`.
- success on a real temp file (editorCmd `'true'`) `status 204`.
- **argv assertion:** write a tiny recorder script into the temp dir (`#!/bin/sh; printf '%s\n' "$@" > "$ARGS_OUT"`), set `editorCmd` to it, call with `line:42`, assert the recorded argv is `--goto`, `<file>:42`; call an unknown editor name argv is just `<file>` (no `--goto`).
Then implement `openFileInEditor` + `isGotoEditor` to green.
3. **`test/integration/server.test.ts` (extend; harness at line 229).** Boot the app, `POST /open-in-editor`:
- `{file:<abs tmp file>, line:3}` with a valid `Origin` `204`.
- foreign/missing `Origin` `403` (proves the CSRF guard still covers the new branch).
- `{path:<abs tmp dir>}` still `204` (regression: directory mode intact).
Wire the `server.ts` branch to green.
4. **`test/terminal-session.test.ts` (extend, jsdom).** Extend `FakeTerminal` (line 14) with `registerLinkProvider = vi.fn(p => { this.captured = p; return {dispose:vi.fn()} })` and a `buffer = { active: { getLine: (y)=>({ translateToString:()=> this.lineText }) } }`. Keep the `WebLinksAddon` mock (line 48) but assert the constructor **received a handler fn**. Tests:
- after construct, a link provider is registered; feeding a line with `src/app.ts:42` `provideLinks` callback yields one `ILink` whose `text/range` match `findPathMatches`.
- calling `link.activate(mouseEvent, text)` when `cwd` is set (drive an OSC-7 via the captured handler, or set via a resolved path) `fetch` (stub via `vi.stubGlobal('fetch', …)` as in `test/preview-grid.test.ts:140`) called once with `'/open-in-editor'`, method `POST`, body `{file:<abs>, line:42}`.
- relative path **with null cwd** **no** fetch; a `statusLine` is written to the terminal (assert `term.write`).
- in-flight guard: two rapid `activate` calls **one** fetch.
- `openWebLink('javascript:alert(1)')` `window.open` **not** called; `openWebLink('https://x')` `window.open('https://x','_blank','noopener,noreferrer')` called.
Wire `terminal-session.ts` to green.
**Coverage:** the matcher (branch-heavy) is fully covered by the node test; the DOM wiring by jsdom; the server branch + validators by editor/integration tests. This keeps the 80% gate comfortably.
---
## Edge cases & failure modes
- **No cwd yet** (OSC-7 never fired, `this.cwdValue === null`, `terminal-session.ts:97`) relative paths are unresolvable **skip activation**, write a one-line `statusLine('cannot open <path>: working dir unknown')`. Absolute paths still work.
- **Path doesn't exist / is a dir** server returns `404`/`400`; frontend shows a non-blocking `statusLine` toast, no throw (mirror the existing `openProjectInEditor` catch that only `console.error`s).
- **Wide (CJK) glyphs before a path** shift xterm columns vs JS string index. Paths are ASCII, but a preceding CJK run offsets `startX`. Acceptable v1 caveat; note it. (Fixable later by walking cells; YAGNI now.)
- **URL/path overlap** (`https://host/a.ts:5`) `WebLinksAddon` links the URL; the `/`-preceded guard stops the path provider from double-linking the tail. Verify the two providers don't both underline.
- **`provideLinks` line indexing** pass `terminal.buffer.active.getLine(bufferLineNumber - 1)` and set `range.{start,end}.y = bufferLineNumber` (xterm gives a 1-based buffer row). **Verify once in a real browser** the single indexing footgun.
- **Rapid clicks** spawn N detached GUI processes on the host in-flight boolean guard (+ optional 500 ms cooldown) on the frontend.
- **Non-`code` editor** without `--goto` support `isGotoEditor` returns false open bare file (never inject a stray `--goto` argv the editor would treat as a filename).
- **Line-only false positives** like `12:34` (a timestamp) filtered because the token needs a filename-with-extension before the `:line`.
---
## Security
- **Origin/CSRF:** unchanged. `/open-in-editor` stays behind `requireAllowedOrigin` (`src/server.ts:385`, `:352`). The frontend POST is same-origin, so the browser sends `Origin` and passes; a foreign page's no-preflight POST is rejected `403`. Integration test #3 asserts this on the new branch.
- **No shell / no injection:** `openFileInEditor` uses `execFile` with an **argv array** (as `editor.ts:51`); the file path and `<file>:<line>` are argv elements, never a command line. `line` is validated to an integer before interpolation, so `${file}:${line}` can't smuggle shell metacharacters via the line field.
- **Path containment:** the resolved path is validated **absolute + existing + `isFile()`** server-side. Terminal output is attacker-influenced (a malicious repo could print `../../etc/hosts:1`), but opening a file the user could already `cat` in the shell this app *already grants* adds no privilege (threat model: LAN, no auth, full shell). Optional hardening (note, not required v1): reject resolved paths that escape the session `cwd` root.
- **URL activation hardening (the real new surface):** custom `WebLinksAddon` handler (replacing the default at line 148) **allowlists schemes** to `http:/https:/mailto:` and opens with `window.open(uri, '_blank', 'noopener,noreferrer')` blocks `javascript:`/`data:`/`file:` URIs and prevents reverse-tabnabbing. Activation is gated on the click (a genuine user gesture); no hover/auto-open.
- **Rate-limit:** frontend in-flight guard caps editor-spawn fan-out; the route's `express.json({limit:'4kb'})` bounds body size. No new secrets, no logging of paths beyond the existing `console.error` on failure.
---
## Effort & dependencies
- **Effort:** ~**1.52 days.** Matcher + tests (0.5d), frontend wiring + jsdom tests + hardened URL handler (0.5d), server `openFileInEditor` + editor/integration tests (0.5d), manual browser verify of link indexing/overlap (0.25d).
- **Depends on:** nothing OSC-7 `cwd` capture (`terminal-session.ts:155159`), the `/open-in-editor` route, and the `WebLinksAddon` dependency all already exist. Self-contained, W1.
- **Unlocks / synergy:** the **Approval preview (W1, task #7)** and **diff viewer (W4, task #13)** can reuse `findPathMatches` + `openFileInEditor` to make paths in a diff/command preview clickable. `link-paths.ts` is deliberately a standalone pure module for that reuse.
- **Scope flag for the orchestrator:** Option A adds a ~35-line server function (contradicting the brief's "no server change"). It is additive and backward-compatible, but it *is* a deviation record it in `PROGRESS_LOG.md`. If the "no server change" constraint is hard, ship Option B (folder-open fallback) and defer file:line jump.

View File

@@ -0,0 +1,138 @@
# Server-side PTY-inject + idle-queued follow-up prompt
**Feature id:** `w2-pty-inject-queue` · **Branch base:** `develop`
A thin, Origin/CSRF-guarded HTTP route writes text into a live session's PTY, plus a **bounded per-session queue** whose head entry fires **once** when Claude next goes idle (Stop/SessionEnd), after a short **settle delay**. This is the unlocking primitive for templated launches, auto-continue, and issue-intake.
Grounding facts from the code I read:
- `writeInput(session, data)``src/session/session.ts:201` — no-op after PTY exit (L4); today called only from the WS input handler at `src/server.ts:876`.
- Idle is observable in the hook side-channel: the Stop/SessionEnd branch is `src/server.ts:414` (`if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd')`), immediately after `manager.handleHookEvent(...)` at `:412`. `handleHookEvent` (`src/session/manager.ts:223`) sets `session.claudeStatus` and broadcasts.
- `broadcast(session, msg)``src/session/session.ts:52` — fan-out to all `session.clients`.
- CSRF helper `requireAllowedOrigin(req, res)``src/server.ts:352`; per-IP `createRateLimiter(max, windowMs)``src/server.ts:109`; `isLoopback``src/server.ts:151`; `SESSION_ID_RE` (UUID v4, M7) — `src/protocol.ts:22`.
- Timer/hold precedent: `pendingApprovals` map + `setTimeout` live in **server.ts** (`:232`, `:459`), not the manager — the manager owns session state; the server owns wiring/timers. The queue follows the same split.
- Session shape `src/types.ts:201`; `LiveSessionInfo` `:246`; `ServerMessage` union `:109`; `SessionManager` iface `:314`. `timeline`/`stuckNotified`/`telemetry` are the precedent for "mutable runtime handle on immutable meta, replaced wholesale".
---
## Contract
### New HTTP routes (all in `src/server.ts`, registered near the `/live-sessions/:id/preview` block ~`:334`)
| Method / path | Guard | Body | Success | Errors |
|---|---|---|---|---|
| `POST /live-sessions/:id/queue` | `requireAllowedOrigin` (CSRF) + per-IP rate limit + `SESSION_ID_RE` check | `{ text: string, appendEnter?: boolean }` (`express.json({limit:'16kb'})`) | `200 { length }` | `400` bad id / empty text / non-string; `413` text > `QUEUE_ITEM_MAX_BYTES`; `404` unknown or exited session; `409` queue at `QUEUE_MAX_ITEMS`; `429` rate; `503` when `QUEUE_ENABLED=false` |
| `GET /live-sessions/:id/queue` | none (read-only, same threat model as `/live-sessions`) | — | `200 { length, items: string[] }` | `404` unknown |
| `DELETE /live-sessions/:id/queue` | `requireAllowedOrigin` | — | `200 { length: 0 }` (escape hatch: cancel all pending) | `404` unknown |
Not loopback-gated (unlike `/hook`): these are LAN-device actions, so Origin-guarded like `/open-in-editor` (`:384`).
### `src/types.ts` (coordination edit — the frozen shared-contract source)
- **`ServerMessage`** — add a variant so all attached devices see pending count live:
`| { type: 'queue'; length: number }`
- **`Session`** — add a mutable runtime handle (precedent: `timeline`):
`queue: readonly string[]` (verbatim byte strings, head fires first; replaced wholesale, never mutated in place).
- **`LiveSessionInfo`** — add optional `readonly queueLength?: number;` (additive/optional like `lastOutputAt` at `:261`) so `/live-sessions` and the manage grid show depth.
- **`SessionManager`** — add three methods:
- `enqueueFollowup(id: string, text: string): EnqueueResult`
- `drainOne(id: string): string | null` *(pops head, writes to PTY, broadcasts; null if none/exited)*
- `clearQueue(id: string): boolean`
- New result type:
`export type EnqueueResult = { ok: true; length: number } | { ok: false; reason: 'unknown' | 'full' | 'exited' };`
### `src/config.ts` env vars (add to `Config` in types.ts `:21` block **and** `loadConfig`)
| Env | Field | Default | Parser (existing helper) |
|---|---|---|---|
| `QUEUE_ENABLED` | `queueEnabled: boolean` | `true` | `parseBool` (`config.ts:89`) |
| `QUEUE_MAX_ITEMS` | `queueMaxItems: number` | `10` | `parseNonNegativeInt` (`:73`) |
| `QUEUE_ITEM_MAX_BYTES` | `queueItemMaxBytes: number` | `4096` | `parseNonNegativeInt` |
| `QUEUE_SETTLE_MS` | `queueSettleMs: number` | `1500` | `parseNonNegativeInt` |
### Client→server protocol
No new `ClientMessage`. Enqueue is HTTP POST (Origin-guarded), deliberately **not** a WS frame — it must survive "walked away, zero tabs open" and be usable from the manage page for any session, not just the WS-bound one. (Contrast: quick-reply chips send *immediately* over the active WS via `sendToActive`; the queue is *deferred* + cross-session, so HTTP is the right seam.)
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `queue` to `Session`; `queueLength?` to `LiveSessionInfo`; `{type:'queue';length}` to `ServerMessage`; 4 `Config` fields; 3 `SessionManager` methods; `EnqueueResult` type. |
| `src/config.ts` | Parse the 4 new env vars in `loadConfig` (helpers already exist); include in returned `Config`. |
| `src/session/session.ts` | Init `queue: Object.freeze([])` in the `createSession` session literal (~`:137`, beside `timeline`). No other logic here — reuse existing `writeInput` (`:201`) and `broadcast` (`:52`). |
| `src/session/manager.ts` | Implement `enqueueFollowup` / `drainOne` / `clearQueue`; add `queueLength: s.queue.length` to `list()` map (`:184`); import `writeInput` from `./session.js` (add to the existing import at `:43`). Add the three names to the returned object (`:337`). |
| `src/server.ts` | Register the 3 routes; add a `QUEUE_RATE_MAX` const + a `createRateLimiter` instance (beside `:218`); in the Stop/SessionEnd branch (`:414`) call a new local `scheduleDrain(sessionId)` that debounces a `setTimeout(cfg.queueSettleMs)` (map `drainTimers: Map<string, Timeout>`, `.unref()`); on fire, re-check idle + stable `lastOutputAt`, then `manager.drainOne(id)`. Clear all `drainTimers` in `doShutdown` (`:930`). |
| `public/queue.ts` **(new)** | Tiny FE module: `enqueueFollowup(sessionId, text, appendEnter): Promise<Result>` — POSTs `/live-sessions/:id/queue` (same-origin), never throws; `clearQueue(sessionId)`; parse `{type:'queue'}` frames to update a badge. |
| `public/tabs.ts` | Handle the incoming `{type:'queue'}` frame (near the existing status/telemetry frame handling) to show a "N queued" badge on the tab; add an "Queue…" affordance (long-press on the quick-reply `+`, or a small button) that calls `enqueueFollowup(this.activeSessionId(), text)` instead of `sendToActive` (`:875`). |
| `public/manage.*` (grid) | Optional: render `queueLength` per card and a cancel (DELETE) button. |
---
## TDD steps (ordered — RED → GREEN, matching repo style)
1. **`src/types.ts`** — make the coordination edit first so everything compiles. Update **every** test `CFG` fixture that lists all Config fields (`test/manager.test.ts:47`, and the same literal in `test/session.test.ts`, plus any others — `grep -rl "worktreeTimeoutMs" test/`) to add the 4 new fields. (Compile gate, no assertion.)
2. **`test/config.test.ts`** (node) — RED: assert defaults (`queueEnabled=true`, `queueMaxItems=10`, `queueItemMaxBytes=4096`, `queueSettleMs=1500`), env overrides parse, and invalid (`QUEUE_MAX_ITEMS=-1`) throws (fail-fast, like existing `parseNonNegativeInt` tests). → GREEN in `src/config.ts`.
3. **`test/manager.test.ts`** (node, node-pty mocked via `createMockPty`) — RED then GREEN in `src/session/manager.ts`:
- `enqueueFollowup` appends → returns `{ok:true,length:1}` and **broadcasts** `{type:'queue',length:1}` to a stub `WebSocketLike` (assert `ws.send` payload via `serialize`). Second call → `length:2`.
- Cap: with `queueMaxItems:2`, third enqueue → `{ok:false,reason:'full'}`, no broadcast, queue unchanged (immutability).
- Unknown id → `{ok:false,reason:'unknown'}`.
- `drainOne` on a 2-item queue → returns head string, asserts **`mockPty.write` called with that exact string**, queue now length 1, broadcasts `{type:'queue',length:1}`.
- `drainOne` empty queue → `null`, no write. Exited session (`session.exitedAt` set) → `null` (double-guards L4).
- `clearQueue` → empties + broadcasts `length:0`, returns true.
- `list()` includes `queueLength`.
*(Use the existing mock-pty `write` spy pattern from `test/session.test.ts:364`.)*
4. **`test/integration/queue.test.ts`** (new, node, real `startServer` + `fetch`, PTY-gated with the `itPty` helper at `server.test.ts:51`) — RED then GREEN for the routes in `src/server.ts`:
- `POST /live-sessions/:id/queue``403` foreign Origin; `403`/missing Origin default-deny (mirror `server.test.ts:770`).
- Allowed Origin, malformed id → `400`; empty `text``400`; `text` of `queueItemMaxBytes+1``413`; unknown session → `404`; over rate → `429`.
- Happy path on a **real attached** session (open WS, attach, capture `sessionId`): `200 {length:1}`, then `GET /live-sessions` shows `queueLength:1`; `DELETE …/queue``queueLength:0`.
- **Idle-drain wiring** (`itPty` + `vi.useFakeTimers`): attach real session, enqueue `"echo QUEUED_MARKER\r"`, POST `/hook` (loopback) with `{hook_event_name:'Stop', ...}` and header `x-webterm-session`, advance timers past `queueSettleMs`, assert the client WS receives an `output` frame containing `QUEUED_MARKER` (the shell echoes it). Also assert a *second* enqueue does **not** fire until the next Stop (one-per-idle pacing).
- Settle guard: enqueue, POST Stop, then before `queueSettleMs` push a `/hook` event that produces output (changes `lastOutputAt`) → advance timers → assert **no** drain (Claude still active). *(This targets the `scheduleDrain` cursor check.)*
5. **`test/queue.test.ts`** (new, **jsdom**, mocked `fetch`) — RED then GREEN in `public/queue.ts`:
- `enqueueFollowup` POSTs to the right URL/body, returns parsed result; on non-2xx returns `{ok:false}` and **never throws**; on network reject returns `{ok:false}` (mirrors quick-reply's never-throw discipline, `quick-reply.ts:99`).
- A `{type:'queue',length:3}` frame → badge helper returns/sets 3.
6. **`test/tabs.test.ts`** — extend: a `{type:'queue',length:N}` server frame updates the active tab's badge; the enqueue affordance calls `enqueueFollowup` with `activeSessionId()` (not `sendToActive`).
**Coverage:** manager + config + routes are node-testable deterministically (queue mutation, caps, broadcasts, drain, rate/Origin/validation all hit without a real PTY). The FE module is small and fully jsdom-mockable. Only the real echo-through-PTY assertion is `itPty`-gated (auto-skips in sandbox, runs in CI) — keeps the 80% gate.
---
## Edge cases & failure modes
- **Idle flapping / repeated Stop:** `scheduleDrain` **debounces** — clears any existing `drainTimers` entry and restarts the settle timer on each Stop; only fires once the window elapses.
- **New output during settle window:** capture `outputCursor = session.lastOutputAt` at schedule time; on timer fire, drain **only if** `session.lastOutputAt === outputCursor` **and** `claudeStatus === 'idle'` **and** `exitedAt === null`. Otherwise skip (a later genuine Stop reschedules). Prevents injecting mid-render.
- **One-per-idle pacing (intended):** `drainOne` fires exactly one entry; the injected prompt makes Claude work again → its next Stop drains the next entry. If Claude errors and never emits Stop, remaining items **wait** (no spamming).
- **Session exits with items queued:** `drainOne` guards on `exitedAt` (returns null); on session removal (`onSessionExit` L2 / `killById`) the queue dies with the session. Server clears its `drainTimers` entry in `doShutdown`; stale timers are harmless (drain returns null) and `.unref()`ed.
- **Queue full → `409`** (never silently drop). **Oversized text → `413`.** Both actionable to the caller.
- **Concurrent enqueue from two devices:** single-threaded, immutable array replace → both land; `{type:'queue'}` broadcast keeps every device's badge consistent.
- **Enqueue to exited/unknown session → `404`** (checked before append).
- **`QUEUE_ENABLED=false`** → routes `503` (graceful disable, like `/push/vapid-key:478`); Stop branch skips scheduling.
- **Verbatim bytes / Enter:** queue stores the exact string (byte-shuttle invariant). FE decides `appendEnter` (append `\r`) at enqueue time, mirroring quick-reply's `appendEnter` (`quick-reply.ts:24`). No server-side text parsing.
---
## Security
- **CSRF:** `POST`/`DELETE …/queue` are state-changing and cause **shell input**, so they carry `requireAllowedOrigin` (`:352`) — the same guard as the DELETE-session routes. Without it a foreign page could inject commands into a running Claude. `GET` is read-only (queue length + prompt text the user themselves queued) → no guard, consistent with `/live-sessions`.
- **Not loopback-gated:** intentionally Origin-gated (LAN device), not `isLoopback``/hook` is loopback (host-only) but enqueue must work from the phone.
- **Path/ID containment:** validate `:id` against `SESSION_ID_RE` (`protocol.ts:22`) before any Map lookup or PTY write; reject non-UUID with `400`. The id is only a Map key — never touches argv/fs.
- **Input validation at the boundary:** `text` must be a non-empty `string`; byte length (`Buffer.byteLength`) ≤ `queueItemMaxBytes` → else `413`. `appendEnter` coerced to boolean. Body capped by `express.json({limit:'16kb'})`. Bytes are passed **verbatim** to the PTY (raw keyboard bytes — do not filter content, per the protocol rule), but bounded in size and count.
- **Rate limit:** dedicated per-IP `createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS)` (e.g. 20/min) → `429`, matching the `DECISION_RATE_MAX`/`SUBSCRIBE_RATE_MAX` pattern (`:75`). Bounds injection-flood risk.
- **DoS bounds:** `queueMaxItems` caps depth; `queueItemMaxBytes` caps size; drain writes one entry per idle → no unbounded PTY write burst.
- **No capability tokens needed** — this reuses the app's existing LAN/Origin trust boundary (same as every other control route); it does **not** widen it. No secrets logged; sanitize any queued text before logging via existing `sanitizeForLog` (`:162`).
- **Loopback drain source:** the drain trigger is the Stop hook, which is already loopback-gated at `/hook` (`:399`) — so the *timing* signal can't be forged remotely; only the *content* (Origin-gated) and it fires against the caller's own session.
---
## Effort & dependencies
- **Rough effort:** ~23 dev-days. Backend (types + config + manager methods + 3 routes + settle-timer wiring) ~1.5 d incl. tests; FE (`public/queue.ts` + tabs badge/affordance) ~0.51 d.
- **Depends on:** nothing new — builds entirely on shipped primitives (`writeInput`, `broadcast`, `handleHookEvent` idle branch, `requireAllowedOrigin`, `createRateLimiter`, `LiveSessionInfo`). No schema migrations, no new deps.
- **Coordination:** the `src/types.ts` edit is the only cross-cutting change — freeze it first (it touches `Config`, so every all-fields test `CFG` fixture must be updated in the same commit).
- **Unlocks (roadmap):** templated launches / auto-continue (queue a follow-up prompt on kickoff), issue-intake (external POST that enqueues), and any "when Claude finishes, do X" automation. The manage-page `queueLength` surface also feeds the multi-session workbench view.

View File

@@ -0,0 +1,131 @@
# Diff against a base branch (?base=<rev>)
Adds an optional `base` revision to the read-only git-diff side-channel so the viewer can compare a whole branch against `main` (or any commit-ish), not just the working tree / index. This lands the `FR-B1.9` deferral called out in `src/http/diff.ts:18-19`, using the exact mitigation named there: a `git rev-parse --verify` allow-list before any revision reaches the diff CLI. The diff **parsers and the render core stay untouched**; only a two-stage revision guard (backend), a reflected `base` field, and a toolbar picker (frontend) are added.
---
## Contract
### Route (unchanged path, one new optional query param)
`GET /projects/diff` (`src/server.ts:661-678`)
| Param | Type | Notes |
|---|---|---|
| `path` | string (required) | absolute git dir; validated by `isValidGitDir` (`src/server.ts:122-133`) — unchanged |
| `staged` | `0`\|`1` (optional) | current behavior; **ignored when `base` is present** |
| `base` | string (optional) | a commit-ish (branch/tag/sha/`HEAD~N`). When present → three-dot diff `git diff <base>... --`; untracked files are not listed |
Response is the existing `DiffResult` JSON, now with an optional reflected `base`:
- `200` structured `DiffResult` (with `base` echoed when supplied)
- `400 {error}` — missing `path`, **or** a `base` that fails the syntactic pre-check (flag injection / junk)
- `404 {error}` — path not a git dir (unchanged)
- Best-effort: a syntactically-valid but **unknown/unrelated** `base` (rev-parse miss, no merge-base) yields `200` with `files: []` — consistent with the module's "git failure → empty, never throw" house style (`src/http/diff.ts:14`, `:346`).
### Message / data types — `src/types.ts` (coordination edit)
Extend `DiffResult` (`src/types.ts:475-479`) with one **optional** field so the shape stays backward-compatible and the viewer's required-field validation is unaffected:
```
export interface DiffResult {
files: DiffFile[];
staged: boolean;
truncated: boolean;
base?: string; // NEW — echoed when the diff was against a base revision
}
```
No other shared type changes. `GetDiffOptions` lives in `src/http/diff.ts:260-263` (not `types.ts`) and gains `base?: string`.
### Env vars — `src/config.ts`
**None required.** rev-parse + diff reuse the existing `diffTimeoutMs` / `diffMaxBytes` bounds (`src/config.ts:347-362`). *(Optional kill-switch `DIFF_BASE_ENABLED` (default true) could be added mirroring `worktreeEnabled` at `src/config.ts:372` if a runtime disable is wanted — deferred, not needed for correctness.)*
### New/changed function signatures — `src/http/diff.ts`
```
export function isPlausibleRev(base: string): boolean // pure boundary check
async function resolveBaseRev(cwd, base, timeoutMs, maxBytes): Promise<string | null> // rev-parse --verify → canonical sha | null
export interface GetDiffOptions { staged: boolean; base?: string; cfg: Pick<Config,...> } // +base
export async function getDiff(repoPath, opts): Promise<DiffResult> // branches on opts.base
```
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add optional `base?: string` to `DiffResult` (`:475-479`). |
| `src/http/diff.ts` | Add exported pure `isPlausibleRev` (charset + no-`..` + no-leading-`-` + length≤250). Add `resolveBaseRev` (runs `git rev-parse --verify --quiet --end-of-options <base>^{commit}` via `runGit` `:289-309`; return trimmed `/^[0-9a-f]{7,64}$/` sha or `null`). Add `base?` to `GetDiffOptions` (`:260-263`). In `getDiff` (`:347-365`): if `opts.base` set → `resolved = resolveBaseRev(...)`; `null``{files:[],staged:false,truncated:false,base:opts.base}`; else run `git diff --no-color <resolved>... --` and `git diff --numstat <resolved>... --`, **skip** `listUntracked` (`:322-340`), set `staged:false`, echo `base:opts.base`. Working-tree path unchanged. |
| `src/server.ts` | Diff route (`:661-678`): read `base` (`typeof q==='string' && q!=='' ? q : undefined`); if present and `!isPlausibleRev(base)``400 {error:'invalid base revision'}`; else pass `base` into `getDiff(target,{staged,base,cfg})`. Import `isPlausibleRev` from `./http/diff.js`. Route stays no-Origin-guard (read-only, unchanged threat model). |
| `public/diff.ts` | `fetchDiff` (`:110-120`): change signature to `fetchDiff(repoPath, opts:{staged?:boolean; base?:string})`; build URL with `&base=<enc>` (omit `staged`) when `base` set, else `&staged=`. `normalizeDiffResult` (`:38-51`): pass through optional `base` (`typeof o['base']==='string' ? o['base'] : undefined`; keep other fields required). `MountDiffViewerOpts` (`:240-243`): add `bases?: string[]`. `mountDiffViewer` (`:253-347`): add a `<select>` "compare-base" control to the toolbar (`:265-272`) — first option `Working tree` (base=null), then one option per `bases[]`; track `base: string \| null`; when a base is chosen disable/grey the Working/Staged tabs and `loadDiff` calls `fetchDiff(repoPath,{base})`; back on "Working tree" restores `fetchDiff(repoPath,{staged})`. **Render core (`renderDiff`/`renderDiffFile`/`renderLine`/`renderHunk`) untouched.** |
| `public/projects.ts` | `buildDiffSection` (`:611-643`): add `bases: string[]` param; pass `{ bases, onClose }` into `mountDiffViewer` (`:625`). `renderProjectDetail` (`:672`, call site `:709`): derive `bases` = unique of `detail.worktrees.map(w=>w.branch)` (`WorktreeInfo.branch`, `src/types.ts:292`) `[detail.branch]`, filtered to defined strings; pass into `buildDiffSection(detail.path, diffRef, bases)`. This is the "reuse worktree/branch data" wiring. |
| `test/http/diff.test.ts` | unit `isPlausibleRev` + `getDiff` base integration (below). |
| `test/integration/worktree.test.ts` | route-level base tests (real `startServer`). |
| `test/diff.test.ts` | jsdom `fetchDiff`/`normalizeDiffResult`/picker tests. |
| `test/worktree-form.test.ts` | assert `bases` reach the `mountDiffViewer` mock. |
---
## TDD steps (ordered)
**1. Pure guard — `test/http/diff.test.ts` (node)** — add a `describe('isPlausibleRev')`:
- ✅ accepts `main`, `feature/x`, `HEAD~3`, `v1.2.0`, a 40-hex sha, `main^`, `HEAD@{1}`.
- ❌ rejects `''`, a 300-char string, `-rf`/`--output=x` (leading `-`), `a..b`, `x y` (whitespace), `` `id` `` / `$(x)` / `;` (metachars), `\x00`.
- Implement `isPlausibleRev` → GREEN. (Matches the existing pure-parser layer at `:34-266`.)
**2. `getDiff` with base — `test/http/diff.test.ts` (node, real repo)** — extend the `describe('getDiff (real git repo)')` block (`:274`). In a `beforeAll`-style setup, commit on `main`, then `git(repo,'checkout','-b','feature')`, commit a change:
- `getDiff(repo,{staged:false,base:'main',cfg:LIMITS})``result.base==='main'`, `result.staged===false`, the feature-only change present, **no `untracked` entries**, counts numstat-consistent (mirror `:292-302`).
- `base:'main'` while HEAD===main → `files:[]` (empty, no changes).
- `base:'no-such-branch'``{files:[], truncated:false}` (rev-parse miss → empty; assert never throws, like `:340-347`).
- `base:'-x'` never reaches here (route-guarded) but assert `getDiff` still returns empty (defense-in-depth) — optional.
- Implement `resolveBaseRev` + the base branch in `getDiff` → GREEN.
**3. Route — `test/integration/worktree.test.ts` (node, `startServer`)** — this file already covers `GET /projects/diff` (header comment `:2-11`); add, using its `itGit` + temp-repo + two-branch setup:
- `GET /projects/diff?path=<repo>&base=feature``200`, `DiffResult` with `base:'feature'`, verbatim content.
- `GET /projects/diff?path=<repo>&base=-rf``400`.
- `GET /projects/diff?path=<repo>&base=ghost-branch``200` with `files:[]`.
- Wire `base` parse + `isPlausibleRev` 400 into the route → GREEN.
**4. Frontend fetch/normalize — `test/diff.test.ts` (jsdom)** — the file mocks `fetch`; add:
- `fetchDiff('/repo',{base:'main'})` builds `/projects/diff?path=%2Frepo&base=main` (no `staged=`); `fetchDiff('/repo',{staged:true})` builds the current `&staged=true` URL (update the existing signature-based tests).
- `normalizeDiffResult({files:[],staged:false,truncated:false,base:'main'})``.base==='main'`; a payload without `base``.base===undefined` and still valid.
- Implement `fetchDiff` opts + `normalizeDiffResult` pass-through → GREEN.
**5. Base picker — `test/diff.test.ts` (jsdom)** — extend `describe('mountDiffViewer')` (`:353`):
- `mountDiffViewer(container,'/repo',{bases:['main','dev']})` renders a `<select>` with options `Working tree` + `main` + `dev`.
- Selecting `main` (dispatch `change`) triggers a fetch whose URL contains `base=main` and disables the Working/Staged tabs; selecting `Working tree` restores a `staged`-mode fetch.
- Empty/absent `bases` → no `<select>` (backward-compatible with existing tests that call `mountDiffViewer(container,'/repo',{})`).
- Implement toolbar select + state → GREEN.
**6. Wiring — `test/worktree-form.test.ts` (jsdom)** — it already mocks `mountDiffViewer` (`:31`) and tests `renderProjectDetail`/`buildDiffSection` (`:402-421`). Add: give a `ProjectDetail` with `worktrees:[{branch:'main',...},{branch:'feat',...}]`, click "View Diff", assert the `mockMountDiffViewer` was called with `bases` containing `main` and `feat`. Implement `buildDiffSection` + `renderProjectDetail` derivation → GREEN.
**7. Refactor / coverage**`npm test`; confirm the 80% gate holds (every new branch — `isPlausibleRev` both arms, `resolveBaseRev` hit/miss, `getDiff` base/no-base, route 400/200, picker on/off — is exercised above).
---
## Edge cases & failure modes
- **`base=''`** → treated as absent (route coerces to `undefined`) → normal working-tree diff.
- **`base` + `staged=1` both set** → `base` wins; staged silently ignored; result `staged:false`. (Documented; the picker disables the staged tab in base mode so the UI can't send both.)
- **Flag injection** (`base=-rf`, `--output=/etc/passwd`) → rejected by `isPlausibleRev` (leading `-`) → `400`; even if it slipped through, `--end-of-options` in rev-parse and the trailing `--` in `git diff` neutralize it.
- **Range injection** (`base=a..b`, `base=a...b`) → `isPlausibleRev` rejects `..`; we construct the `...` ourselves from a single resolved sha.
- **Unknown ref** (typo, deleted branch) → rev-parse `--verify` miss → `resolveBaseRev` returns `null` → empty `DiffResult` (no crash). Rare in practice since the picker only offers real worktree branches.
- **Unrelated histories** (no merge-base for `<base>...HEAD`) → `git diff` errors → `runGit` returns empty (`:302-308`) → empty result.
- **`base` peels to a tree/tag-of-tree, not a commit** → `^{commit}` peel fails → `null` → empty.
- **Detached HEAD in the repo** → `HEAD` still resolves; three-dot works.
- **Huge branch diff** → existing `diffMaxFiles`/`diffMaxBytes`/timeout truncation applies unchanged (`:360-364`).
- **Rename/binary/new/deleted across the base range** → handled by the untouched `parseUnifiedDiff`/`parseNumstat` (numstat is authoritative for counts, `:187-205`).
- **Old git without `--end-of-options`** (pre-2.24) → not a concern in 2026, but since `isPlausibleRev` already blocks leading `-`, the flag can be dropped without loss if a legacy git is hit.
- **jsdom picker with `bases:[]` or omitted** → no select rendered; existing `mountDiffViewer(container,'/repo',{})` tests keep passing.
## Security
- **Revision allow-list (the core mitigation, `src/http/diff.ts:18-19`)** — two stages, both before the diff CLI: (1) `isPlausibleRev` — a pure boundary check (`/^[A-Za-z0-9][A-Za-z0-9._/@^~{}-]{0,249}$/`, reject `..`) rejecting flag-injection/junk fast with a `400`; (2) `git rev-parse --verify --quiet --end-of-options <base>^{commit}` — git itself is the authoritative allow-list, and its output (a canonical 40/64-hex sha) is what's passed to `git diff`, fully decoupling the raw user string from the diff invocation.
- **No shell** — all git calls stay `execFile('git',[...])` (`:296`), args as an array; trailing `--` terminates options on every diff command (`:352-353`), matching the file's SEC note (`:11-12`).
- **Read-only** — rev-parse and `git diff` are read-only; `base` introduces **no** write path, so the route keeps its no-Origin-guard status (same threat model as `/projects`, `:660`). No new state-changing surface → no `requireAllowedOrigin` / CSRF change needed.
- **Path containment** — unchanged: `isValidGitDir` three-prong (`:122-133`) still gates `path`; `base` cannot escape the repo (rev-parse resolves inside `cwd`).
- **DoS bounds** — the extra rev-parse spawn reuses `diffTimeoutMs`/`diffMaxBytes` via `runGit` (`:289-301`); no unbounded work added.
- **Frontend XSS** — `base` is echoed and rendered only via `textContent`/`<option>.textContent`; the SEC-H4 "zero innerHTML" invariant of `public/diff.ts` (`:8-9`) is preserved (render core untouched).
- **Rate-limit** — parity with the existing diff route (no per-route limiter today); base adds one bounded read-only spawn per request, no new amplification. If the route is later rate-limited, this feature needs no change.
## Effort & dependencies
- **Effort:** ~1.52 days. Backend guard + `getDiff` branch (~0.5d incl. tests), route wiring (~0.25d), FE picker + `projects.ts` wiring (~0.75d incl. jsdom tests), polish/coverage (~0.25d).
- **Depends on:** the shipped B1 diff stack — `src/http/diff.ts`, `public/diff.ts`, the `/projects/diff` route, and B3 worktree/branch data in `ProjectDetail.worktrees` (`src/types.ts:290-312`) which the picker reuses. No new features required.
- **Unlocks / adjacent:** W13 "Stage / commit / push from the diff viewer" (a base-vs-branch view is the natural surface for review-before-push) and W10 "PR + CI status chip" (comparing a feature branch against its PR base). Keeping the render core and parsers unchanged means those build on the same `DiffResult` without churn.

181
docs/plans/w3-pr-ci-chip.md Normal file
View File

@@ -0,0 +1,181 @@
# PR + CI/checks status chip via gh
A per-project chip in the project-detail view that shows, for the repo's current branch: **PR state** (open / draft / merged / closed / none), **N checks passing** (from `statusCheckRollup`), and **mergeable** (clean / conflicting). It is a read-only, out-of-band side-channel — exactly like `getDiff` (`src/http/diff.ts`): `execFile('gh', …)` (no shell), timeout + `maxBuffer` bound, parses `gh`'s `--json` output, and **capability-degrades** (chip explains itself) when `gh` is missing, unauthenticated, or the branch has no PR. Cached at module scope with a short TTL (reuses `cfg.projectScanTtlMs`) so opening/refreshing a project detail doesn't hammer the GitHub API.
Grounding: `buildProjectDetail` (`src/http/projects.ts:420`) surfaces branch/dirty/worktrees but nothing PR. `getDiff`/`runGit` (`src/http/diff.ts:289`) is the runner+degrade pattern to mirror. `isValidGitDir` (`src/server.ts:123`) + the `/projects/diff` route (`src/server.ts:661`) are the exact route mirror. The FE detail header is `renderProjectDetail` (`public/projects.ts:672`, header at lines 696-706); `buildDiffSection` (`public/projects.ts:612`) and `public/diff.ts` `fetchDiff`/`normalizeDiffResult` (lines 110/38) are the FE fetch+degrade+textContent pattern.
---
## Contract
### New route
`GET /projects/pr?path=<abs-repo-dir>` — read-only, **no** Origin guard (same threat model as `/projects` and `/projects/diff`; see `src/server.ts:660` comment).
- `400 {error}``path` missing/empty (mirror `src/server.ts:662-666`).
- `404 {error:'project not found'}``!isValidGitDir(target)` (mirror `src/server.ts:667-670`, SEC-H7 three-prong).
- `200 PrStatus`**always** on a valid git dir, including all degrade cases (the availability lives in the body, not the HTTP status — so the FE renders one chip regardless). `500 {error}` only on an unexpected throw (mirror `src/server.ts:674-677`).
### New message type — `src/types.ts` (coordination edit, next to `DiffResult` at line 475)
```ts
/* ── W3 PR + CI status chip (gh) ── */
/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */
export type PrAvailability =
| 'ok' // a PR exists for the current branch; fields below are populated
| 'no-pr' // gh works but the branch has no PR (or no remote/default repo)
| 'not-installed' // `gh` binary not found on PATH (ENOENT)
| 'unauthenticated' // gh present but not logged in (needs `gh auth login`)
| 'disabled' // GH_ENABLED=0 — feature off, never spawns gh
| 'error'; // gh spawned but failed for another reason (timeout, etc.)
/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */
export interface PrCheckSummary {
total: number;
passing: number; // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS
failing: number; // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE
pending: number; // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED
}
/** GET /projects/pr result. Only present-when-'ok' fields are optional. */
export interface PrStatus {
availability: PrAvailability;
number?: number;
title?: string;
url?: string;
state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED
isDraft?: boolean;
mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh
headRefName?: string;
baseRefName?: string;
checks?: PrCheckSummary;
}
```
### `gh` invocation (single spawn — KISS)
One command; `statusCheckRollup` already carries per-check state, so no second `gh pr checks` spawn:
```
gh pr view --json number,state,title,url,isDraft,mergeable,headRefName,baseRefName,statusCheckRollup
```
Run with `cwd = repoPath`. gh resolves the PR from the current branch. `statusCheckRollup` items are a mix of `{__typename:'CheckRun', status, conclusion}` and `{__typename:'StatusContext', state}` — the pure parser handles both.
### New env vars — `src/config.ts` + `Config` in `src/types.ts` (coordination edit)
| Env var | Default | Purpose |
|---|---|---|
| `GH_ENABLED` | `true` (`parseBool`) | Feature flag. `false` → route returns `{availability:'disabled'}`, never spawns gh. |
| `GH_TIMEOUT_MS` | `8000` (`parseNonNegativeInt`) | Hard-kill timeout for the gh spawn. Larger than `diffTimeoutMs` (2 s) because gh hits the network. |
Cache TTL **reuses** `cfg.projectScanTtlMs` (`src/config.ts:311`, default 10 000 ms) — no new TTL var. Add the two fields to the assembled object in `loadConfig` (`src/config.ts:390-438`) and to the `Config` interface.
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `PrAvailability`, `PrCheckSummary`, `PrStatus` near `DiffResult` (line 475). Add `ghEnabled: boolean` + `ghTimeoutMs: number` to the `Config` interface (find `Config` — used by `loadConfig`). |
| `src/config.ts` | Add `const DEFAULT_GH_TIMEOUT_MS = 8000` near line 65; parse `GH_ENABLED` via `parseBool(env['GH_ENABLED'], true)` and `GH_TIMEOUT_MS` via `parseNonNegativeInt(...)`; add both to the frozen object (lines 413-438). |
| `src/http/gh.ts` | **New file** (~180 lines), mirrors `diff.ts` structure: a `runGh` runner (`execFileAsync('gh', args, {cwd, timeout, maxBuffer})` that captures `stdout`/`stderr`/`code`/spawn-ENOENT), a **pure** `parsePrView(json): PrStatus`-core + `summarizeChecks(rollup): PrCheckSummary`, a `classifyGhFailure(exec): PrAvailability`, module-scope short-TTL cache mirroring `discoverCache` (`projects.ts:239-317`) with in-flight dedupe, `getPrStatus(repoPath, cfg): Promise<PrStatus>`, and a `_clearPrCache()` test hook (mirror `_clearProjectCache`, `projects.ts:314`). |
| `src/server.ts` | Add `import { getPrStatus } from './http/gh.js'` (next to line 41). Add `GET /projects/pr` route immediately after `/projects/diff` (after line 678), copying the `path`-missing → 400 and `!isValidGitDir` → 404 guards, then `res.json(await getPrStatus(target, cfg))` in a try/catch → 500 (mirror lines 671-677). |
| `public/gh-chip.ts` | **New file** (~120 lines), render-only, mirrors `public/diff.ts`: `normalizePrStatus(raw): PrStatus \| null`, `fetchPrStatus(repoPath): Promise<PrStatus \| null>` (mirror `fetchDiff`, `diff.ts:110`), `chipText(status): {label, cls, title}` (pure, unit-tested), `renderPrChip(status): HTMLElement` (all text via **`textContent`**, zero `innerHTML` — SEC-H4), `mountPrChip(container, repoPath): {destroy()}` that shows a loading placeholder then swaps in the resolved chip. |
| `public/projects.ts` | Add `import { mountPrChip } from './gh-chip.js'` (near line 27). In `renderProjectDetail`, after the dirty indicator (line 704) inside the `if (detail.isGit)` guard, append the chip host and mount it; track the handle in a `prRef` and `destroy()` it in the `back` click handler (lines 683-688) alongside `diffRef.h?.destroy()`. |
| `public/styles.css` (or the existing project-detail CSS file) | Add `.proj-pr-chip` + state modifier classes (`.proj-pr-open/.draft/.merged/.closed/.none/.unavailable`, `.proj-pr-checks-ok/.fail/.pending`, `.proj-pr-conflict`). Reuse the existing `.proj-branch` chip look (line 699) as the base. |
| `test/http/gh.test.ts` | **New** (node) — pure parsers + `getPrStatus` classification. |
| `test/integration/pr-status.test.ts` | **New** (node) — real `startServer`, `gh` stubbed via a PATH shim. |
| `test/gh-chip.test.ts` | **New** (jsdom) — `normalizePrStatus`/`chipText`/`renderPrChip`/`mountPrChip`. |
---
## TDD steps
Ordered; each "write test → run RED → implement → GREEN". Backend-pure first (cheap, deterministic), then route, then FE. Keeps the 80 % gate because the pure parser + classifier are the bulk of the logic and are fully covered without spawning gh.
**Backend — `test/http/gh.test.ts`** (node; mirror `test/http/diff.test.ts:1-33`). Import from `../../src/http/gh.js`.
1. `summarizeChecks` — canned `statusCheckRollup` arrays:
- CheckRun `{status:'COMPLETED',conclusion:'SUCCESS'}` → passing++. Implement `summarizeChecks`.
- CheckRun `conclusion:'FAILURE'` → failing++; `TIMED_OUT`/`CANCELLED`/`ACTION_REQUIRED` → failing.
- CheckRun `status:'IN_PROGRESS'`/`'QUEUED'` (null conclusion) → pending.
- StatusContext `{state:'SUCCESS'}` → passing; `'PENDING'` → pending; `'FAILURE'/'ERROR'` → failing.
- `NEUTRAL`/`SKIPPED` → passing (don't block). Empty/`undefined` rollup → all-zero. Unknown shape → counted in `total` only, treated as pending. Assert `total === passing+failing+pending`.
2. `parsePrView` — feed a full canned JSON string:
- Valid PR JSON → `availability:'ok'`, lower-cased `state`/`mergeable`, `number`/`title`/`url`/`isDraft`/`headRefName`/`baseRefName` mapped, `checks` from `summarizeChecks`. Implement `parsePrView` (never throws — `try/JSON.parse`; malformed → `{availability:'error'}`, mirroring `diff.ts` "never throws" house style).
- `isDraft:true` still `availability:'ok'` (FE decides the "draft" label); `mergeable:'UNKNOWN'``'unknown'`.
- Malformed / non-object JSON → `{availability:'error'}`.
- **Security assert**: a PR `title` containing `<script>alert(1)</script>` survives verbatim in `PrStatus.title` (proves no parsing-side mangling; FE renders it inert).
3. `classifyGhFailure` — given synthetic exec results:
- spawn ENOENT (`code:'ENOENT'`) → `'not-installed'`.
- stderr containing `gh auth login` / `not logged` / `authentication` / `HTTP 401``'unauthenticated'`.
- stderr containing `no pull requests found` / `no default remote` / `no git remote``'no-pr'`.
- other non-zero exit → `'error'`. Implement `classifyGhFailure` (regex on lower-cased stderr).
4. `getPrStatus` cache/dedupe — inject a fake runner (or spy) so no real gh spawns:
- `ghEnabled:false` in cfg → resolves `{availability:'disabled'}` **without** invoking the runner.
- Two rapid calls for the same path share one in-flight run (assert runner called once); after `_clearPrCache()`, it runs again. Mirror `projects.ts:289-311`. Implement the module cache + `_clearPrCache`.
- Cache key includes the current branch (cheap read of `.git/HEAD` like `readBranch`, `projects.ts:88`) so a branch switch busts the cache before TTL. Test: same path, different HEAD branch → runner re-invoked.
> To keep `getPrStatus` unit-testable without gh, factor the spawn into an injectable `runGh` (default real, overridable in tests) — same seam idea as `getDiff`'s `runGit`. `parsePrView`/`summarizeChecks`/`classifyGhFailure` stay pure and exported.
**Route — `test/integration/pr-status.test.ts`** (node; mirror `test/integration/projects-endpoint.test.ts:1-55`). Use `getFreePort` + a temp dir with a fake `.git` repo (reuse `makeFakeGitRepo` shape). Stub gh with a **PATH shim**: write an executable script named `gh` into a temp `bin/` that echoes canned JSON (or exits 1 with a canned stderr), then set `process.env.PATH = binDir + ':' + process.env.PATH` before `startServer` (execFile resolves `gh` via PATH). Restore PATH + `_clearPrCache()` in `afterEach`.
5. Missing `path`**400**. Implement route guard 1.
6. Non-git dir path → **404** (`isValidGitDir` fails). Implement guard 2.
7. gh shim emits valid PR JSON → **200** with `availability:'ok'`, correct `checks`. Wire `res.json(await getPrStatus(...))`.
8. gh shim exits 1 with `no pull requests found` on stderr → **200 `{availability:'no-pr'}`**.
9. `GH_ENABLED='0'` env → **200 `{availability:'disabled'}`**, and (assert via a shim that writes a marker file) gh is never spawned.
**Frontend — `test/gh-chip.test.ts`** (jsdom; mirror `test/diff.test.ts:1-14`, `// @vitest-environment jsdom`, dynamic `await import('../public/gh-chip.js')`, `vi.stubGlobal('fetch', …)`).
10. `normalizePrStatus` — valid object round-trips; non-object / bad `availability``null` (mirror `normalizeDiffResult`, `diff.ts:38`). Implement.
11. `chipText(status)` — pure map: `ok`+open → `"PR #12 ✓ 5/5"`; failing checks → `"PR #12 ✕ 3/5"`; `mergeable:'conflicting'` adds a `⚠ conflicts` marker/class; `no-pr``"No PR"`; `not-installed``"gh not installed"` + `title` link to cli.github.com; `unauthenticated``"gh auth login"`; `disabled` → chip hidden (returns null/`display:none`). Implement.
12. `renderPrChip`**SEC-H4 assert**: a `title` of `<img src=x onerror=...>` appears as literal text (`el.textContent` contains it, `el.querySelector('img')` is null). Zero `innerHTML`.
13. `mountPrChip``fetch` stubbed to resolve `ok` JSON → placeholder replaced by the chip; `fetch` rejects → degrades to an `error` chip (no throw); `destroy()` removes the node. Mirror `mountDiffViewer` (`diff.ts:253-`).
**Frontend wiring — extend `test/projects.test.ts`** (jsdom; `renderProjectDetail` is already exported/tested there).
14. `renderProjectDetail` with `detail.isGit:true` → a `.proj-pr-chip` host is present in the header; with `isGit:false` → absent. (Mock `gh-chip`'s `mountPrChip` via `vi.mock` so the DOM assertion doesn't depend on fetch.) Implement the `renderProjectDetail` edit + `prRef.destroy()` in the back handler.
Run `npm test` after each GREEN. The pure backend parser tests (steps 1-3) carry most of the coverage weight for `gh.ts`; FE steps 10-13 cover `gh-chip.ts`.
---
## Edge cases & failure modes
- **`gh` not installed** → spawn ENOENT → `'not-installed'`. Chip shows "gh not installed" (never a 500, never a stack trace).
- **`gh` present, not authenticated** → stderr auth pattern → `'unauthenticated'` → "gh auth login".
- **No PR for branch / no remote / detached HEAD** → `'no-pr'` → "No PR". (Detached HEAD: `.git/HEAD` isn't `ref:` → cache-key branch is `null`; gh itself errors → `no-pr`.)
- **PR exists but checks not started / all pending** → `checks.total>0, passing=0, pending=total` → "⧗ 0/N".
- **`mergeable:'UNKNOWN'`** (GitHub computes mergeability async right after a push) → `'unknown'` → neutral marker, not a red "conflict". Only `'conflicting'` shows the ⚠.
- **Draft PR** → `isDraft:true` → "Draft" styling; still `availability:'ok'`.
- **Merged/closed PR still on the branch** → `state:'merged'/'closed'` badge (gh returns the most recent PR).
- **gh timeout** (network hang) → `execFileAsync` kills at `ghTimeoutMs``'error'` → generic "PR status unavailable". Bounded, never hangs the request.
- **Huge `statusCheckRollup`** (100s of checks / monorepo) → bounded by `maxBuffer` (reuse `diffMaxBytes`); overflow → `'error'` (don't try to parse a truncated JSON). Counts are aggregate so the chip stays tiny.
- **Malformed `--json` output** (gh version drift) → `parsePrView` `JSON.parse` throws → caught → `'error'`.
- **Branch switch within TTL** → cache key includes HEAD branch, so it busts immediately rather than showing the previous branch's PR for up to 10 s.
- **FE fetch/network error** → `fetchPrStatus` returns `null` → chip renders an `'error'` state, never throws (mirror `fetchDiff`, `diff.ts:117`).
---
## Security
- **No shell**: `execFile('gh', [fixed argv])` — identical guarantee to `runGit` (`diff.ts:296`, SEC-M9). The **only** user-influenced input reaching gh is `cwd`, which is the already-validated `repoPath`. No untrusted string is ever placed in argv (gh derives the PR from the branch; we never pass a branch/base/rev). This sidesteps the `?base=` deferral rationale in `diff.ts:18-20`.
- **Path containment**: route calls `isValidGitDir(target)` (`server.ts:123`) before spawning — absolute + is-dir + has `.git` (SEC-H7). Path traversal / arbitrary-cwd is blocked exactly as `/projects/diff`.
- **DoS bounds**: `timeout: ghTimeoutMs` (hard kill) + `maxBuffer: cfg.diffMaxBytes` bound a slow/huge gh. Module-scope TTL cache + in-flight dedupe cap outbound GitHub-API calls to ≈1 per repo per `projectScanTtlMs` even under rapid detail-view refreshes (the panel auto-refreshes every 5 s, `projects.ts:33`).
- **Network egress note**: unlike every other side-channel (all local), gh talks to GitHub's API using the host's existing `gh`/`GH_TOKEN` credential. The endpoint **never accepts or forwards a token** — it only triggers gh's own auth. Document this in the route comment and in TECH_DOC §7 (this is the first feature to make an outbound call on behalf of a LAN client; the `GH_ENABLED=0` kill-switch lets a cautious operator disable it entirely).
- **No secret leakage**: never log gh **stdout** (may contain private PR titles) or the token. If logging a failure, log only `availability` + `sanitizeForLog(stderr.slice(0,200))` (reuse `server.ts:162`) — never raw stderr, mirroring the worktree audit line (`server.ts:714`) and SEC-M10 "never raw git stderr".
- **Origin/CSRF**: GET is read-only and non-mutating → no Origin guard, consistent with `/projects` and `/projects/diff` (`server.ts:660`). It spawns a subprocess but performs no state change, so CSWH/CSRF risk is limited to triggering a cached, rate-bounded read.
- **XSS**: PR `title` is attacker-controllable (anyone who can open a PR on a repo the host has access to). It is carried verbatim server-side and rendered **only via `textContent`** in `gh-chip.ts` (SEC-H4, same discipline as `diff.ts` line-rendering). Explicit jsdom test (step 12) asserts no element injection.
- **Rate-limit**: no per-IP limiter needed (read-only, same as `/projects/diff`); the TTL cache is the effective throttle. If desired later, the `createRateLimiter` helper (`server.ts:109`) is available.
---
## Effort & dependencies
- **Effort**: ~1.52 days. Backend `gh.ts` + route ≈ 0.75 day (the runner/cache pattern is a near-copy of `diff.ts`/`projects.ts`; the pure `parsePrView`/`summarizeChecks` classifier is the real work). FE `gh-chip.ts` + wiring + CSS ≈ 0.5 day. Tests (3 files) ≈ 0.5 day, with the PATH-shim integration harness the only novel piece.
- **Depends on**: nothing hard-blocking — `Config`/`src/types.ts`/`config.ts` coordination edits and `isValidGitDir` all exist today. Requires `gh` on the host for the live path, but the feature is designed to degrade cleanly without it (so it ships regardless).
- **Unlocks**: the **W3 "Quick wins" chip** (task #11 — sync/ahead-behind chip, recent commits) can reuse the same `gh.ts`/`gh-chip.ts` side-channel + short-TTL-cache scaffold (e.g. `gh pr status`, `git rev-list --count @{u}...HEAD`). The PATH-shim gh-integration harness is reusable by any future gh-backed feature.
- **Interacts with (no conflict)**: **W3 `?base=` diff** (task #9) touches `diff.ts`/`/projects/diff` only; this feature is an independent file (`gh.ts`) and route (`/projects/pr`). Both add a chip/section to the same `renderProjectDetail` header — coordinate the header layout (place the PR chip after the branch chip, before or beside the diff toggle) but they do not edit the same functions.

174
docs/plans/w3-quick-wins.md Normal file
View File

@@ -0,0 +1,174 @@
# Quick wins: sync chip + cost guard + reconnect digest + recent commits
Four small, independent features that ride existing seams. None touches the byte-shuttle WS stream; all new HTTP routes are side-channel and follow the established `/projects/*` / `/hook/*` patterns. Read-only routes get no Origin guard (same threat model as `/projects``src/server.ts:281`); the cost guard rides the already-injected `NotifyService` DI seam and the `stuckNotified` one-shot-latch pattern (`src/session/manager.ts:271-287`).
Shared coordination edit up front: **`src/types.ts`** gains `ProjectInfo.ahead/behind/lastCommitMs` (§a), `NotifyClass` `'budget'` + `Session.budgetNotified` + `Config.costBudgetUsd` + `UiConfig.costBudgetUsd` (§b), `DigestResult`/`DigestSession` (§c), `CommitLogEntry`/`GitLogResult` (§d). Do all type edits in one commit so the four sub-features can then proceed in parallel.
---
## Contract
### (a) Sync chip — no new route
Folded into the existing `GET /projects` payload. `ProjectInfo` gains three optional fields, populated in the cached per-repo metadata pass:
```ts
// src/types.ts — extend ProjectInfo (currently line 279-287)
export interface ProjectInfo {
name: string
path: string
isGit: boolean
branch?: string
dirty?: boolean
lastActiveMs?: number
ahead?: number // commits on HEAD not on @{u} (git rev-list, right count)
behind?: number // commits on @{u} not on HEAD (git rev-list, left count)
lastCommitMs?: number // git log -1 --format=%ct * 1000 (HEAD commit time)
sessions: ProjectSessionRef[]
}
```
`@{u}` with no upstream / non-git → all three `undefined` (best-effort, never throws). No new env var: gated by the existing `projectDirtyCheck` (which already means "spend git subprocess time per repo").
### (b) Cost budget guard + push alert
- **New env** `COST_BUDGET_USD` (dollars, float ≥ 0; default `0` = disabled) → `Config.costBudgetUsd: number` (`src/types.ts` Config, alongside B2 `statuslineTtlMs` at line 65).
- **New `NotifyClass` member** `'budget'` (`src/types.ts:376`): `'needs-input' | 'done' | 'stuck' | 'budget'`.
- **New `Session` field** `budgetNotified: boolean` (`src/types.ts` Session, next to `stuckNotified` at line 227-228) — one-shot latch, **never re-armed** (cost is monotonic).
- **`GET /config/ui`** payload gains `costBudgetUsd?: number` so the FE can derive warn-styling client-side:
```ts
export interface UiConfig { allowAutoMode: boolean; costBudgetUsd?: number }
```
- **No new ServerMessage variant.** The "warning broadcast" is the *existing* `{type:'telemetry', telemetry}` frame (already broadcast on every statusLine, `manager.ts:260`); the warn is derived on the client (`costUsd >= costBudgetUsd`). The distinct new server action on threshold crossing is a single `notifyService.notify(session, 'budget')` push. (Rationale: keeps the frozen `ServerMessage` contract in `types.ts:109-120` untouched — KISS/YAGNI. A dedicated frame was considered and rejected: cost overage is not a `ClaudeStatus`.)
- `renderTelemetryGauge` (`public/preview-grid.ts:197`) gains a 4th param `costBudgetUsd?: number`; the cost chip (line 224-227) gets class `tg-cost-warn` when `telemetry.costUsd >= budget`, mirroring the ctx>80% path at line 219.
### (c) While-you-were-away reconnect digest
**New read-only route** `GET /digest?since=<epochMs>` (no Origin guard; same as `/live-sessions`). Aggregates `manager.list()`:
```ts
export interface DigestSession {
id: string
title?: string // last cwd segment
status: ClaudeStatus
costUsd?: number // telemetry.costUsd
lastOutputAt?: number
finished: boolean // status==='idle' && lastOutputAt > since
needsInput: boolean // status==='waiting'
stuck: boolean // status==='stuck'
}
export interface DigestResult {
since: number
generatedAt: number
total: number
finished: number
needsInput: number
stuck: number
working: number
totalCostUsd: number
sessions: DigestSession[]
}
```
Response: `200` + `DigestResult` (empty aggregate when no sessions). `since` clamped to a finite non-negative number (bad/absent → `0`, i.e. "everything is new").
### (d) Recent-commits log per project
**New read-only route** `GET /projects/log?path=<abs>&n=<int>` (no Origin guard; guarded by `isValidGitDir`, `src/server.ts:123`, exactly like `/projects/diff` at line 661-678).
```ts
export interface CommitLogEntry { hash: string; at: number; subject: string } // at = %ct*1000
export interface GitLogResult { commits: CommitLogEntry[]; truncated: boolean }
```
- `path` missing/empty → `400`; not a valid git dir → `404` (three-prong `isValidGitDir`); git failure → `500`.
- `n` parsed to int, clamped to `[1, GIT_LOG_MAX]` (const `50`; default `20`).
- Git command (NUL-record, US-field delimited so subjects with tabs/newlines can't corrupt parsing):
```
git log --no-color -z -n <n> --format=%h%x1f%ct%x1f%s (cwd: repoPath, timeout, maxBuffer)
```
---
## Files to change
| Path | Change |
|---|---|
| `src/types.ts` | **Coordination edit.** Add `ProjectInfo.ahead/behind/lastCommitMs`; `NotifyClass` `'budget'`; `Session.budgetNotified`; `Config.costBudgetUsd`; `UiConfig.costBudgetUsd`; new `DigestResult`/`DigestSession`, `CommitLogEntry`/`GitLogResult`. |
| `src/config.ts` | Add `parseNonNegativeFloat(raw,label,fallback)` helper; parse `COST_BUDGET_USD`→`costBudgetUsd` (near B2 block line 365-369); add `costBudgetUsd` to the frozen object (spread block line 415-437). |
| `src/http/projects.ts` | Add `readSync(repoPath)` helper (2 execFile calls); extend `MakeProjectArgs` (line 121) + `makeProject` (line 129) with `ahead/behind/lastCommitMs`; call `readSync` in `runDiscovery`'s per-repo `mapWithConcurrency` (line 276-280), gated by `cfg.projectDirtyCheck`. |
| `src/http/git-log.ts` | **New.** `parseGitLog(stdout,max)` (pure) + `getGitLog(repoPath,{n,timeoutMs})` (async, execFile, no shell). Modelled on `src/http/diff.ts` / `readDirty` (`projects.ts:98`). |
| `src/http/digest.ts` | **New.** `buildDigest(live: readonly LiveSessionInfo[], since: number): DigestResult` — pure, injected list (mirrors `buildProjects` injection, `projects.ts:387`). |
| `src/session/manager.ts` | In `handleStatusLine` (line 256): after storing/broadcasting telemetry, run the budget-latch check → `notifyService?.notify(session,'budget')`. |
| `src/session/session.ts` | Init `budgetNotified: false` in `createSession` object literal (~line 138). **Do not** re-arm (leave line 150 as-is). |
| `src/server.ts` | Add `GET /projects/log` (after `/projects/diff`, line 678); add `GET /digest` (after `/live-sessions`, line 279); extend `GET /config/ui` (line 728-731) with `costBudgetUsd`. Import `getGitLog`, `buildDigest`. |
| `public/preview-grid.ts` | `renderTelemetryGauge` gains `costBudgetUsd?` param; add `tg-cost-warn` class on the cost chip (line 224-227). |
| `public/projects.ts` | `normalizeProject` (line 236): pass through numeric `ahead/behind/lastCommitMs`. `makeProjectCard` (line 454): add sync chip after branch chip. `renderProjectDetail` (line 709 area, git repos only): mount recent-commits section. |
| `public/git-log.ts` | **New.** `mountGitLog(container, repoPath)` — fetch `GET /projects/log`, render inert rows via `textContent` only. |
| `public/digest.ts` | **New.** `mountDigest(host)` — read `localStorage` last-seen, fetch `GET /digest?since=`, render dismissible banner, update last-seen. |
| `public/tabs.ts` | In `loadUiConfig` (line 248-261): also read `costBudgetUsd`; store on the instance; pass to `renderTelemetryGauge` call (line 1391). |
| `public/main.ts` | `mountDigest(...)` in the toolbar/app wiring block (~line 51-119). |
| `public/style.css` | Add `.tg-cost-warn`, `.proj-sync`, `.proj-commitlog` rows, `.wya-banner` (mirror `.tg-ctx-warn`, `.proj-branch`). |
---
## TDD steps (ordered)
Repo style: backend unit tests are **node** env (default), FE tests declare `// @vitest-environment jsdom` at the top (`test/projects-panel.test.ts:1`) and mock `@xterm/xterm`; route tests spin a real server on a free port (`test/integration/projects-endpoint.test.ts`). `test/manager.test.ts` uses a mock `NotifyService` recording `notify(session,cls,token)` (line 100-107) and a `parseSent(ws)` helper.
**0. Types (RED→GREEN, compile-only)**
- [ ] Edit `src/types.ts` with all shapes above. `npx tsc --noEmit` fails where callers/mocks lack new required fields → gives the worklist. `Session.budgetNotified` is required → `test/manager.test.ts` + `src/session/session.ts` must init it.
**(b) Cost guard — highest security value, do first**
- [ ] `test/config.test.ts`: add defaults block — `loadConfig({}).costBudgetUsd === 0`; override `COST_BUDGET_USD:'5.50'`→`5.5`; `throw` for `'abc'` and `'-1'` (mirror line 156-181). Implement `parseNonNegativeFloat` + wire in `src/config.ts`.
- [ ] `test/manager.test.ts` (extend `describe('handleStatusLine')` line 803): with `cfg.costBudgetUsd=1`, feed telemetry `costUsd=0.5` → `notify` **not** called, `session.budgetNotified===false`; feed `costUsd=1.2` → `notify` called once with `(s,'budget')`, latch `true`; feed `costUsd=2` again → **not** called again. With `costBudgetUsd=0` → never called. Implement the latch in `manager.handleStatusLine`.
- [ ] `test/session.test.ts`: assert `createSession(...).budgetNotified === false`. Implement init in `session.ts`.
- [ ] `test/preview-grid.test.ts` (jsdom): `renderTelemetryGauge(c, {costUsd:6,at:now}, ttl, 5)` → cost chip has class `tg-cost-warn`; with budget `0`/`undefined` or `costUsd<budget` → no warn class. Implement param.
- [ ] Route: extend `test/integration` (or `test/http`) — `GET /config/ui` returns `costBudgetUsd`. Implement in `server.ts:728`.
**(d) Recent commits**
- [ ] `test/http/git-log.test.ts` (new): `parseGitLog` — NUL-record + US-field splitting; empty stdout→`[]`; malformed record (missing field) skipped; subject truncated at cap; `truncated` flag when records===max. (Pure, no spawn.)
- [ ] `test/http/git-log.test.ts`: `getGitLog` against a real temp repo made with `git init` + 3 commits (pattern from `test/http/diff.test.ts` / worktrees test) → 3 entries newest-first, `n=2`→2 + `truncated:true`.
- [ ] `test/integration/projects-log-endpoint.test.ts` (new, model on `projects-endpoint.test.ts`): `GET /projects/log?path=<repo>` → 200 array; missing `path`→400; non-git temp dir→404; `?n=999`→clamped. Implement route in `server.ts`.
- [ ] FE `test/git-log.test.ts` (jsdom): `mountGitLog` renders rows via `textContent`; a commit subject containing `<img onerror>` appears verbatim (no HTML injection); fetch failure → empty/error inert text. Implement `public/git-log.ts` + detail-section wiring in `public/projects.ts`.
**(a) Sync chip**
- [ ] `test/projects.test.ts` (extend, node): real temp repo (uses `execFileP` already imported line ~20) with an upstream branch ahead/behind → `buildProjects` yields `ahead`/`behind`/`lastCommitMs`; repo with **no** upstream → those `undefined`, no throw; `projectDirtyCheck:false` → sync skipped (undefined). Implement `readSync` + fold into `runDiscovery`.
- [ ] `test/projects-panel.test.ts` (jsdom): `normalizeProject` passes numeric `ahead/behind/lastCommitMs`, drops non-numbers; `makeProjectCard` renders `↑2 ↓1` chip when set, omits when `undefined`/`0`. Implement FE.
**(c) Reconnect digest**
- [ ] `test/http/digest.test.ts` (new, node): `buildDigest([...LiveSessionInfo], since)` — counts finished (`idle` & `lastOutputAt>since`), needsInput (`waiting`), stuck, working; `totalCostUsd` sums `telemetry.costUsd`; empty list → zeroes; `since` in the future → 0 finished.
- [ ] `test/integration/digest-endpoint.test.ts` (new): server up, `GET /digest?since=0` → 200 `DigestResult`; malformed `since` → treated as 0. Implement route.
- [ ] FE `test/digest.test.ts` (jsdom): `mountDigest` fetches with the stored last-seen; renders banner only when `finished+needsInput+stuck>0`; dismiss updates localStorage last-seen and hides; fetch failure → no banner (best-effort). Implement `public/digest.ts` + `main.ts` mount.
**Coverage:** every new pure function (`parseGitLog`, `buildDigest`, `readSync` via `buildProjects`, budget latch, gauge warn) has a direct unit test; routes have integration tests. Keeps the ≥80% gate — the new code is mostly pure/tested; the thin `server.ts` wiring is exercised by the integration tests.
---
## Edge cases & failure modes
- **(a)** No upstream (`@{u}` fatal) → catch → `ahead/behind` undefined; chip hidden. Detached HEAD → `git log -1 --format=%ct` still works (lastCommitMs set), `@{u}` fails (sync hidden). Empty repo (no commits) → both git calls fail → all undefined. Slow git → 2s timeout kill (reuse `GIT_STATUS_TIMEOUT_MS`, `projects.ts:36`). Adds ≤2 spawns/repo bounded by `GIT_CONCURRENCY=8`; gated off entirely when `projectDirtyCheck=false`. `ahead=behind=0` (in sync) → chip omitted (only render when >0).
- **(b)** `costUsd` undefined in a telemetry frame → skip guard (no crossing). Budget `0` → disabled. Latch persists across statusLine frames; a **new** session gets its own latch (per-`Session`). DND/`notifyDone` interplay: `'budget'` is neither `'done'` nor gated, so `shouldSend` sends it unless global DND is on (matches stuck). Late-joining device: gets current telemetry via `manager.ts:139-140`, derives warn from `/config/ui` budget — no missed styling. Push disabled (no VAPID) → latch still flips, broadcast still happens, just no push (graceful, like stuck).
- **(c)** `since` absent/NaN/negative → `0`. No sessions → all-zero `DigestResult` (banner suppressed client-side). Clock skew / `lastOutputAt` in future → still counted as finished if `idle` (acceptable; coarse "what happened" view). First-ever visit (no localStorage) → `since=0`, banner may list everything → set last-seen to `generatedAt` after first render so it doesn't re-nag.
- **(d)** Binary/huge subjects: `maxBuffer` cap + subject truncation. Non-repo/deleted path → 404 (isValidGitDir). Repo with 0 commits → `[]`. Merge commits/unusual chars in subject → US-field + NUL-record delimiters immune to embedded whitespace. `n` non-numeric → clamp to default.
---
## Security
- **Path containment:** `/projects/log` reuses `isValidGitDir` (`server.ts:123`) — absolute + isDirectory + has `.git` (SEC-H7 three-prong), identical to `/projects/diff`. `readSync` runs only against paths already discovered by the bounded BFS scan (`scanRepos`, symlink/dotdir/`node_modules`-skipping, `projects.ts:148`).
- **No shell, ever:** all git calls use `execFile('git', [...])` with `timeout` + `maxBuffer` (mirrors `readDirty` line 100). `repoPath` is passed as `cwd`, never interpolated into argv. `n` is coerced to an int and clamped before reaching argv.
- **Output is untrusted:** commit subjects and digest labels rendered via `textContent` only (SEC-H5, as in `preview-grid.ts` and `projects.ts` worktree/CLAUDE.md rendering) — zero `innerHTML`. FE `normalizeProject`-style narrowing for the new numeric fields (drop non-numbers) and for `/digest`/`/projects/log` responses (never trust the API shape).
- **Origin/CSRF:** all four routes are **read-only GETs** → no Origin guard, consistent with `/projects`, `/live-sessions`, `/projects/diff`. `GET /config/ui` stays read-only. No state-changing surface is added, so no new `requireAllowedOrigin` / rate-limit needed. (The budget **push** goes out the existing `pushService` seam — no new inbound route.)
- **Secrets:** cost budget is a non-secret number; `costBudgetUsd` is safe to expose in `/config/ui`. Push payload for `'budget'` carries only sessionId + cwd-basename label (no cost figure, no terminal bytes) via the existing `buildPayload` (`push-service.ts:88`) — SEC-C5 byte-shuttle boundary preserved.
- **DoS:** sync adds bounded spawns (concurrency 8, 2s timeout, gated by `projectDirtyCheck`); `/projects/log` `n` clamped ≤50; `/digest` is O(sessions) over an already-capped table.
---
## Effort & dependencies
**~34 dev-days total** (four independent slices; can be parceled to parallel builders after the shared `src/types.ts` edit lands):
| Sub | Effort | Notes |
|---|---|---|
| (a) sync chip | ~0.50.75d | Backend `readSync` + fold-in + 2 FE renders. |
| (b) cost guard | ~0.75d | Config float parser, manager latch, gauge warn, `/config/ui` + tabs.ts wiring. Highest test surface. |
| (c) reconnect digest | ~1d | New pure aggregate + route + new FE banner module + main.ts mount + localStorage last-seen. |
| (d) recent commits | ~1d | New backend git-log module + route + new FE render module + detail-section wiring. |
**Dependencies (into these):** all four build only on already-shipped infra — `buildProjects` cache pass (v0.6), `NotifyService` DI + `stuckNotified` latch (A5/A1), `renderTelemetryGauge` (B2), `isValidGitDir` + `getDiff` pattern (B1), `/config/ui` seam (review #4). No dependency on other roadmap items.
**Unlocks / synergy:** (d)'s `getGitLog` + NUL-parsing helper and (a)'s upstream detection are reusable by **#9 "diff against a base branch"** (upstream/`@{u}` resolution) and **#10 "PR + CI status chip"** (a per-repo git/`gh` metadata pass can fold into the same `runDiscovery` concurrency slot as the sync chip). (c)'s `buildDigest` gives **#8 idle-queued follow-up** a ready read-side "which sessions are idle/waiting" aggregate. `NotifyClass 'budget'` establishes the pattern for any future threshold alerts.

View File

@@ -0,0 +1,135 @@
# Stage / commit / push from the diff viewer
`id: w4-commit-push` — the highest-risk git **write** set. Scope is bounded to the MVP the task defines: **per-file stage/unstage toggle + commit + push-current-branch**. Discard / checkout / restore-working-tree are explicitly **deferred** (they are the only destructive-to-working-tree ops; nothing in this feature ever touches file contents on disk).
This mirrors the existing read-only diff channel (`src/http/diff.ts`) and the one existing git-write channel (`src/http/worktrees.ts` + the `POST /projects/worktree` route at `src/server.ts:699725`). The server stays a byte-shuttle; these are out-of-band side-channel routes.
---
## Contract
### New routes (all in `src/server.ts`, all `express.json`, all `requireAllowedOrigin` + `isValidGitDir`)
Every route: `requireAllowedOrigin(req, res)` (CSRF, `src/server.ts:352`) → `gitOpsEnabled` gate (403 if off) → per-IP rate limit → validate `path` with the in-file `isValidGitDir` (three-prong: absolute + dir + `.git`, `src/server.ts:123`) → delegate to `src/http/git-ops.ts`. The delegate re-validates and **realpath-contains** every path (defense in depth — the routes `isValidGitDir` does not follow symlinks).
| Method + path | Body | Success | Notes |
|---|---|---|---|
| `POST /projects/git/stage` | `{ path: string, files: string[], stage?: boolean }` | `200 {ok:true, staged:boolean, count:number}` | `stage` default `true``git add`; `false` → unstage (`git restore --staged`). `stage` is an **addition** to the tasks bare `{path,files[]}` so one endpoint serves the toggle in both directions. |
| `POST /projects/git/commit` | `{ path: string, message: string }` | `200 {ok:true, commit:string}` (short SHA) | Commits **staged** changes only. `message` capped at `commitMsgMaxLen`. |
| `POST /projects/git/push` | `{ path: string }` | `200 {ok:true, branch:string, remote:string}` | Pushes the **current branch** to its existing upstream, else `-u <sole-remote> <branch>`. Remote/branch are **derived server-side**, never taken from the client. |
Error shape (mirrors the worktree route at `src/server.ts:724`): `res.status(result.status).json({ error: result.error })` where `error` is a **safe** message (never raw git stderr, SEC-M10 — see `classifyWorktreeError`, `src/http/worktrees.ts:193`).
Status codes: `400` invalid input / detached HEAD / no remote / identity unset; `403` disabled or bad Origin; `404` not a git dir; `409` nothing staged / non-fast-forward / index.lock held; `401` push auth required; `429` rate-limited; `500` unclassified; `200` success.
### `src/types.ts` (coordination edit — frozen shared-contract source)
Add, next to `CreateWorktreeResult` (`src/types.ts:485`):
```
export interface GitOpResult {
ok: boolean;
status?: number; // HTTP status on failure
error?: string; // SAFE message only (never raw git stderr)
// success payloads (route-specific, all optional):
staged?: boolean; // stage: direction applied
count?: number; // stage: files affected
commit?: string; // commit: short SHA
branch?: string; // push: branch pushed
remote?: string; // push: remote pushed to
}
```
One interface keeps the coordination churn minimal (like `CreateWorktreeResult`). The FE narrows with an `isGitOpResult` guard mirroring `isWorktreeResult` (`public/projects.ts:545`).
### `src/config.ts` env vars (4 new, mirroring the B3 worktree group at `src/config.ts:371378`)
| Env | Field | Default | Purpose |
|---|---|---|---|
| `GIT_OPS_ENABLED` | `gitOpsEnabled: boolean` | `true` | Master kill-switch (mirrors `worktreeEnabled`, `src/config.ts:372`). Off → all three routes 403. |
| `GIT_OPS_TIMEOUT_MS` | `gitOpsTimeoutMs: number` | `10_000` | stage/commit exec timeout (mirrors `DEFAULT_WORKTREE_TIMEOUT_MS`, `src/config.ts:65`). |
| `GIT_PUSH_TIMEOUT_MS` | `gitPushTimeoutMs: number` | `120_000` | push is network-bound → longer bound. |
| `COMMIT_MSG_MAX_LEN` | `commitMsgMaxLen: number` | `5_000` | commit-message length cap. |
Parsed with the existing `parseBool` / `parseNonNegativeInt` helpers and appended to the frozen object (`src/config.ts:413438`). File-count cap for `stage` reuses the existing `diffMaxFiles` (`src/config.ts:358`, default 300) — no new var. `src/types.ts` `Config` interface gains the 4 fields (same coordination note the file already carries at `src/config.ts:15`).
---
## Files to change
| Path | Change |
|---|---|
| **`src/http/git-ops.ts`** (new) | The whole write engine. Mirrors `diff.ts`/`worktrees.ts` execFile pattern: no shell, `timeout`+`maxBuffer`, `--` terminator, `env:{...process.env, GIT_TERMINAL_PROMPT:'0'}`. Exports (all never-throw, return `GitOpResult`): `stageFiles(repoPath, files, stage, opts)`, `commit(repoPath, message, opts)`, `push(repoPath, opts)`, plus pure `classifyGitError(stderr): {status,error}` and `validateRepoFiles(repoRealPath, files): string[] \| null`. |
| **`src/http/git-path.ts`** (new, small) | Extracts the containment helper so it isnt duplicated: `resolveRealPath(target)` (copy of `worktrees.ts:118`) + `isContained(realBase, realCandidate): boolean`. Used by `git-ops.ts`. (Optional follow-up: refactor `worktrees.ts:141` `computeWorktreeDir` onto it — **deferred**, out of this tasks lane.) |
| **`src/types.ts`** | Coordination edit: add `GitOpResult` (above) + 4 `Config` fields. |
| **`src/config.ts`** | Parse the 4 new env vars; append to the frozen config object. |
| **`src/server.ts`** | Register the 3 routes after the worktree route (`:725`). Add two module-level rate constants + two limiters via `createRateLimiter` (`:109`). Reuse in-file `isValidGitDir`, `requireAllowedOrigin`, `sanitizeForLog` (`:162`) for the commit/push audit log. Import from `./http/git-ops.js`. |
| **`public/diff.ts`** | `mountDiffViewer` (`:253`) gains: per-file **Stage/Unstage** toggle button in each file row, and a bottom **commit/push bar** (message `<textarea>` + Commit + Push). Add optional `onToggleStage` to the render path; add POST helpers `postStage/postCommit/postPush`. All text via `textContent`/`el()` (SEC-H4, `:139` note). Re-`loadDiff()` after any successful write. |
| **`public/projects.ts`** | No logic change required — `buildDiffSection` (`:612`) already mounts `mountDiffViewer` and gets the new UI for free. Only add CSS-class hooks if styling inline. |
| **`public/style.css`** (or wherever `df-*` classes live) | Styles for `df-file-stage`, `df-commitbar`, `df-commit-msg`, `df-commit-btn`, `df-push-btn`, `df-op-error`, `df-op-busy`. |
---
## TDD steps (ordered)
Backend uses **node env + real throwaway git repos** in `os.tmpdir()` exactly like `test/http/diff.test.ts:277` and `test/http/worktrees-create.test.ts:42`. FE uses **jsdom + mocked `fetch`** like `test/diff.test.ts`.
**Pure classifier first (fast, deterministic):**
1. `test/http/git-ops.test.ts``describe('classifyGitError')`: feed canned stderr strings, assert `{status,error}` and that `error` never contains `fatal:` (SEC-M10, mirror `worktrees-create.test.ts:193`). Cases: `nothing to commit`→409; `Please tell me who you are`→400; `! [rejected]`/`non-fast-forward`/`fetch first`→409; `could not read from Username`/`Authentication failed`/`terminal prompts disabled`/`Permission denied (publickey)`→401; `index.lock`→409; unknown→500. → then implement `classifyGitError`.
2. `describe('validateRepoFiles')`: rejects `[]`, absolute paths, leading-`-`, `../escape`, > `diffMaxFiles` entries; accepts in-repo relative paths (incl. a **deleted** file whose path no longer exists on disk — `resolveRealPath` resolves the existing prefix). Symlink-escape: pre-plant a symlink inside the repo pointing outside, assert rejection (mirror `worktrees-create.test.ts:137`). → implement `validateRepoFiles` on `git-path.ts`.
**Integration against real repos:**
3. `describe('stageFiles')`: init repo + commit; modify a tracked file + add an untracked file; `stageFiles(repo,[file],true)` → assert `git diff --cached --name-only` lists them; `stage:false` → assert unstaged. Assert non-git path → `{ok:false,status:404}`. → implement `stageFiles`.
4. `describe('commit')`: stage a change → `commit(repo,'msg')``{ok:true, commit}` and `git log -1 --format=%H` starts with it. Empty index → `{ok:false,status:409}`. Repo with `user.name` unset → 400. Message length > cap → 400 (route-level; test the length guard where it lives). → implement `commit`.
5. `describe('push')`: create a **bare** repo as `origin` (`git init --bare`), `git remote add origin`, first push sets upstream via `-u`; assert bare repo received the ref (`git --git-dir=<bare> rev-parse <branch>`). Detached HEAD → 400. Zero remotes → 400. Two remotes + no upstream → 409. Second push (upstream now set) → plain `git push`. → implement `push`.
**Config + route wiring:**
6. `test/config.test.ts`: the 4 new vars default correctly and parse/validate (reuse existing patterns).
7. `test/integration/server.test.ts` (extend, using the `startServer` + `fetch` + `Origin` pattern at `:783`): for each of the 3 routes — foreign Origin → 403, **no** Origin → 403 (default-deny, mirror `:778`), non-git path → 404, missing field → 400, `gitOpsEnabled:false` → 403, and a happy-path 200 against a temp repo (push against a temp bare remote). Confirm `error` bodies carry no `fatal:`.
**Frontend (jsdom):**
8. `test/diff.test.ts` (extend): stub `global.fetch`. Assert each file row renders a Stage button on the Working view and Unstage on the Staged view; clicking POSTs to `/projects/git/stage` with the right `{path,files,stage}` body and re-fetches. Assert the commit bar disables Commit when the message is empty, POSTs `/projects/git/commit`, and shows a failure via `textContent` (SEC-H4 — assert zero `innerHTML`, mirror the files existing security assertions). Assert Push POSTs `/projects/git/push` and reflects busy/disabled state. → implement the `mountDiffViewer` changes to pass.
Coverage: the pure `classifyGitError`/`validateRepoFiles` + real-repo integration cover `git-ops.ts` branches; steps 78 cover the new server + `diff.ts` lines. Keeps the 80% gate.
---
## Edge cases & failure modes
- **Nothing staged → commit**: git exits non-zero (`nothing to commit`) → 409 "Nothing staged to commit." (not a 500).
- **Author identity unset**: `commit` fails (`Please tell me who you are`) → 400 with a safe hint. Do **not** auto-inject a fake identity.
- **Unborn HEAD (no commits yet)**: unstage via `git restore --staged` errors → classify to a safe 409/400; commit still works (creates the first commit). Documented limitation.
- **Detached HEAD on push**: `git rev-parse --abbrev-ref HEAD``HEAD` → refuse 400 "Cannot push a detached HEAD."
- **No upstream, one remote**: `git push -u <remote> <branch>`. **No upstream, ≥2 remotes**: 409 "Set an upstream first." **Zero remotes**: 400.
- **Non-fast-forward / remote ahead**: `! [rejected]` → 409 "Push rejected — pull/rebase first." (never force-push).
- **Credential prompt hang**: `GIT_TERMINAL_PROMPT=0` (+ optional `GIT_SSH_COMMAND='ssh -o BatchMode=yes'`) makes auth fail fast → 401 instead of hanging; the exec `timeout` is the backstop.
- **`index.lock` held (concurrent op)**: → 409 "Another git operation is in progress." (Optional hardening: a per-realpath in-memory promise-chain mutex in `server.ts` to serialize writes; gits own lock is the correctness backstop, so this is MEDIUM, not required for MVP.)
- **Deleted file staged**: path absent on disk → `resolveRealPath` resolves the existing prefix and re-appends the basename; `git add -- <path>` records the deletion. **Renamed file**: FE sends both `oldPath` and `newPath` so the deletion+addition both stage.
- **Huge `files[]`**: capped at `diffMaxFiles` → 400.
- **maxBuffer overflow / timeout**: caught, returned as a safe 500 (never a thrown rejection — house style, `diff.ts:302`).
- **Commit message with newlines / leading `-` / emoji**: safe — passed as the single value of `-m` via argv (no shell), length-capped, never a pathspec.
- **Write succeeds but re-fetch fails**: FE shows the diff error state but the write already happened; surface a non-blocking notice.
---
## Security
- **Origin/CSRF**: all 3 routes call `requireAllowedOrigin` (`src/server.ts:352`) — this is a **write** channel, unlike read-only `/projects/diff` which has no guard. Tested for foreign-Origin **and** missing-Origin (default-deny).
- **No shell, ever**: `execFile('git', argv, …)` only — mirrors `diff.ts:296` / `worktrees.ts:242`. Untrusted strings (message, file paths) are argv elements, never interpolated. `--` terminates options before any pathspec so a path cant become a flag; file paths additionally rejected if they start with `-`.
- **Path containment (highest-risk)**: `isValidGitDir` at the route (absolute+dir+`.git`) **plus** `realpath`-based containment of the repo and of **every** `files[]` entry inside `git-ops.ts` (`resolveRealPath` + `startsWith(realBase+sep)`, the M2 pattern from `worktrees.ts:141`). Defeats `../` traversal and pre-planted-symlink escapes even though the diff route only does the lighter three-prong check.
- **Server-derived remote/branch**: push never accepts a remote or refspec from the client — both are read back from the repo — eliminating arg-injection and push-to-arbitrary-URL risk.
- **Safe error classification**: `classifyGitError` maps stderr substrings to safe messages; raw git stderr is never returned (SEC-M10). Tested that responses contain no `fatal:`.
- **Rate limiting**: reuse `createRateLimiter` (`src/server.ts:109`, 60 s window) — `gitWriteLimiter` (stage+commit) ≈ 30/min/IP, `gitPushLimiter` ≈ 6/min/IP; over-limit → 429. Fixed policy constants beside the existing ones (`src/server.ts:7476`).
- **Audit log**: commit/push log actor+path via `sanitizeForLog` (`src/server.ts:162`, control-char strip + truncate), like the worktree audit at `:714`.
- **Kill-switch**: `gitOpsEnabled=false` disables all three (mirrors `worktreeEnabled`, `src/server.ts:701`) for locked-down deployments.
- **Body size**: `express.json({ limit: '4kb' })` for commit/push, `'64kb'` for stage (large `files[]`) — matching the existing routes caps.
- **FE injection**: all rendered git output/errors via `textContent`/`el()` — zero `innerHTML` (SEC-H4, asserted in `test/diff.test.ts`).
---
## Effort & dependencies
- **Estimate**: ~34 days. Backend `git-ops.ts` + classifier + containment ≈ 1.5 d; route wiring + config + integration tests ≈ 0.75 d; FE toggles + commit/push bar + jsdom tests ≈ 1 d; polish/edge-cases ≈ 0.5 d.
- **Depends on**: the existing B1 diff channel (`src/http/diff.ts`, `public/diff.ts:253` `mountDiffViewer`) and B3 worktree channel (`src/http/worktrees.ts`) — both shipped; this reuses their execFile + realpath-containment + error-classification patterns directly. No new dependency on other roadmap items.
- **Shares infra with** `w4-worktree-remove` (task #12): both are git-write routes under `requireAllowedOrigin` + `classifyGitError`; landing the shared `git-path.ts` + `classifyGitError` here de-risks that one. Extractable-later: a generic `runGitWrite()` wrapper could later back `worktrees.ts` too (deferred — out of lane).
- **Unlocks**: an in-browser commit loop for the walk-away workflow (stage → commit → push from a phone), and a natural home for a future "recent commits" chip (task #11).

View File

@@ -0,0 +1,147 @@
# Worktree remove / prune
Delete losing worktrees and prune stale ones from any device. Backend adds `removeWorktree`/`pruneWorktrees` to `src/http/worktrees.ts` (same execFile-no-shell + timeout + containment machinery as the shipped `createWorktree`), two Origin-guarded routes in `src/server.ts`, and the FE makes the already-rendered `locked`/`prunable`/main tags in `public/projects.ts` `makeWorktreeRow` (lines 492502) actionable.
## Contract
### New backend functions — `src/http/worktrees.ts`
Reuse the private helpers already in the file: `isGitRepo` (line 168), `listWorktrees`/`parseWorktrees` (lines 29, 69), `extractStderr` (line 180), `resolveRealPath` (line 118). Match `createWorktree`'s structured-result-never-throws contract (line 219).
```ts
export interface RemoveWorktreeOptions { readonly force?: boolean; readonly timeoutMs: number }
export async function removeWorktree(
repoPath: string, targetPath: string, opts: RemoveWorktreeOptions,
): Promise<RemoveWorktreeResult>
export interface PruneWorktreesOptions { readonly timeoutMs: number }
export async function pruneWorktrees(
repoPath: string, opts: PruneWorktreesOptions,
): Promise<PruneWorktreesResult>
```
`removeWorktree` algorithm (the security spine):
1. `isGitRepo(repoPath)` false → `{ ok:false, status:404, error:'Not a git repository.' }`.
2. `typeof targetPath !== 'string'` or empty → `{ ok:false, status:400, error:'Worktree path is required.' }`.
3. `listWorktrees(repoPath)` → find the entry whose **realpath equals** `resolveRealPath(targetPath)` (canonical compare, not raw-string compare, to defeat symlink tricks). No match → `{ ok:false, status:404, error:'That path is not a worktree of this repository.' }`.
4. Matched entry `.isMain === true``{ ok:false, status:400, error:'Cannot remove the main worktree.' }`.
5. Matched entry `.locked``{ ok:false, status:409, error:'This worktree is locked; unlock it in a terminal first.' }` (never double-force `-f -f`).
6. Run `git worktree remove` **with the canonical path from git's own list entry** (`match.path`), never the raw user string: `['worktree','remove', ...(force?['--force']:[]), '--', match.path]` via `execFileAsync` (cwd `repoPath`, `timeout: opts.timeoutMs`, `maxBuffer: WORKTREE_MAX_BUFFER`). On success `{ ok:true, path: match.path }`.
7. On error → `classifyRemoveError(err)` (new; sibling of `classifyWorktreeError` line 193): stderr containing `contains modified or untracked files` / `use --force` / `is dirty``{ ok:false, status:409, error:'Worktree has uncommitted changes — force required.' }`; `not a working tree`/`is not a working tree``{ ok:false, status:404, ... }`; else `{ ok:false, status:500, error:'Failed to remove the worktree.' }`. Never leak raw stderr (SEC-M10).
`pruneWorktrees` algorithm: `isGitRepo` gate (404), then `git worktree prune -v` via execFile (timeout/maxBuffer). Parse verbose lines (`Removing worktrees/<name>: <reason>`, emitted on stdout/stderr — capture both) into `pruned: string[]` (best-effort; empty when nothing prunable — idempotent). Error → `{ ok:false, status:500, error:'Failed to prune worktrees.' }`.
### Changed message types — `src/types.ts` (coordination edit)
Add next to `CreateWorktreeResult` (line 485):
```ts
export interface RemoveWorktreeResult { ok: boolean; path?: string; status?: number; error?: string }
export interface PruneWorktreesResult { ok: boolean; pruned?: string[]; status?: number; error?: string }
```
`WorktreeInfo` (line 290), `Config` worktree fields (lines 6769) unchanged. Option interfaces stay local to `worktrees.ts` (mirrors `CreateWorktreeOptions`, line 207).
### New routes — `src/server.ts`
Insert both immediately after the create route (ends line 725), reusing `requireAllowedOrigin` (line 352), `sanitizeForLog` (line 162), `cfg.worktreeEnabled`, `cfg.worktreeTimeoutMs`. Add `removeWorktree, pruneWorktrees` to the import at line 43.
| Route | Body / gate | Response |
|---|---|---|
| `DELETE /projects/worktree` | `express.json({limit:'4kb'})`; `{ path, worktreePath, force? }`. Guard order: `requireAllowedOrigin``worktreeEnabled` (403) → `path`+`worktreePath` present (400). Audit-log via `sanitizeForLog`. | `result.ok``200 {ok:true,path}`; else `result.status ?? 500` + `{error}` |
| `POST /projects/worktree/prune` | `express.json({limit:'4kb'})`; `{ path }`. Same guard order (path required → 400). | `200 {ok:true,pruned}` or `result.status` + `{error}` |
`force` coerced strictly: `const force = body['force'] === true`.
### Env vars — `src/config.ts`
**None new.** Reuse `worktreeEnabled` (line 372, gates all worktree writes), `worktreeTimeoutMs` (line 374) for remove/prune timeouts.
### FE — `public/projects.ts`
- New module-level fetch helpers (siblings of `killSession`, line 275): `removeWorktreeReq(repoPath, worktreePath, force)``DELETE /projects/worktree` with JSON body, returns `{ok, status, error}`; `pruneWorktreesReq(repoPath)``POST /projects/worktree/prune`, returns `{ok, pruned?, error?}`. Both same-origin (Origin guard passes), best-effort catch.
- `makeWorktreeRow` (line 492) gains an optional `actions?: { onRemove:(wt)=>void }` param. When `actions` present and **not** `wt.isMain` and **not** `wt.locked`: append a `proj-wt-remove` `✕` button (`aria-label` "Remove worktree", `title` "Remove this worktree"). Locked rows keep the `locked` tag but no remove button (tooltip explains). Prunable rows still get remove.
- `DetailCallbacks` (line 666) gains `onRemoveWorktree:(worktreePath:string)=>void` and `onPruneWorktrees:()=>void`. `renderProjectDetail` (line 672) threads `actions` into the worktree-list loop (line 721) and, when `detail.worktrees.some(w=>w.prunable)`, renders a section-level `Prune stale worktrees` button beside the "Worktrees" title (line 712) wired to `cb.onPruneWorktrees`.
- `mountProjects` (line 820) implements the confirm→force→refresh flow (encapsulated here, like `killAndRefresh` line 897), passed into `renderDetail` (line 959):
- `onRemoveWorktree`: `confirm("Remove worktree at <path>? This deletes the working tree.")``removeWorktreeReq(detailPath, wtPath, false)`; on `409` → second `confirm("Uncommitted changes will be lost. Force-remove?")` → retry with `force:true`; on other failure show error via `textContent` (never innerHTML, SEC-L3/H6); then `void refresh()`.
- `onPruneWorktrees`: `confirm("Prune worktrees whose folders are gone?")``pruneWorktreesReq(detailPath)``refresh()`.
- `public/diff.ts`: **no change**`grep` confirms it renders no worktree rows/tags; the task's mention is covered entirely by `projects.ts`. (Note this deviation from the task wording in the log.)
## Files to change
| Path | Change |
|---|---|
| `src/types.ts` | **Coordination edit:** add `RemoveWorktreeResult`, `PruneWorktreesResult` after line 491. |
| `src/http/worktrees.ts` | Add `removeWorktree`, `pruneWorktrees`, `RemoveWorktreeOptions`, `PruneWorktreesOptions`, `classifyRemoveError`; reuse existing `isGitRepo`/`listWorktrees`/`resolveRealPath`/`extractStderr`. |
| `src/server.ts` | Import (line 43) + two routes after line 725 (`DELETE /projects/worktree`, `POST /projects/worktree/prune`). |
| `public/projects.ts` | `removeWorktreeReq`/`pruneWorktreesReq` helpers; extend `makeWorktreeRow` (492), `DetailCallbacks` (666), `renderProjectDetail` (672), `mountProjects` (820). |
| `test/http/worktrees-remove.test.ts` | **New** — node, real temp repos; unit + integration of the two functions. |
| `test/integration/worktree.test.ts` | Extend with `DELETE`/prune route cases. |
| `test/worktree-form.test.ts` | **New describe blocks** — jsdom FE row/confirm/prune tests. |
## TDD steps (ordered)
Backend pure/logic — `test/http/worktrees-remove.test.ts` (node env, mirror `worktrees-create.test.ts`: `makeRepo()` helper, `gitAvailable` guard, real temp repos, no network):
1. Test `removeWorktree` returns `{ok:false,status:404}` for a non-git dir → implement `isGitRepo` gate.
2. Test empty/missing `targetPath``{ok:false,status:400}` → implement guard.
3. Test a path not in `git worktree list``{ok:false,status:404}` → implement realpath-match against `listWorktrees`.
4. Test main worktree (the repo root) → `{ok:false,status:400,error:/main/}` → implement `isMain` reject.
5. Test happy path: create a worktree via `createWorktree`, then `removeWorktree` (clean tree) → `{ok:true}`; assert `git worktree list` no longer contains it.
6. Test dirty worktree (write an untracked file into it) → `removeWorktree(force:false)``{ok:false,status:409}`; then `force:true``{ok:true}` → implement `classifyRemoveError` + force arg.
7. Test error message never contains `fatal:`/`error:` (SEC-M10) → assert on the 409 case.
8. Test locked worktree (`git worktree lock`) → `{ok:false,status:409,error:/locked/}` → implement locked reject.
9. Test symlink alias: pass a symlink pointing at a real worktree as `targetPath` → still matches via realpath and removes git's canonical path → verify (M2-style containment).
10. Test `pruneWorktrees` on a repo with a manually-`rm -rf`'d worktree dir → `{ok:true, pruned:[...]}` length ≥1; on a clean repo → `{ok:true, pruned:[]}` (idempotent). Non-git → `{ok:false,status:404}`.
Backend route — extend `test/integration/worktree.test.ts` (reuse `spawnServer`, `makeRealRepo`, `itGit`):
11. `DELETE /projects/worktree` foreign Origin → 403; missing Origin → 403.
12. `WORKTREE_ENABLED=0` → 403.
13. Missing `worktreePath` → 400.
14. Attempt to remove main worktree → 400.
15. `itGit`: create worktree via `POST /projects/worktree`, then `DELETE` it (clean) → 200 `{ok:true}`; `git worktree list` no longer lists it.
16. `itGit`: dirty worktree → DELETE without force → 409; with `force:true` → 200.
17. `POST /projects/worktree/prune`: Origin 403, disabled 403, and `itGit` prune-after-manual-delete → 200 with `pruned`.
FE — `test/worktree-form.test.ts` (jsdom; extend, reuse `makeDetail`, `makeHooks`, `makeCbs`, stubbed `fetch`):
18. `makeWorktreeRow` for a non-main non-locked wt with `actions` → contains a `.proj-wt-remove` button; main and locked rows → no button.
19. `renderProjectDetail` with a prunable worktree → a `Prune stale worktrees` button exists; without → absent.
20. Clicking Remove: stub `window.confirm=()=>true`, stub `fetch``{ok:true}`; assert `fetch` called `DELETE /projects/worktree` with `force:false` in body.
21. Dirty retry: `fetch` first resolves `{ok:false,status:409}` then `{ok:true}`; `confirm` returns true twice → assert second `fetch` body has `force:true`.
22. `confirm` returns false → assert `fetch` **not** called (no accidental deletion).
23. Error render: `fetch``{ok:false,status:500,error:'boom'}` → error shown via `textContent` (assert `.textContent`, no HTML injection).
Run `npm test`; keep the 80% gate — the new functions and both routes carry direct + error-path coverage; FE branches (button presence, confirm true/false, force retry, error) are all exercised.
## Edge cases & failure modes
- **Main worktree** — always rejected (400); the repo root can never be deleted.
- **Not-a-worktree path** — arbitrary FS path (e.g. `/etc`) never matches the list → 404, git never invoked against it.
- **Dirty tree** (modified tracked or untracked files) — git refuses without `--force`; surfaced as 409 → explicit second confirm before force.
- **Locked worktree** — 409 with a "unlock first" message; UI hides the remove button; never auto-escalate to `-f -f`.
- **Removing the worktree you're viewing** (`isCurrent` but not `isMain`) — allowed; after refresh the detail path may 404 → detail shows "Project not found" (existing null branch, line 691). Acceptable.
- **Already-removed / concurrent delete** — git errors "not a working tree" → 404 safe message; the follow-up `refresh()` reconciles the UI.
- **Prune with nothing prunable** — `{ok:true, pruned:[]}`, no error (idempotent).
- **git binary missing / timeout** — execFile rejects → 500 safe message; timeout bounded by `worktreeTimeoutMs`.
- **Path with control chars / flag-like leading `-`** — never reaches argv as-is: we pass git's own canonical list path, and `--` terminates options; audit log runs through `sanitizeForLog`.
- **DELETE-with-body stripped by an intermediary** — same-origin fetch, no proxy in the LAN threat model; body reliably delivered. (If ever a concern, mirror as query params — noted, not implemented.)
## Security
- **Origin/CSRF:** both routes call `requireAllowedOrigin` first (line 352) — destructive state change, mandatory (SEC-C3). Integration tests 1112, 17 assert 403 for foreign/missing Origin.
- **Feature gate:** `cfg.worktreeEnabled` (403 when off) governs remove/prune exactly as it governs create.
- **No-shell exec:** `execFile('git', [...])` only; never a shell string. `timeout` + `maxBuffer` bound resource use.
- **Path containment (the core defense):** the target is accepted **only** if its realpath matches an entry git itself reports in `worktree list`, and the command runs against **git's canonical path**, not the user string — so no traversal/symlink/arbitrary-path deletion is reachable (M2-consistent). `--` belt-and-suspenders before the path arg.
- **Main-worktree protection:** `isMain` reject prevents deleting the repository itself.
- **Safe error messages:** `classifyRemoveError`/prune mapping return fixed strings; raw git stderr never returned (SEC-M10) — asserted in test 7.
- **FE injection:** error/label rendering uses `textContent` only (SEC-L3/H6), asserted in test 23.
- **Destructive-intent confirmation:** browser `confirm()` before any delete, plus a **second** confirm before `force` on a dirty tree — no single-click data loss.
- **Rate-limit:** inherits the app's per-connection posture; these are Origin-gated same-origin calls. (No new limiter added — consistent with the create route.)
## Effort & dependencies
- **Effort:** ~1.52 days (backend + routes ~0.75d, FE wiring + confirm flow ~0.5d, tests ~0.5d).
- **Depends on:** W4 *create worktree* (shipped `createWorktree`, whose `isGitRepo`/`listWorktrees`/`resolveRealPath`/`classify*` are reused) and the v0.6 project-detail worktree list (`makeWorktreeRow`).
- **Unlocks:** full worktree lifecycle from any device (create → work → **remove/prune losers**), and pairs naturally with W4 *stage/commit/push from the diff viewer* (issue #13) to close the "spin up a worktree, land the winner, delete the rest" loop.

View File

@@ -0,0 +1,155 @@
# App-level access token (leave-the-LAN bar-raiser)
> **Feature id:** `w5-access-token` · **Effort: M** · Branch: `develop`
> Adds an **optional** shared secret (`WEBTERM_TOKEN`) that gates remote HTTP + the WS handshake with a constant-time-compared, `HttpOnly`/`SameSite=Strict` cookie. **Unset ⇒ auth disabled**, preserving today's LAN zero-config. The token is **additive** — it sits *in front of* the existing Origin/CSRF model (`isOriginAllowed` at `src/http/origin.ts:22`, `requireAllowedOrigin` at `src/server.ts:480`), never replaces it.
---
## Honest tradeoff (read first — belongs in the PR description too)
This is a **bar-raiser, not a TLS/Tailscale substitute.** On bare LAN the terminal stream is still `ws://` (plaintext) — see `buildWsUrl` at `public/terminal-session.ts:47`, which only upgrades to `wss` when the *page* is HTTPS. When the token travels over `ws://`/`http://`, **anyone sniffing the LAN sees the cookie/token in cleartext.** The token only meaningfully hardens the **relay/tunnel path**, where the edge terminates TLS and the browser speaks `wss://`/`https://`. It is a *single shared secret* (no per-user identity, no revocation except changing the env var + restart, no lockout beyond rate-limiting). Ship it with that framing; do not let it read as "now it's safe on the public internet."
---
## Contract (routes / messages / env / types)
### Env (new — `src/config.ts`)
| Env var | Type | Default | Meaning |
|---|---|---|---|
| `WEBTERM_TOKEN` | string \| undefined | **unset** | Shared access token. **Unset/empty ⇒ auth DISABLED** (everything open, exactly as today). When set: **validated at load** — must match `^[A-Za-z0-9._~+/=-]{16,512}$` (cookie/URL-safe charset, min 16 chars). Invalid ⇒ **throw** (fail-fast, like `parsePort`). |
| `WEBTERM_TOKEN_TTL` | number (sec) | `2592000` (30d) | *(optional, YAGNI-dial)* Cookie `Max-Age`. Parsed via the existing `parseNonNegativeInt` helper (`src/config.ts:83`). Ship the constant first; only wire the env if trivial. |
Charset validation is **not cosmetic**: it blocks `Set-Cookie` header/response-splitting injection and query-string ambiguity, and the ≥16 floor multiplies brute-force cost against the rate limiter.
### Config type (`src/types.ts`)
Add one field to `Config` (interface at `src/types.ts:21`, alongside the other secret-ish fields near lines 4447 / 82):
```
readonly webtermToken: string | undefined; // WEBTERM_TOKEN; undefined ⇒ auth disabled (SECRET — never log/expose)
```
Follow the `vapidPrivateKey` precedent (`src/config.ts:349`): read as `env['WEBTERM_TOKEN'] || undefined`, **never** log it, **never** return it over `/config/ui`.
### New pure module `src/http/auth.ts` (mirrors the shape/discipline of `src/http/origin.ts`)
```
export const AUTH_COOKIE_NAME = 'webterm_auth'
export function isAuthEnabled(cfg: Config): boolean // webtermToken != null && != ''
export function parseCookieHeader(header: string | undefined): Record<string, string>
export function constantTimeEqual(a: string, b: string): boolean // SHA-256 both → timingSafeEqual (fixed-length guard)
export function cookieIsAuthed(cfg: Config, cookieHeader: string | undefined): boolean
export function buildSetCookie(cfg: Config, opts: { secure: boolean }): string // the Set-Cookie value
export function isHttpsRequest(req: IncomingMessage): boolean // x-forwarded-proto==='https' || socket.encrypted
```
- **`constantTimeEqual`** hashes *both* inputs with `crypto.createHash('sha256')` to a fixed 32 bytes, then `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard": it removes the length side-channel **and** avoids `timingSafeEqual`'s throw-on-length-mismatch. (Present-vs-absent, i.e. undefined/empty cookie, short-circuits to `false` — a missing cookie is not a secret-comparison oracle.)
- **`buildSetCookie`** returns:
`webterm_auth=<token>; Path=/; Max-Age=<ttl>; HttpOnly; SameSite=Strict` **+ `; Secure`** only when `opts.secure` is true. Dynamic `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`, so forcing it would silently break LAN-over-HTTP auth; over the relay (`x-forwarded-proto: https`) it must be present.
### New/changed HTTP routes (`src/server.ts`)
| Route | Guard | Behavior |
|---|---|---|
| `GET /login` | **always reachable** (registered *before* the gate) | Serves the self-contained `public/login.html`. |
| `POST /auth` | **always reachable**, rate-limited (`authLimiter`, 10/min/IP) | Body `token` (accepts `urlencoded` *and* `json`, `limit:'1kb'`). Valid ⇒ `Set-Cookie` (`buildSetCookie`) + **302 → `/`** (native-form path) or **204** for XHR. Invalid ⇒ **401** (+ `302 → /login?e=1` for form navigations). Over rate limit ⇒ **429**. |
| `GET /?token=<t>` | handled **inside the gate**, rate-limited | Bootstrap link. Valid ⇒ `Set-Cookie` + **302 → same path with `token` stripped** (no token left in history; `Referrer-Policy: no-referrer` already set at `src/server.ts:307`). Invalid ⇒ **302 → /login**. |
| **The global auth gate** (`app.use`, new) | — | Runs after the security-headers middleware (`src/server.ts:304`) and **before** `express.static` (`src/server.ts:317`). See allow-list below. |
### WS handshake (`src/server.ts` upgrade handler, `:1130`)
After the existing Origin check passes (`:11411146`) and **before** `wss.handleUpgrade` (`:1149`), insert:
> if `isAuthEnabled(cfg)` and **not** `cookieIsAuthed(cfg, req.headers['cookie'])` → `socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return`.
The browser auto-sends the `HttpOnly` cookie on the same-origin WS handshake — **no frontend change to `buildWsUrl`/`connect()` is required** for the authed path.
### The gate's allow-list (single central policy point — the `origin.ts:13` idiom)
In order, the gate:
1. `!isAuthEnabled(cfg)``next()` *(zero-config LAN unchanged)*.
2. `isLoopback(req.socket.remoteAddress)` (`src/server.ts:160`) → `next()` *(loopback hook ingest — `POST /hook` `:533`, `/hook/permission` `:561`, `/hook/status` `:890` — has no cookie and must keep working; the token is about **remote** access).*
3. `GET` with `?token=` → validate (rate-limited) → set-cookie+redirect, or → `/login`.
4. `cookieIsAuthed(...)``next()`.
5. else unauthed: `Accept: text/html` navigation → **302 → /login**; otherwise → **401** JSON `{ error: 'authentication required' }`.
**Scope note (decision to surface at review):** this gate is a **superset** of the ROADMAP's stated "WS + `requireAllowedOrigin` routes." It *also* gates the read-only GET side-channels (`/live-sessions`, `/projects`, **`/projects/diff` — which leaks source**, `/sessions` — which leaks prompts, `/config/ui`, etc.). That is deliberate: for a "don't-expose-a-shell-off-LAN" bar-raiser, leaving source/prompt reads open off-LAN is the bigger hole, and one central gate is simpler + DRYer than threading a token check through ~19 route handlers. `requireAllowedOrigin` is left **untouched** so the CSRF layer stays independent (defense in depth: gate = "are you authorized to be here", Origin = "is this request forged cross-site"). If a reviewer wants the strictly-minimal scope instead, the fallback is a `requireToken(req,res)` helper called only inside `requireAllowedOrigin` + the WS check — but the global gate is recommended.
---
## Files to change
| Path | Concrete change |
|---|---|
| `src/types.ts` | Add `readonly webtermToken: string \| undefined` to `Config` (interface `:21`, near the secret fields `:4447`). **Coordination point** — this is the frozen shared contract; do it here, not locally. Optionally add `authRequired?: boolean` to `UiConfig` (`:656`) for the FE expiry hint. |
| `src/config.ts` | In `loadConfig` (`:271`): read `WEBTERM_TOKEN` (`env['WEBTERM_TOKEN'] \|\| undefined`), validate charset+min-length when present (throw on bad, like `parsePort` `:170`), add `webtermToken` to the frozen return (`:474`). Never log it. *(Optional: parse `WEBTERM_TOKEN_TTL`.)* |
| `src/http/auth.ts` | **NEW.** Pure, dependency-light helpers listed in Contract (`isAuthEnabled`, `parseCookieHeader`, `constantTimeEqual`, `cookieIsAuthed`, `buildSetCookie`, `isHttpsRequest`, `AUTH_COOKIE_NAME`). Imports `createHash`, `timingSafeEqual` from `node:crypto`. No Express/DOM types — keep it unit-testable like `origin.ts`. |
| `src/server.ts` | (1) import the auth helpers (near `:36`). (2) Add `AUTH_RATE_MAX = 10` to the rate-limit constants (`:8086`). (3) Instantiate `const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS)` (near `:227`). (4) Register `GET /login` + `POST /auth` **then** `app.use(authGate)` between `:313` and `:317`. (5) `authGate` closure implements the allow-list (reuses `isLoopback` `:160`, `requireAllowedOrigin` untouched). (6) WS upgrade: insert the cookie check after Origin (`:1146`, before `:1149`). Serve `login.html` from a startup-cached read of `path.join(publicDir,'login.html')`. |
| `public/login.html` | **NEW, fully self-contained.** A `<form method="POST" action="/auth">` with a `type="password"` token field + submit. **Inline `<style>` only, NO inline `<script>`** — the CSP at `src/server.ts:310` is `script-src 'self'` (blocks inline JS) but `style-src 'self' 'unsafe-inline'` (allows inline CSS). Native form POST → server 302 → cookie set → app loads; **needs zero JS**. Show an error banner when `?e=1`. |
| `public/terminal-session.ts` | *(OPTIONAL polish, low priority)* On WS close-before-`attached` while auth is enabled, print a `statusLine` (`:38`) hint like "Locked — reload to sign in" instead of silent reconnect loops. Covers the cookie-expired-mid-session case. |
| `docs/ROADMAP.md`, `CLAUDE.md`, `.env`/README env list | Tick the ROADMAP item (`:124`); document `WEBTERM_TOKEN` in the env-var list (CLAUDE.md "Planned Commands" block) with the honest-tradeoff sentence. |
| `docs/PROGRESS_LOG.md` | Orchestrator appends the completion entry (not the builder). |
---
## TDD steps (ordered; matches repo vitest style — pure-unit + `test/integration/*` real-server)
**Write each test RED first, then implement to GREEN.** Follow AAA and descriptive names, per the repo's `test/origin.test.ts` / `test/integration/server.test.ts` conventions.
1. **`test/auth.test.ts` (unit, like `origin.test.ts`)**
- `constantTimeEqual`: equal strings → `true`; unequal same-length → `false`; **different length → `false`** (no throw); empty/`undefined``false`.
- `parseCookieHeader`: `'a=1; webterm_auth=xyz; b=2'` → map; missing/empty header → `{}`; malformed pairs ignored.
- `isAuthEnabled`: undefined/empty → `false`; set → `true`.
- `cookieIsAuthed`: correct cookie → `true`; wrong value → `false`; absent cookie → `false`; disabled cfg → design choice (assert `false` and gate short-circuits before calling it).
- `buildSetCookie`: asserts substrings `HttpOnly`, `SameSite=Strict`, `Path=/`, `Max-Age=`; **`Secure` present iff `opts.secure`**; token value present.
2. **`test/config.test.ts` (extend existing)**
- `WEBTERM_TOKEN` unset → `cfg.webtermToken === undefined`.
- Valid token (≥16, safe charset) → stored verbatim.
- Too short (`'abc'`) → `loadConfig` **throws**.
- Bad charset (contains `;`, space, control char) → **throws**.
3. **`test/integration/auth.test.ts` (NEW real-server, pattern from `server.test.ts`)** — extend `makeTestConfig` to accept `WEBTERM_TOKEN`.
- **Regression / disabled:** no token set → WS connects with Origin only; `DELETE /live-sessions` with valid Origin works; `GET /live-sessions` open. *(Proves zero-config LAN is untouched.)*
- **WS enabled:** valid Origin + **no cookie → 401** (`waitForOpen` rejects, mirroring the `:262` bad-Origin test); valid Origin + **valid `Cookie: webterm_auth=<t>` → connects → `attached`**; valid Origin + **wrong cookie → 401**.
- **`POST /auth`:** wrong token → 401; correct token → 302 + `Set-Cookie` (assert flags; **no `Secure` over http**); `>10` bad attempts/min → **429**.
- **`x-forwarded-proto: https`** on `POST /auth``Set-Cookie` **includes `Secure`**.
- **`GET /?token=<valid>`** → 302 to `/` (Location has no `token`) + `Set-Cookie`; **`?token=<invalid>`** → 302 `/login`, no cookie.
- **`GET /login`** → 200 HTML, reachable while unauthed.
- **Unauthed HTML nav** (`GET /`, `Accept: text/html`, no cookie) → 302 `/login`; **unauthed XHR** (`GET /live-sessions`, no cookie) → 401 JSON.
- **Gate + CSRF stacking:** `DELETE /live-sessions` valid Origin, **no cookie → 401** (gate); with cookie → passes gate, then Origin check as before.
- **Loopback bypass:** `POST /hook` from 127.0.0.1 with **no cookie** still returns 204 (hooks unaffected). *(Guard against regressing the side-channel.)*
4. Run `npm test` (vitest) + `npm run typecheck`; confirm the 80% coverage gate holds for `src/http/auth.ts` and the new server branches.
---
## Edge cases
- **Cookie-less non-browser clients (curl/scripts):** already rejected by the WS Origin check (undefined Origin → `false`, `origin.ts:26`); the token gate adds a second wall for HTTP. Intended.
- **Cookie expiry mid-session:** loaded FE keeps a live WS but new WS reconnects/`/config/ui` fetches start 401ing → the OPTIONAL `terminal-session.ts` hint tells the user to reload. No crash.
- **`SameSite=Strict` + bootstrap link:** the `?token=` response *sets* the cookie and 302-redirects; the follow-up top-level navigation to `/` carries it (Strict permits top-level same-site sends). Works.
- **Token with URL-reserved chars:** prevented by the config-load charset validation (no `%`-encoding ambiguity, no cookie/header injection).
- **`?token=` on a non-GET or with a body:** gate only intercepts `?token=` for GET; other methods fall through to the cookie check.
- **PWA offline shell after expiry:** `sw.js` may serve the cached shell offline (same-origin, no data) but the WS still 401s — no data leak.
- **Rate-limiter memory:** reuses the existing in-memory sliding-window `createRateLimiter` (`:118`); per-IP arrays are pruned per call — no unbounded growth for the auth endpoint beyond active IPs.
- **Login page assets vs. gate:** `login.html` must reference **no** external CSS/JS (inline-styles-only) so the gate can 401 everything else including `/build/main.js` while unauthed.
- **Trailing-slash / `/index.html`:** treat both `/` and `/index.html` navigations the same in the "HTML nav → /login" branch.
## Security
- **Constant-time compare** via SHA-256→`timingSafeEqual` (no length or content timing oracle; no throw). ✔ security.md secret-handling.
- **Cookie flags:** `HttpOnly` (JS can't read the token → XSS can't exfiltrate it), `SameSite=Strict` (cross-site pages can't ride the cookie — complements the Origin/CSWSH defense and `requireAllowedOrigin`), `Secure`-when-https, `Path=/`.
- **Rate limiting** on `/auth` and `?token=` (10/min/IP) raises brute-force cost; combined with the ≥16-char token floor this is not trivially guessable. No account lockout (single shared secret) — documented.
- **Secret hygiene:** `webtermToken` never logged (follows `vapidPrivateKey` at `:349`), never returned by `/config/ui` (`:1099`). Charset validation blocks `Set-Cookie`/response-splitting injection.
- **Additive, non-breaking:** Origin check (`origin.ts:22`) and `requireAllowedOrigin` (`:480`) are unchanged; the gate is a new earlier layer. Loopback hook ingest is explicitly bypassed so the smart-features side-channel keeps working.
- **Honest boundary (repeat in code comments + PR):** plaintext on bare `ws://` — token is a relay/tunnel hardener, **not** a TLS/Tailscale replacement. Keep the "never port-forward this raw" guidance from TECH_DOC §7 intact.
- **Security-review trigger:** this is auth + cookie + crypto code → run the `security-reviewer` before merge per code-review.md.
## Effort & dependencies
- **Effort: M** (matches ROADMAP `:129`). No new npm deps — `node:crypto` (`timingSafeEqual`/`createHash`) and Express's built-in `urlencoded`/`json` parsers cover it. `~1` new pure module + `~1` new HTML file + surgical server wiring.
- **Hard dependencies:** none — self-contained; does not block on the fan-out board or Android parity.
- **Coordination:** touches two shared files — `src/types.ts` (frozen contract; the `Config.webtermToken` add is the coordination point) and `src/server.ts` (shared wiring). If run in parallel with the other Wave-5 tasks, use `isolation: worktree` and land the `types.ts` bump first so the server change type-checks. `PROGRESS_LOG.md` is orchestrator-written.
- **Cross-cutting risk to watch:** the CSP `script-src 'self'` constraint (`:310`) forces the login page to be JS-free (native form) — easy to get wrong by reaching for inline `<script>`; the integration test `GET /login → 200` plus a manual load catches it.

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