106 Commits

Author SHA1 Message Date
Yaojia Wang
0da9e7d175 Merge diff-panel-design: style the project-detail diff panel (52 unstyled classes)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-30 22:34:49 +02:00
Yaojia Wang
38274a5271 fix(projects): style the diff panel — 52 classes had no CSS at all
The project-detail Git panel's diff viewer emitted 52 class names that
style.css had no rule for, so it rendered as browser defaults: Working /
Staged / the base picker / ✕ Close as native OS buttons on a dark warm
panel, and a diff body with no monospace font and no +/- colouring at
all. The user's screenshot showed the toolbar; the audit (literal AND
template-literal class names) found the rest.

Adds the B1 diff-viewer block and the proj-*/tl-* rules, all on :root
tokens, following the file's existing two-button-role doctrine.

Also fixes, in passing:
- .df-commit-btn hardcoded #1a1712 where THEME.md mandates
  var(--on-accent) for text on a gold background
- .proj-pr-hidden — a "hidden" state class with no rule, so it never hid
- .proj-dot-stuck — ClaudeStatus has 'stuck' but only four of the five
  dots were styled, so a stuck session was the same colour as "no signal"
- --warn-wash: a derived token for the deletion wash, documented in
  THEME.md, so a re-skin moves the wash with --warn

Notable decisions, recorded in the CSS comments:
- .proj-tl-container gets NO height cap. Every capping mechanism tried
  (overflow-y:auto, mask-image, an ::after fade, a :has() row gate) cost
  more than the height it saved — the 5s wipe-and-re-append render loop
  cannot host a scroll position, and mask-clip:border-box ate the well's
  own border. It grew freely before this CSS existed; maxEvents=50 bounds it.
- The horizontal scroller stays per-hunk on .df-lines. Hoisting it to
  .df-file would drag the file path out of view when you scroll right.
- .df-file > .df-file-header keeps a smaller unscoped padding: the
  terminal page's .approval-preview also calls renderDiffFile().

Verified against the real DOM in a browser at 900px and 390x844 via a
throwaway harness (removed before commit), not from the CSS text alone.
typecheck exit 0; 2216 unit + 27 e2e tests pass.
2026-07-30 22:34:40 +02:00
Yaojia Wang
576c033031 Merge orphan-parity-plan: schedule the orphan tmux session client work
Some checks failed
android / build-and-test (push) Has been cancelled
android / instrumented-tests (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (ClientTLS) (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 (iPad Pro 11-inch (M4), -skip-testing:WebTermTests/LiveServerSmokeTests, ipad, false) (push) Has been cancelled
ios / app-tests (iPhone 16, , iphone, true) (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (iPad Pro 11-inch (M4), ipad, -only-testing:WebTermUITests/ProjectsLayoutUITests) (push) Has been cancelled
ios / ui-test (iPhone 16, iphone, ) (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-30 18:25:18 +02:00
Yaojia Wang
f000a9d979 docs(plan): schedule the orphan tmux session client parity work
The server half shipped 2026-07-30 as feature A (4892fa7/f6ef19e/d39a0ab) and
both native clients are at zero. This is NOT a regression — OrphanSessionInfo is
deliberately a separate type rather than a flag on LiveSessionInfo, and
src/server.ts:600 names the reason: the clients decode /live-sessions, so a
variant shape there would have broken them.

It is worth planning anyway because SessionManager.shutdown only detaches the
tmux client for tmux-backed sessions (manager.ts:410-417), so a server restart
or a desktop-app quit turns every session into an orphan. The web launcher lists
them and re-adopts on tap; the phone still shows an empty list — which is the
walk-away case this product exists for.

The plan doc carries the eleven traps a client implementer will hit, each one a
shipped review finding or a documented server subtlety — notably that preview
returns cols/rows of literally 0 (both clients currently size thumbnails from
that response), that a 429 must not collapse to "no orphans", and that the
destructive confirmation must come from count-idle rather than card count (that
one nearly ended 33 shells while the dialog said 24).

Also corrects two now-false claims found while cross-referencing: the ROADMAP
row for tmux discovery was half-stale, and ANDROID_CLIENT_PLAN's do-not-consume
list still listed POST /projects/worktree (both clients consume it now) and
GET /sessions (iOS consumes it, Android does not — so "match iOS" now argues
for building it, not against).
2026-07-30 18:25:09 +02:00
Yaojia Wang
97e39949a8 Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
2026-07-30 18:12:03 +02:00
Yaojia Wang
ddab77b337 test(ios): close T-iOS-32's end-to-end half — real worktree lifecycle against a real server
T-iOS-32's acceptance is "builder 测试 + 端到端一次". The builder half was green,
but grep for projects/worktree across the whole test tree returned nothing — no
test had ever created or removed a real git worktree against a real server.

Six tests on a throwaway git fixture: create/remove/prune verified against both
the filesystem and git's own `worktree list --porcelain` + .git/worktrees/, and
asserting the values the SERVER derives rather than echoing our input (branch
feature/e2e-worktree becomes directory feature-e2e-worktree; the raw branch name
must never appear in the path). Includes a differential leg for the guarded-route
rule — the same request without Origin is 403 with zero side effects, and with it
is 200 — which needs raw URLSession, since APIClient structurally cannot emit an
Origin-less guarded request.

GET /sessions gets its own HOME-isolated server: history.ts reads
os.homedir()/.claude/projects, so against the shared harness the assertions would
land on the developer's real Claude history, and in CI the directory is absent so
it degrades to [] and proves nothing.

Integration tests 26 -> 32, independently re-run.
2026-07-30 16:58:22 +02:00
Yaojia Wang
5cc755b0b6 fix(ios,android): close the acceptance gaps the review found, add Android CI
- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar
  froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped —
  recorded as a withKnownIssue rather than fixed. Height now derives from the
  content size category (and tracks live changes via registerForTraitChanges);
  the known-issue marker is gone, replaced by positive assertions including a
  12-category no-clip sweep and a guard that stays red if anyone writes the
  constant back. Honest tradeoff: the keycap font is clamped at .accessibility2,
  the same policy the design system already applies to dense content, because an
  unclamped AX5 bar would eat the terminal. A test pins the clamp so the two
  cannot drift.

- Thumbnails silently 401'd on a token-gated host: the pipeline built its own
  transport with no token source, and by design never throws, so every preview
  degraded to a placeholder with no signal. Assembled from AppEnvironment now.

- Android had zero CI while the token wave shipped 24 files of secret-handling
  code. The instrumented leg is workflow_dispatch-only and says why in the file:
  no one has ever seen it green on a runner, and a required leg nobody trusts
  just produces a false green.

- Android persisted a validated token before the host probe succeeded, stranding
  a secret for a host that never paired.

App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
2026-07-30 16:46:20 +02:00
Yaojia Wang
822364d12b test(server): prove the H1 precondition instead of assuming it
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
`H1 shell state survives a server restart` failed in the full parallel suite
and passed in isolation, which reads like a timing flake in the assertion. It
was not. Polling for the marker instead of sleeping a fixed 900ms showed the
shell replying promptly with:

    echo MARK-$WEBTERM_MARK
    MARK-

An EMPTY variable. The post-restart shell was fine; the variable had never been
set in the PRE-restart shell, because a fixed 500ms is not enough for the shell
to become ready and run the assignment when 81 test files are competing for the
machine. The symptom then appears at the far end of the test and reads as "the
tmux session did not survive the restart", sending whoever debugs it after
entirely the wrong bug.

So the setup now proves itself: it echoes the variable back and waits for
`SET-tmuxlives` before restarting, and fails there with "the variable was never
set in the pre-restart shell" if it did not take. The final assertion likewise
waits for its marker with a bounded timeout rather than guessing a duration.

Neither wait can mask a genuine failure: if the tmux session really did not
survive, the shell is fresh, the variable is empty, and the marker never appears
no matter how long we wait.

Added a `waitUntil` helper for this. It carries its own sleep because the
`delay` the cases use is a local const inside a test body, not module scope.

Verified: full `npx vitest run test/` -> 81 files, 2243 tests, 0 failures.

NOT fixed, and not mine to guess at: `server.test.ts` has at least one more
real-PTY case that flakes under the same parallel load (⑤ attach → attached →
shell prompt output times out in `waitForMessage`, then its afterEach hook
times out too). It passes in isolation. Same class of fixed-delay assumption,
different case.
2026-07-30 16:27:32 +02:00
Yaojia Wang
217206e9a9 Merge android-blocker-fixes: make the Android client work on a device
The client reported COMPLETE with 617 green tests and a buildable APK, but had
never run on hardware, and the first interaction on a device crashed it. Seven
real defects, four of them crashes:

  - every key press NPE'd (no TerminalViewClient was ever installed)
  - the PTY never learned the real grid (no resize was ever sent)
  - bare-LAN ws:// could not connect (a STUB network-security-config)
  - swipe-scrolling inside an alternate-screen TUI NPE'd, i.e. during ordinary
    use of Claude Code, which is one
  - any password manager crashed the app (unguarded autofill)
  - onDraw tore the terminal buffer and killed the process — the defect the docs
    had explicitly classified as 'Accepted (not a defect)', and which was dormant
    only because resize never happened
  - text selection's Copy button was an NPE, so copy-out was cut rather than
    shipped broken

Also: the queue frame and status.preview were silently dropped (remote approvals
were blind), the v0.6 git panel was unrendered, ThumbnailPipeline/QuickReply/
PointerContextMenu were tested dead code with no call sites, the WEBTERM_TOKEN
gate was inert, release builds emitted unsigned APKs, and A34/A35 had no code at
all despite being marked done.

Then the audit's own leftovers: silent certificate rotation (which surfaced a bug
that would have failed every renewal, plus a latent iOS defect Android now
avoids), /device/:id/recover, the W2 inject-queue routes, unread-watermark
persistence, host removal, and /config/ui finally honoured fail-closed.

617 -> 1150 JVM tests, plus 67 instrumented tests that actually execute on an
emulator against this repo's own server. The device crashes are reproduced as
JVM tests where possible, so they are guarded without hardware.

Still open and recorded, not papered over: S2 needs two physical handsets under
Doze; the rest of DEVICE_QA_CHECKLIST has no automated cover.

# Conflicts:
#	docs/PROGRESS_LOG.md
2026-07-30 16:09:22 +02:00
Yaojia Wang
284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00
Yaojia Wang
14f28e9d66 docs(android): record what the device run proved and what is still open
The correction section written earlier today said A34/A35 had no code and
device QA was ~0%. Both were true when written and are not any more, so they
are marked resolved rather than left to mislead the next reader — with the
instrumented invocation recorded (the emulator needs
ALLOWED_ORIGINS=http://10.0.2.2:<port>, because allowed origins are derived
from NIC IPs and 10.0.2.2 is not one).

What stays open is stated plainly: S2 needs two physical handsets under Doze,
the rest of DEVICE_QA_CHECKLIST has no automated cover, and one instrumented
test kills the process when driven without host arguments on a heavily-recycled
emulator while passing reproducibly under the documented invocation — which is
recorded as unresolved rather than explained away.
2026-07-30 15:38:10 +02:00
Yaojia Wang
390dd11202 feat(android): close the audit's remaining parity gaps
Seven items the earlier repair waves deliberately left alone, so that the
blocker fixes could land with a working safety net first.

CERTIFICATE LIFECYCLE (the audit's only "high"). iOS ships a
CertificateRotationScheduler; Android had no counterpart, so a device
certificate simply expired and needed a manual re-enroll. The decision is a
pure total function over five answers, with RE_ENROLL_REQUIRED checked first so
it outranks any backoff. Renewal is single-flight — a second trigger coalesces,
and a cancelled leader releases the slot so cancellation cannot wedge rotation
for the process lifetime. Failure leaves the prior identity fully live (the
existing atomic ping-pong flip already guaranteed that) and is published rather
than swallowed.

Two real defects surfaced while building it:

  renew() decoded its 201 with EnrollResponseDto, whose deviceId is required —
  but /device/:id/renew returns only {cert, caChain, notAfter}; only
  /device/enroll echoes deviceId. Every silent renewal would have thrown
  MalformedResponse. Fixed with a RenewResponseDto plus the deviceId from the
  path segment we addressed.

  And because no renew 201 carries renewAfter, a client that persisted it would
  rotate exactly once and then report not-due until the cert died. That is why
  renewAfter is re-derived from the live leaf as notBefore + 2/3·lifetime — the
  control plane's own formula. This is a latent iOS defect that Android now
  avoids rather than inherits.

/device/:id/recover was confirmed real (control-plane/src/api/renew.ts:443,
contract pinned by that suite's CP6e) and is now wired. It goes over a separate
NON-mTLS client that throws rather than falling back, because an expired client
certificate cannot authenticate its own recovery — the deadlock a previous
session already hit and recorded.

Asked explicitly about step-up: rotation is unaffected. stepUp appears nowhere
in control-plane/src; it lives only on the relay's WebSocket upgrade. On a
step-up host the SESSION is denied, not the rotation, so the certificate still
stays alive and that principal gap is orthogonal.

FOLLOW-UP QUEUE (W2). The queue frame decoded but the three routes managing it
were missing, so Android could see "N queued" and not queue anything. One
outcome union covers both guarded writes and distinguishes full / too-large /
disabled / session-gone / rate-limited, because they need different copy. 429 is
surfaced and never auto-retried — POST and DELETE share one 20/min bucket.
Queued text is raw shell input and travels verbatim, proven with control
characters and CJK.

UNREAD WATERMARK now persists, so the unread state survives process death —
which is exactly when it matters, since the product premise is walking away and
coming back. It stores a plain map rather than an UnreadLedger: the reducer
lives in :session-core, which :host-registry may not depend on, so the logic is
not restated anywhere.

COPY-OUT. Text selection was a recorded feature gap: the stock ActionMode's own
Copy handler dereferences the absent TerminalSession, so making it reachable
puts an NPE on the button — worse than no selection. The refusal was
re-verified from the bytecode, and the same disassembly showed the way out:
TerminalBuffer.getSelectedText touches only mLines/TerminalRow, no session and
no view. A first-party selection layer now builds on that, with a pure
row-major model so a backwards drag normalises correctly. Terminal content
reaches the clipboard only on an explicit user copy; OSC 52 stays declined.

HOST REMOVAL. PushRegistrar.unregisterHost had zero call sites, so a removed
host kept receiving pushes. Removal is now confirm-gated and cleans everything
keyed by host id — a half-removed host is worse than none.

/config/ui is finally honoured. It was implemented and never called, so
allowAutoMode was ignored and the plan gate offered "approve + auto" even where
the operator had disabled it. It fails CLOSED: an unfetchable config treats auto
as disabled, because the permissive default is the dangerous one.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:macrobenchmark:assembleBenchmark koverVerify -> BUILD SUCCESSFUL, 1150 JVM
tests, 0 failures (903 -> 1150). Instrumented suite re-run on emulator-5554
against live servers: 67 tests, 0 failures, 0 skipped — no device regression.
2026-07-30 15:37:09 +02:00
Yaojia Wang
8075d2c671 test(android): the instrumented suite A34/A35 never had
The plan marked A34 (instrumented E2E against a real host) and A35
(macrobenchmark) DONE. Neither had a single line of code — :app had no
androidTest source set at all, and `testInstrumentationRunner` named a
HiltTestRunner class that did not exist. They had been converted into checklist
bullets. This adds 67 instrumented tests and 3 macrobenchmarks, and every one
of them has actually been executed on an emulator against this repo's own
server — not merely compiled.

Why this layer had to exist: four crash-on-first-interaction defects shipped
behind a green 900-test suite, for one structural reason — no JVM test
instantiates an android.view.View, creates an InputConnection, dispatches a
MotionEvent or runs a layout pass. So the rig builds the REAL RemoteTerminalView
in a REAL Activity and drives it through the REAL framework entry points,
faking only the wire so assertions can be about BYTES rather than "did not
crash". That distinction matters: a no-crash-only test would have passed against
the silent-black-hole variant of the IME bug, which was arguably the worse half
of it.

The four crash regressions (28 tests):
  keys — Enter is \r not \n, Ctrl+C is 0x03, Backspace 0x7f, and arrows in BOTH
    DECCKM modes driven off the live emulator bit, so ESC[A vs ESC OA is
    asserted rather than assumed; BACK falls through and emits nothing; soft
    keyboard commitText/sendKeyEvent/deleteSurroundingText and a CJK
    composition pass through verbatim.
  alternate-screen scroll — every test feeds ESC[?1049h FIRST and asserts the
    buffer is active before touching anything, because that is the branch that
    used to crash. Real dispatchTouchEvent drags both directions, the DECCKM
    form, the mouse-tracking branch emitting a wheel report and NO arrows
    (branch order), the wheel's 3-rows-per-notch, and the fling proven not to
    start by 800 ms of silence after ACTION_UP.
  autofill — the framework's own autofill-structure gate is closed on both the
    frame and the stock child, before and after focus.
  resize — including the draw-race counterpart of the AIOOBE fixed in 538c8eb.

A34 E2E against a live server (15 tests): attach → attached → output round
trip, ring-buffer replay on reconnect, spawn failure via a second server
deliberately configured with a bad SHELL_PATH, a held gate resolved through
POST /hook/decision, and CswshDefenceE2eTest — the F9 bad-Origin rejection,
which the checklist calls the one non-skippable defence and which had never
been executed anywhere.

A35 (3 macrobenchmarks): startup, and pair → attach → type → approve.

Reaching a host from the emulator needs ALLOWED_ORIGINS=http://10.0.2.2:<port>,
because the server derives allowed origins from host NIC IPs and 10.0.2.2 is
the emulator's alias for the host, not an interface address. Tests take the
host as an instrumentation argument and SKIP with a clear message when it is
absent, so "passed" can never be confused with "never ran".

Verified independently of the authoring agent, on emulator-5554 (webterm AVD,
android-35, arm64, software GPU), with both servers live:

    Starting 67 tests on webterm(AVD) - 15
    Finished 67 tests on webterm(AVD) - 15
    tests="67" failures="0" skipped="0"

JVM gate unaffected: ./gradlew test :app:assembleDebug
:app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify ->
BUILD SUCCESSFUL, 903 tests, 0 failures.
2026-07-30 13:42:26 +02:00
Yaojia Wang
538c8ebf34 fix(android): move emulator mutation to the render thread
The instrumented suite was run on a device for the first time and the app died
on the first test:

    ArrayIndexOutOfBoundsException: length=31; index=31
        at com.termux.terminal.TerminalRow.getStyle
        at com.termux.view.TerminalRenderer.render
        at com.termux.view.TerminalView.onDraw

PROGRESS_ANDROID.md had classified exactly this as "Accepted (not a defect):
steady-state append runs concurrently with the UI-thread onDraw ... torn read
self-corrects next frame". Both halves of that were wrong.

It is not cosmetic. TerminalRenderer.render caches its column bound once from
TerminalEmulator.mColumns and then indexes TerminalRow.getStyle(column) into a
raw long[] sized when that row was allocated. TerminalEmulator.resize publishes
the new mColumns BEFORE resizeScreen() reallocates the rows, so a draw landing
in that window reads the new bound against old-width rows. The first
out-of-range column is exactly oldColumns, which is why the exception was
always length == index — the consistency was a deterministic signature of a
grow, not a rare interleaving. A shrink is harmless, which is probably why
"self-corrects" looked plausible. TerminalBuffer.resize is non-atomic in
several more ways, and TerminalRow.setChar swapping mText for a larger array is
the same hazard on the append path.

Nor does it match upstream. Upstream's background reader only fills a
ByteQueue; TerminalSession$MainThreadHandler is what calls append, and
TerminalView.updateSize resizes — both on the main thread. Upstream has no
concurrent reader at all. There is also no lock to lean on: monitorenter
appears nowhere in TerminalEmulator, TerminalBuffer, TerminalRow or
TerminalRenderer.

The race was dormant until this session. updateSize had no call sites, so
mColumns never changed after bind and there was never a width mismatch to tear
on. Making resize work is what made this reachable — the same lesson as the
rest of this pass: the green suite could not see any of it because no JVM test
instantiates a View.

Fix: every emulator mutation now runs on the render thread, from inside the
same single confined consumer. §6.2 is intact — still one writer draining one
ordered channel, suspending across the main hop, so a resize and an append
still cannot interleave and submission order still holds. They are now also
serialised against onDraw, because one Handler work item cannot run inside
another. Resize still reaches the wire and the §6.4 forced re-assert still
bypasses the dedup.

Chunking becomes load-bearing rather than incidental: each 4 KiB slice is its
own main-thread work item, so a multi-MB ring replay interleaves with frames
and input instead of blocking behind one long call — which is what §6.2's
no-ANR requirement actually needs, rather than "runs off the main thread".

Gating the child's dispatchDraw was considered and rejected: it would have to
cover appends too, leaving a choice between blanking the terminal and blocking
the UI thread on a full reflow. A read/write lock was rejected because append
calls back out through TerminalOutput (title, bell, DA/DSR reply), so holding a
write lock across it would bury a deadlock invariant in app-level callbacks.

The KDoc that made the false claims now states the mechanism with offsets, and
RemoteTerminalHostView records why dispatchDraw is deliberately not gated.

Verified: the device crash is reproduced as a JVM test first (red), so this
regression is now guarded without a device. ./gradlew test :app:assembleDebug
koverVerify -> 903 tests, 0 failures. On the emulator the previously-crashing
test now passes and 5 of 6 alternate-screen scroll tests pass.
2026-07-30 13:25:42 +02:00
Yaojia Wang
53bee034ca docs(progress): log the desktop USE_TMUX default fix 2026-07-30 12:48:02 +02:00
Yaojia Wang
2a24935b8d Merge desktop-tmux-default: app-started sessions survive quitting the app 2026-07-30 12:47:25 +02:00
Yaojia Wang
5833529c0c fix(desktop): default USE_TMUX to auto so quitting the app stops killing sessions
The desktop shell pinned USE_TMUX=0 on every platform, not just Windows. Without
tmux the shell is a direct child of the embedded server, so SessionManager.shutdown
takes the kill branch and quitting the app — the tray Quit, a logout, an update —
ends every session with it.

That defeats the app's premise. The point is to hand Claude Code a task, walk away,
and reconnect from another device; a session that cannot survive the window closing
does not do that. With tmux the node-pty process is only a tmux CLIENT, so shutdown
detaches and the shell keeps running, which is exactly what the standalone server
has always done (USE_TMUX unset → 'auto' → is the binary present?).

Non-Windows now defaults to 'auto'. Windows stays forced off — tmux is a *nix
keepalive. An explicit ambient USE_TMUX still wins either way, so USE_TMUX=0 remains
the opt-out.

Tests assert the end-to-end result, not just the emitted string: buildServerEnv fed
through loadConfig yields useTmux === true with tmux installed, and false for an
explicit opt-out. The original bug was these two layers disagreeing about what a
value meant, so checking the string alone would not have caught it.

Sessions started in the app before this change are NOT retroactively protected —
they have no tmux session behind them and will still die with the app. Only sessions
created after the new build is installed survive.
2026-07-30 12:47:05 +02:00
Yaojia Wang
9114630c3a feat(android): access-token support — same frozen contract as iOS
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
2026-07-30 12:45:27 +02:00
Yaojia Wang
a5fa843f00 test(ios): ClientTLS coverage 55.76% -> 89.49%, gate it, fix the three dead CI legs
ClientTLS was the most security-sensitive package in the tree and the least
covered, and it was not in the coverage gate at all (the gate's 4-package set
predates it). 48 -> 84 tests against the real macOS keychain, serialized with a
custom Testing trait after a @globalActor proved insufficient (actors yield at
await, so cross-await critical sections got interleaved by other cases' cleanup).

CI: the app/ipad/ios17 legs ran a bundle containing LiveServerSmokeTests, which
spawns tsx, with no npm ci -- a hard failure, not a skip, on a bare checkout.
Adds the missing iPad UI-test leg, and makes a missing iOS 17 runtime fail loudly
instead of silently reporting green.
2026-07-30 12:45:26 +02:00
Yaojia Wang
850531fd07 feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four
distinct outcomes from the frozen contract, Cookie/Accept landed at the same
single header choke point that already enforces Origin-iff-G, plus the whole
project-ops surface the server has had since late July and iOS consumed none of:
/projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/
fetch, worktree create/remove/prune, GET /sessions, follow-up queue.

HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain
under the existing SecItemShim conventions (device-only, never synchronizable),
charset/length validated at the boundary, old token-less records still decode.

SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from
the same point that writes Origin, and a 401 handshake is a terminal
.unauthorized -- never entering the backoff loop, since retrying one wrong
shared token is a brute-force generator against the server's 10/min limiter.
2026-07-30 12:45:26 +02:00
Yaojia Wang
c4f8b5b47f feat(ios): unblock device builds — dev team, push-entitlements env switch, mic/speech/background keys
DEVELOPMENT_TEAM=C738Z66SRW + target-level CODE_SIGN_IDENTITY (XcodeGen's
settingPreset injects the deprecated "iPhone Developer" at target level, so a
project-level override cannot win). Device build now BUILD SUCCEEDED on the free
personal team with an on-the-fly 7-day profile.

aps-environment lives in a new WebTerm.entitlements that is NOT attached by
default: CODE_SIGN_ENTITLEMENTS expands ${WEBTERM_PUSH_ENTITLEMENTS}, empty
unless set at xcodegen time. Proven both ways — attaching it on a free team
fails with "Personal development teams ... do not support the Push
Notifications capability".

Also pins SwiftTerm to exactVersion 1.13.0: the floating "from: 1.13.0" now
resolves to 1.15.0, whose new public hasActiveSelection collides with the local
one in TerminalScreen.swift:328 and broke every generate+build.
2026-07-30 12:45:08 +02:00
Yaojia Wang
c1612d0145 build(android): make release buildable and instrumented testing possible
Four gaps that made "ship it" and "test it on hardware" impossible.

RELEASE SIGNING. assembleRelease was silently producing
app-release-unsigned.apk because there was no signingConfigs block at all —
the failure mode where you only notice at install time. Credentials now
resolve from keystore.properties, then local.properties, then
WEBTERM_RELEASE_* env; nothing is created or committed, and
keystore.properties.example is the template.

The degradation is ENFORCED, not documented. The signingConfig is only
created when credentials actually resolve — an empty config is precisely what
makes AGP emit an unsigned artifact quietly — and a guard on packageRelease
throws with an actionable message, distinguishing "not configured" from
"credentials found but the keystore file is missing: <path>". It hooks the
PACKAGE task on purpose: R8 has already run and reported by then, so a
developer without a keystore still gets the full shrink result rather than an
early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails
loudly, and no unsigned APK is written.

VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01",
where the suffix is a factual claim about device verification rather than
decoration: alpha means device QA is essentially unstarted, beta means the
A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole
checklist is ticked. versionCode stays 1 because no artifact has ever left
the build machine.

MINIFICATION. release had isMinifyEnabled = false and an empty rules file —
so nothing had ever exercised R8 on this app, and the first release build
would have been the experiment. Now minify + resource shrinking, with a
deliberately short rules file: the actual artifacts were checked, and
kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship
their own consumer rules, so none are re-declared. The termux packages are
kept whole because the JitPack artifacts ship no rules and :terminal-view
binds to non-API internals — it writes the public TerminalView.mEmulator
field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting
that wrong yields a build that crashes only in production.
lintVitalRelease also passes now, the two pre-existing lint ERRORS having
been fixed earlier this session.

ANDROID TEST ENABLEMENT. :app had no androidTest source set and no
instrumentation runner, which is the mechanical reason A34/A35 have no code
at all. The runner (Hilt-aware) and dependencies are wired, and a
:macrobenchmark module exists. The tests themselves are a separate change.

Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:app:assembleBenchmark :macrobenchmark:assembleBenchmark
:app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0
failures on a forced re-execution with test-results deleted and
--no-build-cache.
2026-07-30 12:14:53 +02:00
Yaojia Wang
b09b90e5bb fix(android): the ThumbnailPipeline bugs its skipped review would have caught
This file's cross-review was explicitly skipped when it was written —
PROGRESS_ANDROID.md says so — and it had no production call sites until this
session, so nothing had ever reviewed OR executed it. Wiring it to the home
screen finally made an adversarial concurrency review worthwhile. It confirmed
the hard parts are right (the check-then-insert dedup critical section,
withPermit's exactly-once release with the fallback outside it, the
NonCancellable finally, and rethrowing CancellationException) and then found
five real defects. Every premise was re-verified from the termux v0.118.0
bytecode before fixing.

1. Thumbnails drew DUPLICATED ROWS. TerminalBuffer.externalToInternalRow is
   `(mScreenFirstRow + row) % mTotalRows` and mTotalRows is the constructor's
   transcriptRows, so passing TERMINAL_TRANSCRIPT_ROWS_MIN (100) while rows
   went up to 200 aliased external rows 100..199 onto 0..99 — and left the
   emulator's scroll state invalid during append(). A pure ThumbnailGrid now
   derives transcriptRows = max(rows, MIN).

2. A legitimately-sized session was LMK bait. cols/rows are server-supplied
   and the protocol validates resize only to [1,1000], so a 1000x1000 session
   forced 2800x2800 ARGB_8888 = 31.4 MB, twice over for two permits, every
   poll tick. Rather than clamp the grid — which would CROP a real screen —
   the cell size is now derived from the target, shrinking the font's own cell
   by an exact integer fraction with the aspect preserved. Any geometry in
   1..1000 now yields at most 480x480 = 900 KiB; a real 161x50 goes from
   3.1 MB to 322 KiB.

3. Two stalled fetches wedged the feature PERMANENTLY. There is no OkHttp
   callTimeout (the per-socket read timeout is reset by every trickled byte)
   and the render is deliberately uncancellable by any caller, so two
   slow-trickle previews held both permits until close() and no thumbnail
   rendered again. A withTimeout now sits inside withPermit, budgeting the
   work rather than the permit wait. The subtlety that makes this correct:
   TimeoutCancellationException IS a CancellationException, so it is caught
   BEFORE that branch and routed to the placeholder — otherwise a slow host
   would still kill the pipeline instead of degrading.

4. The in-flight entry was leakable, contradicting a comment that claimed it
   never was. `scope.async` on an already-cancelled scope never runs its body,
   so the finally never ran: a post-close() request left a dead Deferred that
   every later request awaited forever, and inFlight grew unbounded. Now
   guarded inside the mutex, and the comment names the one path that genuinely
   cannot release (where the map dies with the pipeline anyway).

5. Obsolete renders did guaranteed wasted work. lastOutputAt advances on every
   PTY byte and the list polls every 5s, so an active session mints a new key
   each tick; the row cancelled the old caller but the old render ran a full
   GET plus a full raster whose bitmap was cached under a key nothing would
   request again — occupying both permits ahead of the key actually on screen,
   and growing without bound once render latency exceeded arrival rate. A
   newer key now cancels the older in-flight render for that session.

Also made two false statements true rather than leaving them: retainOnly now
prunes under the same monitor publish() takes, so a render landing after the
prune cannot resurrect a killed session's bitmap; and PreviewCap's KDoc no
longer claims to prevent an oversized-body OOM (the body is already buffered
and parsed by then) nor to "mirror the server's cap" (the server defaults to
24 KiB, not 256 KiB, and an operator can raise it unbounded).

RED was observed for all four behavioural fixes before implementing.

Verified: ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify
-> BUILD SUCCESSFUL, 901 JVM tests, 0 failures (893 -> 901). The agent
distrusted a FROM-CACHE green, forced a real execution, and confirmed the code
reached the packaged APK by scanning its dex.
2026-07-30 12:09:25 +02:00
Yaojia Wang
3ad8d8beba docs(progress): log the round-2 tmux target prefix-matching hole + dual clocks 2026-07-30 11:48:32 +02:00
Yaojia Wang
a6cf547f04 Merge orphan-review-round2: exact tmux targets, dual activity clocks 2026-07-30 11:47:11 +02:00
Yaojia Wang
d39a0ab8d1 fix(sessions): tmux -t prefix-matching let orphan ops reach the user's own sessions
Round two of adversarial review. The skeptic confirmed the session_activity finding
with extra evidence from this host and raised three things the first pass missed.

SECURITY — `tmux -t <name>` resolves exact name, then NAME PREFIX, then fnmatch.
The whole module rests on "we only ever touch `web_` + a UUID v4, so a tmux session
the user made for their own work is never enumerated and never offered for
deletion". Prefix matching goes around that: a session named `web_<uuid>_mine` is
refused by parseSessionList (its suffix is not a UUID) and so never appears in the
listing — but `-t web_<uuid>` matches it. So a DELETE aimed at an id that does not
exist would end the user's session, and a preview would print its screen.

Verified on tmux 3.6a, isolated socket:
  has-session -t web_<uuid>    → succeeds against web_<uuid>_MINE
  has-session -t =web_<uuid>   → "can't find session"
  capture-pane -p -t web_<uuid>   → printed the DECOY's screen
  capture-pane -p -t =web_<uuid>: → "can't find session"

Two target forms are needed, because `=` only qualifies the session part of a
target: session targets (has-session, kill-session) take `=name`, while
capture-pane takes a PANE and rejects a bare `=name` outright ("can't find pane"),
so it needs `=name:`. Both are now the only way a name reaches tmux, with an
integration test that stands up a real decoy and asserts the listing omits it and
both the preview and the DELETE report 404 while it stays alive.

CORRECTNESS — take max(window_activity, session_activity), not window_activity
alone. The previous commit swapped one clock for the other, but they are not
ordered: window_activity tracks pane output while an attach with no output bumps
only session_activity. On this host one session's session_activity was 4 s NEWER
than its window_activity (and another's window_activity was 25 days newer). The
question is "has anything happened here", so the answer is the later of the two.
Also documents the caveat that window_activity resolves against the session's
current window only — exact for the single-window sessions this app creates, and
bounded by the max for anything else.

CORRECTNESS — parseSessionList accepted an empty numeric field. Number('') is 0
and 0 is finite, so a blank clock parsed as "created at the epoch, idle ever
since": instantly eligible for the idle cleanup. Fields must now be actual digits.

SAFETY — bulk cleanup re-reads the world immediately before killing. The candidate
list is a snapshot and the kill loop takes time, so a session could be attached, or
adopted into the table, inside that window. This is the irreversible path; it now
verifies each candidate against a fresh listing rather than trusting the snapshot.

PERFORMANCE — captureOrphan no longer probes with hasSession first. That was a
second tmux spawn per thumbnail, and a SYNCHRONOUS one on the path a whole grid
refreshes, buying nothing: capture-pane already fails cleanly on a missing session
and we already turn that into null. The guard splits into a pure half
(mayActOnOrphan: UUID shape + not in the table) used by both paths, and the
existence probe, which only the destructive path needs.

Tests: 2213 unit, 9 orphan integration (incl. the decoy regression). Confirmed
`has-session -t =<name>` still resolves the 69 real sessions, so the Case 3.5
re-attach path is unaffected.

Note: test/integration/server.test.ts "H1 shell state survives a server restart"
flakes ~1 run in 3 when all 27 run together on a loaded machine, and passes 4/4 in
isolation. That is the pre-existing real-PTY timing flake already recorded in
PROGRESS_LOG, not this change — it exercises Case 3.5, which is verified above.
2026-07-30 11:46:51 +02:00
Yaojia Wang
2bfc76b397 docs(progress): log the orphan tmux session feature + the session_activity clock bug 2026-07-30 11:30:24 +02:00
Yaojia Wang
22a7929a7b Merge orphan-tmux-sessions: surface and manage untracked tmux sessions 2026-07-30 11:28:56 +02:00
Yaojia Wang
f6ef19ebf6 fix(sessions): orphan cleanup was reading the wrong tmux clock
Adversarial review of the previous commit found ten defects. The critical one
would have destroyed exactly the work this feature exists to protect.

CRITICAL — `#{session_activity}` is a CLIENT clock, not an output clock.
It advances on attach and on keypresses, and never moves for a detached session
however much its shell is printing. Measured on tmux 3.6a: a detached session
running `while true; do echo tick; sleep 1; done` still reported
session_activity == session_created after 6 s, while `#{window_activity}` tracked
the output exactly. So "idle for 7 days" was really "nobody has typed for 7 days",
and `Clean up idle 7d+` would have killed long-running unattended sessions that
were busy working — the walk-away case the whole app is built around.
Measured against this host's 69 sessions: the old clock condemned 36, the new one
condemns 33, so three sessions doing real work were one click from deletion.
Now reads window_activity, with a test that fails if the format string regresses.

HIGH — no timeout on any tmux call. maxBuffer bounds a flood but nothing bounded
the wait, and a synchronous exec cannot be interrupted by the event loop, so a
tmux server stopped mid-syscall hung the whole server permanently. All five calls
(including the three that predate this feature) now carry one.

HIGH — listSessions and capturePane were synchronous on a POLLED path. Measured
73 ms and 58 ms per call on this host; every home screen on every device refreshes
on a 5 s timer, and each of those was 73 ms with the event loop stopped, i.e.
73 ms in which no PTY byte reached any terminal anywhere. Both are async now, so
the manager's listOrphans/captureOrphan/killOrphansIdleSince are too. The server
is a byte-shuttle first: nothing polled may block it.

HIGH — GET /orphan-sessions had no rate limiter although, unlike every other read
route, it spawns a subprocess. It shares the preview bucket now.

HIGH — the cleanup confirmation counted CARDS, and the grid is capped at 24 while
the cleanup matches across every session the server enumerates. On this host it
would have said "24" and ended 33 shells. New GET /orphan-sessions/count-idle
answers the real question, and the dialog states that number, or refuses to
proceed if it cannot be determined.

MEDIUM — fetchOrphanSessions collapsed every failure into [], so one 429 or 500
read as "all recovered sessions are gone", tearing down every mounted xterm and
rebuilding it on the next success. It returns null for failure now, and the
refresh leaves the section untouched.

MEDIUM — the preview latch was "card was created", not "preview succeeded", so a
card that lost its single request stayed blank forever. Latches on success.

MEDIUM — overlapping refreshes. refresh() is entered from a 5 s timer, from
killOrphan and from cleanup, with awaits inside and no guard, so a slow poll could
resurrect cards for sessions already killed or re-mount them into a root that
setVisible(false) had just cleared. A generation counter, bumped on teardown and
checked after every await, invalidates stale runs.

MEDIUM — renderPreview could write to a disposed xterm (it throws "Object has
been disposed"). PreviewCard carries an explicit `disposed` flag set by a new
disposeCard() helper, so no caller can dispose a terminal and forget the flag.
Deliberately NOT el.isConnected: a card not yet appended is alive, and two
existing preview-grid tests correctly caught that conflation.

LOW — kill and cleanup discarded their results, so a refusal (403 behind a proxy
whose Origin differs, 404 on a lost race) looked like a dead button. Both report.

Tests: 2209 unit, 27 e2e, 8 orphan integration. Verified live against all 69 real
sessions with none harmed; the window_activity fix confirmed on an isolated tmux
socket (-L) so the developer's sessions were never involved.
2026-07-30 11:28:39 +02:00
Yaojia Wang
980dbaa928 feat(android): wire the dead code, render the git panel, make the token gate real
Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.

Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:

  - Session-list preview thumbnails. SessionsHome called SessionListScreen
    without the `thumbnails` argument, so the `= null` default won and every
    row rendered a placeholder forever — on the home session chooser, whose
    entire purpose is seeing what each session is doing.
  - Quick-reply chips and their palette editor.
  - PointerContextMenu (large-screen secondary click).

ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.

The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.

Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.

The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.

And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.

Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.

Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
2026-07-30 11:23:23 +02:00
Yaojia Wang
4892fa7b49 feat(sessions): surface tmux sessions the server does not track
A `web_*` tmux session that outlives the process which created it was unreachable
from the UI, permanently. Nothing enumerated tmux — src/session/tmux.ts had
hasSession but no list — so recovery only worked if a client still had the id in
localStorage. list() walks the in-memory table, and so does reapIdle, so such a
session could not be listed, previewed, joined or killed, and never aged out.

On this host that was 69 tmux sessions with 5 clients: 64 unreachable, the oldest
from 26 Jun, one of them still running a Claude Code session with 7 agents.

This is the "catalogue" approach: make them visible and actionable WITHOUT
adopting them. Nothing is attached, because attaching makes tmux resize the window
to the new client's dimensions and SIGWINCH whatever is running inside — doing
that to dozens of live TUIs at startup is exactly the outcome to avoid. Adoption
stays an explicit user action: opening an orphan re-attaches by id through the
existing re-attach path, and it becomes an ordinary live session.

  src/session/tmux.ts   listSessions() + parseSessionList() (pure, so the filter
                        rules are unit-testable) and capturePane(), which reads a
                        session's current screen via `capture-pane -p -e` — no
                        client, no resize, colour preserved.
  src/session/manager.ts listOrphans/captureOrphan/killOrphan/killOrphansIdleSince
  src/server.ts         GET /orphan-sessions, GET /orphan-sessions/:id/preview,
                        DELETE /orphan-sessions/:id, DELETE /orphan-sessions
                        ?idleDays=N
  public/               a "Recoverable" section under the home grid, reusing the
                        existing card/thumbnail machinery via orphanAsLive()

Guards, all enforced in the manager so no route can forget one:
- the tmux name must be `web_` + a UUID v4 (SESSION_ID_RE, the same gate the
  attach protocol applies). This is what stops a hostile or malformed name from
  reaching `tmux -t` — a literal `-t`, or path traversal — and it means a tmux
  session the user created for their own work is never enumerated, never
  previewed and never offered for deletion.
- an id the table already owns is refused everywhere: killing it via tmux would
  end the shell behind a live PTY's back and leave a zombie table entry. Those go
  through killById.
- every function is inert when cfg.useTmux is off, so the non-tmux deployment is
  byte-for-byte unchanged.
- bulk cleanup skips ATTACHED sessions and requires idleDays >= 1: there is no
  "delete everything" form of the route. "This server does not track it" is not
  evidence that it is abandoned — a plain `tmux attach` and a second server
  process both report as attached.
- reads carry no Origin guard (same threat model as /live-sessions); both DELETEs
  do. Preview gets its own rate-limit bucket since each call spawns a process.

Two things the live run exposed and this fixes: previews were loading
sequentially (69 tmux spawns per 5 s refresh) — now once per card, fire-and-forget
— and the grid is capped at 24 cards because each thumbnail is an xterm instance.
The remainder is stated in the UI with the tmux command to reach it, never
silently truncated.

Separate wire type from LiveSessionInfo on purpose: an orphan supports none of the
live-session operations, and the Android/iOS clients decode /live-sessions, so a
variant shape there would break them.

Tests: 2203 unit (+42: parse/filter rules, all four manager guards, tmux-off
inertness) and 8 integration against real tmux — create a throwaway session,
list it, capture it while asserting it stays unattached, reject non-UUID ids,
reject a foreign Origin, kill it, 404 the second time. The integration file never
issues the bulk DELETE: it runs against the host's real tmux server and would end
the developer's own sessions. Verified live against all 69 with none harmed.
2026-07-30 10:40:10 +02:00
Yaojia Wang
35d32f4670 feat(android): decode the v0.6 git-panel surface
The server shipped the project-detail git panel in 8fe1f52/f711db9/042c4cd,
all after the last Android commit, so the client silently ignored every new
field (they are additive and optional, so nothing crashed — it just rendered
the pre-w6 shape: one bare dirty dot and no sync information at all).

Added: SyncState + ProjectDetail.sync/dirtyCount, ProjectInfo.dirtyCount,
ProjectSessionRef.cwd, GitLogResult.upstream, CommitLogEntry.unpushed, plus
GET /projects/worktree/state (read) and POST /projects/git/fetch (write).

Two rules from the server's own commit message are encoded as behaviour
rather than left to each call site to remember:

  `SyncState.isFullySynced(now)` is the only sanctioned way to ask whether
  the green all-clear may be rendered, and it answers false for anything
  unknown. ahead/behind compare against @{u}, a locally cached ref that only
  a fetch moves, so `behind = 0` from a stale FETCH_HEAD proves nothing. Most
  importantly NO UPSTREAM leaves both counts undefined — which is not zero —
  and that is the normal state of a fresh worktree branch. The server calls
  that fall-through the easiest bug to ship here; there is a test per way it
  could regress, including a fetch timestamp in the future, which is clock
  skew rather than evidence that anything was checked.

  `GitLogResult.upstreamBoundaryIndex` finds the LAST unpushed commit rather
  than assuming the unpushed ones come first. git log is date-ordered, so
  merging an older branch interleaves unpushed commits below pushed ones, and
  the row-count shortcut fails in the dangerous direction — it would label an
  unpushed commit as pushed. It returns null, not -1, when no boundary may be
  drawn, so "no upstream" cannot be mistaken for "boundary before everything".

Origin policy is asserted in both directions: worktree/state carries no
Origin, git/fetch carries a byte-equal one, and a test also asserts the fetch
body contains no remote or refspec — the remote is derived server-side
precisely so a client cannot aim a fetch at an arbitrary URL. Fetch has its
own rate-limit bucket, so a 429 is surfaced rather than retried, and a failed
fetch leaves lastFetchMs unknown so the UI keeps saying "stale" instead of
pretending it refreshed.

Verified: :api-client:test + koverVerify green; 22 new tests.
2026-07-30 09:45:59 +02:00
Yaojia Wang
3fe719f874 feat(android): decode the two server frames the client was throwing away
Both had shipped on the server for a while and Android silently discarded
them, which is worse than not supporting them — the UI looked correct while
missing information the user needed.

W2 `queue`: `ServerMessageType` had no member for it, so `decodeServer`
returned null and `SessionEngine.onFrame` counted it as undecodable and
dropped it. The "N queued" badge could therefore never appear on Android,
on any device, ever. Adding the branch made the compiler flag the
non-exhaustive `when` in the engine, which is the same gap seen from the
other side. A negative depth drops the frame rather than feeding nonsense
to a badge; the next broadcast carries the real value.

W1 `status.preview`: not modelled at all, so a remote approval was BLIND —
the gate could show that `Bash` wanted to run, but not what it wanted to
run, and the same for an Edit's diff. Approving blind defeats the point of
the remote gate, which exists so whoever is holding the phone can make an
informed call. `ApprovalPreview` now models both variants and
`GateState.preview` carries it to the UI.

Two decode rules are load-bearing and pinned by tests:

  - A broken preview must never cost the FRAME. `pending` is what makes the
    gate appear at all, so trading a missing preview for a missing gate
    would be a bad bargain. Every malformed shape — null, non-object, no
    kind, unknown kind, command without text, diff without a file — degrades
    the preview to null and still yields the Status.
  - Unknown values are kept, not coerced. A diff line kind or file status
    this client has never seen renders as itself; dropping a line we cannot
    classify would hide part of what is being approved. Only a diff file
    missing its paths is dropped, because then the preview cannot be trusted
    to describe the right file.

A sustained pending refreshes the preview without minting a new epoch: the
server's bounded render can arrive a frame after the gate rises, and a
decision tapped in between has to stay valid.

The preview's diff subset lives in :wire-protocol rather than reusing the
:app diff model — :session-core may not depend on :app, and new wire types
belong in the frozen contract module (CLAUDE.md).

Verified: :wire-protocol:test :session-core:test + both koverVerify green;
16 new tests (10 codec, 6 engine-level).
2026-07-30 09:39:50 +02:00
Yaojia Wang
0de5557921 docs(ios): freeze ios-completion coordination contracts (token/git-panel/signing) 2026-07-30 09:19:31 +02:00
Yaojia Wang
3e5cfdc1cf fix(android): make the terminal actually usable on a device
The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.

1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
   but never installed a `TerminalViewClient`, and the stock Termux view
   dereferences `mClient` with no null guard on the first line of the paths
   a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
   offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough:
   returning false continues into `mTermSession.write()` at offset 150, and
   `mTermSession` is null forever because `TerminalSession` is final and
   forking a process is what this app must not do. So the client consumes
   the key itself and `KeyRouting` has no defer-to-stock branch at all —
   the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
   stays the authority for the key tables, so DECCKM still emits `ESC O A`.

2. The PTY never learned the real size. Nothing called
   `RemoteTerminalSession.updateSize`, and the stock fallback is dead
   (`TerminalView.updateSize` early-returns without a session), so every
   full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
   it from real cell metrics — read through the public `getFontWidth()` /
   `getFontLineSpacing()` accessors rather than reflection, which would
   break silently under R8, or a re-measured Paint, which would drift from
   what the renderer actually draws (plan risk R5).

3. Bare-LAN connections were impossible. `network_security_config.xml` was
   a self-labelled STUB denying all cleartext while `HostEndpoint` accepts
   http and derives ws://. The format has no CIDR syntax, so the allowlist
   its own comment promised cannot be written; cleartext is permitted in
   base-config, the tunnel domain keeps a TLS block, trust anchors are
   pinned to system-only, and the guard moves to the §5.4 pairing tiers.

Adversarial review then found three more, and one it got wrong:

  - Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
    path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
    into `handleKeyCode` whenever the alternate buffer is active, and that
    dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
    this was a crash during normal use on the app's main screen.
    `TerminalScrollGesture` claims the vertical drag first and reproduces
    all three `doScroll` branches, closing the fling runnable and
    `onGenericMotionEvent` with it.
  - `autofill()` dereferences `mTermSession` unguarded while the view
    advertises itself autofillable, so any password manager crashed it.
    The subtree is excluded from the autofill structure.
  - The review asked for stock text selection to be restored. It cannot be:
    the ActionMode's own Copy and Paste handlers dereference the absent
    session, so a reachable selection UI has an NPE on its Copy button.
    Long press stays consumed and copy-out is recorded as a feature gap.

Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal
failure persists nothing rather than falling back to plaintext), and
`allowBackup=false` keeps a 30-day shell credential off Google Drive.

Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
2026-07-30 08:59:01 +02:00
Yaojia Wang
ffc185aa2d fix(desktop): stop shipping node-gyp, which broke codesign
`npm run dist:mac` failed at the signing step:

  codesign --timestamp .../node_modules/node-gyp/gyp/pylib/gyp/generator/\
    __pycache__/make.cpython-313.pyc
  → "A timestamp was expected but was not found."

node-gyp only COMPILES native modules. Nothing loads it at runtime — node-pty
resolves its prebuilt .node directly, and a grep over the packaged tree finds no
require of it outside the package itself. But it vendors a Python tree, and
`npmRebuild: true` runs it and leaves __pycache__/*.pyc behind. Those got copied
into Contents/Resources and codesign then tried to sign Python bytecode, which
takes the entire build down.

Excluding it removes both the dead weight (5 MB) and the failure surface. Also
excludes **/__pycache__ outright, so no Python bytecode can reach the bundle by
another route.

Verified on a real build: signature verifies --strict, node-gyp absent, zero .pyc
in the bundle, public/style.css and build/main.js byte-identical to the repo,
express/ws/web-push/qrcode/google-auth-library all still resolve from the
packaged tree, and node-pty's Electron-ABI pty.node is in place.

Note: the immediate failure was transient — a plain retry of the same config
succeeded (Apple's timestamp server). So this is the durable fix for a landmine
that would otherwise re-arm on every npmRebuild, not the cause of that one run.

Deviation from the one-worktree-per-session rule: the desktop build packages from
desktop/node_modules and its script does `cd .. && npm run build`, so it can only
be exercised in the main checkout. Committed on develop directly.
2026-07-30 08:27:25 +02:00
Yaojia Wang
029bec831a docs(progress): log the design-fidelity pass + the stale-desktop-bundle diagnosis 2026-07-30 07:59:56 +02:00
Yaojia Wang
e506d0dbbf Merge panel-fidelity-leftovers: last four design gaps + readable projects.ts diffs 2026-07-30 07:57:19 +02:00
Yaojia Wang
dba83559d3 fix(projects): close the last four git-panel design gaps
The low-severity leftovers from the panel audit, plus the tooling trap that made
reviewing this work harder than it needed to be.

.gitattributes (new) — `public/projects.ts diff`
  That file embeds literal NUL bytes on purpose: the namespace sentinels
  '\0active' / '\0other' are prefixed so they cannot collide with a real
  `First.Second` key. git therefore classified it as binary and printed
  "Binary files differ" for every diff, and `git diff --stat` reported
  "Bin 66815 -> 68701 bytes" instead of line counts — the file was effectively
  unreviewable. The `diff` attribute makes git diff it as text without setting
  `text`, so no line endings are normalised and the bytes on disk are untouched.
  (Same trap in the shell: grep prints only "Binary file ... matches", which
  reads as a no-match. The comment in the file says to use grep -a.)

Detached HEAD now shows its short sha
  The Head cell held the "detached HEAD" chip and nothing else: it said what was
  wrong and nothing about where you were. SyncState gains an optional `head`,
  populated from the .git/HEAD text readSyncState ALREADY reads for the detached
  test — so this costs no extra I/O and no extra git call. Guarded by a hex regex
  and rendered as textContent.

One geometry for the ghost button role
  The design defines two button roles. The outline one had drifted to three
  different sizes (.proj-wt-open at 11.5px/4px 12px/6px, .proj-diff-toggle at
  12.5px/7px 15px/7px) and both used full-strength --text instead of the muted
  label. All four outline buttons are now declared once, together, at the
  design's geometry; the duplicate .proj-diff-toggle block further down the file
  is gone, since a second block at equal specificity is exactly how the role
  drifted in the first place.

Header branch chip joins the chip language
  .proj-branch was the only chip with no border and no mono face, and 1px tighter
  all round, so the detail header's branch did not match the branch chips in the
  worktree list right below it.

"Changes" heading over the View Diff toggle
  Every other block on the page is introduced by a section heading. Without one
  the toggle hung 4px off the bottom of the sync band and read as part of it.

Tests: 2167 unit (4 new — detached sha present/absent on both the server and the
panel) and 27 integration, twice.
2026-07-30 07:57:00 +02:00
Yaojia Wang
a25fe30f1a Merge panel-design-fidelity: close the git-panel design gaps 2026-07-29 20:47:38 +02:00
Yaojia Wang
950d2298a1 fix(projects): close the design gaps in the git panel
An audit of the project-detail panel against docs/mockups/project-detail-git.html
turned up 59 differences. The ones that changed what you see:

Controls that had class names but no rules, so they rendered as raw OS widgets:
- .proj-wt-form had no layout at all — the branch input and Create Worktree
  button sat flush together, and the button jumped to its own line the moment a
  validation error appeared between them.
- .proj-wt-error / .proj-wt-actions-error had no rule, so a validation failure
  rendered as 16px near-white body text, indistinguishable from content.
- .proj-wt-remove (the worktree ✕) and .proj-wt-prune had no rule either.
- .proj-fanout-mode never reset `appearance`, so WebKit kept its own light
  dropdown — the one control in the panel that stayed a native grey widget.
- --danger was never defined, so every validation message fell back to an
  off-palette red.

Cascade bugs — a later rule at equal specificity was silently winning:
- .proj-wt-row-current's amber wash and border were completely dead. "You are
  here" rendered identically to every other row.
- .proj-wt-branch inherited the 16px user-agent default in the UI sans, making a
  branch name the largest text on the page. Now 13.5px mono.

Layout:
- .proj-syncband dropped flex-wrap. Wrapping produced ragged half-empty rows
  between 721px and 1000px — at 900px the Fetch button dropped alone onto a
  second row. One cell now absorbs the shrink so the band stays a single row
  down to the 720px column break.
- Commit rows are a 4-column grid, not flex, and the ↑ mark cell is rendered on
  every row instead of only unpushed ones. Previously no two lines agreed on
  where the sha started, so the unpushed group did not read as a group.
- Pushed commit subjects step down to --text-dim, which is what makes the
  upstream boundary mean anything.

Honesty of the sync band — the design's two hard rules:
- Green "✓ in sync" was reachable when ahead/behind were UNKNOWN, because
  `?? 0` folded undefined into zero. Unknown counts now render "↑ —" / "↓ —"
  and are marked unverified. Green requires real zeros and a fresh fetch.
- A stale ↓ painted the digit warning-red. Since a missing FETCH_HEAD is the
  default state, the largest glyph in the band was almost always red and the
  panel read as "this repo is broken". The doubt now sits on the `unverified`
  chip and the footnote, never on the number.

Also: semantic tokens (--sunk/--warn/--ok/--dirty/--mono-font) replacing hex
literals, one chip language across the panel, section headings as tracked caps
with the count as a separate lighter element, and a ≤720px rule that drops the
age column — placed after the base rule, since a media query adds no specificity.

Verified in a real browser at 1280/1000/900/780/721/600/420px: the sync band
holds one row down to 721px and stacks below it, commit columns align on every
row, and nothing overflows or scrolls the page horizontally.
2026-07-29 20:35:07 +02:00
Yaojia Wang
c3613c2fae Merge wt-path-alignment: path sits under its branch, not across the card 2026-07-29 18:43:18 +02:00
Yaojia Wang
609e7cea69 fix(projects): stop stranding the worktree path across the card
margin-left:auto was correct for the old single-line row, where it pushed the
path to the far right of a flex row. The two-line card stacks branch over path
in a column, so the same declaration shoved the path to the opposite edge from
the branch it labels. Removed, with a note saying why it must not come back.

Same shape as the .proj-wt-row duplicate earlier in this series: an element
reused in a new layout kept a positioning declaration that only made sense in
the old one. Caught by screenshot; the DOM assertions could not see it.
2026-07-29 18:43:05 +02:00
Yaojia Wang
2066631c0a Merge projects-control-styling: dark-theme controls, real theme tokens 2026-07-29 18:39:44 +02:00
Yaojia Wang
3c0b2f6bae fix(projects): style the panel controls and use the real theme tokens
Two things, both invisible in code review and obvious on screen.

1. Eight controls in the project detail had class names but no CSS rule at
   all, so they rendered as raw user-agent widgets — white input boxes and grey
   buttons sitting inside a dark panel: View Diff, the new-branch input, Create
   Worktree, and the five fan-out fields. Gave them the skin the panel already
   established, with two button roles: filled accent for the control that
   commits a form (matching Fetch), outline for one that only reveals or
   toggles (matching Open). Layout stays in each element's existing rule; this
   block sets only the shared skin, so no property is declared twice.

2. The w6 CSS referenced --fg, --fg-dim, --fg-faint, --bg-sunk and --mono,
   none of which exist in :root. Every one of those 19 references was silently
   falling through to its hardcoded fallback, which happened to match the
   default amber theme — so it looked correct while being wired to nothing.
   Under any other theme the sync band would have kept the default colours
   while the rest of the app changed. Rewired to the real tokens: --text,
   --text-dim, --text-faint, --surface-1, --on-accent, and the mono stack the
   file already uses.

The fallback values are what hid this: a var() fallback turns a typo into a
silent success. Worth remembering when reaching for one.

Verified: tsc clean, npm test green (78 files / 2161, e2e 27), bundle rebuilt.
2026-07-29 18:39:34 +02:00
Yaojia Wang
e004da1a55 Merge git-panel-layout-fixes: right-pinned worktree actions, count on the heading 2026-07-29 18:30:09 +02:00
Yaojia Wang
042c4cd4a9 fix(projects): pin the worktree actions right and put the count on the heading
Two layout defects the screenshots caught that the DOM tests could not.

1. `.proj-wt-row` was declared twice — my two-line-card rule earlier in the
   file, the original flex rule later. Same specificity, so the later copy won
   and the Open button sat against the text instead of at the card's right
   edge. Folded the grid into the ORIGINAL rule and left a note where the
   duplicate was. One rule per selector; a second declaration of a layout
   property is a silent override waiting to happen.

2. The unpushed count rendered as its own line under "Recent commits". The
   design has it ON the heading, because it qualifies that heading rather than
   making a separate statement. renderGitLog now takes the heading element and
   writes the badge there, clearing any previous badge first so a re-render
   cannot stack duplicates.

Both were invisible to the existing assertions: they checked that the elements
exist and carry the right text, which was true in each case. Added a test for
the no-duplicate-badge path; the right-edge alignment is a pure CSS outcome and
is verified by screenshot, not by the suite.
2026-07-29 18:29:53 +02:00
Yaojia Wang
c8d12780b9 Merge git-panel-design-fidelity: match the approved design
Closes the three deviations between the shipped git panel and
docs/mockups/project-detail-git.html: the four-cell sync band with its
explanatory footnotes, the dirty count on the title line, and worktree rows as
two-line cards with a working Open button. Adds structural assertions so
layout drift is caught by the suite, not by eye.
2026-07-29 18:23:02 +02:00
Yaojia Wang
f711db9315 fix(projects): build the git panel the way the design specifies
The shipped panel was functionally right — every number matched git — but it
was not the approved design. I implemented from the plan's semantics (which
state is green, when to flag stale) and let the mock's layout drift, and
nothing caught it: the tests asserted behaviour, and no assertion covered
structure. Three deviations, now closed against docs/mockups/project-detail-git.html.

1. The sync band was compressed into one row of chips. Restored to the four
   labelled cells: Upstream / To push / To pull / Fetch, with display-size
   numerals and a footnote under each count. The footnotes are load-bearing,
   not decoration — "Last fetched 19d ago — this number cannot be trusted" is
   what tells a reader WHY the zero is suspect. Compressed to
   "fetched 19d ago", the causal link was left for the reader to infer, and
   not having to infer it is the entire point of the panel. The unverified
   state is a chip on the count again, so the doubt attaches to the digit.

2. The working-tree count sat inside the band. Moved to the title line beside
   the branch, where the design puts it: the band is about the remote, and the
   dirty count is not. Reads "● 3 uncommitted" instead of a bare dot; the dot
   remains as the fallback when a server sends no count.

3. Worktree rows were the old single line with the path right-aligned against
   the chips. Rebuilt as the two-line card the design calls for — identity and
   state on top, path underneath — plus the Open button, which was specified
   and simply missing, so a worktree row could be read but not entered. Open
   reuses the detail view pointed at the worktree path: a linked worktree is a
   project directory, so this needs no new concept and no new route.

Also added the unpushed count beside the commit-list heading, so the number is
readable before the eye walks the rows hunting for rails.

Tests now assert structure, not just behaviour: cell captions exist, the stale
footnote says why, counts render at display size, the path is outside the chip
row, and Open fires with its worktree. That is the gap that let the drift
through, so it is the gap that is now covered.

Verified: tsc and build clean; npm test green (unit 78 files / 2159, e2e 27).
2026-07-29 18:22:50 +02:00
Yaojia Wang
e81c426329 Merge w6-project-git-panel: ambient git state on the project detail page
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
Adds the sync band (upstream, ↑ to push, ↓ to pull, staleness), the unpushed
boundary in the commit list, a reworked worktree panel with per-row state and
session counts, and a read-only POST /projects/git/fetch so the numbers can be
refreshed without a pull.

The governing rule throughout: only one state may look reassuring — even with
upstream and a fetch inside the hour. behind is derived from a locally cached
ref, so a stale zero is a guess and is marked as one; no upstream renders as
'no upstream', never as synced.

Also fixes the test suite's long-standing flakiness (5-11 shifting failures per
run) so the feature could actually be gated on it.
2026-07-29 17:12:36 +02:00
Yaojia Wang
cc811dd18d test: stop the suite flaking on real-git and real-PTY fixtures
`npm test` was failing 5-11 tests per run with the set shifting between runs,
which made it useless as a gate. Two independent causes, neither of them a
product defect.

1. Fixtures had outgrown vitest's 5 s default. The git ones spawn 6-12
   sequential `git` processes (init/config/commit/clone/push); the real-server
   ones boot a server and a shell. Alone they finish easily; under a full
   parallel run they do not, and the failure reads `Test timed out in 5000ms`.
   Gave those describes an explicit 30 s ceiling, and the real-PTY waits a
   named PTY_WAIT_MS. The deliberate short bounds in the "must be rejected"
   handshake tests are left alone — there a timeout IS the assertion.
   (The same rebudgeting also touched the test files in the preceding commit.)

2. test/integration/server.test.ts cannot share the machine with the rest of
   the suite. It asserts on real prompt output within seconds, which is not
   achievable while ~8 workers saturate the box: it passed alone (27/27,
   repeatedly) and flaked in the full run. Raising the numbers further only
   moved the flake around, so this is fixed as a scheduling problem — `npm
   test` now runs two passes, `test:unit` (everything else, parallel) then
   `test:e2e` (that file on its own). `vitest run` still runs everything at
   once for anyone who wants that.

Also bounded the srv.close() in H1's finally. Unbounded, it swallowed whatever
really went wrong in the body — the inner waits threw, control jumped to the
finally, close() blocked, and the case reported a bare timeout instead of its
own diagnosis. The root-causing above only became possible after making that
failure legible.

Note on the `[needs real PTY (sandbox-off)]` labels: those cases were NOT
skipping here. PTY_AVAILABLE is true on this machine, so they were running and
failing on timing, not being gated out by a sandbox.

Verified: npm test green end to end across repeated runs (unit 78 files /
2147 tests, e2e 27).
2026-07-29 17:12:16 +02:00
Yaojia Wang
8fe1f52e5d feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.

One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:

  - `ahead` needs only local refs and is never flagged.
  - `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
  - Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
    "I checked, ignore this"; getting it wrong is lying to the user.
  - No upstream (the normal state of a fresh worktree branch) leaves ahead and
    behind undefined — it renders an explicit `no upstream`, never the green
    path. That fall-through is the easiest bug to ship here.

What landed:

G1  SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
    and .dirtyCount. All additive and optional — the Android and iOS clients
    decode these shapes. The ahead/behind helper already existed for the list
    view; buildProjectDetail had simply never called it.
    Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
    directly, so it returned nothing inside a LINKED worktree, where .git is a
    file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
    the shared common dir (FETCH_HEAD).

G2  POST /projects/git/fetch. Same discipline as push: the remote is derived
    server-side and no remote or refspec is ever read from the body, so a
    client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
    working tree, no index, no merge; it is not a pull. Own rate-limit bucket
    so refreshes cannot eat the budget a real push needs. On failure
    lastFetchMs is left alone, so the UI keeps saying "stale" instead of
    pretending it refreshed.

G3  makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
    dirty count, Fetch button (disabled on a detached HEAD).

G4  The commit list marks unpushed commits and draws the upstream boundary
    once, after the last of them. Marking is server-side from `rev-list`,
    deliberately NOT "the first N rows": `git log` is date-ordered, so merging
    an older branch interleaves unpushed commits BELOW pushed ones, and that
    shortcut fails in the dangerous direction — calling an unpushed commit
    pushed. A regression test builds exactly that backdated-merge shape.

G5  The worktree section is always "Worktrees (n)" (it used to rename itself
    to "Branch" at n=1) and the current row carries its own state chips.

G6  Cost control. The plan called for a .git-mtime cache; that was dropped
    during implementation because a fingerprint over HEAD/index/reflog does
    NOT move when push updates a remote-tracking ref — the cached `ahead`
    would still claim "9 to push" right after a successful push, which is the
    exact lie the feature exists to prevent. Replaced with three measures that
    cannot go stale: in-flight coalescing (N devices watching one repo cost
    one probe, entry dropped as it settles, nothing cached across time),
    skipping the re-render when the payload is byte-identical (this also stops
    the 5 s re-mount of the commit log, two more git spawns per tick), and
    pausing the timer while the document is hidden.

G7  Per-worktree state via GET /projects/worktree/state, kept narrower than
    /projects/detail so N rows do not pay for worktree listing and CLAUDE.md
    reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
    carried no cwd, so sessions could not be attributed to a worktree. Added
    it, plus countSessionsByWorktree, which matches DEEPEST-first because
    .claude/worktrees/<name> lives INSIDE the main checkout and prefix
    matching would count every worktree session against the parent repo too.

Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.

Verified: tsc and build clean; 46 new tests.
2026-07-29 17:12:00 +02:00
Yaojia Wang
7db7be456c chore: track .claude/settings.json for the worktree base ref
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
`worktree.baseRef: head` is the setting CLAUDE.md's session workflow depends on:
without it EnterWorktree defaults to `fresh` and branches from `origin/main`,
which trails `develop` by ~75 commits. Shared config, so it belongs in the repo —
personal overrides still go in the ignored `.claude/settings.local.json`.
2026-07-29 11:14:12 +02:00
Yaojia Wang
1137090626 chore: adopt one-worktree-per-session workflow
Sessions that change files now work in their own git worktree and merge back to
`develop`, instead of developing directly in the main checkout. Documents the
tool's actual behaviour rather than the assumed one: `EnterWorktree` prefixes the
branch as `worktree-<name>`, branches from the last commit (so uncommitted edits
do not carry over), and `ExitWorktree({action: "remove"})` deletes the branch
with the directory — hence the merge must happen before the cleanup.

Base ref is pinned to `head` in `.claude/settings.json` because `develop` runs
well ahead of `origin/main`, and the default `fresh` would branch from the stale
release trunk.

Also ignores `.claude/worktrees/`, which lives on disk per session and is never
committed.
2026-07-29 11:13:26 +02:00
Yaojia Wang
553a00c32f docs(progress): log the three leftover fixes + live verification 2026-07-29 10:45:46 +02:00
Yaojia Wang
d92caedaee Merge fix-tunnel-leftovers: gitignore-swallowed source, host identifiers in logs, device recovery
Closes the three leftovers called out after the renewal-deadlock fix: a source
file the blanket `dist/` ignore rule kept out of every checkout, tunnel logs
that named no host (including all 6380 warnings during the outage), and the
missing phone-track counterpart to /recover.
2026-07-29 10:41:49 +02:00
Yaojia Wang
2a602d5289 fix(tunnel): close the three leftovers from the renewal-deadlock fix
1. `.gitignore` swallowed a source file. `agent/src/dist/buildBinary.ts` is the
   packaging config, not build output, but the blanket `dist/` rule meant it was
   never committed — a fresh clone could neither typecheck `agent/src/index.ts`
   nor import it from the committed `agent/test/buildBinary.test.ts`. Re-included
   the DIRECTORY first (git does not descend into an excluded one, so un-ignoring
   just the file would not have worked) and committed the file. Verified build
   output under `agent/dist/`, `dist/`, `public/build/` is still ignored.

2. Tunnel logs named no host. `pair` learned hostId/subdomain from the enroll
   response and threw them away, so the long-running `run` process logged
   `{"subdomain":null,"hostId":null}` — including all 6380 warnings during the
   8-day outage, at exactly the moment you want to know which host. New
   `config/hostRecord.ts` persists them at enrol; `resolveHostIdentity` resolves
   config > record > the subdomain embedded in the leaf's SPIFFE SAN, so hosts
   enrolled before the record existed get an identifier back without re-pairing.

3. The phone track had no recovery path. Added `POST /device/:id/recover`,
   mirroring the host route: cert in the body, device-CA path validation, SPIFFE
   parse, `notBefore` never graced, and the full registry check (active + same
   account + `:id` matches the cert + same key) — only `notAfter` is relaxed.

Verified: agent 300/300, control-plane 296/296, tsc clean on both.
NOTE: the iOS/Android clients are not wired to call /device/:id/recover yet —
the server capability exists, the client-side trigger does not.
2026-07-29 10:40:15 +02:00
Yaojia Wang
befe677759 Merge fix-cert-renew-deadlock: break the expired-leaf renewal deadlock
The tunnel had been down 8 days because /renew is mTLS-authenticated by the very
leaf it renews, so a lapsed leaf could never renew itself. Recovery now runs over
plain HTTPS with the expired cert in the body (CSR self-signature still proves key
possession), bounded by a 30-day grace; past that the agent stops retrying and
names the re-pair. Verified live end-to-end: an 8-day-expired leaf self-healed and
the tunnel returned HTTP 200 with no human action.
2026-07-29 10:09:35 +02:00
Yaojia Wang
7064a39bf1 chore: drop accidentally committed node_modules symlinks
They were local worktree conveniences (symlinks into the main checkout so vitest
could resolve the file: workspace deps), swept in by a broad `git add agent`.
2026-07-29 10:09:28 +02:00
Yaojia Wang
0970c623eb docs(progress): log the expired-leaf renewal deadlock fix + live self-heal proof 2026-07-29 10:08:58 +02:00
Yaojia Wang
5509c81eee fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS
Correction to the previous commit. Its recovery vhost could not work: nginx
will not forward an expired client certificate in ANY `ssl_verify_client` mode.
`optional` answers a bare `400 The SSL certificate error` before any location
runs, and `optional_no_ca` only tolerates CHAIN failures — see nginx's
`ngx_ssl_verify_error_optional()`, which covers self-signed / unknown-issuer /
unverifiable-leaf and NOT `X509_V_ERR_CERT_HAS_EXPIRED`. Verified live against
the deployed :8472 server, which rejected the real expired leaf.

So recovery drops mTLS instead of trying to bend it:

- agent: `buildTlsOptions` and the mTLS renew transport go back to being
  strictly fail-closed on expiry — the relaxation is gone from the TLS layer
  entirely. The rotator now decides per attempt: valid → mTLS `/renew`,
  expired-inside-grace → plain `/recover`, expired-beyond-grace → terminal
  `onExhausted` with no request issued at all.
- new `recoverCert` POSTs `{cert, csr}` with no client certificate. Possession
  of the private key is still proven: the CSR is self-signed by it and the host
  signer already enforces CSR PoP plus `CSR key == registered key`, so a
  replayed (public) cert without the key yields at most a certificate the
  attacker cannot authenticate with.
- control-plane: `/renew` is strict again (grace 0, matching the terminator).
  The grace lives on the new `POST /recover`, which reads the cert from the BODY
  and ignores the `x-client-cert` header, then runs the identical trust
  pipeline: X.509 path validation to the frp-client-CA anchors, SPIFFE parse,
  `notBefore` (never graced), registry `active` + account match. Revocation
  still bites.
- deploy: no new vhost, no new SNI, no DNS. One `location = /recover` merged
  into the existing enroll vhost, documented in
  `deploy/nginx/enroll-recover-location.md` with the nginx source citation.

The load-bearing new test is "a self-signed cert with a FORGED SPIFFE SAN is
refused → 401": nginx no longer validates the chain on this path, so that
assertion is what keeps `/recover` from being a cert vending machine.

Verified: agent 289/289, control-plane 290/290, tsc clean on both.
2026-07-29 09:52:17 +02:00
Yaojia Wang
f3f4d8baa6 fix(tunnel): break the expired-leaf renewal deadlock
`POST /renew` is authenticated by mTLS with the very leaf it renews, so once
that leaf lapsed the host could never renew it and the tunnel stayed down until
an operator re-paired by hand. Production hit exactly this: the Mac slept
through its 8h renewal window, the 24h leaf expired, and the agent then logged
`client certificate has expired; renew before dialling` 6380 times over 8 days
without recovering.

Three layers independently refused an expired leaf, so all three had to move:

- agent `buildTlsOptions` gains an opt-in `expiredGraceMs`. Absent/0 keeps the
  historical fail-closed behaviour, and the TUNNEL dial never passes it — only
  the renew transport does. Past the window it throws the new
  `CertExpiredBeyondGraceError`.
- agent rotator routes an already-expired leaf to a separate recovery endpoint
  and treats "beyond grace" as TERMINAL: report once via `onExhausted`, stop
  retrying, and name the fix (re-pair) instead of spamming warnings forever.
- control-plane `assertPresentedCertTrusted` grants a bounded grace on
  `notAfter` only. Chain validation, SPIFFE identity, `notBefore`, and the
  registry `active`/account checks are all unchanged, so revocation still bites.
- new `deploy/nginx/recover-mtls.conf` (:8472). The strict enroll vhost cannot
  host this: under `ssl_verify_client optional` nginx answers a bare 400 as soon
  as a presented cert fails verification, so an expired leaf never reaches the
  location — and the directive is server-level, not per-location.

Grace defaults to 30 days on both ends and is configurable (`RECOVER_URL`,
`expiredRenewGraceMs`). The honest trade is recorded in the code: a stale stolen
leaf stays reusable for the window, which widens an existing exposure (an
unexpired stolen leaf already renews indefinitely) rather than opening a new one.

Verified: agent 296/296, control-plane 286/286, tsc clean on both.
2026-07-29 09:41:03 +02:00
Yaojia Wang
b1bc50ccd1 fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK
A launchd/Finder-launched server has no LANG/LC_* at all, so tmux — which
decides per client whether the terminal is UTF-8 capable purely from those
variables — fell back to its non-UTF-8 mode and rewrote the byte stream
server-side, before it ever reached xterm:

  - every wide character became `_`  (中文测试 → ________)
  - `✓` / `⏺` / emoji became `_`
  - `═║╔╗` was downgraded to DEC Special Graphics (ESC ( 0), so rounded
    corners vanished and boxes rendered as loose horizontal lines

The `-l` login shell cannot fix this: tmux is the PTY's root process and has
already made its decision by the time the shell sources ~/.zprofile.

withUtf8Locale() fills a locale in on the spawn env, and only when needed —
an existing UTF-8 LC_ALL / LC_CTYPE / LANG (zh_CN.UTF-8, ja_JP.UTF-8, …) is
left untouched; only a missing or non-UTF-8 charset is replaced.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:19:53 +02:00
572 changed files with 83659 additions and 1374 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"worktree": {
"baseRef": "head"
}
}

12
.gitattributes vendored Normal file
View File

@@ -0,0 +1,12 @@
# public/projects.ts embeds literal NUL bytes on purpose: the namespace-group
# sentinels ACTIVE_GROUP_KEY = '\0active' and OTHER_GROUP_KEY = '\0other' are
# prefixed with NUL so they can never collide with a real `First.Second` key.
#
# git classifies any file containing NUL as binary, so `git diff`/`git show`
# print "Binary files differ" and review of this file is impossible. The `diff`
# attribute tells git to diff it as text anyway. It does NOT set `text`, so
# nothing is line-ending-normalised and the bytes on disk are untouched.
#
# Same trap in the shell: `grep` prints only "Binary file ... matches" and looks
# like a no-match. Use `grep -a` on this file.
public/projects.ts diff

184
.github/workflows/android.yml vendored Normal file
View File

@@ -0,0 +1,184 @@
# Android CI (F2). Until this file existed the Android client had ZERO CI — 10 modules,
# 691 JVM tests and the whole `WEBTERM_TOKEN` secret-handling wave (Tink AEAD under an
# AndroidKeystore master key, the POST /auth probe, cookie stamping on every route and on
# the WS upgrade) were gated by nothing at all. Modelled on ios.yml: same `on:` shape
# (push + pull_request, path-filtered), same "one job per verification layer" structure,
# and — like ios.yml — deliberately NO `concurrency:` group, so the two workflows behave
# the same way on rapid pushes.
#
# ── HONESTY NOTE (read before trusting a green badge) ────────────────────────────────────
# This repository's only remote is a self-hosted Gitea instance, so GitHub Actions has
# NEVER run here and this file has never been executed by the platform. What IS verified:
# every command below was run locally on macOS against the same Gradle build
# (`./gradlew test :app:assembleDebug koverVerify koverLog` + the androidTest compile),
# and the YAML parses. What is NOT verified: the runner-side pieces — action resolution,
# the preinstalled Android SDK layout on `ubuntu-latest`, and the emulator leg.
#
# ── Why no `src/**` path trigger (ios.yml HAS one) ───────────────────────────────────────
# ios.yml watches `src/**` because it owns `integration-tests`, a Swift layer that boots the
# real Node server and would catch client↔server protocol drift. Android has no equivalent
# leg (android/README.md, "DEFERRED — end-to-end access token": nothing here starts a server
# with WEBTERM_TOKEN set and completes a cookie-gated WS upgrade). Triggering on `src/**`
# would therefore burn runner minutes without checking a single contract. When an Android
# server-contract leg is added, add `src/**` here at the same time.
name: android
on:
push:
paths:
- "android/**"
- ".github/workflows/android.yml"
pull_request:
paths:
- "android/**"
- ".github/workflows/android.yml"
# Manual trigger for the instrumented (device) leg below, which is dispatch-only.
workflow_dispatch:
jobs:
# Layer 1 — everything that runs without a device. This is exactly the green gate
# android/README.md documents ("./gradlew test :app:assembleDebug koverVerify"), plus a
# compile-only pass over the instrumented sources so they cannot silently rot.
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 45
defaults:
run:
working-directory: android
steps:
- uses: actions/checkout@v4
# jvmToolchain(17) is declared by every module; Gradle resolves it from the
# installed JDKs, so 17 must actually be present (the runner image's default may
# be a different major).
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
# :app/:terminal-view/:host-registry/:client-tls-android compile against SDK 36 with
# build-tools 36.0.0 (android/README.md "Android SDK setup"). The runner image ships an
# Android SDK, but which platforms it holds drifts image to image — so install what the
# build actually needs instead of assuming, and FAIL LOUDLY if there is no sdkmanager.
- name: Install the Android SDK packages this build compiles against
run: |
# `set -eu` WITHOUT pipefail on purpose: `yes | sdkmanager --licenses` kills `yes`
# with SIGPIPE (141) as soon as the prompts stop, and pipefail would report that
# normal ending as a failed step.
set -eu
sdkmanager_bin="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sdkmanager_bin" ]; then
sdkmanager_bin="$(ls -1 "$ANDROID_HOME"/cmdline-tools/*/bin/sdkmanager 2>/dev/null | tail -n 1 || true)"
fi
if [ ! -x "$sdkmanager_bin" ]; then
echo "::error title=No sdkmanager::none found under $ANDROID_HOME/cmdline-tools — the Android SDK is not usable on this runner" >&2
exit 1
fi
yes | "$sdkmanager_bin" --licenses > /dev/null
"$sdkmanager_bin" "platforms;android-36" "build-tools;36.0.0" "platform-tools"
# 10 modules of JVM unit tests (JUnit 5 + coroutines-test + Turbine + MockK). Includes
# the Android-library modules' testDebugUnitTest and :app's — no device needed.
- name: JVM unit tests (all modules)
run: ./gradlew test
# The APK build is its own step: a Kotlin/KSP/Hilt/Compose breakage that unit tests do
# not reach (resources, manifest merge, DI graph assembly) must not hide behind them.
- name: Debug APK builds
run: ./gradlew :app:assembleDebug
# Coverage floor: >=80% line coverage on the four modules that apply the Kover plugin
# (:wire-protocol, :session-core, :api-client, :client-tls). koverLog prints the actual
# percentages into the run log so a near-miss is visible before it becomes a failure.
# The Android-framework modules are NOT gated (no Kover on them) — a known gap.
- name: Coverage gate (Kover, >= 80% on the four gated modules)
run: ./gradlew koverVerify koverLog
# The instrumented sources cannot RUN here, but they must keep COMPILING. Three modules
# carry them, 119 @Test in total: `:app` (67 — the device-blocker suite: key routing,
# resize, alternate-screen scroll, autofill, driven through a custom HiltTestRunner),
# `:host-registry` (31 — incl. TinkAccessTokenStoreTest, the only assertion anywhere that
# the access token is ciphertext at rest) and `:client-tls-android` (21 — AndroidKeyStore
# import). A silent compile break would delete all of that without anyone noticing.
- name: Instrumented test sources still compile
run: ./gradlew :app:assembleDebugAndroidTest :host-registry:assembleDebugAndroidTest :client-tls-android:assembleDebugAndroidTest
- name: Upload test + coverage reports
if: failure()
uses: actions/upload-artifact@v4
with:
name: android-reports
path: |
android/*/build/reports/tests/**
android/*/build/test-results/**
android/*/build/reports/kover/**
retention-days: 7
if-no-files-found: ignore
# Layer 2 — instrumented (device) tests. DISPATCH-ONLY, on purpose.
#
# What is at stake: `TinkAccessTokenStoreTest` is the ONLY test anywhere that proves the
# frozen at-rest properties of the access token (ios-completion §1.1) — that a fresh store
# instance decrypts it after a cold start, and that the bytes on disk are ciphertext rather
# than the token in the clear. Tink's AEAD + the AndroidKeystore-wrapped master key have no
# JVM double, so this can only run on a device or emulator.
#
# The suite itself is NOT unproven: the android-blocker-fixes session ran `:app`'s 67
# instrumented tests green on real hardware. What has never been proven is this leg — the
# CI *emulator* path. Those are different claims and only the second one gates this `if:`.
#
# Why it is not on push: this workflow has never executed on any runner (see the honesty
# note at the top), and an emulator leg is the single most fragile thing in Android CI —
# it needs KVM, a system image download, and an AVD boot. Wiring an unvalidated, ~15-minute,
# KVM-dependent job into every push would either block the repo on a leg nobody has seen
# pass, or push people to make it non-blocking — and a leg that cannot fail is a false green
# (the same trap ios.yml's `ios17-floor-tests` comment calls out).
#
# THEREFORE: run it by hand from the Actions tab (workflow_dispatch) whenever the token
# store, the DataStore stores, the AndroidKeyStore importer, or the terminal view/input
# path change. Once it has been seen green on a real runner, promote it by deleting the
# `if:` below — the suite has already earned it on hardware.
#
# MANUAL EQUIVALENT (the path that has actually been walked — DEVICE_QA_CHECKLIST.md):
# with a device/emulator attached,
# cd android && ANDROID_HOME=<sdk> ./gradlew :app:connectedDebugAndroidTest \
# :host-registry:connectedDebugAndroidTest \
# :client-tls-android:connectedDebugAndroidTest
# (StrongBox falls back to software keys on an emulator, so hardware-backed key claims still
# need real hardware.)
instrumented-tests:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Enable KVM for the emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# API 29 == the app's minSdk: the deployment floor is where at-rest crypto behaviour is
# most likely to differ (mirrors ios.yml's iOS 17 floor leg).
- name: connectedDebugAndroidTest on an API 29 emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 29
target: google_apis
arch: x86_64
working-directory: android
script: ./gradlew :app:connectedDebugAndroidTest :host-registry:connectedDebugAndroidTest :client-tls-android:connectedDebugAndroidTest
- name: Upload instrumented reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-instrumented-reports
path: android/*/build/reports/androidTests/**
retention-days: 7
if-no-files-found: ignore

View File

@@ -6,8 +6,9 @@
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected # the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh # per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*). # (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle, # 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
# iPhone 16 simulator): ViewModels/components of the app glue layer. # an iPhone AND an iPad simulator: ViewModels/components of the app glue
# layer, on both the compact and the regular size class.
# 3. integration-tests — Swift Testing against the REAL Node server. The # 3. integration-tests — Swift Testing against the REAL Node server. The
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral # ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
# loopback port (127.0.0.1:<free-port> is always in the derived Origin # loopback port (127.0.0.1:<free-port> is always in the derived Origin
@@ -35,14 +36,15 @@ on:
jobs: jobs:
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate. # Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs # The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
# tests below without a gate (test doubles are excluded from the gate). # mTLS identity/keychain package, previously the only ungated one at 55.76%).
# TestSupport runs tests below without a gate (test doubles are excluded).
package-tests: package-tests:
runs-on: macos-15 runs-on: macos-15
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
package: [WireProtocol, SessionCore, HostRegistry, APIClient] package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
@@ -56,47 +58,66 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages) - name: swift test (TestSupport — no coverage gate; it IS the test doubles)
run: swift test --package-path ios/Packages/TestSupport run: swift test --package-path ios/Packages/TestSupport
# Layer 2: app-target unit tests (WebTermTests, hosted by the app). # Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the
# compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive
# NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only
# compact). One matrix instead of two near-identical jobs.
#
# THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains
# LiveServerSmokeTests, whose SimServerHarness spawns
# `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no
# node_modules, so the harness throws
# setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci
# and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every
# run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the
# live-server smoke; the iPad leg SKIPS that suite instead of paying a second
# node-pty native build. Rationale: the iPad leg exists to exercise the app's
# regular-width layout, not to re-verify the client↔server contract, which is
# already covered three times over (this leg, integration-tests, ui-test).
# `-skip-testing` (rather than a test plan) keeps the change inside this file:
# test plans live in ios/project.yml, owned by another task.
app-tests: app-tests:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- leg: iphone
device: iPhone 16
needsNode: true
extraArgs: ""
- leg: ipad
device: iPad Pro 11-inch (M4)
needsNode: false
extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- uses: actions/setup-node@v4
if: matrix.needsNode
with:
node-version: 22
- name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx)
if: matrix.needsNode
run: npm ci
- name: Install XcodeGen - name: Install XcodeGen
run: brew install xcodegen run: brew install xcodegen
- name: Generate project - name: Generate project
run: cd ios && xcodegen generate run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPhone 16 simulator) - name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the # No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
# real data-protection keychain, which returns -34018 for UNSIGNED test # real data-protection keychain, which returns -34018 for UNSIGNED test
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix # hosts; simulator ad-hoc signing needs no certificates. (W5-fix
# handoff finding, verified locally in the ui-test leg runs.) # handoff finding, verified locally in the ui-test leg runs.)
run: | run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPhone 16' \ -destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
test ${{ matrix.extraArgs }} \
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
# exercised in CI, not only the compact iPhone path.
ipad-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
test test
# Layer 3: contract tests against the real Node server (T-iOS-16 test list). # Layer 3: contract tests against the real Node server (T-iOS-16 test list).
@@ -121,9 +142,27 @@ jobs:
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's # runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the # TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission). # server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
#
# iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch
# (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran —
# the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT
# suite: HappyPathUITests has no regular-width branch at all (no sidebar
# reveal), so running it on iPad would fail for a missing-feature reason rather
# than a regression — making the happy path iPad-adaptive is app-layer work,
# tracked separately.
ui-test: ui-test:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 45 timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- leg: iphone
device: iPhone 16
suites: ""
- leg: ipad
device: iPad Pro 11-inch (M4)
suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
@@ -159,7 +198,7 @@ jobs:
# inputAccessoryView — it only exists while the SOFT keyboard is up. # inputAccessoryView — it only exists while the SOFT keyboard is up.
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard) - name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator) - name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it # TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
# strips the prefix and injects <VAR> into the test-runner process); # strips the prefix and injects <VAR> into the test-runner process);
# passing it as a KEY=VALUE argument makes it a build setting, which # passing it as a KEY=VALUE argument makes it a build setting, which
@@ -172,7 +211,8 @@ jobs:
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217 TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
run: | run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \ xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
-destination 'platform=iOS Simulator,name=iPhone 16' test -destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
${{ matrix.suites }} test
- name: Server log + shutdown - name: Server log + shutdown
if: always() if: always()
run: | run: |
@@ -181,15 +221,21 @@ jobs:
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the # Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
# WebTermTests unit bundle on an iOS 17.x simulator runtime. # WebTermTests unit bundle on an iOS 17.x simulator runtime.
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose #
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so # CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
# Xcode 16.x can build against it. Local machines are NOT expected to # runtime, so this leg is never reproducible on a dev box.
# download the ~8 GB runtime. If the runner image drops the 17.x runtime, #
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures # NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
# fail the job (no silent pass). # runner image had no iOS 17.x runtime, and the test step was gated on
# `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole
# deployment-floor round vanished and the job still reported GREEN. A leg that
# can silently stop testing is worse than no leg. Now: if the runtime is
# missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS
# -buildVersion`); if the download does not produce a usable runtime, the job
# FAILS. The test step is unconditional.
ios17-floor-tests: ios17-floor-tests:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 45 timeout-minutes: 90 # the runtime download alone can take ~15 min
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 (build toolchain) - name: Select Xcode 16.3 (build toolchain)
@@ -198,23 +244,40 @@ jobs:
run: brew install xcodegen run: brew install xcodegen
- name: Generate project - name: Generate project
run: cd ios && xcodegen generate run: cd ios && xcodegen generate
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent) - name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
id: sim17 id: sim17
env:
IOS17_FALLBACK_VERSION: "17.5"
run: | run: |
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)" find_runtime() {
xcrun simctl list runtimes \
| grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \
| tail -n 1
}
runtime="$(find_runtime || true)"
if [ -z "$runtime" ]; then if [ -z "$runtime" ]; then
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run" echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
echo "runtime=" >> "$GITHUB_OUTPUT" sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
exit 0 runtime="$(find_runtime || true)"
fi
if [ -z "$runtime" ]; then
echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2
xcrun simctl list runtimes >&2
exit 1
fi fi
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")" udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
echo "created $udid with $runtime" echo "created $udid with $runtime"
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
echo "udid=$udid" >> "$GITHUB_OUTPUT" echo "udid=$udid" >> "$GITHUB_OUTPUT"
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed # No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018. # (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
#
# -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that
# suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`.
# This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on
# iOS 17), not the server contract, so it stays Node-free.
- name: xcodebuild test (WebTermTests on the iOS 17.x floor) - name: xcodebuild test (WebTermTests on the iOS 17.x floor)
if: steps.sim17.outputs.runtime != ''
run: | run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test -destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
-skip-testing:WebTermTests/LiveServerSmokeTests \
test

10
.gitignore vendored
View File

@@ -3,6 +3,13 @@ node_modules/
# build output # build output
dist/ dist/
# EXCEPTION: `agent/src/dist/` holds SOURCE (the packaging config for the distributable binary),
# not build output. The blanket `dist/` above swallowed it, so it was never committed — a fresh
# clone was missing it and could neither typecheck `agent/src/index.ts` nor import it from the
# committed `agent/test/buildBinary.test.ts`. The directory must be re-included FIRST: git does not
# descend into an excluded directory, so un-ignoring only the file inside it would not work.
!agent/src/dist/
!agent/src/dist/**
public/build/ public/build/
desktop/build/ desktop/build/
desktop/dist-app/ desktop/dist-app/
@@ -10,6 +17,9 @@ desktop/dist-app/
# local Claude Code settings (not shared) # local Claude Code settings (not shared)
.claude/settings.local.json .claude/settings.local.json
# per-session git worktrees (EnterWorktree) — live on disk, never committed
.claude/worktrees/
# logs / OS cruft # logs / OS cruft
*.log *.log
npm-debug.log* npm-debug.log*

View File

@@ -16,6 +16,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*. **Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
## Session Workflow: One Worktree per Session (MANDATORY)
**Every session that changes files works in its own git worktree, and merges back to `develop` when the work is done.** Don't develop directly on `develop` in the main checkout (the one exception is a change to this workflow itself — the rule can't bootstrap inside its own worktree).
1. **Start of session** — before touching any file, call the `EnterWorktree` tool with a task-descriptive name (e.g. `EnterWorktree({name: "fix-cjk-locale"})`). It creates `.claude/worktrees/<name>/` on branch **`worktree-<name>`** (the tool prefixes it — the name you pass is *not* the branch name) and moves the session's cwd into it. Do all work there.
- Base ref is `head` (configured in `.claude/settings.json``worktree.baseRef`), so the worktree branches from the **current `develop` HEAD**, not `origin/main`. This matters: `develop` runs ~75 commits ahead of `origin/main`, so the default `fresh` base ref would silently produce a badly stale worktree. `develop` is the working trunk; `main` is the release branch.
- It branches from the last **commit**, so uncommitted edits sitting in the main checkout do **not** carry over. Commit or stash them first if the task needs them.
- Read-only sessions (answering a question, inspecting a remote host) don't need a worktree — only create one when files will change.
2. **During the session** — commit inside the worktree as normal (conventional-commit format, see the global git-workflow rule). Tests/`tsc` run against the worktree copy, so concurrent sessions never collide on the working tree; each holds a `locked` worktree of its own, so never `git worktree remove` a directory this session didn't create.
3. **End of session — merge back.** Commit everything in the worktree first, then, **in this order**:
```bash
# 1. ExitWorktree({action: "keep"}) → cwd returns to the main checkout, branch survives
git merge --no-ff worktree-<name> # 2. from the main checkout, on develop
git worktree remove .claude/worktrees/<name> && git branch -d worktree-<name> # 3. clean up
```
**Order matters:** `ExitWorktree({action: "remove"})` deletes the branch along with the directory, so calling it before the merge throws the work away. (It does refuse when commits aren't yet on `develop` — a safety net, not a plan.) `keep` is also the right call whenever the work is unfinished and the session should be resumable.
4. **Do not merge to `main`** as part of this flow — `main` is promoted from `develop` separately.
Subagents dispatched with `isolation: worktree` (PLAN §4) get their own throwaway worktrees on top of this — that is a separate, nested mechanism and does not replace the session-level worktree.
## Development Workflow: Plan & Progress Log (MANDATORY) ## Development Workflow: Plan & Progress Log (MANDATORY)
Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules: Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:
@@ -65,6 +85,8 @@ npm test # unit tests (vitest, all modules)
Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1. Config is via env vars only (no hardcoding): `PORT`, `SHELL_PATH`, `BIND_HOST`, `IDLE_TTL`, `SCROLLBACK_BYTES`, `MAX_PAYLOAD_BYTES`, `USE_TMUX` (1/0/auto), `ALLOWED_ORIGINS`. Note `allowedOrigins` is derived from the host's network-interface IPs (not from `BIND_HOST` — `0.0.0.0` is never a valid Origin); see ARCHITECTURE §3.1.
`WEBTERM_TOKEN` (w5-access-token, optional) — a shared access token that gates the WS handshake (alongside, not replacing, the Origin check) and every remote HTTP route. **Unset ⇒ auth disabled**, so LAN zero-config is preserved exactly as before; only when set does the gate activate. When set it must be 16512 URL/cookie-safe chars (`[A-Za-z0-9._~+/=-]`) or the server refuses to start. Deliver it once via `GET /?token=<t>` (or `POST /auth`), which sets an `HttpOnly; SameSite=Strict; Secure-when-https` cookie the browser auto-sends thereafter; loopback hook ingest (`/hook*`) is exempt so the smart-features side-channel keeps working. **Honest tradeoff:** it is a bar-raiser, **not** a TLS/Tailscale substitute — on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it only meaningfully hardens the relay/tunnel (TLS-terminated) path. Never port-forward the raw port to the internet. See `src/http/auth.ts` and `docs/plans/w5-access-token.md`.
## Architecture (the parts that span files) ## Architecture (the parts that span files)
The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server. The server is a **byte-shuttle, not a terminal**. It does not parse ANSI/terminal semantics — xterm.js (browser) interprets escape sequences and renders; node-pty (server) provides the pseudo-terminal so the shell believes it has a real TTY. This separation is the central simplification — keep it. Don't add terminal-semantic parsing on the server.

View File

@@ -4,7 +4,7 @@ A self-hosted, browser-based terminal **and** Claude-Code session/project workbe
Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY. Sessions survive disconnects: the shell (and whatever's running in it) keeps going when you close the tab; reconnect and the scrollback replays. The server is a **byte-shuttle** — it ferries raw bytes between the shell and the browser and never parses terminal/ANSI semantics; xterm.js renders, node-pty provides the TTY.
> ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only, no authentication. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment). > ⚠️ **This hands a full shell to anyone who can reach the port.** LAN-only by design; the optional `WEBTERM_TOKEN` access token raises the bar but is **not** a substitute for TLS/Tailscale. **Never** port-forward or tunnel it to the public internet. See [Security & deployment](#security--deployment).
--- ---
@@ -24,6 +24,14 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
- **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view. - **Sessions ↔ Projects toggle** — a segmented control on the home screen flips between the running-sessions view and the projects view.
- **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs. - **⌂ Home overlay** — a Home button in the tab bar overlays the chooser on top of the current terminal so you can start another session/project without closing your tabs.
### Split-grid watch board (v0.8, desktop)
On a large screen (≥ 1024px) the terminal area can split into a grid so several **live, interactive** sessions show at once — built for watching multiple Claude Code runs in parallel. Desktop-only (a 2×2 of terminals is unusable on a phone); the server and wire protocol are untouched, and single-pane mode is unchanged.
- **Layouts** — a toolbar toggle cycles **single / 1×2 / 1×3 / 2×2 / 2×3**. The board shows the first N tabs (drag-reorder the tab bar, or drag a tab straight onto a quadrant, to choose which); a dashed **+ New session** tile fills any empty slot. The choice persists.
- **Click-to-focus** — the quadrant you click wears a focus ring and owns the keyboard, mobile key-bar, voice, and the approval bar; **Ctrl+`** cycles focus (⇧ reverses). Only the focused pane takes keyboard focus, so panes don't fight over it.
- **Inline approve per quadrant** — a background quadrant waiting on a tool permission glows amber and shows its own **✓ / ✗** buttons, so you can clear approvals across several sessions without switching; its OS notification is suppressed while it's on screen.
- **Maximize (⛶) / monitor (👁) per quadrant** — ⛶ expands one quadrant to fill the grid (the others stay live behind it); 👁 flips a quadrant to a **read-only preview** (polled screen snapshots — no WebSocket attach, no resize) so watching a session in a small quadrant never shrinks it for another device using it full-screen.
- **Resizable splitters + saved presets** — drag the gutters between panes to re-balance column/row sizes (persisted per layout), and save a layout + its split as a named preset to re-apply in one click.
### Claude Code cockpit ### Claude Code cockpit
- **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`. - **Live per-tab status** — Claude Code hooks POST to the server (loopback side-channel); each tab badge shows **working / waiting-for-approval / idle / stuck** in real time. Install once with `npm run setup-hooks`.
- **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others). - **Remote approve / reject** — when Claude asks for tool permission, the request is *held* server-side and an **Approve / Reject** bar appears on every attached device — resolve it with a tap, no typing. Works across multiple devices (closing one mirror doesn't cancel the prompt for the others).
@@ -34,7 +42,8 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
### Projects (v0.6) ### Projects (v0.6)
- **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks. - **Auto-discovered git repos** — scans configurable roots for `.git`, showing each repo's branch and dirty state. Read-only, cached, with a depth-bounded BFS that skips `node_modules`/dotdirs/symlinks.
- **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count). - **Per-project launchers** — each card has brand-logo buttons: **Claude** and **Codex** open a new tab running that CLI in the repo; **VS Code** asks the *host* to open the editor on that path. Projects with an active Claude session highlight the Claude button (with a count).
- **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and a **create-worktree** action. - **Project detail page** — branch + a **git worktrees** list, the project's **active sessions** (open/kill), a **CLAUDE.md viewer** with a **Generate / Update (`/init`)** button, a **read-only git diff viewer**, and worktree **create / prune / remove**.
- **Git panel** — ambient git state on the detail page: a **sync band** (`↑ahead ↓behind`, upstream / detached-HEAD / "never fetched" states, green "in sync" only when nothing is pending *and* the last fetch is recent), a **commit log** with the unpushed boundary marked, **PR status** (via the host's `gh`, when installed), and **stage / commit / push / fetch** actions. All writes are Origin-guarded `POST /projects/git/*` routes running `git` through `execFile` (no shell) with timeouts.
### Walk-away workbench (v0.7) ### Walk-away workbench (v0.7)
- **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push. - **Mobile Web Push + lock-screen triage** — the host actively notifies your phone on **needs-input** (high priority, with **Allow / Deny** action buttons) and **done** (low priority). Approve or deny a held tool request straight from the lock screen without opening the app, secured by a per-decision capability token. Optional **ntfy / Pushover** bridge for setups without HTTPS Web Push.
@@ -51,19 +60,35 @@ Sessions survive disconnects: the shell (and whatever's running in it) keeps goi
## Clients ## Clients
The browser is the reference client; two native clients and a remote-access The browser is the reference client; three native clients and a remote-access
service are also in the repo (all consume the same wire protocol — the server service are also in the repo (all consume the same wire protocol — the server
stays a byte-shuttle). stays a byte-shuttle).
- **iOS / iPad app** ([`ios/`](ios/), branch `feat/ios-client`) — a native - **iOS / iPad app** ([`ios/`](ios/)) — a native SwiftUI + SwiftTerm **pure
SwiftUI + SwiftTerm **pure remote client** built for the walk-away loop: remote client** built for the walk-away loop: native terminal with scrollback
native terminal with scrollback replay, QR/manual pairing (Keychain), the replay, QR/manual pairing (Keychain), the remote **Approve / Reject** gate +
remote **Approve / Reject** gate + three-way plan gate, **APNs push with three-way plan gate, **APNs push with lock-screen Allow / Deny behind Face ID**,
lock-screen Allow / Deny behind Face ID**, deep links, Projects, multi-session deep links (including the web's `?join=` share link), Projects with a **git
switcher, timeline, diff, quick-reply, and an **adaptive iPhone/iPad split panel + worktree lifecycle**, `claude --resume` history, multi-session switcher,
layout**. Looks like the desktop (amber-gold, dark-first). P0 needs zero server timeline, diff, quick-reply, **terminal find bar**, **voice push-to-talk with a
changes; P1 adds two declared additive touch-points. See confirm step**, theme (system/dark/light) + Dynamic Type, and an **adaptive
[`ios/README.md`](ios/README.md). *(Not yet merged.)* iPhone/iPad split layout**. Supports the optional `WEBTERM_TOKEN` access token
(per-host, in the Keychain). Looks like the desktop (amber-gold, dark-first).
P0 needed zero server changes; P1 added two declared additive touch-points.
**Merged into `develop`.** See [`ios/README.md`](ios/README.md).
- **Android app** ([`android/`](android/)) — a Kotlin/Compose client targeting
functional parity with iOS, module-for-module mirroring the iOS package set
(`:wire-protocol` / `:session-core` / `:api-client` / `:client-tls` /
`:host-registry` / `:terminal-view` / `:app`). Same wire protocol, same
Origin-iff-guarded discipline, same **`WEBTERM_TOKEN`** contract (hand-written
cookie, Keystore-backed storage), plus FCM push with lock-screen Allow / Deny,
Projects, timeline, diff, quick-reply and an adaptive layout. Terminal
rendering wraps the Apache-2.0 **Termux** terminal view. All modules build and
unit-test locally (`./gradlew test :app:assembleDebug koverVerify`) and the app
has been installed and launched on an emulator; **real-device QA is still
pending**. Design in
[`docs/ANDROID_CLIENT_PLAN.md`](docs/ANDROID_CLIENT_PLAN.md), setup notes in
[`android/README.md`](android/README.md).
- **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that - **Desktop app** ([`desktop/`](desktop/)) — a Mac/Windows **Electron shell that
embeds this Node server + node-pty** (all-in-one), for native notifications, embeds this Node server + node-pty** (all-in-one), for native notifications,
tray, deep links and launch-at-login. Design in tray, deep links and launch-at-login. Design in
@@ -113,7 +138,7 @@ This wires Claude Code's hooks → **live per-tab status**, the **statusLine gau
### Tests ### Tests
```bash ```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 typecheck # tsc (backend + frontend)
npm run build # compile backend to dist/ npm run build # compile backend to dist/
``` ```
@@ -138,6 +163,7 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
| `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). | | `MAX_MSGS_PER_SEC` | `2000` | Per-connection WS frame-rate cap; over-limit frames are dropped (not disconnected). |
| `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. | | `USE_TMUX` | `auto` | `1`/`0`/`auto` — run the shell inside tmux (keepalive across restart); `auto` = on if `tmux` is on PATH. |
| `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. | | `ALLOWED_ORIGINS` | (derived) | Extra allowed WS origins, comma-separated. The base list is derived from the host's NIC IPs + localhost — never from `BIND_HOST`. |
| `WEBTERM_TOKEN` | (unset → auth **disabled**, **secret**) | Optional shared access token gating every HTTP route + the WS upgrade behind a `webterm_auth` cookie (alongside, never instead of, the Origin check). 16512 chars of `[A-Za-z0-9._~+/=-]` or the server refuses to start. Never logged. See [Security & deployment](#security--deployment) for the honest limits. |
| `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. | | `PERM_TIMEOUT_MS` | `300000` (5 min) | How long a held tool-permission request waits for a remote decision before falling back to Claude's own prompt. Must be > 0. |
| `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. | | `REAP_INTERVAL_MS` | `60000` | Idle-reaper / stuck-sweep tick interval. |
| `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. | | `PREVIEW_BYTES` | `24576` (24 KB) | Tail of scrollback served for live preview thumbnails. |
@@ -183,14 +209,15 @@ All config is via environment variables (no hardcoding). Invalid values fail fas
## Security & deployment ## Security & deployment
This is a **no-auth, LAN-only** tool by design — it hands a full shell to anyone who can reach the port. The defenses that matter: This is a **LAN-only** tool by design — with no token set it hands a full shell to anyone who can reach the port, and even with one set it is not an internet-facing service. The defenses that matter:
- **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade. - **WS Origin validation (cannot be skipped):** the WebSocket handshake rejects any `Origin` not on the allow-list (HTTP 401). This blocks Cross-Site WebSocket Hijacking — a malicious page in your browser trying to connect to `ws://<your-lan-ip>:3000`. Only `WS_PATH` is accepted for upgrade.
- **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't. - **CSRF / Origin guards on state-changing routes:** every route with a side effect (`DELETE /live-sessions`, `POST /open-in-editor`, `POST /push/subscribe`, `POST /hook/decision`, `POST /projects/worktree` + `/worktree/prune` + `DELETE /projects/worktree`, `POST /projects/git/{stage,commit,push,fetch}`) requires an allowed Origin (403 otherwise). Read-only discovery routes don't.
- **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host. - **Loopback-only hook ingest:** the side-channel ingest endpoints (`/hook`, `/hook/permission`, `/hook/status`) only accept loopback peers — the Claude process always runs on the host.
- **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap. - **Per-IP rate limits** on push subscribe and lock-screen decision routes; a per-connection WS frame-rate cap.
- **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout). - **Safe git exec + per-decision capability tokens:** all `git` calls use `execFile` (no shell) with timeouts and path validation; the lock-screen Allow/Deny is authorized by a token bound to that session's current pending request (it expires on resolve/timeout).
- **No authentication yet.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge. - **Optional shared access token (`WEBTERM_TOKEN`) — a bar-raiser, not authentication.** Unset ⇒ the gate is **disabled** and behaviour is byte-identical to a no-auth server (LAN zero-config preserved). Set (16512 chars of `[A-Za-z0-9._~+/=-]`, else the server refuses to start) ⇒ every HTTP route **and** the WS upgrade require an `HttpOnly; SameSite=Strict` `webterm_auth` cookie, delivered once by `GET /?token=<t>` or `POST /auth`; the loopback hook ingest (`/hook*`) stays exempt so the Claude Code side-channel keeps working. It sits *in front of* the Origin check and never replaces it. **Honest limits:** on bare `ws://` the token travels in cleartext and is replayable by a LAN sniffer; it is one shared secret with no per-user identity and no revocation short of changing the env var and restarting. It meaningfully hardens only the TLS-terminated relay/tunnel path. See `src/http/auth.ts` and [`docs/plans/w5-access-token.md`](docs/plans/w5-access-token.md).
- **Beyond that, there is no authentication.** Treat the whole app as "shell access for anyone on the network." **Never expose it to the public internet.** `ws://` is unencrypted, so on an untrusted network keystrokes (passwords, API keys) can be sniffed. The recommended deployment is **[Tailscale](https://tailscale.com/)** (WireGuard-encrypted), which also gives you `wss://` — the frontend auto-selects `wss` on HTTPS. **Web Push requires HTTPS** (a secure context), so the push features need the Tailscale/TLS deploy; bare LAN-over-HTTP falls back to the ntfy/Pushover bridge.
--- ---
@@ -200,6 +227,6 @@ The server is a **byte-shuttle, not a terminal**: `node-pty` gives the shell a r
The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session. The other central design point: **PTY lifecycle ≠ WebSocket lifecycle.** A WS close *detaches* a client (the PTY keeps running for other devices and for reconnect); only an idle timeout (or explicit kill / server shutdown) ends a session.
Tested with **vitest** (~470 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else. Tested with **vitest** (~1600 tests, 80% coverage gate across backend + the logic-bearing frontend modules), plus real-PTY integration tests that auto-skip where `posix_spawn` is unavailable (sandboxes) and run everywhere else.
Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7). Design and rationale: [`docs/TECH_DOC.md`](docs/TECH_DOC.md) (the *why*) and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (the *how*). Feature PRDs: [`docs/FEATURE_PROJECT_MANAGER.md`](docs/FEATURE_PROJECT_MANAGER.md) (v0.6) and [`docs/FEATURE_WALKAWAY_WORKBENCH.md`](docs/FEATURE_WALKAWAY_WORKBENCH.md) (v0.7).

View File

@@ -0,0 +1,273 @@
/**
* 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 { resolveHostIdentity } from '../config/hostRecord.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 CertExpiredBeyondGraceError,
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.
// Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so
// a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here.
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),
})
})
// Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once,
// at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh
// cert files that the restart-on-exit frpc child picks up without a manual service restart.
rotator.onExhausted((err) => {
logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair <CODE>` to re-pair this host', {
...meta,
expiredForMs: err.expiredForMs,
graceMs: err.graceMs,
})
})
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
/** Window in which an already-expired leaf may still be recovered via `/recover`. */
readonly expiredGraceMs?: number
/** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */
readonly recoverFetchImpl?: typeof fetch
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,
...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}),
...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}),
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 }) }
: {}),
})
// Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings
// actually name the host — `cfg` alone is null on every install that predates the record.
const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
return wireAutoRenew(rotator, hooks, logger, ids)
}

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,15 +13,48 @@ import type { AgentConfig } from '../config/agentConfig.js'
import type { AgentIdentity } from '../keys/identity.js' import type { AgentIdentity } from '../keys/identity.js'
import type { Keystore } from '../keys/keystore.js' import type { Keystore } from '../keys/keystore.js'
import type { TimerLike } from '../transport/seams.js' import type { TimerLike } from '../transport/seams.js'
import { createBackoff, type BackoffPolicy } from '../transport/backoff.js'
import { buildCsr } from '../enroll/csr.js' import { buildCsr } from '../enroll/csr.js'
import { certResponseToPem } from './pem.js'
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
/**
* How long after `notAfter` a lapsed leaf may still be swapped for a fresh one (30 days).
*
* `/renew` is mTLS-authenticated by the very leaf it renews, so a lapsed leaf cannot renew itself —
* a deadlock that bricked a host for 8 days in production (the laptop slept through its renewal
* window, then the agent logged `client certificate has expired` 6380 times and never recovered).
* Inside this window the agent switches to the `/recover` endpoint instead; past it, only a re-pair
* can help and the rotator says so once and stops.
*/
export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000
/** The leaf lapsed longer ago than the recovery grace allows ⇒ operator must re-pair this host. */
export class CertExpiredBeyondGraceError extends Error {
constructor(
/** How long ago the leaf expired (ms) — non-secret, safe to log. */
readonly expiredForMs: number,
/** The grace window that was exceeded (ms). */
readonly graceMs: number,
) {
super('client certificate expired beyond the recovery grace window; re-pair required')
this.name = 'CertExpiredBeyondGraceError'
}
}
export interface CertRotator { export interface CertRotator {
start(): void start(): void
stop(): void stop(): void
onRotated(cb: () => void): void onRotated(cb: () => void): void
onRevoked(cb: () => void): void onRevoked(cb: () => void): void
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
onError(cb: (err: unknown) => void): void
/**
* TERMINAL: the leaf expired past the renewal grace window, so no future attempt can succeed. The
* rotator has stopped; recovery requires an operator re-pair.
*/
onExhausted(cb: (err: CertExpiredBeyondGraceError) => void): void
} }
export type RenewOutcome = 'rotated' | 'revoked' export type RenewOutcome = 'rotated' | 'revoked'
@@ -31,6 +64,23 @@ export function renewalUrlFor(cfg: AgentConfig): string {
return cfg.enrollUrl.replace(/\/enroll$/, '/renew') return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
} }
/**
* Recovery route for a leaf that has ALREADY EXPIRED — a sibling PATH on the same enroll host.
*
* It cannot be `/renew`, because nginx will not forward an expired client certificate at all: under
* `ssl_verify_client optional` it answers a bare `400 Bad Request`, and `optional_no_ca` does not
* help either — nginx only tolerates CHAIN errors there (`ngx_ssl_verify_error_optional` covers
* self-signed / unknown-issuer / unverifiable-leaf, NOT `X509_V_ERR_CERT_HAS_EXPIRED`). So recovery
* drops mTLS entirely: it is a plain HTTPS POST carrying the expired cert in the BODY. Nothing is
* lost by that — the accompanying CSR is self-signed by the same private key, and the control-plane
* signer already enforces CSR proof-of-possession plus `CSR key == registered key`, so possession is
* proven exactly as the TLS handshake used to prove it.
*/
export function recoveryUrlFor(cfg: AgentConfig): string {
if (cfg.recoverUrl != null && cfg.recoverUrl.length > 0) return cfg.recoverUrl
return cfg.enrollUrl.replace(/\/enroll$/, '/recover')
}
/** Ms until (validTo renewBeforeMs), clamped to ≥ 0. */ /** Ms until (validTo renewBeforeMs), clamped to ≥ 0. */
export function computeRenewDelayMs( export function computeRenewDelayMs(
certPem: string, certPem: string,
@@ -52,20 +102,49 @@ export async function renewCert(
id: AgentIdentity, id: AgentIdentity,
ks: Keystore, ks: Keystore,
fetchImpl: typeof fetch, fetchImpl: typeof fetch,
opts: { url?: string } = {},
): Promise<RenewOutcome> { ): Promise<RenewOutcome> {
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent') const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
const res = await fetchImpl(renewalUrlFor(cfg), { const res = await fetchImpl(opts.url ?? renewalUrlFor(cfg), {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ csr }), body: JSON.stringify({ csr }),
}) })
if (res.status === 403) return 'revoked' if (res.status === 403) return 'revoked'
if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`) if (!res.ok) throw new Error(`cert renewal failed: HTTP ${res.status}`)
const json = (await res.json()) as { cert?: string; caChain?: string } // The control-plane returns cert=base64(DER) + caChain=base64(DER)[]; normalize to PEM for the
if (typeof json.cert !== 'string' || typeof json.caChain !== 'string') { // keystore + frpc (same shape as native enroll).
throw new Error('cert renewal response missing cert/caChain') const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
} const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
ks.saveCert(json.cert, json.caChain) // atomic whole-file install ks.saveCert(certPem, caChainPem) // atomic whole-file install
return 'rotated'
}
/**
* One recovery round-trip for an EXPIRED leaf: plain HTTPS (no client cert — see `recoveryUrlFor`)
* POSTing the expired cert alongside a fresh CSR over the SAME key. Same outcome contract as
* `renewCert`: 'rotated' installs the new leaf, 403 ⇒ 'revoked', anything else throws to the retry.
*/
export async function recoverCert(
cfg: AgentConfig,
id: AgentIdentity,
ks: Keystore,
fetchImpl: typeof fetch,
url: string = recoveryUrlFor(cfg),
): Promise<RenewOutcome> {
const certs = ks.loadCert()
if (certs === null) throw new Error('cannot recover without the expired leaf on disk')
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
const res = await fetchImpl(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cert: certs.certPem, csr }),
})
if (res.status === 403) return 'revoked'
if (!res.ok) throw new Error(`cert recovery failed: HTTP ${res.status}`)
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
ks.saveCert(certPem, caChainPem)
return 'rotated' return 'rotated'
} }
@@ -79,6 +158,12 @@ export function createCertRotator(
fetchImpl?: typeof fetch fetchImpl?: typeof fetch
now?: () => Date now?: () => Date
parseCert?: (pem: string) => Date parseCert?: (pem: string) => Date
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
retryBackoff?: BackoffPolicy
/** Plain (NON-mTLS) fetch used only for the expired-leaf `/recover` call. */
recoverFetchImpl?: typeof fetch
/** Window in which an expired leaf may still be recovered. 0 ⇒ no recovery at all. */
expiredGraceMs?: number
} = {}, } = {},
): CertRotator { ): CertRotator {
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
@@ -90,10 +175,15 @@ export function createCertRotator(
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>), clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
} }
const doFetch = opts.fetchImpl ?? fetch const doFetch = opts.fetchImpl ?? fetch
const recoverFetch = opts.recoverFetchImpl ?? fetch
const expiredGraceMs = opts.expiredGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS
const now = opts.now ?? (() => new Date()) const now = opts.now ?? (() => new Date())
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
let handle: unknown = null let handle: unknown = null
let rotatedCb: (() => void) | null = null let rotatedCb: (() => void) | null = null
let revokedCb: (() => void) | null = null let revokedCb: (() => void) | null = null
let errorCb: ((err: unknown) => void) | null = null
let exhaustedCb: ((err: CertExpiredBeyondGraceError) => void) | null = null
function schedule(): void { function schedule(): void {
const certs = ks.loadCert() const certs = ks.loadCert()
@@ -102,19 +192,49 @@ export function createCertRotator(
handle = timer.setTimeout(runRenewal, delay) handle = timer.setTimeout(runRenewal, delay)
} }
/**
* How this attempt must be made, derived from how stale the leaf on disk is:
* `normal` — still valid ⇒ ordinary mTLS `/renew`;
* `recover` — expired but inside the grace window ⇒ plain `/recover` with the cert in the body;
* `exhausted` — expired past the grace window ⇒ nothing can succeed; only an operator re-pair.
*/
function attemptPlan(): { mode: 'normal' | 'recover' | 'exhausted'; expiredForMs: number } {
const certs = ks.loadCert()
if (certs === null) return { mode: 'normal', expiredForMs: 0 }
const expiredForMs = now().getTime() - parseCert(certs.certPem).getTime()
if (expiredForMs <= 0) return { mode: 'normal', expiredForMs: 0 }
return { mode: expiredForMs > expiredGraceMs ? 'exhausted' : 'recover', expiredForMs }
}
function runRenewal(): void { function runRenewal(): void {
void renewCert(cfg, id, ks, doFetch) const plan = attemptPlan()
if (plan.mode === 'exhausted') {
// Terminal: report ONCE and arm nothing. The old code retried forever, which is how a single
// real failure turned into 6380 identical warnings that buried the signal.
handle = null
exhaustedCb?.(new CertExpiredBeyondGraceError(plan.expiredForMs, expiredGraceMs))
return
}
const attempt =
plan.mode === 'recover'
? recoverCert(cfg, id, ks, recoverFetch)
: renewCert(cfg, id, ks, doFetch)
void attempt
.then((outcome) => { .then((outcome) => {
if (outcome === 'revoked') { if (outcome === 'revoked') {
revokedCb?.() revokedCb?.()
return return
} }
retryBackoff.reset() // a healthy renewal clears the retry backoff for the next cycle
rotatedCb?.() rotatedCb?.()
schedule() schedule()
}) })
.catch(() => { .catch((err: unknown) => {
// network error: retry after renewBeforeMs; the tunnel stays up meanwhile. // Network/HTTP failure (never a 403 revoke): surface it (caller logs, no secret) and retry
handle = timer.setTimeout(runRenewal, renewBeforeMs) // with backoff. The cert is still valid until expiry, so the tunnel stays up meanwhile — a
// failed renewal must NEVER tear the supervisor down.
errorCb?.(err)
handle = timer.setTimeout(runRenewal, retryBackoff.nextDelayMs())
}) })
} }
@@ -132,5 +252,11 @@ export function createCertRotator(
onRevoked(cb): void { onRevoked(cb): void {
revokedCb = cb revokedCb = cb
}, },
onError(cb): void {
errorCb = cb
},
onExhausted(cb): void {
exhaustedCb = cb
},
} }
} }

View File

@@ -13,6 +13,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import type { CliDeps, NativeEnrollResult } from '../cli.js' import type { CliDeps, NativeEnrollResult } from '../cli.js'
import type { AgentConfig } from '../config/agentConfig.js' import type { AgentConfig } from '../config/agentConfig.js'
import { resolveHostIdentity, saveHostRecord } from '../config/hostRecord.js'
import { loadAgentConfig } from '../config/agentConfig.js' import { loadAgentConfig } from '../config/agentConfig.js'
import { openKeystore } from '../keys/keystore.js' import { openKeystore } from '../keys/keystore.js'
import { generateIdentity, generateP256Identity } from '../keys/identity.js' import { generateIdentity, generateP256Identity } from '../keys/identity.js'
@@ -23,6 +24,7 @@ import { runTunnel } from '../transport/runTunnel.js'
import { buildNativeFrpcToml } from '../transport/frpcToml.js' import { buildNativeFrpcToml } from '../transport/frpcToml.js'
import { superviseFrpc } from '../transport/frpSupervise.js' import { superviseFrpc } from '../transport/frpSupervise.js'
import { provisionFrpc } from '../provision/frpcBinary.js' import { provisionFrpc } from '../provision/frpcBinary.js'
import { startNativeAutoRenew } from '../certs/nativeRenew.js'
import { import {
probeLoopbackBaseApp, probeLoopbackBaseApp,
renderHealthStatus, renderHealthStatus,
@@ -98,7 +100,10 @@ async function enrollNative(
id: AgentIdentity, id: AgentIdentity,
ks: Keystore, ks: Keystore,
): Promise<NativeEnrollResult> { ): Promise<NativeEnrollResult> {
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks) const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
// Write the identifiers down: the long-running `run` process has no other way to learn them, and
// without them every log line from the tunnel reads `{"subdomain":null,"hostId":null}`.
saveHostRecord(cfg.stateDir, { hostId: enroll.hostId, subdomain: enroll.subdomain })
return { hostId: enroll.hostId, subdomain: enroll.subdomain } return { hostId: enroll.hostId, subdomain: enroll.subdomain }
} }
@@ -156,9 +161,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) * 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> { function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
const logger = createLogger('info') const logger = createLogger('info')
@@ -180,16 +188,33 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
}), }),
(report) => { (report) => {
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged. // INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) } const host = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
const ids = { ...host, certNotAfter: certNotAfter(ks) }
for (const line of renderHealthStatus(ids, report)) logger.log('info', line) 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 => { const onSignal = (): void => {
void handle.stop() void handle.stop()
} }
process.once('SIGTERM', onSignal) process.once('SIGTERM', onSignal)
process.once('SIGINT', 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. */ /** Build the concrete CliDeps used by the real CLI entrypoint. */

View File

@@ -18,6 +18,13 @@ export interface AgentConfig {
readonly localTargetUrl: string readonly localTargetUrl: string
readonly subdomain: string | null readonly subdomain: string | null
readonly hostId: string | null readonly hostId: string | null
/**
* Renewal endpoint used ONLY when the current leaf has already expired (the strict `/renew` vhost
* rejects an expired client cert before it reaches the control-plane). Optional: when unset it is
* derived from `enrollUrl` by swapping the `enroll.` label for `recover.` — see
* `certs/rotation.ts` `recoveryRenewalUrlFor`.
*/
readonly recoverUrl?: string | null | undefined
} }
/** /**
@@ -59,6 +66,11 @@ export const AgentConfigSchema = z
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'), .refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
subdomain: z.string().min(1).nullable(), subdomain: z.string().min(1).nullable(),
hostId: z.string().min(1).nullable(), hostId: z.string().min(1).nullable(),
recoverUrl: z
.string()
.refine((u) => hasScheme(u, 'https:'), 'recoverUrl must be an https:// URL')
.nullable()
.optional(),
}) })
.strict() .strict()
.readonly() .readonly()
@@ -85,6 +97,7 @@ export function loadAgentConfig(
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET, localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null, subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
hostId: argv.hostId ?? env.HOST_ID ?? null, hostId: argv.hostId ?? env.HOST_ID ?? null,
recoverUrl: argv.recoverUrl ?? env.RECOVER_URL ?? null,
} }
return AgentConfigSchema.parse(merged) return AgentConfigSchema.parse(merged)
} }

View File

@@ -0,0 +1,97 @@
/**
* Enrolment identifiers (`hostId` / `subdomain`) persisted next to the keystore.
*
* WHY: `pair --install` learns both from the control-plane's enroll response, but nothing ever wrote
* them down — so the long-running `run` process had `cfg.subdomain === null` and `cfg.hostId === null`
* and every log line came out as `{"subdomain":null,"hostId":null}`. When the tunnel broke in
* production, 6380 warnings named no host at all, which is exactly the moment you want them to.
*
* They are NOT secrets (the subdomain is a public DNS label), so this is a plain JSON file — kept in
* `stateDir` only because that is the one directory the agent already owns on every platform.
*
* Legacy installs enrolled before this existed have no record. Their leaf still carries the
* subdomain in its SPIFFE URI SAN, so `resolveHostIdentity` recovers it from there rather than
* forcing a re-pair just to get an identifier back into the logs.
*/
import { X509Certificate } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { AgentConfig } from './agentConfig.js'
const RECORD_FILE = 'host.json'
const DIR_MODE = 0o700
/** Non-secret identifiers assigned by the control-plane at enrolment. */
export interface HostRecord {
readonly hostId: string | null
readonly subdomain: string | null
}
/** A non-empty string, or null — anything else on disk is treated as absent (never trusted). */
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
/** Persist the enrolment identifiers into `stateDir`. Overwrites any previous record. */
export function saveHostRecord(stateDir: string, record: HostRecord): void {
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
writeFileSync(join(stateDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`)
}
/**
* Read the persisted identifiers, or null if this host has none. A missing, unreadable, or
* malformed file is reported as "no record" — this feeds a logging path and must never throw into
* the run loop.
*/
export function loadHostRecord(stateDir: string): HostRecord | null {
const path = join(stateDir, RECORD_FILE)
if (!existsSync(path)) return null
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (typeof parsed !== 'object' || parsed === null) return null
const rec = parsed as Record<string, unknown>
return { hostId: stringOrNull(rec['hostId']), subdomain: stringOrNull(rec['subdomain']) }
} catch {
return null
}
}
/** SPIFFE IDs issued for hosts end in `/host/<subdomain>` (see relay-auth `spiffeIdFor`). */
const SPIFFE_HOST_RE = /URI:(spiffe:\/\/[^\s,]*\/host\/([^\s,/]+))/
/**
* Recover the subdomain from a leaf's SPIFFE URI SAN, or null if it carries none. Parse failures
* are null, never throws — a legacy install with a damaged cert must still start.
*/
export function subdomainFromCertPem(certPem: string): string | null {
try {
const san = new X509Certificate(certPem).subjectAltName ?? ''
const match = SPIFFE_HOST_RE.exec(san)
return match?.[2] ?? null
} catch {
return null
}
}
/**
* Resolve the identifiers to log for this host, most authoritative first:
* 1. explicit config (argv/env `SUBDOMAIN` / `HOST_ID`) — an operator override always wins;
* 2. the enrolment record written by `pair`;
* 3. the subdomain embedded in the stored leaf (legacy installs; yields no hostId).
*
* `readCertPem` is injected so this stays a pure decision over a supplied cert.
*/
export function resolveHostIdentity(
cfg: AgentConfig,
readCertPem: () => string | null,
): HostRecord {
const record = loadHostRecord(cfg.stateDir)
const subdomain =
cfg.subdomain ??
record?.subdomain ??
((): string | null => {
const pem = readCertPem()
return pem === null ? null : subdomainFromCertPem(pem)
})()
return { hostId: cfg.hostId ?? record?.hostId ?? null, subdomain }
}

45
agent/src/dist/buildBinary.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
/**
* Static-binary build spec — PLAN_RELAY_AGENT T16 (EXPLORE §6 distribution rank 2). Produces a
* `bun --compile` spec for a one-`curl | sh` install. `npx web-terminal-agent` stays the MVP path
* (rank 1). The bundle EXCLUDES dev/test deps and any terminal parser (INV11 re-check at the
* package boundary — the agent is a byte-shuttle, never an ANSI interpreter).
*/
export type BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'
export const BINARY_TARGETS: readonly BinaryTarget[] = [
'darwin-arm64',
'darwin-x64',
'linux-x64',
'linux-arm64',
]
export interface BuildSpec {
readonly tool: 'bun'
readonly entry: string
readonly target: BinaryTarget
readonly bunTarget: string // bun's --target triple
readonly outfile: string
readonly minify: true
/** Package-name substrings that must NOT appear in the bundle graph (INV11 tripwire). */
readonly forbiddenDeps: readonly string[]
}
const BUN_TRIPLE: Readonly<Record<BinaryTarget, string>> = {
'darwin-arm64': 'bun-darwin-arm64',
'darwin-x64': 'bun-darwin-x64',
'linux-x64': 'bun-linux-x64',
'linux-arm64': 'bun-linux-arm64',
}
/** Build the `bun --compile` spec for a target triple. Entry is the CLI. */
export function buildBinaryConfig(target: BinaryTarget): BuildSpec {
return {
tool: 'bun',
entry: 'src/cli.ts',
target,
bunTarget: BUN_TRIPLE[target],
outfile: `dist/web-terminal-agent-${target}`,
minify: true,
forbiddenDeps: ['xterm', 'ansi', 'vt100'],
}
}

View File

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

View File

@@ -11,6 +11,7 @@
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the * - 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. * base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
*/ */
import { dirname, join } from 'node:path'
import type { AgentConfig } from '../config/agentConfig.js' import type { AgentConfig } from '../config/agentConfig.js'
import { import {
agentLabel, agentLabel,
@@ -202,13 +203,37 @@ export async function installService(
// so nothing is ever written for a rejected install. // so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options) const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath() 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') { if (platform === 'launchd') {
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel()) 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()) 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]) { for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path) const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args) await deps.runCommand(cmd, args)
@@ -217,8 +242,8 @@ export async function installService(
} }
const baseAppOptions: SystemdUnitOptions = options.envFile const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile } ? { env: baseAppEnvWithPath, envFile: options.envFile }
: { env: baseAppEnv } : { env: baseAppEnvWithPath }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName()) const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile( deps.writeFile(
baseAppPath, baseAppPath,
@@ -227,7 +252,7 @@ export async function installService(
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName()) const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile( deps.writeFile(
agentPath, 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()]) { for (const unit of [baseAppUnitName(), agentUnitName()]) {
const { cmd, args } = systemdEnableCommand(unit) const { cmd, args } = systemdEnableCommand(unit)

View File

@@ -78,6 +78,7 @@ export function buildLaunchdPlist(
programArguments: readonly string[], programArguments: readonly string[],
env: ServiceEnv = {}, env: ServiceEnv = {},
label: string = AGENT_LABEL, label: string = AGENT_LABEL,
logPath?: string,
): string { ): string {
return [ return [
'<?xml version="1.0" encoding="UTF-8"?>', '<?xml version="1.0" encoding="UTF-8"?>',
@@ -91,6 +92,16 @@ export function buildLaunchdPlist(
' <true/>', ' <true/>',
' <key>KeepAlive</key>', ' <key>KeepAlive</key>',
' <true/>', ' <true/>',
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
...(logPath !== undefined
? [
' <key>StandardOutPath</key>',
` <string>${escapeXml(logPath)}</string>`,
' <key>StandardErrorPath</key>',
` <string>${escapeXml(logPath)}</string>`,
]
: []),
...environmentVariablesBlock(env), ...environmentVariablesBlock(env),
'</dict>', '</dict>',
'</plist>', '</plist>',

View File

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

View File

@@ -62,6 +62,12 @@ export interface FrpSuperviseHandle {
readonly done: Promise<number> readonly done: Promise<number>
/** True while a frpc child is currently running (for the health probe). */ /** True while a frpc child is currently running (for the health probe). */
isChildAlive(): boolean 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. */ /** A frpc run lasting at least this long is "stable" ⇒ reset the restart backoff. */
@@ -196,5 +202,8 @@ export function superviseFrpc(
}, },
done, done,
isChildAlive: () => child?.isAlive() ?? false, 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 await stopping
expect(spawn).toHaveBeenCalledTimes(1) expect(spawn).toHaveBeenCalledTimes(1)
}) })
it('restartChild() kills the running child so the loop respawns onto the fresh cert files', async () => {
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
const children = [makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
now: () => 1_000_000, // fixed clock: run counts as unstable, but sleep is a no-op → respawn is immediate
})
await flush()
expect(handle.isChildAlive()).toBe(true) // child[0]
handle.restartChild() // A5: rotator calls this after a cert rotation to reload the new leaf
expect(children[0]!.killed).toBe(true)
await flush()
expect(n).toBe(2) // child[1] spawned — frpc now reads the rotated cert on disk
expect(handle.isChildAlive()).toBe(true)
await handle.stop()
})
it('restartChild() after stop is a no-op (no spawn past shutdown)', async () => {
const first = makeChild()
let n = 0
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
const stopping = handle.stop()
first.exit(0)
await stopping
handle.restartChild() // must not resurrect the supervisor
expect(spawn).toHaveBeenCalledTimes(1)
})
}) })

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
loadHostRecord,
resolveHostIdentity,
saveHostRecord,
subdomainFromCertPem,
} from '../src/config/hostRecord.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://enroll.terminal.example.com/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: null,
hostId: null,
}
function tmpState(): string {
return mkdtempSync(join(tmpdir(), 'wta-hr-'))
}
/** A real frp-client leaf as issued by the control-plane (SPIFFE URI SAN carries the subdomain). */
const LEAF_PEM = `-----BEGIN CERTIFICATE-----
MIIB1TCCAXygAwIBAgIUUj+CZ+6p29yI59VpyrekwSp9tAgwCgYIKoZIzj0EAwIw
EDEOMAwGA1UEAwwFaDdmZDgwHhcNMjYwNzI5MDgzNzIyWhcNMzYwNzI2MDgzNzIy
WjAQMQ4wDAYDVQQDDAVoN2ZkODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABACg
xWQCQuxawnkkPZIgagEFtG0oBiuron4SSw3U1Q0FwCSH3BJep1MJtIuEQU3HfM4N
6Tk5kW4MWuIM8sNriiqjgbMwgbAwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC
B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwXAYDVR0RBFUwU4IaaDdmZDgudGVybWlu
YWwuZXhhbXBsZS5jb22GNXNwaWZmZTovL3JlbGF5LmV4YW1wbGUuY29tL2FjY291
bnQvYWNjLTEyMy9ob3N0L2g3ZmQ4MB0GA1UdDgQWBBSy/SJwjH/lm8TaY5Yk/TF+
wpg78TAKBggqhkjOPQQDAgNHADBEAiAjq1o5xpk+iF55uVfdyLP/a9OC09O0mN4P
YRk8x5MFaQIgYC3GTWqkwu0azrdffKl6jX0stbG+oM+0Cx2Cn7wy27c=
-----END CERTIFICATE-----`
describe('host record persistence', () => {
it('round-trips the enrolment identifiers through stateDir', () => {
const dir = tmpState()
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
expect(loadHostRecord(dir)).toEqual({ hostId: 'h-1', subdomain: 'h7fd8' })
rmSync(dir, { recursive: true, force: true })
})
it('returns null when nothing was ever enrolled here', () => {
const dir = tmpState()
expect(loadHostRecord(dir)).toBeNull()
rmSync(dir, { recursive: true, force: true })
})
it('treats a corrupt record as absent rather than throwing (never crashes the run loop)', () => {
const dir = tmpState()
writeFileSync(join(dir, 'host.json'), '{not json')
expect(loadHostRecord(dir)).toBeNull()
rmSync(dir, { recursive: true, force: true })
})
it('ignores a record whose fields are the wrong shape', () => {
const dir = tmpState()
writeFileSync(join(dir, 'host.json'), JSON.stringify({ hostId: 42, subdomain: [] }))
expect(loadHostRecord(dir)).toEqual({ hostId: null, subdomain: null })
rmSync(dir, { recursive: true, force: true })
})
})
describe('subdomainFromCertPem', () => {
it('reads the subdomain out of the leaf SPIFFE URI SAN', () => {
expect(subdomainFromCertPem(LEAF_PEM)).toBe('h7fd8')
})
it('returns null for a certificate with no SPIFFE SAN', () => {
expect(subdomainFromCertPem('-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----')).toBeNull()
})
it('returns null for garbage instead of throwing', () => {
expect(subdomainFromCertPem('not a cert')).toBeNull()
})
})
describe('resolveHostIdentity precedence', () => {
it('keeps explicit config (env/argv) over everything else', () => {
const dir = tmpState()
saveHostRecord(dir, { hostId: 'from-file', subdomain: 'from-file' })
const out = resolveHostIdentity(
{ ...CFG, stateDir: dir, subdomain: 'from-env', hostId: 'from-env' },
() => LEAF_PEM,
)
expect(out).toEqual({ hostId: 'from-env', subdomain: 'from-env' })
rmSync(dir, { recursive: true, force: true })
})
it('falls back to the persisted enrolment record', () => {
const dir = tmpState()
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
hostId: 'h-1',
subdomain: 'h7fd8',
})
rmSync(dir, { recursive: true, force: true })
})
/**
* Hosts enrolled before the record existed have no `host.json`. Their leaf still carries the
* subdomain, so they get an identifier in the logs without needing a re-pair.
*/
it('falls back to the leaf SPIFFE SAN when there is no record (legacy installs)', () => {
const dir = tmpState()
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => LEAF_PEM)).toEqual({
hostId: null,
subdomain: 'h7fd8',
})
rmSync(dir, { recursive: true, force: true })
})
it('yields nulls when nothing is known (unenrolled host)', () => {
const dir = tmpState()
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
hostId: null,
subdomain: null,
})
rmSync(dir, { recursive: true, force: true })
})
})

View File

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

View File

@@ -0,0 +1,350 @@
/**
* 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'
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.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
exhausted?: (e: CertExpiredBeyondGraceError) => void
}
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} {
const fire: {
rotated?: () => void
revoked?: () => void
error?: (e: unknown) => void
exhausted?: (e: CertExpiredBeyondGraceError) => 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
},
onExhausted: (cb) => {
fire.exhausted = 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 })
})
})
/**
* The mTLS renew transport stays STRICT about expiry: nginx refuses to forward an expired client
* cert at all, so an expired leaf must be routed to the plain `/recover` endpoint by the rotator
* rather than smuggled through this transport.
*/
describe('createMtlsFetch stays fail-closed on an expired leaf', () => {
it('refuses to present a lapsed leaf (recovery is the rotator\'s job, not this transport\'s)', async () => {
const { dir, ks } = enrolledKs()
let called = 0
const request: MtlsRequest = async () => {
called += 1
return { status: 201, body: '{}' }
}
const f = createMtlsFetch(ks, {
request,
certParser: () => ({ validTo: new Date(Date.now() - 86_400_000) }),
})
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(
/expired/i,
)
expect(called).toBe(0)
rmSync(dir, { recursive: true, force: true })
})
})
describe('wireAutoRenew exhausted routing', () => {
it('logs an actionable re-pair alarm and leaves the supervisor running', () => {
const fire: { exhausted?: (e: CertExpiredBeyondGraceError) => void } = {}
const rotator: CertRotator = {
start: vi.fn(),
stop: vi.fn(),
onRotated: () => {},
onRevoked: () => {},
onError: () => {},
onExhausted: (cb) => {
fire.exhausted = cb
},
}
const restartChild = vi.fn()
const stop = vi.fn()
const lines: string[] = []
wireAutoRenew(rotator, { restartChild, stop }, createLogger('info', (l) => lines.push(l)), {
subdomain: 'h7fd8',
hostId: 'h-1',
})
fire.exhausted?.(new CertExpiredBeyondGraceError(40 * 86_400_000, 30 * 86_400_000))
const alarm = lines.find((l) => /re-pair/i.test(l))
expect(alarm).toBeDefined()
expect(alarm).toContain('h7fd8')
// The supervisor keeps running: a later `pair` writes fresh cert files that the restarting
// frpc child picks up. Tearing down here would make recovery need a manual restart too.
expect(stop).not.toHaveBeenCalled()
expect(restartChild).not.toHaveBeenCalled()
})
})

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

@@ -8,9 +8,13 @@ import { openKeystore } from '../src/keys/keystore.js'
import { import {
computeRenewDelayMs, computeRenewDelayMs,
createCertRotator, createCertRotator,
recoverCert,
recoveryUrlFor,
renewCert, renewCert,
renewalUrlFor, renewalUrlFor,
} from '../src/certs/rotation.js' } from '../src/certs/rotation.js'
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
import { createBackoff } from '../src/transport/backoff.js'
import { FakeTimer } from './fixtures/fakes.js' import { FakeTimer } from './fixtures/fakes.js'
const CFG: AgentConfig = { const CFG: AgentConfig = {
@@ -52,10 +56,10 @@ describe('renewCert (T13)', () => {
it('installs a fresh cert atomically on success (same key)', async () => { it('installs a fresh cert atomically on success (same key)', async () => {
const { dir, ks } = enrolledKs() const { dir, ks } = enrolledKs()
const before = ks.loadIdentity()!.publicKey const before = ks.loadIdentity()!.publicKey
const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) const fetchImpl = vi.fn(async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] }))
const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch) const out = await renewCert(CFG, ks.loadIdentity()!, ks, fetchImpl as unknown as typeof fetch)
expect(out).toBe('rotated') expect(out).toBe('rotated')
expect(ks.loadCert()).toEqual({ certPem: 'NEWCERT', caChainPem: 'NEWCA' }) expect(ks.loadCert()!.certPem).toContain('NEWCERT'); expect(ks.loadCert()!.caChainPem).toContain('NEWCA')
// pubkey unchanged — only the cert rotated // pubkey unchanged — only the cert rotated
expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true) expect(Buffer.from(ks.loadIdentity()!.publicKey).equals(Buffer.from(before))).toBe(true)
rmSync(dir, { recursive: true, force: true }) rmSync(dir, { recursive: true, force: true })
@@ -101,7 +105,7 @@ describe('createCertRotator (T13)', () => {
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, { const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer, timer,
renewBeforeMs: 1000, renewBeforeMs: 1000,
fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: 'NEWCA' })) as unknown as typeof fetch, fetchImpl: (async () => jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })) as unknown as typeof fetch,
now: () => new Date(0), now: () => new Date(0),
parseCert: () => new Date(2000), parseCert: () => new Date(2000),
}) })
@@ -113,8 +117,194 @@ describe('createCertRotator (T13)', () => {
timer.advance(1000) timer.advance(1000)
await flush() await flush()
expect(rotated).toBe(1) expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toBe('NEWCERT') expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
it('invokes onError and retries with backoff (not renewBeforeMs) when a renewal throws', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let calls = 0
const fetchImpl = (async () => {
calls += 1
if (calls === 1) throw new Error('network down')
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch
const errors: unknown[] = []
let rotated = 0
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
fetchImpl,
now: () => new Date(0),
parseCert: () => new Date(2000), // initial renewal scheduled at ~1000ms
})
rotator.onError((e) => errors.push(e))
rotator.onRotated(() => {
rotated += 1
})
rotator.start()
timer.advance(1000) // first attempt fires → throws
await flush()
expect(errors).toHaveLength(1)
expect(rotated).toBe(0)
// The retry is armed at the 500ms backoff delay, NOT renewBeforeMs (1000): advancing only 500
// must fire it. A crash-loop never escapes here (the supervisor keeps running).
timer.advance(500)
await flush()
expect(rotated).toBe(1)
expect(ks.loadCert()!.certPem).toContain('NEWCERT')
rotator.stop() rotator.stop()
rmSync(dir, { recursive: true, force: true }) rmSync(dir, { recursive: true, force: true })
}) })
}) })
/**
* Expired-leaf recovery (production deadlock, 2026-07): `/renew` is mTLS-authenticated by the leaf
* it renews, so a lapsed leaf can never renew itself — and nginx will not even forward an expired
* client cert. Recovery is therefore a PLAIN POST to `/recover` carrying the expired cert in the
* body, plus a terminal signal once the grace window is spent so the agent stops retrying forever.
*/
describe('expired-leaf recovery', () => {
const HOUR = 3_600_000
it('derives /recover as a sibling PATH on the same enroll host', () => {
expect(recoveryUrlFor({ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' })).toBe(
'https://enroll.terminal.example.com/recover',
)
})
it('honours an explicit recoverUrl over the derivation', () => {
expect(recoveryUrlFor({ ...CFG, recoverUrl: 'https://elsewhere.example.com/recover' })).toBe(
'https://elsewhere.example.com/recover',
)
})
it('recoverCert posts the EXPIRED cert plus a CSR, with no client cert, and installs the result', async () => {
const { dir, ks } = enrolledKs()
let seenUrl = ''
let seenBody: { cert?: string; csr?: string } = {}
const fetchImpl = (async (u: string, init: { body: string }) => {
seenUrl = u
seenBody = JSON.parse(init.body)
return jsonRes(201, { cert: 'FRESHCERT', caChain: ['FRESHCA'] })
}) as unknown as typeof fetch
const outcome = await recoverCert(
{ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' },
ks.loadIdentity()!,
ks,
fetchImpl,
)
expect(outcome).toBe('rotated')
expect(seenUrl).toBe('https://enroll.terminal.example.com/recover')
expect(seenBody.cert).toBe('OLDCERT') // the lapsed leaf travels in the BODY, not the TLS layer
expect(seenBody.csr).toBeTruthy()
expect(ks.loadCert()!.certPem).toContain('FRESHCERT')
rmSync(dir, { recursive: true, force: true })
})
it('a still-valid leaf uses the mTLS /renew fetch, never the recovery one', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let renewCalls = 0
let recoverCalls = 0
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
fetchImpl: (async () => {
renewCalls += 1
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch,
recoverFetchImpl: (async () => {
recoverCalls += 1
return jsonRes(200, {})
}) as unknown as typeof fetch,
now: () => new Date(0),
parseCert: () => new Date(2000), // valid at now=0
})
rotator.start()
timer.advance(1000)
await flush()
expect(renewCalls).toBe(1)
expect(recoverCalls).toBe(0)
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
it('an expired leaf INSIDE the grace window switches to the recovery fetch', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let renewCalls = 0
let recoverCalls = 0
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
expiredGraceMs: 30 * 24 * HOUR,
fetchImpl: (async () => {
renewCalls += 1
return jsonRes(200, {})
}) as unknown as typeof fetch,
recoverFetchImpl: (async () => {
recoverCalls += 1
return jsonRes(201, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch,
now: () => new Date(8 * 24 * HOUR), // 8 days after the leaf lapsed
parseCert: () => new Date(0),
})
let rotated = 0
rotator.onRotated(() => {
rotated += 1
})
rotator.start()
timer.advance(0)
await flush()
expect(recoverCalls).toBe(1)
expect(renewCalls).toBe(0)
expect(rotated).toBe(1)
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
it('BEYOND the grace window it fires onExhausted, issues no request, and stops retrying', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let requests = 0
const countingFetch = (async () => {
requests += 1
return jsonRes(201, {})
}) as unknown as typeof fetch
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
expiredGraceMs: 30 * 24 * HOUR,
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
fetchImpl: countingFetch,
recoverFetchImpl: countingFetch,
now: () => new Date(31 * 24 * HOUR), // 31 days stale ⇒ past a 30-day grace
parseCert: () => new Date(0),
})
const errors: unknown[] = []
let exhausted: CertExpiredBeyondGraceError | null = null
rotator.onError((e) => errors.push(e))
rotator.onExhausted((e) => {
exhausted = e
})
rotator.start()
timer.advance(0)
await flush()
expect(exhausted).toBeInstanceOf(CertExpiredBeyondGraceError)
expect(requests).toBe(0) // nothing is even attempted — it cannot succeed
expect(errors).toHaveLength(0)
// Terminal: no retry armed. Retrying forever is what produced 6380 identical warnings.
timer.advance(60_000)
await flush()
expect(requests).toBe(0)
rmSync(dir, { recursive: true, force: true })
})
})

3
android/.gitignore vendored
View File

@@ -40,3 +40,6 @@ service-account*.json
# Kover / test reports # Kover / test reports
**/kover/ **/kover/
# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root)
.kotlin/

View File

@@ -1,19 +1,133 @@
# Android client — device-QA checklist (A36 / plan §7) # Android client — device-QA checklist (A36 / plan §7)
Everything below is **device/emulator-only** it could NOT run in the build environment (no emulator, Everything below is **device/emulator-only**: it cannot be proven by the JVM suite. The pure logic
no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests, underneath each item IS unit-tested (757 JVM tests across 10 modules, Kover ≥80% on the pure modules),
Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping. but the 2026-07-30 audit established the hard lesson this file now exists to enforce — **a green JVM
suite says nothing about whether the app works.** Three crash-on-first-keypress defects and a
cleartext posture that made bare-LAN connection impossible all sat behind 484 passing tests, because
no JVM test instantiates an Android `View` and no JVM test dials a socket.
**Rules for this file:** tick a box ONLY after observing the behaviour on real hardware (or, where
noted, an emulator) — and say what you observed. Never tick something because the code looks right or
a unit test covers it. Unticked is the honest default.
---
## Observed so far (the only on-device evidence that exists)
**2026-07-30, minified `benchmark` variant, `webterm` AVD (android-35, arm64, software GPU):**
- [x] The **R8-minified, resource-shrunk, non-debuggable APK installs and cold-starts.** `am start -W`
`Status: ok`, `LaunchState: COLD`, `TotalTime: 1085 ms` (software-GPU emulator — not a
meaningful performance number, only proof of a clean start). Process stayed alive;
`logcat -b crash` empty; no `FATAL EXCEPTION`, `NoClassDefFoundError` or `ClassNotFoundException`.
This is the check that R8 keep rules are correct — the class of bug that appears ONLY in release.
- [x] **Cold-start route with no paired host → pairing screen**, rendered correctly under R8
(`ColdStartPolicy`, A29): title 配对主机, the 手动输入/扫码 segmented control, the
`主机地址http(s)://…)` field and a disabled 下一步 button. Compose + Material 3 + the design-token
theme + Hilt injection therefore all survive minification.
- [x] **`POST_NOTIFICATIONS` runtime prompt appears** on first launch (API 33+ path, A30).
- [x] **ML Kit component registration** no longer fails under R8 (`ComponentDiscovery` clean). Before
the `ComponentRegistrar` keep rule it logged
`NoSuchMethodException: com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []`,
which would have silently disabled **QR barcode scanning in release builds only**.
**Expected/benign in the same run:** `W FirebaseApp: Default FirebaseApp failed to initialize because
no default options were found` — there is no `google-services.json` yet (see the deploy artifacts
below). A `System UI isn't responding` dialog also appeared; that is the emulator's own `com.android.systemui`
under software GPU, not this app (our process logged no ANR and stayed `topResumedActivity`).
**Everything else in this file is unobserved.** Notably: no real WebTerm host has ever been connected
to from Android, no terminal bytes have ever been rendered on a device, and no push has ever arrived.
---
## Deploy artifacts to provide first (not built here) ## Deploy artifacts to provide first (not built here)
- [ ] **Release signing credentials.** `:app:assembleRelease` deliberately FAILS at packaging until they
exist — it will never emit an unsigned APK. Provide `webterm.release.{storeFile,storePassword,keyAlias,keyPassword}`
in `android/keystore.properties` (see `keystore.properties.example`; confirm it is gitignored
first) or `android/local.properties`, or the `WEBTERM_RELEASE_*` env vars. Keep the keystore
out of the repo — a lost signing key cannot be recovered.
- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its - [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its
absence with `runCatching`, so the app runs without it; push just won't register). absence with `runCatching`, so the app runs without it; push just won't register).
- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately - [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately
omitted it — applying it without the json fails the build). omitted it — applying it without the json fails the build).
- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert - [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert
SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the SHA-256 (for the verified App Link `autoVerify`). Read it with
`FCM_*` env group (service-account key path + project id). `keytool -list -v -keystore <ks> -alias <alias> | grep SHA256`. Server-side: run `A33`
`src/push/fcm.ts` with the `FCM_*` env group (service-account key path + project id).
---
## FIRST PRIORITY — confirm the 2026-07-30 blocker fixes on a device
These were the defects that made the app unusable on hardware. All are fixed and JVM-verified; **none
has been exercised on a device.** Until this block is ticked, nothing further in this file is worth
running, because every one of these sits on the path to the terminal.
- [ ] **Typing does not crash.** Soft-keyboard keys, Enter, Backspace via the installed
`WebTermTerminalViewClient` / `RemoteTerminalHostView` IME contract. (Was: NPE on the first
keypress — stock `TerminalView.onKeyDown` dereferences `mClient`, then `mTermSession`.)
- [ ] **Swipe-scrolling inside an alternate-screen TUI does not crash.** Claude Code IS an
alternate-screen TUI, so this is ordinary use, not an edge case: drag vertically while a Claude
session is running. Also test the fling and, on a device with a mouse/trackpad, wheel scroll
(`onGenericMotionEvent`). (Was: `doScroll``handleKeyCode``mTermSession` NPE.)
- [ ] **A password manager does not crash the app.** Open the app with an autofill service enabled and
focus the terminal. (Was: unguarded `autofill()` while the view advertised itself autofillable;
the subtree is now excluded from the autofill structure.)
- [ ] **`resize` actually reaches the PTY.** A full-screen TUI fills the screen instead of rendering
into an 80x24 box, and reflows on rotation. (Was: `updateSize` had zero call sites.) Confirm the
cols×rows the server reports match what `TerminalResizeDriver` computed from
`TerminalRenderer.getFontWidth()/getFontLineSpacing()`.
- [ ] **Bare-LAN `ws://` connects.** Pair with `http://<lan-ip>:3000` and attach. (Was:
`network_security_config.xml` was a stub denying all cleartext while `HostEndpoint` derives
`ws://` from `http://`.)
- [ ] **The tunnel domain is still TLS-only.** `http://<anything>.terminal.yaojia.wang` must be refused
(the one hostname with `cleartextTrafficPermitted="false"`), and `OkHttpClientFactory` must
refuse a plaintext dial to a non-private host even if the config would allow it.
- [ ] **§6.4 device-switch reclaim.** Attach the same session from a phone and a tablet; the device you
last touched drives the PTY size (latest-writer-wins) and reclaims full screen on
pane-show/window-focus without a re-attach.
- [ ] **`WEBTERM_TOKEN` gate end-to-end** against a server started WITH the token: REST + the WS
upgrade both carry the cookie, it survives a process restart (sealed with Tink AEAD under an
AndroidKeyStore key), and a **seal failure persists nothing** rather than falling back to
plaintext. Also verify the cookie never appears in `logcat`.
⚠️ **Blocked until the cookie jar + cipher are installed in `:app`'s DI** — until then the gate
is inert at runtime no matter what the server does.
- [ ] **Newly-wired surfaces that had ZERO call sites before this session** — each needs a first-ever
look: session-list **preview thumbnails** (`ThumbnailPipeline`), **quick-reply chips + palette**
(`QuickReply`), and the tablet **pointer context menu** (`PointerContextMenu`).
- [ ] **Newly-decoding server frames.** The `queue` frame ("N queued" badge) and `status.preview` were
both being dropped as undecodable — the latter meant remote approvals were **blind**. Confirm a
real gate now shows the command being approved, and that a malformed preview does not cost the
whole frame (the gate must still appear).
---
## KNOWN FEATURE GAP — copy-out via text selection is unavailable (deliberate)
**Not a bug to be fixed by re-enabling stock selection.** At the pinned Termux v0.118.0, the stock
`ActionMode`'s own handlers dereference the `TerminalSession` this app deliberately does not have
(`TextSelectionCursorController$1.onActionItemClicked`, offsets 89-100 for Copy and 126-136 for Paste).
`TerminalSession` is `final` and its constructor forks a local process over JNI — exactly what a remote
terminal must not do (plan §6.1). So making the selection UI reachable would put a guaranteed NPE on
the **Copy** button, which is worse than not offering it. Long press is therefore consumed and
selection never starts.
**The real fix** (not built, tracked): a first-party selection layer — hit-test to a cell, own the
selection anchors, read the text straight out of `TerminalBuffer`, and write it with `ClipboardManager`.
Paste-in is unaffected and already works.
- [ ] Confirm on a device that long press is inert and does **not** show a broken Copy affordance.
---
## A34 — instrumented E2E vs a real `npm start` host
Infrastructure now EXISTS (`:app` has androidTest deps, a Hilt-aware `testInstrumentationRunner`, and
Espresso + Compose-UI-test on the classpath) but **the tests themselves are not written**. See
`android/README.md` → "Instrumented tests" for the one runner class that must be authored first.
## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device)
- [ ] `attach → attached → output` round-trip timing. - [ ] `attach → attached → output` round-trip timing.
- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay. - [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay.
- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy. - [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy.
@@ -22,15 +136,19 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS. - [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS.
## A35 — one macrobenchmark / Espresso happy path ## A35 — one macrobenchmark / Espresso happy path
The `:macrobenchmark` module exists and assembles (`com.android.test`, instrumenting `:app`'s
`benchmark` variant out-of-process); it has **no sources yet**.
- [ ] pair → attach → type → approve, on a real device. - [ ] pair → attach → type → approve, on a real device.
- [ ] cold-start timing on real hardware (`StartupTimingMetric`). The 1085 ms above is a software-GPU
emulator number and must not be quoted as a baseline.
## Terminal (A16/A17/A21) ## Terminal (A16/A17/A21)
- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8 - [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8
WebView fallback ONLY if fidelity diverges — not expected). WebView fallback ONLY if fidelity diverges — not expected).
- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap. - [ ] IME/CJK composition; http/https link tap. (Selection→clipboard is the gap above, not a test.)
- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are - [ ] **real font-metric → grid resize** and resize parity vs web/iOS on the same device sizes (R5).
package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs
web/iOS on the same device sizes (R5).
- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay - [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay
round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background → round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background →
generation bump → fresh emulator replays. generation bump → fresh emulator replays.
@@ -47,30 +165,99 @@ Kover ≥80% on the pure modules); this checklist is what a human runs on real h
- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth - [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth
success; cancel/error/no-enrolled-auth → no POST (fail-safe). success; cancel/error/no-enrolled-auth → no POST (fail-safe).
- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency). - [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency).
- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals - [ ] token registers to every paired host + self-heals on rotation / new host / removal.
on rotation / new host / removal. - [ ] **In a MINIFIED build specifically** — FCM and ML Kit both discover components reflectively, so
re-verify push registration and QR scanning on the `benchmark`/release variant, not just debug.
## Pairing / cert / storage (A19/A27/A11/A12) ## Pairing / cert / storage (A19/A27/A11/A12)
- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. - [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. **Run this on a minified
variant too** (see the `ComponentRegistrar` note above — this path is the one R8 already broke once).
- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't - [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't
bypass); public host explicit-ack. bypass); public host explicit-ack.
- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the - [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the
prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with
no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove. no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove.
- [ ] **Cert summary does not crash** — see the `javax.naming` defect under "Known gaps"; the issuer-CN /
expiry summary is expected to throw `NoClassDefFoundError` on-device TODAY.
- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited. - [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited.
## Access token — `WEBTERM_TOKEN` (B5 / ios-completion §1.1) — needs a token-enabled host
Run the host as `WEBTERM_TOKEN=<16512 chars> npm start`. The pure logic is JVM-tested (`AuthProbeTest`,
`AccessTokenCookieTest`, `PairingProbeTokenTest`, `OkHttpAccessTokenTest`, `SessionEngineUnauthorizedTest`,
`InMemoryAccessTokenStoreTest`); everything below needs real hardware + a real server.
- [ ] `TinkAccessTokenStore` instrumented suite on a device:
`./gradlew :host-registry:connectedDebugAndroidTest` (real AndroidKeyStore + Tink).
- [ ] Pair a token-enabled host with the token typed → paired; the terminal attaches (the **WS upgrade**
carries `Cookie: webterm_auth=…`), the session list, projects, diff and thumbnails all load.
- [ ] Pair the same host with **no** token typed → the probe 401s → the confirm step comes back asking for
the token ("该主机启用了访问令牌"); typing it and retrying pairs.
- [ ] Wrong token → "访问令牌不正确" **under the field** (still on the confirm step, nothing stored).
- [ ] 11 wrong tries within a minute → "尝试过多" (the server's 10/min/IP `/auth` limiter).
- [ ] Pair a host with `WEBTERM_TOKEN` **unset** while typing a token → pairs, and NOTHING is stored
(204-without-`Set-Cookie`); the app must not claim to be "authenticated".
- [ ] Restart the app → the terminal re-attaches with no re-prompt (the token decrypts from the
Keystore-backed store on a cold start).
- [ ] Rotate `WEBTERM_TOKEN` on the host and restart it → the WS upgrade 401s → the terminal banner shows
the 401 copy, offers **no** "新会话", and there is **no reconnect back-off storm** (verify with
`adb logcat` / the server log: exactly one upgrade attempt).
- [ ] `adb logcat` during all of the above: the token string appears **nowhere**; no request URL contains
it (check the server's access log too).
- [ ] `adb backup` / device-to-device transfer excludes the token blob (`allowBackup=false` +
`data_extraction_rules.xml`).
## Nav / deep links / adaptive (A32/A26/A29) ## Nav / deep links / adaptive (A32/A26/A29)
- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the - [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the
right destination; invalid UUID ignored. right destination; invalid UUID ignored. (The App Link half needs the release-signed APK and
- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens. `assetlinks.json`.)
- [ ] continue-last-session banner re-opens. (The no-host → pairing half is observed above.)
- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` + - [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` +
`ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600). `ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600).
## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md) ## 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 gaps (tracked, non-blocking for QA but must be routed to an owner)
- [ ] **`:client-tls` uses `javax.naming.ldap.LdapName`, which does not exist on Android.** Found by R8
(`Missing class javax.naming.ldap.LdapName ... referenced from CertificateSummaryReader.commonName`).
On-device that call throws `NoClassDefFoundError`, an **`Error`** — so it slips straight through the
function's `catch (_: Exception)` guard and propagates out of `summarize()`. The module is pure-JVM
and its unit tests run on a JDK where `LdapName` DOES exist, which is exactly why the green suite
never caught it. Effect: the device-cert screen's issuer-CN/expiry summary (A27) crashes.
Fix: hand-parse the RFC2253 DN (or at minimum catch `Throwable`). The `-dontwarn` in
`proguard-rules.pro` only unblocks R8; it changes nothing at runtime.
- [ ] **The auth cookie jar + cipher are not installed in `:app`'s DI**, so the `WEBTERM_TOKEN` gate is
inert at runtime even though every piece is implemented and tested.
- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't - [ ] 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. carry the sessionId; the gate is still visible in the terminal). MEDIUM.
- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link. - [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link.
- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked). - [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked).
- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView` - [ ] **Device-to-device transfer** is not covered by `allowBackup=false`; it needs a
is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to `dataExtractionRules` `<device-transfer>` xml resource. Until then a D2D migration could copy the
`:app` and delete the shim. sealed auth cookie to another handset.
- [ ] AGP's lint crashes with `FirDeclaration was not found for class KtProperty, fir is null` on
`ThumbnailPipeline.kt` when run under `--rerun-tasks`. `lintVitalRelease` passes normally and from
a cold lint state; this is a lint/K2 internal bug (lint says so itself), not an app defect. Do not
use `--rerun-tasks` in a release gate.
> RESOLVED since the last revision: the A21 reflection shim (`getMethod("getTerminalView")`) is **gone** —
> `RemoteTerminalHostView` now holds the stock `com.termux.view.TerminalView` directly, so there is no
> reflective getter left to break under R8 and no keep rule is needed for one.

View File

@@ -372,6 +372,99 @@ warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid
--- ---
## ⚠️ CORRECTION (2026-07-30): "COMPLETE" below meant COMPILES, not WORKS
The section that follows claimed all 36 tasks had landed. That was true at the level it was measured —
the modules built, the APK assembled and ~484 JVM tests were green — but NOTHING had ever run on an
Android device or emulator, and the first thing that would have happened on one is a crash. An audit on
2026-07-30 found, and a repair pass fixed, the following. Read this before trusting anything below.
**Three defects made the app unusable on real hardware.** Each was invisible to the JVM suite for the
same structural reason: no JVM test instantiates an Android `View`.
1. **Every key press crashed.** `RemoteTerminalView` bound the forked emulator but never installed a
`TerminalViewClient`. The pinned Termux `TerminalView` dereferences `mClient` with no null guard on
the first line of the paths a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
offset 0, `onKeyUp`, `onKeyPreIme`). The KDoc claimed "A17 sets it" — A17 never did; only the
`HardwareKeyRouter` and the KeyBar shipped, and the KeyBar works precisely because it bypasses the
view. Installing a client is not sufficient: returning false continues into `mTermSession.write()`
at offset 150, and `mTermSession` is null forever (`TerminalSession` is final and forking a process
is exactly what this app must not do, §6.1). Fixed by a client that consumes the key itself, with
`KeyRouting` deliberately having NO defer-to-stock branch, so the crash is unrepresentable rather
than merely avoided. `KeyHandler` remains the authority for the key tables, so DECCKM still emits
`ESC O A`.
2. **No `resize` was ever sent.** `RemoteTerminalSession.updateSize` had zero call sites and the stock
fallback is dead (`TerminalView.updateSize` early-returns without a session), so the PTY stayed at
the server's 80x24 and every full-screen TUI rendered into the wrong box. `TerminalResizeDriver`
now drives it from real cell metrics, read via the PUBLIC `TerminalRenderer.getFontWidth()` /
`getFontLineSpacing()` accessors — no reflection (silently breaks under R8) and no re-measured
`Paint` (drifts from what the renderer actually draws, the §R5 risk).
3. **Bare-LAN connections were impossible.** `network_security_config.xml` was a self-labelled STUB
denying all cleartext, while `HostEndpoint` accepts `http` and derives `ws://`. Its own comment
promised a CIDR allowlist the platform cannot express. See plan §6.9 for the corrected posture.
**Adversarial review then found three more, and got one wrong:**
- **Swipe-scrolling inside an alternate-screen TUI still crashed.** The touch path bypasses
`TerminalViewClient` entirely: `doScroll` converts a scroll to `handleKeyCode` whenever the
alternate buffer is active, which dereferences `mTermSession`. Claude Code IS an alternate-screen
TUI, so this was a crash during ordinary use on the app's main screen. `TerminalScrollGesture` now
claims the vertical drag first and reproduces all three `doScroll` branches, closing the fling
runnable and `onGenericMotionEvent` with it.
- **`autofill()` dereferences `mTermSession` unguarded** while the view advertises itself
autofillable, so any password manager crashed the app. The subtree is excluded from the autofill
structure.
- The review also demanded stock text selection be RESTORED after the focus rework made it
unreachable. It cannot be: the ActionMode's own Copy and Paste handlers dereference the absent
session (`TextSelectionCursorController$1.onActionItemClicked` offsets 89-100 / 126-136), so a
reachable selection UI has an NPE on its Copy button. **Long press stays consumed and copy-out is
a recorded FEATURE GAP** — a first-party selection layer over `TerminalBuffer` + `ClipboardManager`
is the real fix. This is the one place the reviewer was wrong and the builder right.
**Silently-lost server data, now decoded:** the W2 `queue` frame had no `ServerMessageType` member so
every one was dropped as undecodable (the "N queued" badge could never appear), and W1
`status.preview` was not modelled at all, which made remote approvals **BLIND** — the user was asked to
approve a `Bash` call without seeing the command. Both now decode, with the rule that a broken preview
never costs the frame (`pending` is what makes the gate appear).
**Dead code that shipped as features:** `ThumbnailPipeline` (session-list previews), `QuickReply`
(chips + palette) and `PointerContextMenu` were fully implemented and unit-tested with ZERO production
call sites. All three are now wired. `ThumbnailPipeline`'s formal cross-review — explicitly skipped at
the time, as the AW3 entry below admits — has finally been run.
**Credential handling:** the `WEBTERM_TOKEN` cookie is now carried on REST and the WS upgrade, sealed
with Tink AEAD under an AndroidKeyStore key, **fail-closed** (a seal failure persists nothing rather
than falling back to plaintext), and `allowBackup=false` keeps a 30-day shell credential out of Google
Drive and D2D transfer.
**RESOLVED LATER THE SAME DAY** (this list was accurate when written; it is not any more):
- **A34 / A35 now exist and RUN.** `:app/src/androidTest/**` holds 67 instrumented tests — the four
crash regressions, the A34 E2E set (attach→output, ring-buffer replay, spawn failure via a second
deliberately-broken server, a gate resolved through `POST /hook/decision`, and the F9 bad-Origin
rejection), and the Compose UI tests plan §7 named — plus `macrobenchmark/` for A35. All 67 execute
green on `emulator-5554` against this repo's own server. Reaching the host needs
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` (the server derives allowed origins from NIC IPs, and
10.0.2.2 is the emulator's alias for the host, not an interface address).
- **Device QA is no longer ~0%.** The blocker fixes are device-verified, not just JVM-verified.
- Release signing, versioning and minification are done: `assembleRelease` runs R8 and now REFUSES to
emit an unsigned artifact instead of quietly producing one.
- **Copy-out is done** — a first-party selection layer over `TerminalBuffer.getSelectedText` +
`ClipboardManager`, since the stock ActionMode's Copy handler dereferences the absent session.
- The six audit leftovers are closed too: silent certificate rotation (+ `/device/:id/recover`), the W2
inject-queue routes, unread-watermark persistence, host-removal UI, and `/config/ui` finally honoured
(fail-closed).
**Still genuinely open:**
- **S2** — the FCM real-handset delivery spike. Needs ≥2 physical devices under Doze/OEM battery
managers; an emulator cannot answer it.
- One instrumented test (`wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch`) was seen to kill the
test process when the suite was driven WITHOUT the host arguments on a heavily-recycled emulator. It
passes reproducibly under the documented invocation on a clean install. Re-check on real hardware
before trusting it either way.
- Everything else device-observable in `DEVICE_QA_CHECKLIST.md` that no automated test covers.
---
## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1A36 + S1) landed ## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1A36 + S1) landed
Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure

View File

@@ -9,22 +9,29 @@ 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 SPM package set and inherits its rule: *dependencies only flow down; nothing points
upward* (ARCHITECTURE §1). upward* (ARCHITECTURE §1).
## ⚠️ No-SDK constraint (why only 5 modules build here) > **State, honestly:** the app builds, minifies, signs (given a keystore) and cold-starts
> to the pairing screen on an emulator. It has **never talked to a real WebTerm host from
> a device**, and almost nothing in
> [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md) is ticked. Version is
> `0.1.0-alpha01` for exactly that reason. Read that checklist before calling anything
> here done.
The current build environment has **no Android SDK**. Everything that can be pure ## Build environment (SDK installed — all modules build)
**Kotlin/JVM** (`kotlin("jvm")`) is built and unit-tested now; anything that needs the
Android framework (`com.android.*` plugins) is **scaffolded but disabled**.
- **Enabled now (pure Kotlin/JVM, `./gradlew test`-able):** The Android SDK **is installed** and every module — pure Kotlin/JVM and Android-framework
`:wire-protocol`, `:session-core`, `:api-client`, `:client-tls`, `:test-support`. alike — builds and unit-tests here. AGP 9.2.1 (built-in Kotlin) + Gradle 9.6.1 build
- **Scaffolded but COMMENTED OUT** in [`settings.gradle.kts`](settings.gradle.kts) against SDK 35/36.
(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`. `:app`, `:terminal-view`, `:host-registry`, `:client-tls-android`.
- **Instrumented-only:** `:macrobenchmark` (`com.android.test`; assembles here, runs only
on a device/emulator).
To bring the Android modules online later: install an SDK, add Setup: `local.properties``sdk.dir=/usr/local/share/android-commandlinetools`;
`local.properties``sdk.dir`, add the Android Gradle Plugin + `google()` to `google()` is in `pluginManagement`/`dependencyResolutionManagement`. Green gate:
`pluginManagement`, then uncomment the `include(...)` lines and the plugin blocks in `./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest koverVerify`.
each stub.
## Module map (mirror of the iOS SPM packages — plan §3) ## Module map (mirror of the iOS SPM packages — plan §3)
@@ -35,28 +42,41 @@ each stub.
| APIClient | `:api-client` | pure Kotlin/JVM | ✅ built | | APIClient | `:api-client` | pure Kotlin/JVM | ✅ built |
| ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built | | ClientTLS (pure half) | `:client-tls` | pure Kotlin/JVM | ✅ built |
| TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built | | TestSupport | `:test-support` | pure Kotlin/JVM (fakes) | ✅ built |
| ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ⏸ SDK-gated | | ClientTLS (fwk half) | `:client-tls-android` | Android (AndroidKeyStore/Tink)| ✅ built |
| HostRegistry | `:host-registry` | Android (DataStore) | ⏸ SDK-gated | | HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ⏸ SDK-gated | | SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ⏸ SDK-gated | | App/WebTerm | `:app` | Android app (Compose/Hilt/FCM)| ✅ built |
| `URLSession*Transport` | `:transport-okhttp` | pure Kotlin/JVM (OkHttp) | ✅ built |
| — (no iOS counterpart) | `:macrobenchmark` | `com.android.test` harness | ⬜ scaffolded, no sources |
> Not yet scaffolded: `:transport-okhttp` (OkHttp `TermTransport`/`HttpTransport` > `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
> impls, JVM) is owned by task **A7** and will be added then. The iOS > implementations that the two iOS `URLSession*Transport`s consolidate into
> `URLSession*Transport`s consolidate into it (plan §3 framing note). > (plan §3 framing note). OkHttp is a plain JVM library, so the module builds and
> unit-tests (MockWebServer) with **no** Android SDK — including the
> access-token cookie on the WS upgrade (`OkHttpAccessTokenTest`) and the
> public-host cleartext refusal (`CleartextGuardTest`).
`:macrobenchmark` (A35) is the one module with no iOS counterpart. It is a
`com.android.test` module — a **separate APK** that drives `:app` out of process via
UiAutomator, which is the only way startup/frame timings are real. It therefore cannot
violate "dependencies only flow down": nothing depends on it, and it reaches `:app`
through `targetProjectPath`, not a `project()` dependency. It instruments `:app`'s
`benchmark` variant (see "Build types" below). The benchmark sources themselves are not
written yet.
### Dependency graph (arrows = "depends on") ### Dependency graph (arrows = "depends on")
``` ```
:app (SDK-gated) :app
┌───────────────┬───┴────┬──────────────┬───────────────┐ ┌───────────────┬───┴────┬──────────────┬───────────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
:terminal-view :session-core :api-client :host-registry :client-tls-android :terminal-view :session-core :api-client :host-registry :client-tls-android
(SDK-gated) │ │ (SDK-gated) │ │
│ │ │ ▼ │ │ │ ▼
│ │ │ :client-tls (pure) │ │ │ :client-tls (pure)
└──────┬───────┴──────────┴──────────────┬────────────────┘ └──────┬───────┴──────────┴──────────────┬────────────────┘
▼ ▼ ▼ ▼
:wire-protocol ◀──────────── :transport-okhttp (A7, not yet) :wire-protocol ◀──────────── :transport-okhttp
└──────── :test-support → test source sets only └──────── :test-support → test source sets only
``` ```
@@ -64,8 +84,45 @@ each stub.
`:wire-protocol` is the **frozen shared contract** (Android analogue of `:wire-protocol` is the **frozen shared contract** (Android analogue of
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`, `src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation), `Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary `AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
interfaces. New wire types are added **only** here (a coordination point). derivation), and the `TermTransport` / `HttpTransport` / `PingableTermTransport`
boundary interfaces. New wire types are added **only** here (a coordination point).
## Access token (`WEBTERM_TOKEN`) — how the Android client authenticates
A server started with `WEBTERM_TOKEN=<16512 chars of [A-Za-z0-9._~+/=-]>` gates **every**
HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie. The Android
client therefore:
- **hand-writes `Cookie: webterm_auth=<t>` itself** for a token it already holds — one
derivation point (`AuthCookie`), stamped in exactly three places: `:api-client`'s
`ApiRoute.toHttpRequest` (all 12+ routes, RO GETs included), the pairing probe's two
hand-built legs, and `:transport-okhttp`'s WS upgrade. The hand-built
`GET /projects/diff` fetcher in `:app` stamps it too.
- **also installs one `AuthCookieJar`** on the single shared `OkHttpClient`, which captures
the `Set-Cookie` a live pairing (`POST /auth`) hands back and replays it on REST *and* on
the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls). The two are
orthogonal: the hand-written header covers a token restored from the Keystore store, the
jar covers a cookie the server just issued. A host with neither sends no cookie at all.
- keeps the header **orthogonal to `Origin`**: the token never replaces the CSWSH
Origin check (the server tests Origin first, then the cookie), and read-only routes
still carry no Origin.
- validates a typed token ONCE at pairing time with `POST /auth`
(`{"token":"…"}`, `Accept: application/json` — an `Accept` containing `text/html`
makes the server answer 302 instead of 204/401). Four outcomes: 204+`Set-Cookie` =
correct → store it; 204 **without** `Set-Cookie` = that host has no auth → store
nothing; 401 = wrong token; 429 = rate-limited (10/min/IP).
- stores it per host (keyed by the canonical origin) in `TinkAccessTokenStore`:
Tink AEAD under an **AndroidKeystore-wrapped, non-exportable** master key, in an
app-private `SharedPreferences` file, with `android:allowBackup="false"` +
`data_extraction_rules.xml` excluding cloud backup and device transfer. The token is
**never logged, never in a URL, never in app UI state**.
- treats a **401 on the WS upgrade as terminal** (`FailureReason.UNAUTHORIZED`): no
reconnect back-off loop, and the banner tells the user to re-enter the token. A 401 on
any REST route is the typed `ApiClientError.Unauthorized`.
A host with no `WEBTERM_TOKEN` is byte-identical to before the feature: no token stored ⇒
no `Cookie` header at all (LAN zero-config preserved).
## Toolchain ## Toolchain
@@ -84,15 +141,142 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
```bash ```bash
# Use the committed wrapper for everything. # Use the committed wrapper for everything.
./gradlew help # sanity: the build configures ./gradlew help # sanity: the build configures
./gradlew projects # lists the 5 pure modules ./gradlew projects # lists every module
./gradlew build # compile all pure modules ./gradlew test # JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK)
./gradlew test # run JVM unit tests (JUnit5 + coroutines-test + Turbine + MockK) ./gradlew :app:assembleDebug # the installable debug APK
./gradlew koverVerify # the ≥80% gate on the pure modules
# Release path (needs a keystore — see "Release signing")
./gradlew :app:assembleRelease # R8 + resource shrinking + signing
./gradlew :app:lintVitalRelease # the release-blocking lint subset; must be clean
# Minified build WITHOUT a keystore — the practical way to test R8 keep rules
./gradlew :app:assembleBenchmark # same shrinking as release, debug-signed → installable
``` ```
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`, > Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data, > `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
> small focused files — same discipline as the rest of the repo. > apply the Kover plugin, and therefore the only ones `koverVerify` gates). TDD,
> immutable data, small focused files — same discipline as the rest of the repo.
>
> **Do not use `--rerun-tasks` in a release gate.** It trips an AGP lint/K2 internal bug
> (`FirDeclaration was not found for class KtProperty, fir is null`, on
> `ThumbnailPipeline.kt`). `lintVitalRelease` passes normally and from a cold lint state.
## Build types
| Type | Minified | Shrunk res | Debuggable | Signed with | Purpose |
|---|---|---|---|---|---|
| `debug` | no | no | yes | debug key | development; stable applicationId for deep-link tests (A32) |
| `release` | **yes** | **yes** | no | **release key (required)** | the shipping artifact |
| `benchmark` | **yes** | **yes** | no | debug key | `initWith(release)` + `isProfileable`; what `:macrobenchmark` measures, and the only way to exercise R8 without a keystore |
`benchmark` exists because a benchmark must measure the code that actually ships. It sets
`isProfileable = true`, which makes AGP inject `<profileable android:shell="true"/>` into
the merged manifest — done there rather than in `AndroidManifest.xml` so the shipping
manifest carries no benchmark-only tag.
## Versioning
Current: `versionCode = 1`, `versionName = "0.1.0-alpha01"`.
- **`versionCode`** is a plain monotonic counter — **+1 for every artifact handed to anyone**
(a Play track, an APK sent to a tester, an archived benchmark build). It is deliberately
NOT derived from `versionName`; keeping them independent is what lets a hotfix ship
without renumbering. It is still `1` because no artifact has ever left the build machine;
the first distributed build takes `2`.
- **`versionName`** is `<server-line>-alphaNN`. The client tracks the server's `0.1.x` line
(root `package.json` is `0.1.0`). The `-alpha01` suffix is a factual claim about device
verification, not marketing:
- `-alphaNN` — builds, minifies, JVM-tested; **device QA essentially unstarted**. ← today
- `-betaNN` — the A34/A35 blocks of `DEVICE_QA_CHECKLIST.md` pass on real hardware.
- `0.1.0` — the whole checklist is ticked.
Bump the suffix on any user-visible change while still in alpha; move to `0.1.1-alphaNN`
only when the server line moves.
## Release signing
There is **no keystore in this repository and there must never be one.** Credentials are
read at configuration time from the first of these that has them:
1. `android/keystore.properties` — the conventional path. Copy
[`keystore.properties.example`](keystore.properties.example).
⚠️ **Confirm `keystore.properties` is listed in `android/.gitignore` before creating it.**
The existing rules cover `*.jks` / `*.keystore` / `*.p12` but not this filename.
2. `android/local.properties` — already gitignored, so it needs no new rule. Same four keys.
3. `WEBTERM_RELEASE_STORE_FILE` / `_STORE_PASSWORD` / `_KEY_ALIAS` / `_KEY_PASSWORD` — for CI.
Keys: `webterm.release.storeFile` (absolute, or relative to `android/`),
`.storePassword`, `.keyAlias`, `.keyPassword`.
**Degradation contract:** with no credentials, `debug`, `benchmark`, `assembleDebugAndroidTest`
and every test still work, and `:app:assembleRelease` **fails at `packageRelease`** with
instructions. It fails at *packaging*, deliberately after R8, so an unconfigured machine
still gets a full, verifiable R8 run. It never silently emits `app-release-unsigned.apk`
(which is exactly what it used to do).
**Archive `app/build/outputs/mapping/release/mapping.txt` with every distributed artifact**
release builds keep line numbers but obfuscate names, so without the mapping file a crash
report cannot be retraced.
### R8 / keep rules
`app/proguard-rules.pro` is deliberately short and every rule is justified in place; most
of the stack (kotlinx.serialization, Hilt, Tink, Firebase, OkHttp, Compose, CameraX)
ships its own consumer rules and must not be re-declared. Two rules are load-bearing and
were both derived from **observed** failures, not guesswork:
- `com.termux.**` is kept whole, because `:terminal-view` binds to non-API internals of the
pinned v0.118.0 (the public `TerminalView.mEmulator` field, `TerminalRenderer.getFontWidth()`)
and the JitPack artifacts ship no consumer rules.
- `implements com.google.firebase.components.ComponentRegistrar { <init>(); }`, because
without it R8 strips the reflectively-invoked constructors of ML Kit's registrars and
**QR scanning silently stops working in release builds only**.
Before adding a rule, read the merged configuration R8 actually used:
`app/build/outputs/mapping/<variant>/configuration.txt`. To validate rules, install the
`benchmark` APK and exercise the feature — a wrong keep rule is invisible in debug.
## Instrumented tests
`:app` has an androidTest classpath (androidx.test + Espresso + Compose UI test +
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
`wang.yaojia.webterm.HiltTestRunner`.
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
```kotlin
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
```
A custom runner is **required**, not stylistic: `newApplication` is the only hook that can
replace the app-under-test's `Application` with the generated `HiltTestApplication`. Setting
`android:name` in the androidTest manifest cannot do it — that manifest is merged into the
*test* APK, not into the app under test. `assembleDebugAndroidTest` builds fine without the
class (the runner name is just a manifest value); an actual instrumentation *run* needs it.
The suite has been **run green on real hardware** (67/0 instrumented tests against this
repo's own server), so `src/androidTest/**` is verified, not aspirational — but it still
needs a connected device/emulator and is therefore **not** part of the `./gradlew test`
gate; run it with `connectedAndroidTest`. An emulator additionally needs
`ALLOWED_ORIGINS=http://10.0.2.2:<port>` on the server, because the server derives its
allowed origins from NIC IPs and `10.0.2.2` (the emulator's alias for the host) is not one.
### What is still NOT verified here
- **DEFERRED — end-to-end access token**: the `WEBTERM_TOKEN` path is covered by
unit tests (`OkHttpAccessTokenTest`, `AuthProbeTest`, `AccessTokenCookieTest`, the
`:api-client` route tests, the `AuthCookie` derivation tests) and by
`TinkAccessTokenStoreTest` on device, but **no automated leg on the Android side starts
a real server with `WEBTERM_TOKEN` set and completes a cookie-gated WS upgrade** — that
end-to-end coverage exists only on the iOS side (`ios/IntegrationTests`).
- **DEFERRED — the rest of `DEVICE_QA_CHECKLIST.md`**: FCM push delivery under Doze on two
physical handsets (S2) and the lock-screen Allow/Deny walkthrough have no automated cover.
## Android SDK setup (proven working) ## Android SDK setup (proven working)
@@ -129,5 +313,32 @@ AGP library module compiled against SDK 35 and produced an AAR):
`android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER `android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER
`kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable. `kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable.
To add more SDK pieces later (e.g. an emulator image for instrumented tests): - **A `com.android.test` module cannot use a versioned plugin alias.** The root build
script already puts AGP on the classpath via the `android.library`/`android.application`
aliases, so a third versioned request for the same artifact fails with *"plugin is already
on the classpath with an unknown version"*. `:macrobenchmark` therefore applies
`id("com.android.test")` bare — same as `:terminal-view` with `com.android.library`. The
version is still pinned once, as `agp` in the catalog.
## Emulator (installed)
An AVD named **`webterm`** exists (android-35, arm64, software GPU) and is what produced the
on-device evidence recorded in [`DEVICE_QA_CHECKLIST.md`](DEVICE_QA_CHECKLIST.md):
```bash
export ANDROID_HOME=/usr/local/share/android-commandlinetools
$ANDROID_HOME/emulator/emulator -avd webterm -gpu swiftshader_indirect &
$ANDROID_HOME/platform-tools/adb devices -l
# validate the MINIFIED build (this is what catches bad keep rules)
./gradlew :app:assembleBenchmark
adb install -r app/build/outputs/apk/benchmark/app-benchmark.apk
adb logcat -c && adb shell am start -W -n wang.yaojia.webterm/.MainActivity
adb logcat -d --pid=$(adb shell pidof wang.yaojia.webterm) | grep -E "FATAL|NoSuchMethod|NoClassDefFound"
```
Note it is a **software-GPU** emulator: it is fine for crash/keep-rule/route validation and
useless for performance numbers. Real macrobenchmark figures need physical hardware.
To add more SDK pieces later:
`sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`. `sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`.

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,240 @@
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 `{ cert, caChain,
* notAfter }`. The server schema is `.strict()`; NO
* keyAlg/subdomain/deviceName.
* `POST /device/:id/recover` [NO client cert — plain HTTPS] `{ cert, csr }` → 201 `{ cert,
* caChain, notAfter }`. The EXPIRED leaf's escape hatch.
*
* 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 `baseUrl + path` is well-formed.
* Readable so a rotation driver can persist WHICH control plane a device enrolled with and renew
* against that same one (a URL is not a credential).
*/
public val baseUrl: 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))
// The renew 201 is `{ cert, caChain, notAfter }` — it does NOT echo deviceId, so the id we
// addressed the request to is the authoritative one.
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
}
/**
* Recover an EXPIRED leaf: `POST /device/:id/recover` carrying the lapsed [expiredCertDer] in the
* BODY plus a fresh [csrDer] over the SAME hardware key.
*
* **This call must NOT ride an mTLS transport.** `/renew` is authenticated by the very leaf it
* renews, so a lapsed leaf cannot renew itself — and no TLS terminator will even forward an expired
* client certificate (nginx answers a bare 400 under `ssl_verify_client optional`; `optional_no_ca`
* tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). The caller therefore supplies a
* PLAIN [HttpTransport] with no client identity; possession is proven by the CSR self-signature,
* which the control plane checks together with `CSR key == registered key`.
*
* No bearer is accepted on this path at all — the body is the whole credential story. Everything
* else the server enforces is unchanged: real X.509 path validation to the device-CA, SPIFFE parse,
* `notBefore` (never graced), `:id` must match the cert, and revocation still applies. Only
* `notAfter` is graced (30 days), so a leaf lapsed beyond that answers 401.
*/
public suspend fun recover(deviceId: String, expiredCertDer: ByteArray, csrDer: ByteArray): EnrollmentResult {
if (deviceId.isEmpty() || expiredCertDer.isEmpty() || csrDer.isEmpty()) {
throw DeviceEnrollmentError.InvalidRequest
}
val body = EnrollJson.encodeToString(
RecoverRequestBody.serializer(),
RecoverRequestBody(cert = base64(expiredCertDer), csr = base64(csrDer)),
)
val path = "$PATH_DEVICE/${encodePathSegment(deviceId)}/recover"
val response = http.send(jsonRequest(HttpMethod.POST, path, body.encodeToByteArray(), bearer = null))
return toReissueResult(decodeOn201(response, RenewResponseDto.serializer()), deviceId)
}
// ── 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 = baseUrl + 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
}
/**
* Map a re-issuance (renew / recover) 201, whose body carries no `deviceId`, using the [deviceId]
* the request was addressed to. `renewAfter` is normally absent here — the rotation policy
* re-derives it from the leaf's own validity window (`RotationPolicy.renewAfterFor`).
*/
private fun toReissueResult(dto: RenewResponseDto, deviceId: String): EnrollmentResult =
EnrollmentResult(
deviceId = deviceId,
certificate = decodeDerOrThrow(dto.cert),
caChain = dto.caChain.map { decodeDerOrThrow(it) },
notBefore = parseInstantOrNull(dto.notBefore),
notAfter = parseInstantOrNull(dto.notAfter),
renewAfter = parseInstantOrNull(dto.renewAfter),
)
private fun decodeDerOrThrow(base64Der: String): ByteArray =
decodeBase64OrNull(base64Der) ?: 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,156 @@
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)
/**
* The `/device/:id/recover` request body — the EXPIRED leaf plus a fresh CSR over the same key.
*
* The lapsed certificate travels in the BODY (not as an mTLS client cert) because no TLS terminator
* forwards an expired client certificate: nginx answers a bare 400 under `ssl_verify_client optional`,
* and `optional_no_ca` tolerates only CHAIN errors, never `X509_V_ERR_CERT_HAS_EXPIRED`. Nothing is
* lost: the CSR is self-signed by the same private key and the control-plane signer enforces CSR
* proof-of-possession plus `CSR key == registered key`, so possession is proven exactly as the
* handshake used to prove it. The server schema is `.strict()` on these two keys.
*/
@Serializable
internal data class RecoverRequestBody(val cert: String, 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,
)
/**
* The `/device/:id/renew` and `/device/:id/recover` 201 body — `{ cert, caChain, notAfter }` ONLY.
*
* Deliberately NOT [EnrollResponseDto]: the re-issuance routes do not echo `deviceId` (nor
* `notBefore`/`renewAfter`) — only `/device/enroll` does (`control-plane/src/api/renew.ts` vs
* `device-enroll.ts`). Decoding a renew 201 against the enroll DTO, whose `deviceId` is required,
* failed EVERY silent rotation with `MalformedResponse`. The device id is supplied by the caller (it
* is the path segment the request was addressed to, which is the authoritative value anyway), and the
* absent `renewAfter` is re-derived from the leaf by `RotationPolicy.renewAfterFor`.
*/
@Serializable
internal data class RenewResponseDto(
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,150 @@
package wang.yaojia.webterm.api.enroll
import java.time.Duration
import java.time.Instant
/**
* Rotation timing of the installed device identity — the input to [RotationPolicy]. Android analogue
* of iOS `DeviceRenewalState`.
*
* [notAfter] is the leaf's HARD expiry (past it the TLS stack rejects the cert outright) and
* [renewAfter] is the advisory "renew from the same key now" instant. Only non-secret timing plus the
* non-secret [deviceId] (which is a URL path segment) surface here: the private key never leaves
* AndroidKeyStore and the cert bytes are never carried through this type, so it is safe to log.
*/
public data class DeviceRenewalState(
/** The enrolled device id — the `:id` in `POST /device/:id/renew` and `/device/:id/recover`. */
val deviceId: String,
val notAfter: Instant?,
val renewAfter: Instant?,
) {
/**
* 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). Mirrors
* [EnrollmentResult.isRenewalDue] and iOS `DeviceRenewalState.isRenewalDue`.
*/
public fun isRenewalDue(now: Instant): Boolean {
val due = renewAfter ?: return false
return !now.isBefore(due) // now >= renewAfter
}
}
/** What a scheduler must do for one rotation pass — the total answer of [RotationPolicy.decide]. */
public enum class RotationDecision {
/** Nothing to do: the renew window has not opened (the cheap, common case). */
NOT_DUE,
/** An attempt IS due but the previous one failed too recently — wait, do not hammer. */
BACKING_OFF,
/** Still valid: re-CSR from the same key and renew over mTLS (`POST /device/:id/renew`). */
RENEW,
/**
* EXPIRED but inside the recovery grace: the leaf can no longer authenticate its own re-issuance,
* so recover over PLAIN HTTPS with the lapsed cert in the body (`POST /device/:id/recover`).
*/
RECOVER,
/** TERMINAL — expired past the grace window. No request can succeed; only a fresh enroll can. */
RE_ENROLL_REQUIRED,
}
/**
* The pure, JVM-testable rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
* `TerminalGridMath`). Given "now", the leaf's timing and the last failed attempt it answers
* renew / recover / wait / re-enroll — no clock, no keystore, no network, so every branch is a unit
* test rather than a device observation.
*
* Ported from iOS `CertificateRotationScheduler`'s timing rules, plus the two branches the phone
* track needs and iOS lacks (it can only renew, so an iOS device whose leaf lapses is stranded):
* - [RotationDecision.RECOVER] — the `/device/:id/recover` escape hatch, because no TLS terminator
* forwards an expired client certificate, so a lapsed leaf can never authenticate its own renewal.
* - [RotationDecision.RE_ENROLL_REQUIRED] — reported ONCE as terminal instead of retrying forever
* (the host-track agent logged the same failure 6380 times before this rule existed).
*/
public object RotationPolicy {
/**
* The control plane issues `renewAfter = notBefore + 2/3 · lifetime`
* (`control-plane/src/api/device-enroll.ts` `DEFAULT_RENEW_FRACTION`). The client mirrors the
* fraction as exact integer duration math so [renewAfterFor] lands on the same instant.
*/
private const val RENEW_FRACTION_NUMERATOR: Long = 2L
private const val RENEW_FRACTION_DENOMINATOR: Long = 3L
/**
* How long after `notAfter` a lapsed leaf may still be swapped via `/device/:id/recover`
* (30 days) — the same window the control plane's `DEFAULT_EXPIRED_RENEW_GRACE_MS` allows. Asking
* outside it is pointless: the server answers 401.
*/
public val DEFAULT_EXPIRED_RECOVERY_GRACE: Duration = Duration.ofDays(30)
/**
* Minimum gap after a FAILED attempt before another is made. A pass runs on every app
* foreground, and the control plane rate-limits renewals to 30/hour per identity, so an
* un-throttled retry converts one transient failure into a 429 storm.
*/
public val DEFAULT_FAILURE_BACKOFF: Duration = Duration.ofMinutes(15)
/**
* Derive the advisory renew instant from the leaf's own validity window.
*
* This derivation is LOAD-BEARING, not a convenience: `POST /device/:id/renew` and
* `POST /device/:id/recover` answer `{cert, caChain, notAfter}` only — neither returns
* `renewAfter` (only `/device/enroll` does). A client that persisted the enroll-time value and
* never recomputed it would rotate exactly once and then report "not due" until the cert died.
*
* Returns null when either bound is unknown or the window is not a lifetime (notAfter <=
* notBefore) — fail-safe, because [decide] treats a null [DeviceRenewalState.renewAfter] as
* never-due rather than inventing a past instant that would renew-storm.
*/
public fun renewAfterFor(notBefore: Instant?, notAfter: Instant?): Instant? {
if (notBefore == null || notAfter == null) return null
if (!notBefore.isBefore(notAfter)) return null
val lifetime = Duration.between(notBefore, notAfter)
return notBefore.plus(
lifetime.multipliedBy(RENEW_FRACTION_NUMERATOR).dividedBy(RENEW_FRACTION_DENOMINATOR),
)
}
/**
* The whole decision table, in priority order:
* 1. expired past [expiredRecoveryGrace] → [RotationDecision.RE_ENROLL_REQUIRED] (terminal; it
* outranks the backoff because no amount of waiting can help),
* 2. otherwise pick the desired action — expired → RECOVER, [DeviceRenewalState.isRenewalDue] →
* RENEW, else NOT_DUE,
* 3. an idle leaf stays [RotationDecision.NOT_DUE] (there is no attempt to throttle),
* 4. a desired action within [failureBackoff] of [lastFailureAt] → [RotationDecision.BACKING_OFF].
*
* @param lastFailureAt when the last attempt FAILED (null = none, or the last one succeeded).
*/
public fun decide(
state: DeviceRenewalState,
now: Instant,
lastFailureAt: Instant? = null,
expiredRecoveryGrace: Duration = DEFAULT_EXPIRED_RECOVERY_GRACE,
failureBackoff: Duration = DEFAULT_FAILURE_BACKOFF,
): RotationDecision {
require(!expiredRecoveryGrace.isNegative) { "expiredRecoveryGrace must not be negative" }
require(!failureBackoff.isNegative) { "failureBackoff must not be negative" }
val notAfter = state.notAfter
// Terminal first: past `notAfter + grace` nothing can succeed. A non-negative grace makes this
// strictly stronger than "expired", so it needs no separate expiry guard.
if (notAfter != null && now.isAfter(notAfter.plus(expiredRecoveryGrace))) {
return RotationDecision.RE_ENROLL_REQUIRED
}
val isExpired = notAfter != null && now.isAfter(notAfter)
val desired = when {
isExpired -> RotationDecision.RECOVER
state.isRenewalDue(now) -> RotationDecision.RENEW
else -> RotationDecision.NOT_DUE
}
if (desired == RotationDecision.NOT_DUE) return RotationDecision.NOT_DUE
if (lastFailureAt != null && now.isBefore(lastFailureAt.plus(failureBackoff))) {
return RotationDecision.BACKING_OFF
}
return desired
}
}

View File

@@ -0,0 +1,63 @@
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 = "",
/**
* w6/G4: reachable from HEAD but NOT from `@{u}` — i.e. this commit has not been pushed.
*
* Server-computed from `rev-list`, and deliberately NOT "the first N rows": `git log` is
* date-ordered, so merging an older branch interleaves unpushed commits BELOW pushed ones. The
* row-count shortcut fails in the dangerous direction — it would call an unpushed commit pushed.
* Absent ⇒ unknown (no upstream to compare against), which must not render as "pushed".
*/
val unpushed: Boolean? = null,
)
/**
* `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,
/**
* w6/G4: upstream short name used to label the pushed/unpushed boundary. Null ⇒ nothing to compare
* against, so **no boundary may be drawn at all** — not a boundary drawn at position zero.
*/
val upstream: String? = null,
) {
/**
* Index of the LAST unpushed commit, or null when no boundary may be drawn.
*
* The boundary is drawn ONCE, after this index — never once per unpushed commit, and never at all
* when [upstream] is absent. Returns null rather than -1 so a caller cannot accidentally treat
* "no boundary" as "boundary before everything".
*/
public val upstreamBoundaryIndex: Int?
get() {
if (upstream == null) return null
val last = commits.indexOfLast { it.unpushed == true }
return last.takeIf { it >= 0 }
}
/** How many commits are waiting to be pushed; 0 when unknown or nothing is pending. */
public val unpushedCount: Int get() = commits.count { it.unpushed == true }
}
/** 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,81 @@
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/git/fetch` 200 payload (w6/G2).
*
* [lastFetchMs] is FETCH_HEAD's mtime AFTER the fetch, so the UI can stop saying "stale" without
* re-probing. On FAILURE the server deliberately leaves the old value alone, which is why this is
* nullable: a fetch that did not happen must keep reading as stale rather than pretending it refreshed.
*/
@Serializable
public data class FetchResult(val lastFetchMs: Long? = 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

@@ -42,4 +42,9 @@ public data class LiveSessionInfo(
/** Server ms timestamp of the last PTY output (== createdAt until first output). Additive /** Server ms timestamp of the last PTY output (== createdAt until first output). Additive
* optional field — pre-P1 servers omit it; `null` means "no unread data source". */ * optional field — pre-P1 servers omit it; `null` means "no unread data source". */
val lastOutputAt: Long? = null, val lastOutputAt: Long? = null,
/** W2 pending prompt-queue depth (`src/types.ts` `queueLength?`) — what the "N queued" badge
* renders. Additive OPTIONAL: `null` on a pre-W2 server (queue unsupported / unknown), `0` when
* the queue is empty; the badge shows only for a positive value. Decoded tolerantly
* ([LossyIntSerializer]) so a garbled value can never hide a running session. */
@Serializable(with = LossyIntSerializer::class) val queueLength: Int? = null,
) )

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

@@ -17,6 +17,12 @@ public data class ProjectSessionRef(
val clientCount: Int, val clientCount: Int,
val createdAt: Long, val createdAt: Long,
val exited: Boolean, val exited: Boolean,
/**
* w6/G7: the session's working directory, needed to attribute it to a worktree. Matching is
* DEEPEST-first, because `.claude/worktrees/<name>` lives INSIDE the main checkout and a prefix
* match would count every worktree session against the parent repo too.
*/
val cwd: String? = null,
) )
/** /**
@@ -34,6 +40,14 @@ public data class ProjectInfo(
val dirty: Boolean? = null, val dirty: Boolean? = null,
/** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */ /** Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. */
val lastActiveMs: Long? = null, 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,
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
val dirtyCount: Int? = null,
@Serializable(with = ProjectSessionRefListSerializer::class) @Serializable(with = ProjectSessionRefListSerializer::class)
val sessions: List<ProjectSessionRef> = emptyList(), val sessions: List<ProjectSessionRef> = emptyList(),
) )
@@ -59,6 +73,10 @@ public data class ProjectDetail(
val isGit: Boolean, val isGit: Boolean,
val branch: String? = null, val branch: String? = null,
val dirty: Boolean? = null, val dirty: Boolean? = null,
/** w6/G1: porcelain line count, gated the same way as [dirty]. */
val dirtyCount: Int? = null,
/** w6/G1: git repos only; null for a non-git dir. See [SyncState] for what may be trusted. */
val sync: SyncState? = null,
@Serializable(with = WorktreeInfoListSerializer::class) @Serializable(with = WorktreeInfoListSerializer::class)
val worktrees: List<WorktreeInfo> = emptyList(), val worktrees: List<WorktreeInfo> = emptyList(),
@Serializable(with = ProjectSessionRefListSerializer::class) @Serializable(with = ProjectSessionRefListSerializer::class)

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* W2 prompt queue — the server-side FIFO of follow-up prompts injected into a session's PTY the next
* time Claude goes idle (`src/server.ts` ~605/644/654, `SessionManager.enqueueFollowup`/`clearQueue`).
*
* A queued entry is **raw keyboard input for a shell** (invariant #9 / byte-shuttle): it is stored and
* later written to the PTY verbatim. Nothing on this path may trim, escape, re-encode or normalise it —
* the only transformation is the server appending `\r` when the caller passed `appendEnter = true`.
*/
/**
* `GET /live-sessions/:id/queue` 200 → `{ length, items }` — the pending depth plus the queued texts
* (verbatim keystroke bytes). Both fields default, so a missing/garbled key degrades to an empty queue
* instead of throwing; a non-object body yields `null` from `LossyDecode` (→ `InvalidResponseBody`).
*/
@Serializable
public data class QueueSnapshot(
/** Pending entries on the server. Equals `items.size` for a healthy server; trust [items] for
* rendering and [length] only as the badge number the server itself broadcasts. */
val length: Int = 0,
/** The queued prompts in FIFO order, verbatim. */
val items: List<String> = emptyList(),
)
/**
* Outcome of the two GUARDED queue writes — `POST` (enqueue) and `DELETE` (cancel-all). One union for
* both ops (the `GitWriteOutcome` precedent: one union, six routes); [Full], [TooLarge] and [Disabled]
* are simply unreachable for the DELETE, whose handler neither validates text nor consults the
* `QUEUE_ENABLED` kill-switch.
*
* Every variant maps to different UI behaviour, which is why they are typed rather than folded into a
* status code:
* - [Ok] — the server accepted it; [length] is the authoritative new depth for the "N queued" badge.
* - [Full] — 409, the bounded FIFO is at `QUEUE_MAX_ITEMS` (default 10). Cancel something first;
* the server never silently drops.
* - [TooLarge] — 413, over `QUEUE_ITEM_MAX_BYTES` (default 4 KiB, server-configurable — the client
* deliberately does NOT pre-validate size, which would invent a policy it cannot know).
* - [Disabled] — 503, `QUEUE_ENABLED=0` on this host: hide the affordance, do not retry.
* - [SessionGone] — 404, unknown or already-exited session.
* - [RateLimited] — 429 from the shared `QUEUE_RATE_MAX = 20`/minute/IP bucket. **Never auto-retry**;
* a retry loop just burns the same budget a real enqueue needs.
* - [Rejected] — any other 4xx/5xx, carrying the server's SAFE `error` string verbatim (`message` may
* be `null` when the body is empty, e.g. the Origin guard's bare 403).
*/
public sealed interface QueueWriteOutcome {
/** 200 — accepted; [length] is the new pending depth (0 for a cleared queue). */
public data class Ok(val length: Int) : QueueWriteOutcome
/** 409 — the bounded FIFO is full (`QUEUE_MAX_ITEMS`). */
public data object Full : QueueWriteOutcome
/** 413 — the prompt exceeds the server's per-item byte cap. */
public data object TooLarge : QueueWriteOutcome
/** 503 — the queue feature is switched off on this host. */
public data object Disabled : QueueWriteOutcome
/** 404 — the session is unknown or has already exited. */
public data object SessionGone : QueueWriteOutcome
/** 429 — shared per-IP queue bucket. Do NOT auto-retry. */
public data object RateLimited : QueueWriteOutcome
/** Any other failure, with the server's inert message (null when unparseable/empty). */
public data class Rejected(val status: Int, val message: String?) : QueueWriteOutcome
}
/**
* Read the new depth out of a queue write's 200 body (`{ length }`). The status already says it
* succeeded, so a missing/garbled body degrades to `0` rather than throwing — the next `queue` read or
* `queue` WS frame re-establishes the true depth.
*/
internal fun decodeQueueDepth(bytes: ByteArray): Int =
LossyDecode.objectOrNull(bytes, QueueSnapshot.serializer())?.length ?: 0

View File

@@ -2,6 +2,8 @@ package wang.yaojia.webterm.api.models
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor
@@ -9,7 +11,9 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.wire.ClaudeStatus import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID import java.util.UUID
@@ -36,6 +40,23 @@ internal object ClaudeStatusSerializer : KSerializer<ClaudeStatus> {
override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire) override fun serialize(encoder: Encoder, value: ClaudeStatus) = encoder.encodeString(value.wire)
} }
/**
* A tolerant `Int?` field decoder: a wrong-typed/garbled value degrades to `null` instead of failing
* the whole enclosing object. Used for ADDITIVE OPTIONAL numeric fields (e.g.
* `LiveSessionInfo.queueLength`) where dropping the entry would hide a running session for the sake of
* a badge. Required numeric fields deliberately keep the strict built-in behaviour.
*/
internal object LossyIntSerializer : KSerializer<Int?> {
private val delegate: KSerializer<Int?> = Int.serializer().nullable
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: Int?) = delegate.serialize(encoder, value)
override fun deserialize(decoder: Decoder): Int? {
val json = decoder as? JsonDecoder ?: return delegate.deserialize(decoder)
val primitive = json.decodeJsonElement() as? JsonPrimitive ?: return null
return if (primitive.isString) primitive.content.toIntOrNull() else primitive.intOrNull
}
}
/** /**
* A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the * A `List<T>` serializer that drops malformed elements and degrades a non-array to `[]` — the
* Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`, * Android analogue of iOS's `LossyList.decode` for NESTED arrays (`ProjectInfo.sessions`,

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.Serializable
/**
* w6/G1 — how far the checked-out branch has drifted from its upstream (`src/types.ts` `SyncState`).
*
* ## The one rule this whole feature turns on
* `ahead`/`behind` are computed against `@{u}`, a **locally cached** remote ref that only a `fetch`
* moves. That asymmetry decides what may be trusted:
*
* - [ahead] needs only local refs, so it is always trustworthy.
* - [behind] is only meaningful if [lastFetchMs] is recent. A stale FETCH_HEAD reports `behind = 0`
* for a branch that is in fact many commits behind — the server's own commit message records finding
* exactly that (`↓0` while FETCH_HEAD had not moved in 19 days).
* - **No upstream leaves [ahead] and [behind] undefined**, which is NOT the same as zero. A fresh
* worktree branch normally has no upstream, and rendering it as "in sync" is the single easiest bug
* to ship here. [isFullySynced] is the only sanctioned way to ask "may I render the all-clear?" and
* it answers false whenever anything is unknown. Do not re-derive that test at a call site.
*
* Every field is optional because every one of them degrades independently on the server (no upstream,
* detached HEAD, never fetched, not a git repo).
*/
@Serializable
public data class SyncState(
/** e.g. `origin/develop`; null ⇒ the branch tracks nothing. */
val upstream: String? = null,
/** Commits on HEAD not on `@{u}`. Null ⇒ unknown (no upstream), NOT zero. */
val ahead: Int? = null,
/** Commits on `@{u}` not on HEAD. Null ⇒ unknown. Trust only when [isFetchFresh]. */
val behind: Int? = null,
/** FETCH_HEAD mtime in ms; null ⇒ never fetched. */
val lastFetchMs: Long? = null,
/** HEAD is not on a branch ⇒ no branch, no ahead/behind, and fetch/push are not offerable. */
val detached: Boolean = false,
) {
/** True when there is an upstream to compare against at all. */
public val hasUpstream: Boolean get() = upstream != null
/**
* True when [lastFetchMs] is recent enough for [behind] to mean anything.
*
* @param nowMs caller-supplied clock so this stays pure and testable.
*/
public fun isFetchFresh(nowMs: Long): Boolean {
val fetched = lastFetchMs ?: return false
val age = nowMs - fetched
// A clock skew that puts the fetch in the future is not evidence of freshness.
return age in 0..FETCH_FRESH_WINDOW_MS
}
/** True when [behind] should be shown with a "stale — I have not checked" qualifier. */
public fun isBehindStale(nowMs: Long): Boolean = hasUpstream && !isFetchFresh(nowMs)
/**
* The ONLY state that may render as the green all-clear: nothing to push, nothing to pull, and a
* fresh enough fetch to justify saying so.
*
* Green means "I checked, you can ignore this". Getting it wrong is lying to the user, so anything
* unknown — no upstream, detached, never fetched, stale fetch, null counts — is false.
*/
public fun isFullySynced(nowMs: Long): Boolean =
hasUpstream && !detached && ahead == 0 && behind == 0 && isFetchFresh(nowMs)
public companion object {
/**
* How long a fetch stays "fresh". Mirrors the server rule that `behind` is flagged stale once
* FETCH_HEAD is older than an hour (w6/G1).
*/
public const val FETCH_FRESH_WINDOW_MS: Long = 60L * 60L * 1000L
}
}
/**
* w6/G7 — the git state of ONE worktree, fetched lazily per row via `GET /projects/worktree/state`
* (`src/types.ts` `WorktreeState`).
*
* Deliberately narrower than `ProjectDetail`: N rows must not each pay for a worktree listing and a
* CLAUDE.md read that nothing renders.
*/
@Serializable
public data class WorktreeState(
val path: String,
val branch: String? = null,
val sync: SyncState? = null,
val dirtyCount: Int? = null,
)

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.api.pairing
import wang.yaojia.webterm.api.routes.Endpoints
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* The outcome of the pairing-time access-token validation probe (`POST /auth`). The first four cases
* are the FROZEN four the server distinguishes (ios-completion §1.1); [Malformed] is a client-side
* pre-check and [Unexpected] keeps an unknown status from being mistaken for success.
*/
public sealed interface AuthProbeResult {
/** **204 + `Set-Cookie: webterm_auth=…`** — the token is correct. Persist it for this host. */
public data object Accepted : AuthProbeResult
/**
* **204 with NO `Set-Cookie`** — this host has `WEBTERM_TOKEN` unset, so there is nothing to
* authenticate. The token MUST NOT be persisted, and this MUST NOT be read as "authenticated"
* (frozen §1.1): the host is simply open, exactly as a zero-config LAN deploy.
*/
public data object AuthDisabled : AuthProbeResult
/** **401** — wrong token. UI: "访问令牌不正确". */
public data object InvalidToken : AuthProbeResult
/** **429** — the server's `/auth` limiter (10/min/IP) tripped. UI: "尝试过多,稍后再试". */
public data object RateLimited : AuthProbeResult
/** The token cannot be a valid server token ([AccessTokenRule]) — rejected before any network I/O. */
public data object Malformed : AuthProbeResult
/** Any other status (e.g. a captive portal's 302). Never treated as success. */
public data class Unexpected(val status: Int) : AuthProbeResult
}
/**
* Validate [token] against [endpoint] via **`POST /auth`** — a one-shot pairing-time probe, NOT a
* session-establishing login: the native client keeps stamping its own `Cookie` header afterwards and
* ignores the `Set-Cookie` value entirely (frozen §1.1). The response's `Set-Cookie` is used only as a
* BOOLEAN signal (was the token accepted, or is auth disabled on this host?).
*
* Transport-level failures propagate unwrapped so the caller can classify them with
* [PairingError.classify] (same convention as [runPairingProbe]).
*
* Never logs the token; the token appears only in the JSON body, never in the URL.
*/
public suspend fun probeAccessToken(
endpoint: HostEndpoint,
http: HttpTransport,
token: String,
): AuthProbeResult {
// Boundary validation first: a token outside the server's charset/length can never be correct, and
// there is no point spending a rate-limit slot (or shipping it over the wire) to find that out.
val normalized = AccessTokenRule.normalize(token) ?: return AuthProbeResult.Malformed
val request = Endpoints.auth(normalized).toHttpRequest(endpoint)
?: return AuthProbeResult.Unexpected(UNBUILDABLE_REQUEST)
val response = http.send(request)
return classifyAuthResponse(response)
}
private fun classifyAuthResponse(response: HttpResponse): AuthProbeResult = when (response.status) {
HTTP_NO_CONTENT ->
// The ONE discriminator between "token correct" and "this host has no auth at all".
if (response.carriesAuthCookie()) AuthProbeResult.Accepted else AuthProbeResult.AuthDisabled
HTTP_UNAUTHORIZED -> AuthProbeResult.InvalidToken
HTTP_TOO_MANY_REQUESTS -> AuthProbeResult.RateLimited
else -> AuthProbeResult.Unexpected(response.status)
}
/**
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
* value must name [AuthCookie.NAME] — some other service's `Set-Cookie` (a proxy's session id) proves
* nothing about our token.
*/
private fun HttpResponse.carriesAuthCookie(): Boolean =
headers.entries.any { (name, value) ->
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
}
/**
* Sentinel status for "the request could not even be built" — unreachable for a validated
* [HostEndpoint], surfaced instead of crashing (never mistaken for a real HTTP status).
*/
private const val UNBUILDABLE_REQUEST = 0
private const val SET_COOKIE_HEADER = "Set-Cookie"
private const val HTTP_NO_CONTENT = 204
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_TOO_MANY_REQUESTS = 429

View File

@@ -52,6 +52,17 @@ public sealed interface PairingError {
/** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */ /** TLS negotiation / certificate failure on an https/wss target (`SSLException`/`CertificateException`). */
public data object TlsFailure : PairingError public data object TlsFailure : PairingError
/**
* The host answered **401** to the probe's HTTP legs — it has `WEBTERM_TOKEN` set and we presented
* no cookie / a wrong one (ios-completion §1.1). Distinct from [OriginRejected]: this is fixable by
* entering the host's access token, not by editing `ALLOWED_ORIGINS`.
*
* NOTE the probe's ORDERING is what makes the two distinguishable: probe ① authenticates over HTTP
* first, so a 401 there is the token; once ① has passed with our cookie, a 401 on the later WS
* upgrade can only be the Origin allow-list (the server writes the same bare 401 for both).
*/
public data object AccessTokenRequired : PairingError
/** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */ /** The probe deadline elapsed, or the transport timed out (`SocketTimeoutException`). */
public data object Timeout : PairingError public data object Timeout : PairingError

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.ClientMessage import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpMethod
@@ -50,8 +52,9 @@ public suspend fun runPairingProbe(
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
ws: TermTransport, ws: TermTransport,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult = ): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT) runPairingProbeCore(endpoint, http, ws, timeout = Tunables.PAIRING_PROBE_TIMEOUT, tokens = tokens)
/** /**
* Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts * Deterministic probe core. [timeout] `null` = no app-level deadline (the transport's own timeouts
@@ -63,9 +66,10 @@ internal suspend fun runPairingProbeCore(
http: HttpTransport, http: HttpTransport,
ws: TermTransport, ws: TermTransport,
timeout: Duration?, timeout: Duration?,
tokens: AccessTokenSource = AccessTokenSource.NONE,
): PairingProbeResult { ): PairingProbeResult {
if (timeout == null) return performProbe(endpoint, http, ws) if (timeout == null) return performProbe(endpoint, http, ws, tokens)
return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws) } return withTimeoutOrNull(timeout) { performProbe(endpoint, http, ws, tokens) }
?: PairingProbeResult.Failure(PairingError.Timeout) ?: PairingProbeResult.Failure(PairingError.Timeout)
} }
@@ -75,10 +79,16 @@ private suspend fun performProbe(
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
ws: TermTransport, ws: TermTransport,
tokens: AccessTokenSource,
): PairingProbeResult { ): PairingProbeResult {
// The access token (if any) rides BOTH HTTP legs as the hand-written auth cookie. The WS leg gets
// it from the transport's own AccessTokenSource (the same store), so all three legs authenticate.
val cookieHeaders = authHeaders(tokens.tokenFor(endpoint))
// ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape // ① Reachability + shape. Any HTTP-level answer that isn't the /live-sessions array shape
// means "some other service" → httpOkButNotWebTerminal ("端口对吗?"). // means "some other service" → httpOkButNotWebTerminal ("端口对吗?"); a 401 means this host
probeReachability(endpoint, http)?.let { return it } // wants an access token we do not have.
probeReachability(endpoint, http, cookieHeaders)?.let { return it }
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an // ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401, so after ① passed an
// unrecognizable connect failure is classified as originRejected. // unrecognizable connect failure is classified as originRejected.
@@ -105,7 +115,7 @@ private suspend fun performProbe(
return try { return try {
when (val adoption = adoptAttachedSession(connection)) { when (val adoption = adoptAttachedSession(connection)) {
is Adoption.Failure -> PairingProbeResult.Failure(adoption.error) is Adoption.Failure -> PairingProbeResult.Failure(adoption.error)
is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http) is Adoption.Success -> killProbeSession(adoption.sessionId, endpoint, http, cookieHeaders)
} }
} finally { } finally {
withContext(NonCancellable) { runCatching { connection.close() } } withContext(NonCancellable) { runCatching { connection.close() } }
@@ -120,14 +130,20 @@ private suspend fun performProbe(
private suspend fun probeReachability( private suspend fun probeReachability(
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult.Failure? { ): PairingProbeResult.Failure? {
val response = try { val response = try {
http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint))) http.send(HttpRequest(method = HttpMethod.GET, url = liveSessionsUrl(endpoint), headers = authHeaders))
} catch (cancel: CancellationException) { } catch (cancel: CancellationException) {
throw cancel throw cancel
} catch (error: Throwable) { } catch (error: Throwable) {
return PairingProbeResult.Failure(PairingError.classify(error, endpoint)) return PairingProbeResult.Failure(PairingError.classify(error, endpoint))
} }
// Checked BEFORE the shape check: the 401 body is the server's auth JSON, not a session array, and
// reporting "端口对吗?" for a token problem would send the user chasing the wrong thing.
if (response.status == HTTP_UNAUTHORIZED) {
return PairingProbeResult.Failure(PairingError.AccessTokenRequired)
}
if (response.status != HTTP_OK || !isJsonArray(response.body)) { if (response.status != HTTP_OK || !isJsonArray(response.body)) {
return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) return PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal)
} }
@@ -162,13 +178,14 @@ private suspend fun killProbeSession(
sessionId: String, sessionId: String,
endpoint: HostEndpoint, endpoint: HostEndpoint,
http: HttpTransport, http: HttpTransport,
authHeaders: Map<String, String>,
): PairingProbeResult { ): PairingProbeResult {
val response = try { val response = try {
http.send( http.send(
HttpRequest( HttpRequest(
method = HttpMethod.DELETE, method = HttpMethod.DELETE,
url = killUrl(endpoint, sessionId), url = killUrl(endpoint, sessionId),
headers = mapOf(ORIGIN_HEADER to endpoint.originHeader), headers = authHeaders + (ORIGIN_HEADER to endpoint.originHeader),
), ),
) )
} catch (cancel: CancellationException) { } catch (cancel: CancellationException) {
@@ -178,6 +195,7 @@ private suspend fun killProbeSession(
} }
return when (response.status) { return when (response.status) {
HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint) HTTP_NO_CONTENT, HTTP_NOT_FOUND -> PairingProbeResult.Success(endpoint)
HTTP_UNAUTHORIZED -> PairingProbeResult.Failure(PairingError.AccessTokenRequired)
HTTP_FORBIDDEN -> PairingProbeResult.Failure( HTTP_FORBIDDEN -> PairingProbeResult.Failure(
PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)), PairingError.OriginRejected(PairingError.originRejectedHint(endpoint)),
) )
@@ -219,9 +237,17 @@ private fun httpBaseUrl(endpoint: HostEndpoint): String {
return "$scheme://$host$portPart" return "$scheme://$host$portPart"
} }
/**
* `Cookie: webterm_auth=<t>` for the probe's HTTP legs, or NO header when the host has no token —
* derived from the single point ([AuthCookie]), never hand-assembled.
*/
private fun authHeaders(token: String?): Map<String, String> =
if (token == null) emptyMap() else mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(token))
private const val LIVE_SESSIONS_PATH = "/live-sessions" private const val LIVE_SESSIONS_PATH = "/live-sessions"
private const val ORIGIN_HEADER = "Origin" private const val ORIGIN_HEADER = "Origin"
private const val HTTP_OK = 200 private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204 private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403 private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404 private const val HTTP_NOT_FOUND = 404

View File

@@ -1,13 +1,30 @@
package wang.yaojia.webterm.api.routes 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.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode 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.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.QueueSnapshot
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.api.models.decodeQueueDepth
import wang.yaojia.webterm.api.models.FetchResult
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.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.WorktreeState
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.HttpTransport
@@ -25,10 +42,18 @@ import java.util.UUID
* *
* The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped), * The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
* statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input. * statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
*
* **Access token (ios-completion §1.1):** [tokens] is consulted once per request and its token is
* stamped as `Cookie: webterm_auth=<t>` on EVERY route (RO included) inside the same single point that
* stamps `Origin` ([ApiRoute.toHttpRequest]). The default [AccessTokenSource.NONE] means "no token" —
* an unauthenticated LAN host behaves exactly as before. A **401** becomes the typed
* [ApiClientError.Unauthorized] so the UI can send the user to re-enter the token — except on the
* routes that define their own 401 ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write family).
*/ */
public class ApiClient( public class ApiClient(
public val endpoint: HostEndpoint, public val endpoint: HostEndpoint,
private val http: HttpTransport, private val http: HttpTransport,
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
) { ) {
// ── RO (read-only — NO Origin header) ────────────────────────────────────────────────── // ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────
@@ -87,6 +112,56 @@ 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 /live-sessions/:id/queue` (W2) — the pending prompt queue: depth + the queued texts,
* verbatim. READ-ONLY, so no Origin (the server guards only the two writes). 404 → the session is
* gone; a non-object body → [ApiClientError.InvalidResponseBody] rather than a fake empty queue
* (claiming "nothing queued" when entries exist would mislead a cancel-all decision).
*/
public suspend fun queue(id: UUID): QueueSnapshot {
val response = perform(Endpoints.queue(id))
requireOk(response)
return LossyDecode.objectOrNull(response.body, QueueSnapshot.serializer())
?: throw ApiClientError.InvalidResponseBody
}
/** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws /** `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). */ * `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
public suspend fun prefs(): UiPrefs { public suspend fun prefs(): UiPrefs {
@@ -132,6 +207,126 @@ public class ApiClient(
} }
} }
// ── G: W2 prompt queue (enqueue / cancel-all) → QueueWriteOutcome ──────────────────────
/**
* `POST /live-sessions/:id/queue` (W2) — queue a follow-up prompt the server injects into the PTY
* the next time Claude goes idle. Works with zero tabs attached, which is the whole point.
*
* [text] travels VERBATIM (invariant #9 — raw keyboard bytes for a shell); [appendEnter] is a flag
* the SERVER turns into a trailing `\r`, so callers must not append one. An empty prompt is
* rejected as [ApiClientError.QueueTextEmpty] before any network I/O; size is NOT pre-checked
* client-side (the byte cap is server config) — an over-cap prompt comes back as
* [QueueWriteOutcome.TooLarge].
*/
public suspend fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean = true): QueueWriteOutcome {
if (text.isEmpty()) throw ApiClientError.QueueTextEmpty
return queueWrite(Endpoints.enqueueFollowup(id, text, appendEnter))
}
/**
* `DELETE /live-sessions/:id/queue` (W2) — cancel every pending entry (escape hatch). 200 →
* [QueueWriteOutcome.Ok] with depth 0.
*/
public suspend fun clearQueue(id: UUID): QueueWriteOutcome = queueWrite(Endpoints.clearQueue(id))
/**
* Shared status mapping for the two guarded queue writes. Distinct typed outcomes because each one
* demands different UI behaviour (see [QueueWriteOutcome]); in particular a 429 from the shared
* `QUEUE_RATE_MAX = 20`/min/IP bucket is surfaced, NEVER auto-retried. Only 409/413/503 are
* POST-specific — the DELETE handler cannot produce them.
*/
private suspend fun queueWrite(route: ApiRoute): QueueWriteOutcome {
val response = perform(route)
return when (response.status) {
HttpStatus.OK -> QueueWriteOutcome.Ok(decodeQueueDepth(response.body))
HttpStatus.CONFLICT -> QueueWriteOutcome.Full
HttpStatus.PAYLOAD_TOO_LARGE -> QueueWriteOutcome.TooLarge
HttpStatus.SERVICE_UNAVAILABLE -> QueueWriteOutcome.Disabled
HttpStatus.NOT_FOUND -> QueueWriteOutcome.SessionGone
HttpStatus.TOO_MANY_REQUESTS -> QueueWriteOutcome.RateLimited
// `decodeGitError` reads the generic `{ error }` failure shape the queue routes share with
// the git ones (400/403 here) — reused rather than duplicated.
in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
QueueWriteOutcome.Rejected(response.status, decodeGitError(response.body))
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
// ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────
/** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())
/** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())
/** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())
/** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())
/** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())
/** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
gitWrite(Endpoints.gitPush(path), PushResult.serializer())
/**
* `POST /projects/git/fetch` (w6/G2) — refresh `refs/remotes` so `behind` becomes trustworthy.
*
* Shares the guarded-write dispatch, so a disabled kill-switch or an Origin failure both surface as
* [GitWriteOutcome.Rejected] with the server's safe message. Fetch has its OWN rate-limit bucket on
* the server precisely so refreshes cannot eat the budget a real push needs — surface a 429 rather
* than retrying.
*/
public suspend fun gitFetch(path: String): GitWriteOutcome<FetchResult> =
gitWrite(Endpoints.gitFetch(path), FetchResult.serializer())
/**
* `GET /projects/worktree/state?path=` (w6/G7) — the per-row git state of ONE worktree.
*
* Mapped like the other project reads. A malformed body throws rather than degrading to an empty
* state: a row that silently claims "clean, in sync" would be a lie, and the caller is expected to
* isolate this failure to its own row (the panel must still render).
*/
public suspend fun worktreeState(path: String): WorktreeState {
if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
val response = perform(Endpoints.worktreeState(path))
return when (response.status) {
HttpStatus.OK -> LossyDecode.objectOrNull(response.body, WorktreeState.serializer())
?: throw ApiClientError.InvalidResponseBody
HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
else -> throw ApiClientError.UnexpectedStatus(response.status)
}
}
/**
* 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 /** `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. */ * tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
public suspend fun registerFcmToken(token: String) { public suspend fun registerFcmToken(token: String) {
@@ -157,9 +352,27 @@ public class ApiClient(
// ── Internals ────────────────────────────────────────────────────────────────────────── // ── Internals ──────────────────────────────────────────────────────────────────────────
/**
* The ONE dispatch point: stamp Origin (iff guarded) + the auth cookie (iff a token is stored),
* send, and translate a **401** into the typed [ApiClientError.Unauthorized] BEFORE any per-route
* status mapping — otherwise a 401 on a read route would degrade into a generic status error and
* the UI would never learn to ask for the token (ios-completion §1.1).
*
* The one exception is declared AT the route ([UnauthorizedPolicy.ROUTE_DEFINED], the git-write
* family): there a 401 is the server classifying a HOST-side git credential failure
* (`src/http/git-ops.ts:108`), so it must flow on to [gitWrite]'s mapping and reach the user as the
* server's own message. Swallowing it would tell the user to rotate an access token that is fine.
*/
private suspend fun perform(route: ApiRoute): HttpResponse { private suspend fun perform(route: ApiRoute): HttpResponse {
val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest val request = route.toHttpRequest(endpoint, tokens.tokenFor(endpoint))
return http.send(request) ?: throw ApiClientError.InvalidRequest
val response = http.send(request)
if (response.status == HttpStatus.UNAUTHORIZED &&
route.unauthorizedPolicy == UnauthorizedPolicy.ACCESS_TOKEN_GATE
) {
throw ApiClientError.Unauthorized
}
return response
} }
/** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */ /** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */

View File

@@ -20,6 +20,15 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */ /** 404 on a `/live-sessions/:id/…` sub-route — the session is gone (exited / reaped / killed). */
public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。") public data object SessionNotFound : ApiClientError("会话已不存在(可能已退出或被清理)。")
/**
* **401** from a route whose 401 can only be the access-token gate (RO or guarded): this host has
* `WEBTERM_TOKEN` set and our request carried no cookie / a wrong one (ios-completion §1.1).
* Distinct from [Forbidden] (that is the Origin guard) — the UI must send the user to enter or fix
* the host's access token. The git-write family is excluded (it defines its own 401 — see
* [UnauthorizedPolicy]), so this copy can never be shown for a host-side git credential failure.
*/
public data object Unauthorized : ApiClientError("此主机需要访问令牌,或令牌已失效。请重新输入访问令牌。")
/** 403 from a G route's Origin guard (CSWSH defence). */ /** 403 from a G route's Origin guard (CSWSH defence). */
public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。") public data object Forbidden : ApiClientError("服务器拒绝了此来源Origin 校验未通过)。")
@@ -44,6 +53,14 @@ public sealed class ApiClientError(public val userMessage: String) : Exception(u
/** 500 from `GET /projects/detail` — the server failed reading the repo. */ /** 500 from `GET /projects/detail` — the server failed reading the repo. */
public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。") public data object ProjectDetailUnavailable : ApiClientError("读取项目详情失败,请稍后再试。")
/** An empty follow-up prompt was passed to `POST /live-sessions/:id/queue` (which the server 400s
* as `text must be a non-empty string`). Raised client-side, before any network I/O — mirrors the
* [ProjectPathInvalid] precedent. */
public data object QueueTextEmpty : ApiClientError("要排队的内容为空——请先输入要发送的提示词。")
/** 500 from `GET /projects/log` — the server failed reading the git log. */
public data object GitLogUnavailable : ApiClientError("读取提交记录失败,请稍后再试。")
/** Any other non-success status code. */ /** Any other non-success status code. */
public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status") public data class UnexpectedStatus(val status: Int) : ApiClientError("服务器返回了意外状态码 $status")
} }

View File

@@ -1,5 +1,6 @@
package wang.yaojia.webterm.api.routes package wang.yaojia.webterm.api.routes
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest import wang.yaojia.webterm.wire.HttpRequest
@@ -10,16 +11,30 @@ internal object HttpStatus {
const val OK = 200 const val OK = 200
const val NO_CONTENT = 204 const val NO_CONTENT = 204
const val BAD_REQUEST = 400 const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val FORBIDDEN = 403 const val FORBIDDEN = 403
const val NOT_FOUND = 404 const val NOT_FOUND = 404
const val CONFLICT = 409
const val PAYLOAD_TOO_LARGE = 413
const val TOO_MANY_REQUESTS = 429 const val TOO_MANY_REQUESTS = 429
const val INTERNAL_SERVER_ERROR = 500 const val INTERNAL_SERVER_ERROR = 500
const val SERVICE_UNAVAILABLE = 503
/** 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). */ /** Header / content-type names (no magic strings inline). */
internal object HeaderName { internal object HeaderName {
const val ORIGIN = "Origin" const val ORIGIN = "Origin"
const val CONTENT_TYPE = "Content-Type" const val CONTENT_TYPE = "Content-Type"
/**
* Explicit on the `/auth` probe: the server treats an `Accept` containing `text/html` as a browser
* form submit and answers **302** instead of 204/401 (`src/server.ts` `acceptsHtml`).
*/
const val ACCEPT = "Accept"
} }
internal object ContentType { internal object ContentType {
@@ -40,6 +55,23 @@ internal enum class OriginPolicy {
GUARDED, GUARDED,
} }
/**
* How a **401** on this route must be READ (ios-completion §1.1) — declared at the route so the
* exception is visible instead of hidden in a call site. The Android analogue of iOS
* `UnauthorizedPolicy` (APIClient/Endpoints.swift).
*/
internal enum class UnauthorizedPolicy {
/** Default — on this route a 401 can only be the access-token gate (`src/server.ts:465`). */
ACCESS_TOKEN_GATE,
/**
* The route owns its 401 and it must NOT be read as "your access token is wrong": the git-write
* family, where the server classifies a HOST-side git credential failure as
* `{status:401, error:"Push authentication required on the host."}` (`src/http/git-ops.ts:108`).
*/
ROUTE_DEFINED,
}
/** /**
* One buildable API route — an immutable snapshot; building never mutates. The Android analogue of * One buildable API route — an immutable snapshot; building never mutates. The Android analogue of
* iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call * iOS `APIRoute`. [percentEncodedQuery] is pre-encoded ONCE by the route builder (never at call
@@ -51,19 +83,37 @@ internal class ApiRoute(
val originPolicy: OriginPolicy, val originPolicy: OriginPolicy,
val body: ByteArray? = null, val body: ByteArray? = null,
val percentEncodedQuery: String? = null, val percentEncodedQuery: String? = null,
/** Explicit `Accept` when the server's behaviour depends on it (the `/auth` probe). */
val accept: String? = null,
/** See [UnauthorizedPolicy]. Defaults to the gate reading. */
val unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
) { ) {
/** /**
* Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the * Build the [HttpRequest] against [endpoint]'s scheme/host/port: the path is REPLACED, the
* query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are * query is REPLACED by [percentEncodedQuery] (dropped when null), fragment/credentials are
* dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base * dropped — the same derivation philosophy as `HostEndpoint.wsUrl`. Returns `null` if the base
* URL cannot be parsed (surfaced by the client as `InvalidRequest`). * URL cannot be parsed (surfaced by the client as `InvalidRequest`).
*
* Two headers are stamped HERE and only here, and they are ORTHOGONAL (ios-completion §1.1):
* - `Origin` **iff** the route is [OriginPolicy.GUARDED] (the CSWSH 铁律, unchanged);
* - `Cookie: webterm_auth=<t>` whenever [accessToken] is non-null — on EVERY route, read-only
* ones included, because the server's access-token gate sits in front of the whole app. A null
* token adds no header at all, keeping an unauthenticated host byte-identical to before.
* The token is secret material: it is only ever placed in this header — never in [path], never in
* [percentEncodedQuery], never logged.
*/ */
fun toHttpRequest(endpoint: HostEndpoint): HttpRequest? { fun toHttpRequest(endpoint: HostEndpoint, accessToken: String? = null): HttpRequest? {
val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null val url = buildUrl(endpoint.baseUrl, path, percentEncodedQuery) ?: return null
val headers = LinkedHashMap<String, String>() val headers = LinkedHashMap<String, String>()
if (originPolicy == OriginPolicy.GUARDED) { if (originPolicy == OriginPolicy.GUARDED) {
headers[HeaderName.ORIGIN] = endpoint.originHeader headers[HeaderName.ORIGIN] = endpoint.originHeader
} }
if (accessToken != null) {
headers[AuthCookie.HEADER_NAME] = AuthCookie.headerValue(accessToken)
}
if (accept != null) {
headers[HeaderName.ACCEPT] = accept
}
if (body != null) { if (body != null) {
headers[HeaderName.CONTENT_TYPE] = ContentType.JSON headers[HeaderName.CONTENT_TYPE] = ContentType.JSON
} }

View File

@@ -12,10 +12,10 @@ import java.util.UUID
* `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token** * `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
* pair (this client uses FCM, not APNs/VAPID). * pair (this client uses FCM, not APNs/VAPID).
* *
* RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui` * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `.../:id/queue`
* · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs` * · `GET /config/ui` · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
* G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs` * G (Origin byte-equal): `DELETE /live-sessions/:id` · `POST|DELETE /live-sessions/:id/queue`
* · `POST|DELETE /push/fcm-token` * · `POST /hook/decision` · `PUT /prefs` · `POST|DELETE /push/fcm-token`
* *
* KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD * KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
* of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` + * of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
@@ -54,9 +54,51 @@ internal object Endpoints {
percentEncodedQuery = "path=${percentEncode(path)}", percentEncodedQuery = "path=${percentEncode(path)}",
) )
/**
* `GET /projects/worktree/state?path=` — RO (w6/G7). Deliberately NARROWER than
* `/projects/detail`: it is fetched once per worktree row, so N rows must not each pay for a
* worktree listing and a CLAUDE.md read that nothing renders.
*/
fun worktreeState(path: String): ApiRoute =
ApiRoute(
HttpMethod.GET,
"/projects/worktree/state",
OriginPolicy.READ_ONLY,
percentEncodedQuery = "path=${percentEncode(path)}",
)
fun getPrefs(): ApiRoute = fun getPrefs(): ApiRoute =
ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY) ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)
/**
* `GET /live-sessions/:id/queue` — RO (W2). Same threat model as `GET /live-sessions`, so the
* server stamps no Origin guard on it and neither may we.
*/
fun queue(id: UUID): ApiRoute =
ApiRoute(HttpMethod.GET, queuePath(id), 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 ──────────────────────────────────────────────────────────────────────────────── // ── G ────────────────────────────────────────────────────────────────────────────────
fun killSession(id: UUID): ApiRoute = fun killSession(id: UUID): ApiRoute =
@@ -92,6 +134,162 @@ internal object Endpoints {
private const val FCM_TOKEN_PATH = "/push/fcm-token" private const val FCM_TOKEN_PATH = "/push/fcm-token"
// ── G: access-token validation probe (`POST /auth`, ios-completion §1.1) ───────────────
/**
* `POST /auth` — the pairing-time token-validation probe. Body `{"token":"<t>"}`.
*
* Two non-obvious requirements, both server-driven (`src/server.ts`):
* - **`Accept` must not contain `text/html`.** The route is dual-mode: an HTML-accepting request
* is treated as a browser form and answered with a **302** redirect instead of 204/401.
* - the route sits BEFORE the auth gate, so it is the one request that legitimately carries no
* auth cookie (the token is in the body — this IS the login).
*
* Guarded (a state-changing POST) so it stamps `Origin` like every other write; the server does not
* Origin-check `/auth`, but keeping the Origin-iff-guarded rule uniform avoids a special case.
*/
fun auth(token: String): ApiRoute {
val body = ModelJson.encodeToString(AuthBody.serializer(), AuthBody(token)).encodeToByteArray()
return ApiRoute(
HttpMethod.POST,
AUTH_PATH,
OriginPolicy.GUARDED,
body = body,
accept = ContentType.JSON,
)
}
private const val AUTH_PATH = "/auth"
// ── G: worktree write (create / remove / prune) ────────────────────────────────────────
//
// Guarded writes, but NOT [UnauthorizedPolicy.ROUTE_DEFINED] (F5): these land in
// `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500), and
// `src/server.ts:1182-1260` adds none. The only 401 they can ever see is the access-token gate, so
// they take the DEFAULT gate reading — pinning them route-defined made a gated host's 401 surface
// as a worktree failure instead of the token flow.
/** `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 =
gitWriteRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))
/** `POST /projects/git/commit` — `{ path, message }`. */
fun gitCommit(path: String, message: String): ApiRoute =
gitWriteRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))
/**
* `POST /projects/git/fetch` — `{ path }` (w6/G2). GUARDED even though it only updates
* `refs/remotes`: it is state-changing and spawns a network git process, so it goes through the
* single Origin-stamping point like every other write. The remote is derived SERVER-side and no
* remote or refspec is ever read from the body, so a client cannot aim it at an arbitrary URL.
* It is NOT a pull: no working tree, no index, no merge.
*/
fun gitFetch(path: String): ApiRoute =
gitWriteRoute(HttpMethod.POST, "/projects/git/fetch", FetchBody.serializer(), FetchBody(path))
/** `POST /projects/git/push` — `{ path }`. */
fun gitPush(path: String): ApiRoute =
gitWriteRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))
// ── G: W2 prompt queue (enqueue / cancel-all) ──────────────────────────────────────────
/**
* `POST /live-sessions/:id/queue` — G. Body is exactly `{ text, appendEnter }` (express.json,
* 16 kb limit).
*
* [text] is RAW KEYBOARD INPUT for a shell and is carried VERBATIM (invariant #9): JSON string
* encoding is the only transformation, and it round-trips byte-exactly through `express.json`.
* Never trim/escape/normalise it here. [appendEnter] is a FLAG — the server materialises the
* trailing `\r` so the stored entry is byte-identical to a keystroke; the client must not append
* one itself.
*/
fun enqueueFollowup(id: UUID, text: String, appendEnter: Boolean): ApiRoute =
jsonBodyRoute(
HttpMethod.POST,
queuePath(id),
EnqueueBody.serializer(),
EnqueueBody(text, appendEnter),
)
/**
* `DELETE /live-sessions/:id/queue` — G, cancel-all escape hatch. Carries **no** body: unlike
* `DELETE /projects/worktree` (the body-carrying DELETE precedent), this handler reads only `:id`
* and clears the whole FIFO, so a body would be dead weight the server never parses.
*/
fun clearQueue(id: UUID): ApiRoute =
ApiRoute(HttpMethod.DELETE, queuePath(id), OriginPolicy.GUARDED)
private fun queuePath(id: UUID): String = "/live-sessions/${pathId(id)}/queue"
/**
* Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]).
*
* The 401 reading defaults to [UnauthorizedPolicy.ACCESS_TOKEN_GATE] — correct for every route
* whose handler never writes a 401 of its own (the W2 queue: 400/404/429/503 only; the worktree
* trio: 403/400/404/500 only). Only the git-ops writes opt out, via [gitWriteRoute].
*/
private fun <T> jsonBodyRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
unauthorizedPolicy: UnauthorizedPolicy = UnauthorizedPolicy.ACCESS_TOKEN_GATE,
): ApiRoute {
val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
return ApiRoute(
method,
path,
OriginPolicy.GUARDED,
body = body,
unauthorizedPolicy = unauthorizedPolicy,
)
}
/**
* Build a GUARDED **git-ops** route with a `ModelJson`-encoded JSON body (Origin stamped in
* [ApiRoute]).
*
* Every route built here is [UnauthorizedPolicy.ROUTE_DEFINED]: the server classifies a HOST-side
* git credential failure as **401** with its own message (`src/http/git-ops.ts:108`), which the UI
* must display verbatim instead of the access-token copy (mirrors iOS `gitOpsRoute`).
*
* Scope is exactly the four routes that reach that classifier — `stage`, `commit`, `push`, `fetch`.
* The worktree trio must NOT be built here (F5): its handler has no 401 of its own.
*/
private fun <T> gitWriteRoute(
method: HttpMethod,
path: String,
serializer: kotlinx.serialization.KSerializer<T>,
value: T,
): ApiRoute = jsonBodyRoute(method, path, serializer, value, UnauthorizedPolicy.ROUTE_DEFINED)
/** 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 * 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 * matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
@@ -124,4 +322,32 @@ internal object Endpoints {
@Serializable @Serializable
private data class FcmTokenBody(val token: String) private data class FcmTokenBody(val token: String)
/** `POST /auth` body. Holds secret material — never log a request built from it. */
@Serializable
private data class AuthBody(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)
@kotlinx.serialization.Serializable
private data class FetchBody(val path: String)
@Serializable
private data class EnqueueBody(val text: String, val appendEnter: Boolean)
} }

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,399 @@
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())
}
@Test
fun renewDecodesTheRealServerResponseWhichCarriesNoDeviceId() = runTest {
// THE REAL WIRE SHAPE: `/device/:id/renew` answers `{cert, caChain, notAfter}` — it does NOT
// echo deviceId/notBefore/renewAfter (only `/device/enroll` does; see
// control-plane/src/api/renew.ts). Decoding it against the enroll DTO (deviceId required)
// made every silent renewal fail as MalformedResponse. The device id comes from the path we
// called, which is authoritative anyway.
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/renew", status = 201,
body = """{"cert":"MAEC","caChain":["MAED"],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
)
val result = client.renew("dev-9", byteArrayOf(0x02))
assertEquals("dev-9", result.deviceId, "the device id comes from the path segment we called")
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
assertNull(result.notBefore, "the renew 201 carries no notBefore")
assertNull(result.renewAfter, "the renew 201 carries no renewAfter — the client derives it")
assertEquals(1, result.caChain.size)
}
// ── recover (EXPIRED-leaf escape hatch — plain HTTPS, cert in the BODY, never mTLS) ──────────
@Test
fun recoverPostsTheExpiredLeafAndAFreshCsrWithNoAuthorizationHeader() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
body = """{"cert":"MAEC","caChain":[],"notAfter":"2026-12-06T00:00:00.000Z"}""".toByteArray(),
)
val expiredLeaf = byteArrayOf(0x30, 0x11)
val csr = byteArrayOf(0x30, 0x22)
val result = client.recover("dev-9", expiredLeaf, csr)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/device/dev-9/recover", request.url)
// Recovery is NOT mTLS-authenticated and carries no bearer: an expired client certificate
// cannot authenticate its own re-issuance (no TLS terminator will even forward one), so the
// lapsed cert travels in the BODY and proof-of-possession comes from the CSR self-signature.
assertNull(request.headers["Authorization"], "recovery carries no bearer")
val obj = bodyObject(request)
assertEquals(setOf("cert", "csr"), obj.keys, "the /recover schema is .strict() on {cert, csr}")
assertEquals(Base64.getEncoder().encodeToString(expiredLeaf), obj["cert"]!!.jsonPrimitive.content)
assertEquals(Base64.getEncoder().encodeToString(csr), obj["csr"]!!.jsonPrimitive.content)
assertEquals("dev-9", result.deviceId)
assertEquals(Instant.parse("2026-12-06T00:00:00Z"), result.notAfter)
}
@Test
fun recoverPercentEncodesTheDeviceIdPathSegment() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/a%2Fb/recover", status = 201,
body = """{"cert":"MAEC","caChain":[]}""".toByteArray(),
)
client.recover("a/b", byteArrayOf(1), byteArrayOf(2))
assertEquals("$BASE/device/a%2Fb/recover", transport.recordedRequests.single().url)
}
@Test
fun recoverRejectsEmptyArgumentsBeforeAnyNetworkIo() = runTest {
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("", byteArrayOf(1), byteArrayOf(1)) }.exceptionOrNull(),
)
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("d", ByteArray(0), byteArrayOf(1)) }.exceptionOrNull(),
)
assertEquals(
DeviceEnrollmentError.InvalidRequest,
runCatching { client.recover("d", byteArrayOf(1), ByteArray(0)) }.exceptionOrNull(),
)
assertTrue(transport.recordedRequests.isEmpty(), "no I/O for a request that cannot succeed")
}
@Test
fun recoverSurfacesA401BeyondTheGraceWindowWithoutLeakingTheCert() = runTest {
// Past `DEFAULT_EXPIRED_RENEW_GRACE_MS` the control plane answers a uniform 401. The raised
// error must carry the STATUS + the server's `error` code only — never the submitted cert or
// CSR bytes, which would put credential material into a crash log.
val expiredLeaf = byteArrayOf(0x30, 0x11, 0x22, 0x33)
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 401,
body = """{"error":"rejected"}""".toByteArray(),
)
val error = runCatching { client.recover("dev-9", expiredLeaf, byteArrayOf(0x30, 0x44)) }.exceptionOrNull()
assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
val rendered = "${error!!.message} $error"
assertFalse(
rendered.contains(Base64.getEncoder().encodeToString(expiredLeaf)),
"the raised error must not carry the certificate bytes",
)
assertFalse(rendered.contains("MDE"), "no base64 credential material in the error rendering")
}
@Test
fun recoverSurfacesA403ForARevokedDevice() = runTest {
// Recovery NEVER bypasses revocation (renew.test.ts CP6e) — surface the 403 verbatim.
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 403,
body = """{"error":"rejected"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.Http(403, "rejected"),
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
)
}
@Test
fun recoverThrowsMalformedResponseOnAnUndecodable201() = runTest {
transport.queueSuccess(
method = HttpMethod.POST, url = "$BASE/device/dev-9/recover", status = 201,
body = """{"cert":"@@not-base64@@"}""".toByteArray(),
)
assertEquals(
DeviceEnrollmentError.MalformedResponse,
runCatching { client.recover("dev-9", byteArrayOf(1), byteArrayOf(2)) }.exceptionOrNull(),
)
}
@Test
fun theBaseUrlIsReadableSoTheRotationTargetCanBePersisted() = runTest {
// The rotation driver must renew against the SAME control plane the device enrolled with; the
// enroller persists this alongside the enrollment record.
assertEquals(BASE, client.baseUrl)
assertEquals(BASE, DeviceEnrollmentClient("$BASE/", transport).baseUrl, "a trailing slash is trimmed")
}
// ── 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,228 @@
package wang.yaojia.webterm.api.enroll
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.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Duration
import java.time.Instant
/**
* The PURE rotation decision table (repo precedent: `ReconnectMachine`, `LayoutPolicy`,
* `TerminalGridMath` — a policy is a function, so it is exhaustively testable with no clock, no
* keystore and no network). Ports the iOS `CertificateRotationSchedulerTests` timing cases and adds
* the two the phone track needs and iOS lacks: the EXPIRED-leaf `/device/:id/recover` window and the
* terminal beyond-grace state.
*/
class RotationPolicyTest {
private companion object {
val NOT_BEFORE: Instant = Instant.parse("2026-08-06T00:00:00Z")
val NOT_AFTER: Instant = Instant.parse("2026-10-06T00:00:00Z")
// notBefore + 2/3 · 61d lifetime — the value the control plane itself computes.
val RENEW_AFTER: Instant = Instant.parse("2026-09-15T16:00:00Z")
fun state(
notAfter: Instant? = NOT_AFTER,
renewAfter: Instant? = RENEW_AFTER,
): DeviceRenewalState = DeviceRenewalState(deviceId = "dev-1", notAfter = notAfter, renewAfter = renewAfter)
}
// ── renewAfter derivation (the client MUST derive it: renew/recover 201s carry no renewAfter) ──
@Test
fun renewAfterIsTwoThirdsThroughTheLeafLifetime() {
// The control plane computes `notBefore + floor(2/3 · lifetime)` (device-enroll.ts
// DEFAULT_RENEW_FRACTION); the client must land on the same instant from the leaf alone,
// because `/device/:id/renew` and `/device/:id/recover` return `{cert, caChain, notAfter}`
// ONLY — no renewAfter. Without this derivation rotation would fire exactly once, ever.
assertEquals(RENEW_AFTER, RotationPolicy.renewAfterFor(NOT_BEFORE, NOT_AFTER))
}
@Test
fun renewAfterIsNullWhenEitherBoundIsUnknown() {
assertNull(RotationPolicy.renewAfterFor(null, NOT_AFTER))
assertNull(RotationPolicy.renewAfterFor(NOT_BEFORE, null))
}
@Test
fun renewAfterIsNullForANonsensicalWindow() {
// notAfter before notBefore is not a lifetime — degrade to "unknown" (fail-safe: the TLS
// stack is the real gate) instead of inventing a past instant that would renew-storm.
assertNull(RotationPolicy.renewAfterFor(NOT_AFTER, NOT_BEFORE))
}
// ── the wait branch ────────────────────────────────────────────────────────────────────────
@Test
fun aLeafInsideItsRenewWindowIsNotDue() {
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(), now = RENEW_AFTER.minusSeconds(1)),
)
}
@Test
fun aMissingRenewAfterNeverTriggers() {
// iOS fail-safe, ported verbatim: absent timing NEVER renews — the TLS stack is the real gate
// and the scheduler only pre-empts expiry.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(renewAfter = null), now = NOT_AFTER.minusSeconds(1)),
)
assertFalse(state(renewAfter = null).isRenewalDue(NOT_AFTER.minusSeconds(1)))
}
@Test
fun anUnknownNotAfterFallsBackToTheRenewAfterBranch() {
// A cert whose expiry could not be read must not be treated as expired (that would push a
// working device into recovery); it is judged solely on the advisory renew timing.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER.minusSeconds(1)),
)
assertEquals(
RotationDecision.RENEW,
RotationPolicy.decide(state(notAfter = null), now = RENEW_AFTER),
)
}
// ── the renew branch ───────────────────────────────────────────────────────────────────────
@Test
fun renewFiresAtTheRenewAfterBoundary() {
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = RENEW_AFTER))
assertTrue(state().isRenewalDue(RENEW_AFTER), "the >= boundary renews (iOS parity)")
}
@Test
fun renewStillFiresAtTheLastInstantBeforeExpiry() {
assertEquals(RotationDecision.RENEW, RotationPolicy.decide(state(), now = NOT_AFTER))
}
// ── the recover branch (expired leaf — the phone-track deadlock escape hatch) ───────────────
@Test
fun anExpiredLeafInsideTheGraceWindowRecovers() {
// mTLS `/renew` cannot authenticate an expired leaf (nginx refuses to forward one at all), so
// the only route left is the plain-HTTPS `/device/:id/recover`.
assertEquals(
RotationDecision.RECOVER,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(8))),
)
}
@Test
fun recoveryIsStillAvailableAtTheExactGraceBoundary() {
val grace = Duration.ofDays(30)
assertEquals(
RotationDecision.RECOVER,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(grace), expiredRecoveryGrace = grace),
)
}
@Test
fun aLeafExpiredBeyondTheGraceWindowRequiresAFreshEnroll() {
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(state(), now = NOT_AFTER.plus(Duration.ofDays(31))),
)
}
@Test
fun aZeroGraceDisablesRecoveryEntirely() {
// Mirrors the control plane's `expiredRenewGraceMs = 0` posture (renew.test.ts CP6e).
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(
state(),
now = NOT_AFTER.plusMillis(1),
expiredRecoveryGrace = Duration.ZERO,
),
)
}
@Test
fun theTerminalStateWinsOverTheFailureBackoff() {
// Beyond grace nothing can ever succeed, so the answer must be the terminal one even while a
// backoff is armed — otherwise the user is told "retrying" forever (the exact failure mode
// the agent's 6380 identical warnings came from).
assertEquals(
RotationDecision.RE_ENROLL_REQUIRED,
RotationPolicy.decide(
state(),
now = NOT_AFTER.plus(Duration.ofDays(31)),
lastFailureAt = NOT_AFTER.plus(Duration.ofDays(31)).minusSeconds(1),
),
)
}
// ── the backoff branch (a failed attempt must not hammer the control plane) ─────────────────
@Test
fun aRecentFailureBacksOffInsteadOfRetryingImmediately() {
// Every foreground fires a pass; the control plane rate-limits renewals to 30/hour/identity,
// so an un-throttled retry turns one failure into a 429 storm.
val now = RENEW_AFTER.plus(Duration.ofMinutes(1))
assertEquals(
RotationDecision.BACKING_OFF,
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minus(Duration.ofMinutes(1))),
)
}
@Test
fun theBackoffExpiresAndTheRenewIsRetried() {
val now = RENEW_AFTER.plus(Duration.ofHours(1))
assertEquals(
RotationDecision.RENEW,
RotationPolicy.decide(
state(),
now = now,
lastFailureAt = now.minus(RotationPolicy.DEFAULT_FAILURE_BACKOFF),
),
)
}
@Test
fun aRecentFailureAlsoThrottlesTheRecoveryPath() {
val now = NOT_AFTER.plus(Duration.ofDays(8))
assertEquals(
RotationDecision.BACKING_OFF,
RotationPolicy.decide(state(), now = now, lastFailureAt = now.minusSeconds(30)),
)
}
@Test
fun aFailureNeverTurnsAnIdleLeafIntoAnAttempt() {
// NOT_DUE has no attempt to throttle — reporting BACKING_OFF there would be a lie.
assertEquals(
RotationDecision.NOT_DUE,
RotationPolicy.decide(
state(),
now = RENEW_AFTER.minusSeconds(1),
lastFailureAt = RENEW_AFTER.minusSeconds(2),
),
)
}
// ── boundary validation ────────────────────────────────────────────────────────────────────
@Test
fun negativeDurationsAreRejectedAtTheBoundary() {
assertThrows(IllegalArgumentException::class.java) {
RotationPolicy.decide(state(), now = RENEW_AFTER, expiredRecoveryGrace = Duration.ofDays(-1))
}
assertThrows(IllegalArgumentException::class.java) {
RotationPolicy.decide(state(), now = RENEW_AFTER, failureBackoff = Duration.ofMinutes(-1))
}
}
@Test
fun theStateCarriesNoCredentialInItsToString() {
// The rotation state is logged/surfaced; it must never be able to carry key or cert bytes.
val rendered = state().toString()
assertTrue(rendered.contains("dev-1"), "the non-secret device id is the only identifier here")
assertFalse(rendered.contains("MII"), "no DER/PEM material may appear in a loggable rendering")
}
}

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,74 @@
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.DisplayName
import org.junit.jupiter.api.Test
/**
* Tolerant decode for the W2 queue read (`GET /live-sessions/:id/queue` → `{length, items}`) and for
* the additive `LiveSessionInfo.queueLength` the badge reads. The server is UNTRUSTED here: unknown
* keys, missing keys and wrong-typed items must degrade, never crash — and the queued strings, which
* are raw keyboard bytes, must come back verbatim.
*/
@DisplayName("W2 queue models — tolerant decode + verbatim items")
class PromptQueueTest {
private fun snapshot(json: String): QueueSnapshot? =
LossyDecode.objectOrNull(json.toByteArray(), QueueSnapshot.serializer())
@Test
fun `decodes length and items`() {
val s = snapshot("""{"length":2,"items":["first","second"]}""")!!
assertEquals(2, s.length)
assertEquals(listOf("first", "second"), s.items)
}
@Test
fun `queued items keep control characters and CJK verbatim`() {
//  + TAB + CR + CJK, exactly as the server stored the keystroke bytes.
val s = snapshot("""{"length":1,"items":["\t你好\r"]}""")!!
assertEquals("\t你好\r", s.items.single())
}
@Test
fun `an empty queue and unknown keys both decode`() {
val s = snapshot("""{"length":0,"items":[],"futureField":true}""")!!
assertEquals(0, s.length)
assertTrue(s.items.isEmpty())
}
@Test
fun `missing fields fall back to an empty queue and a non-object body is null`() {
val s = snapshot("{}")!!
assertEquals(0, s.length)
assertTrue(s.items.isEmpty())
assertNull(snapshot("[]"))
assertNull(snapshot("garbage"))
}
// ── LiveSessionInfo.queueLength (additive optional, src/types.ts:318) ─────────────────────
private val base =
"""{"id":"11111111-2222-3333-4444-555555555555","createdAt":1,"clientCount":0,"exited":false,"cols":80,"rows":24"""
private fun info(extra: String): LiveSessionInfo? =
LossyDecode.objectOrNull("$base$extra}".toByteArray(), LiveSessionInfo.serializer())
@Test
fun `queueLength decodes when present`() {
assertEquals(4, info(""","queueLength":4""")!!.queueLength)
}
@Test
fun `queueLength is null on a pre-W2 server that omits it`() {
assertNull(info("")!!.queueLength)
}
@Test
fun `a wrong-typed queueLength does not drop the whole session`() {
// A garbled value must not make a running session invisible — the field degrades to null.
assertNull(info(",\"queueLength\":\"lots\"")!!.queueLength)
}
}

View File

@@ -0,0 +1,141 @@
package wang.yaojia.webterm.api.models
import kotlinx.serialization.json.Json
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.DisplayName
import org.junit.jupiter.api.Test
/**
* w6/G1 sync state.
*
* The load-bearing rule, quoted from the server commit that introduced the feature: *"Exactly ONE state
* may render green: ↑0 ↓0 AND a fresh fetch. Green means 'I checked, ignore this'; getting it wrong is
* lying to the user."* Most of this file exists to pin the ways that could go wrong — above all the
* no-upstream fall-through, which the server's own commit message calls "the easiest bug to ship here".
*/
@DisplayName("SyncState — when the all-clear may be rendered")
class SyncStateTest {
private val json = Json { ignoreUnknownKeys = true }
private val now = 1_700_000_000_000L
private fun fresh() = now - 60_000L // a minute ago
private fun stale() = now - (SyncState.FETCH_FRESH_WINDOW_MS + 60_000L)
@Test
@DisplayName("decodes the full server shape")
fun decodesFull() {
val s = json.decodeFromString<SyncState>(
"""{"upstream":"origin/develop","ahead":9,"behind":0,"lastFetchMs":$now,"detached":false}""",
)
assertEquals("origin/develop", s.upstream)
assertEquals(9, s.ahead)
assertEquals(0, s.behind)
}
@Test
@DisplayName("every field is optional — each degrades independently on the server")
fun decodesEmpty() {
val s = json.decodeFromString<SyncState>("{}")
assertNull(s.upstream)
assertNull(s.ahead)
assertNull(s.behind)
assertNull(s.lastFetchMs)
assertFalse(s.detached)
assertFalse(s.hasUpstream)
}
// ── the green state ──────────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("↑0 ↓0 with a fresh fetch is the ONLY green state")
fun theOneGreenState() {
val s = SyncState(upstream = "origin/main", ahead = 0, behind = 0, lastFetchMs = fresh())
assertTrue(s.isFullySynced(now))
}
@Test
@DisplayName("NO UPSTREAM is never green — the fall-through the server calls the easiest bug here")
fun noUpstreamIsNeverGreen() {
// The normal state of a fresh worktree branch. ahead/behind are UNDEFINED, not zero.
val s = SyncState(upstream = null, ahead = null, behind = null, lastFetchMs = fresh())
assertFalse(s.isFullySynced(now), "a branch tracking nothing has not been verified as in sync")
assertFalse(s.hasUpstream)
}
@Test
@DisplayName("undefined counts are not zero, even with an upstream and a fresh fetch")
fun undefinedCountsAreNotZero() {
assertFalse(SyncState("origin/main", ahead = null, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
assertFalse(SyncState("origin/main", ahead = 0, behind = null, lastFetchMs = fresh()).isFullySynced(now))
}
@Test
@DisplayName("a stale fetch is not green — behind=0 from a stale FETCH_HEAD proves nothing")
fun staleFetchIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = stale())
assertFalse(s.isFullySynced(now))
assertTrue(s.isBehindStale(now))
}
@Test
@DisplayName("never fetched is not green")
fun neverFetchedIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = null)
assertFalse(s.isFullySynced(now))
assertFalse(s.isFetchFresh(now))
}
@Test
@DisplayName("a detached HEAD is not green")
fun detachedIsNotGreen() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = fresh(), detached = true)
assertFalse(s.isFullySynced(now))
}
@Test
@DisplayName("anything unpushed or unpulled is not green")
fun pendingWorkIsNotGreen() {
assertFalse(SyncState("origin/main", ahead = 1, behind = 0, lastFetchMs = fresh()).isFullySynced(now))
assertFalse(SyncState("origin/main", ahead = 0, behind = 1, lastFetchMs = fresh()).isFullySynced(now))
}
// ── freshness edges ──────────────────────────────────────────────────────────────────────────────
@Test
@DisplayName("a fetch timestamp in the future is not treated as fresh")
fun futureFetchIsNotFresh() {
val s = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now + 60_000L)
assertFalse(s.isFetchFresh(now), "clock skew is not evidence that we checked")
assertFalse(s.isFullySynced(now))
}
@Test
@DisplayName("the freshness boundary is inclusive at exactly the window")
fun boundaryInclusive() {
val atEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS)
assertTrue(atEdge.isFetchFresh(now))
val pastEdge = SyncState("origin/main", ahead = 0, behind = 0, lastFetchMs = now - SyncState.FETCH_FRESH_WINDOW_MS - 1)
assertFalse(pastEdge.isFetchFresh(now))
}
@Test
@DisplayName("staleness is only claimed when there is an upstream to be stale about")
fun noUpstreamIsNotStale() {
assertFalse(SyncState(upstream = null, lastFetchMs = null).isBehindStale(now))
}
@Test
@DisplayName("WorktreeState decodes the narrow per-row shape")
fun worktreeStateDecodes() {
val w = json.decodeFromString<WorktreeState>(
"""{"path":"/repo/.claude/worktrees/x","branch":"wt-x","dirtyCount":3,
"sync":{"upstream":"origin/wt-x","ahead":2}}""",
)
assertEquals("wt-x", w.branch)
assertEquals(3, w.dirtyCount)
assertEquals(2, w.sync?.ahead)
}
}

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.io.IOException
/**
* B5 · `POST /auth` as the pairing-time validation probe (FROZEN contract, ios-completion §1.1).
*
* The four distinct server outcomes must stay distinguishable:
* | 204 **with** `Set-Cookie` | token correct → persist it |
* | 204 **without** `Set-Cookie` | the host has NO auth configured → do NOT persist, and do NOT treat as authenticated |
* | 401 | the token is wrong → UI says "令牌不正确" |
* | 429 | rate-limited (10/min/IP) → UI says "尝试过多" |
*
* Plus the request shape the server needs: JSON body `{"token":"<t>"}` and an `Accept` that does NOT
* contain `text/html` (an HTML-accepting request is treated as a browser form and answered with a 302
* instead of 204/401).
*/
class AuthProbeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun queueAuth(status: Int, headers: Map<String, String> = emptyMap()) {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/auth", status = status, headers = headers)
}
private val setCookieHeader = mapOf(
"Set-Cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict",
)
// ── the four frozen outcomes ────────────────────────────────────────────────────────────────
@Test
fun `204 with Set-Cookie means the token is correct`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `204 without Set-Cookie means the host has no auth configured`() = runTest {
queueAuth(204)
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"204-no-cookie must NEVER be read as 'authenticated' (frozen §1.1)",
)
}
@Test
fun `401 means the token is wrong`() = runTest {
queueAuth(401)
assertEquals(AuthProbeResult.InvalidToken, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `429 means rate-limited`() = runTest {
queueAuth(429)
assertEquals(AuthProbeResult.RateLimited, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `any other status is surfaced as Unexpected with the code`() = runTest {
queueAuth(302) // e.g. a proxy/login redirect — never silently treated as success
assertEquals(AuthProbeResult.Unexpected(302), probeAccessToken(endpoint, transport, TOKEN))
}
// ── request shape ───────────────────────────────────────────────────────────────────────────
@Test
fun `the probe posts the token as JSON with an Accept that excludes text-html`() = runTest {
queueAuth(204, setCookieHeader)
probeAccessToken(endpoint, transport, TOKEN)
val request = transport.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals("$BASE/auth", request.url)
assertEquals("""{"token":"$TOKEN"}""", request.body?.decodeToString())
assertEquals("application/json", request.headers["Content-Type"])
val accept = request.headers["Accept"].orEmpty()
assertTrue(accept.isNotEmpty(), "Accept must be explicit, not left to the HTTP stack")
assertFalse(accept.contains("text/html"), "text/html would make the server answer 302, not 204/401")
}
@Test
fun `the token never appears in the URL`() = runTest {
queueAuth(401)
probeAccessToken(endpoint, transport, TOKEN)
assertFalse(
transport.recordedRequests.single().url.contains(TOKEN),
"a secret must never travel in a URL (query strings land in logs/history)",
)
}
// ── boundary validation + transport failure ─────────────────────────────────────────────────
@Test
fun `a malformed token is rejected before any network IO`() = runTest {
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "short"))
assertEquals(AuthProbeResult.Malformed, probeAccessToken(endpoint, transport, "has spaces in it here"))
assertTrue(transport.recordedRequests.isEmpty(), "a malformed token must not hit the network")
}
@Test
fun `a whitespace-padded token is trimmed once and accepted`() = runTest {
queueAuth(204, setCookieHeader)
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, " $TOKEN\n"))
assertEquals("""{"token":"$TOKEN"}""", transport.recordedRequests.single().body?.decodeToString())
}
@Test
fun `a transport failure propagates so the caller can classify it`() = runTest {
transport.queueFailure(method = HttpMethod.POST, url = "$BASE/auth", error = IOException("refused"))
val thrown = runCatching { probeAccessToken(endpoint, transport, TOKEN) }.exceptionOrNull()
assertTrue(thrown is IOException, "network classification stays with PairingError.classify")
}
@Test
fun `a Set-Cookie header is recognised case-insensitively`() = runTest {
queueAuth(204, mapOf("set-cookie" to "${AuthCookie.NAME}=$TOKEN; Path=/"))
assertEquals(AuthProbeResult.Accepted, probeAccessToken(endpoint, transport, TOKEN))
}
@Test
fun `a Set-Cookie for some other cookie does not count as accepted`() = runTest {
queueAuth(204, mapOf("Set-Cookie" to "sessionid=abc; Path=/"))
assertEquals(
AuthProbeResult.AuthDisabled,
probeAccessToken(endpoint, transport, TOKEN),
"only a webterm_auth cookie proves the token was accepted",
)
}
}

View File

@@ -0,0 +1,90 @@
package wang.yaojia.webterm.api.pairing
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
/**
* B5 · the two-step pairing probe under an access-token-protected host:
* - both HTTP legs (`GET /live-sessions` and the guarded `DELETE /live-sessions/:id`) carry the
* hand-written `Cookie: webterm_auth=<t>`;
* - a **401** on the reachability leg is [PairingError.AccessTokenRequired] — NOT
* "端口对吗?" ([PairingError.HttpOkButNotWebTerminal]) and NOT an Origin problem, so the UI can ask
* for the token instead of sending the user on a wild goose chase;
* - with no token configured nothing changes (no `Cookie` header at all).
*/
class PairingProbeTokenTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
const val SERVER_ID = "11111111-1111-4111-8111-111111111111"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val http = FakeHttpTransport()
private val ws = FakeTermTransport()
private fun scriptHappyPath() {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 204)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
}
private suspend fun probe(tokens: AccessTokenSource): PairingProbeResult =
runPairingProbeCore(endpoint, http, ws, timeout = null, tokens = tokens)
@Test
fun `both HTTP legs of the probe carry the auth cookie`() = runTest {
scriptHappyPath()
val result = probe(AccessTokenSource { TOKEN })
assertEquals(PairingProbeResult.Success(endpoint), result)
assertEquals(2, http.recordedRequests.size, "reachability GET + guarded kill DELETE")
http.recordedRequests.forEach { request ->
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
request.headers[AuthCookie.HEADER_NAME],
"every probe request must present the token",
)
}
}
@Test
fun `with no token the probe requests are byte-identical to before the feature`() = runTest {
scriptHappyPath()
probe(AccessTokenSource.NONE)
http.recordedRequests.forEach { request ->
assertFalse(request.headers.containsKey(AuthCookie.HEADER_NAME))
}
}
@Test
fun `a 401 on the reachability leg asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
@Test
fun `a 401 on the guarded kill leg also asks for an access token`() = runTest {
http.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
http.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$SERVER_ID", status = 401)
ws.emit("""{"type":"attached","sessionId":"$SERVER_ID"}""")
val result = probe(AccessTokenSource.NONE)
assertEquals(PairingProbeResult.Failure(PairingError.AccessTokenRequired), result)
}
}

View File

@@ -0,0 +1,207 @@
package wang.yaojia.webterm.api.routes
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.AuthCookie
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* B5 · the access-token cookie on the REST surface (FROZEN contract, ios-completion §1.1):
* - `Cookie: webterm_auth=<t>` is stamped on **EVERY** route — read-only GETs included — because the
* server's auth gate runs in front of the whole app, not just the guarded routes;
* - it is **orthogonal to `Origin`**: a RO route carries the cookie and still NO Origin (the
* Origin-iff-guarded 铁律 is untouched);
* - no token configured ⇒ NO `Cookie` header at all (zero-config LAN behaviour is byte-identical);
* - a **401** on a route whose 401 can only be the gate is the typed [ApiClientError.Unauthorized],
* never a generic status error, so the UI can route the user to re-enter the token — while the
* git-write family, which defines its own 401 (`src/http/git-ops.ts:108`), keeps the server's
* message (E1 fix; see the rewritten push case below).
*/
class AccessTokenCookieTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val TOKEN = "0123456789abcdefTOKEN"
val ID: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
const val ID_STR = "11111111-2222-4333-8444-555555555555"
}
private val endpoint: HostEndpoint = requireNotNull(HostEndpoint.fromBaseUrl(BASE))
private val transport = FakeHttpTransport()
private fun clientWithToken(): ApiClient =
ApiClient(endpoint, transport, AccessTokenSource { TOKEN })
private fun clientWithoutToken(): ApiClient = ApiClient(endpoint, transport)
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun assertCookie(index: Int) {
val headers = transport.recordedRequests[index].headers
assertEquals(
"${AuthCookie.NAME}=$TOKEN",
headers[AuthCookie.HEADER_NAME],
"every request must carry the hand-written auth cookie",
)
}
@Test
fun `a read-only GET carries the cookie and still no Origin`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithToken().liveSessions()
assertCookie(0)
assertFalse(
transport.recordedRequests[0].headers.containsKey(HeaderName.ORIGIN),
"the cookie is additive — a RO route must still never carry Origin",
)
}
@Test
fun `a guarded write carries BOTH the byte-equal Origin and the cookie`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/hook/decision", status = 204)
clientWithToken().hookDecision(ID, HookDecision.ALLOW, "cap-tok-123")
assertCookie(0)
assertEquals(endpoint.originHeader, transport.recordedRequests[0].headers[HeaderName.ORIGIN])
}
@Test
fun `the cookie rides every route shape - RO query, guarded DELETE and guarded PUT alike`() = runTest {
transport.queueSuccess(
url = "$BASE/projects/detail?path=%2Frepo",
body = """{"name":"repo","path":"/repo","isGit":true}""".toByteArray(),
)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/live-sessions/$ID_STR", status = 204)
transport.queueSuccess(method = HttpMethod.PUT, url = "$BASE/prefs", body = "{}".toByteArray())
val client = clientWithToken()
client.projectDetail("/repo")
client.killSession(ID)
client.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create())
assertEquals(3, transport.recordedRequests.size)
transport.recordedRequests.indices.forEach { assertCookie(it) }
}
@Test
fun `no configured token means no Cookie header at all`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
clientWithoutToken().liveSessions()
assertFalse(
transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME),
"an unauthenticated host must see a byte-identical request to before the feature",
)
}
@Test
fun `the token is re-read per request so a token stored later takes effect`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
transport.queueSuccess(url = "$BASE/live-sessions", body = "[]".toByteArray())
var stored: String? = null
val client = ApiClient(endpoint, transport, AccessTokenSource { stored })
client.liveSessions()
stored = TOKEN
client.liveSessions()
assertFalse(transport.recordedRequests[0].headers.containsKey(AuthCookie.HEADER_NAME))
assertCookie(1)
}
@Test
fun `a 401 on a read-only route throws the typed Unauthorized error`() = runTest {
transport.queueSuccess(url = "$BASE/live-sessions", status = 401, body = """{"error":"authentication required"}""".toByteArray())
val error = errorOf { clientWithoutToken().liveSessions() }
assertEquals(ApiClientError.Unauthorized, error)
assertTrue(
ApiClientError.Unauthorized.userMessage.isNotBlank(),
"the UI needs actionable copy for a missing token",
)
}
/**
* E1 · REWRITTEN (this case previously asserted the opposite and pinned a real defect).
*
* `POST /projects/git/push` defines its OWN 401: `src/http/git-ops.ts:108` classifies a HOST-side
* git credential failure ("could not read Username", "permission denied (publickey)", …) as
* `{status:401, error:"Push authentication required on the host."}`. Swallowing that into
* [ApiClientError.Unauthorized] tells the user their *access token* is broken and sends them to
* rotate a secret that is fine, while hiding the real cause. iOS gets this right via
* `unauthorizedPolicy: .routeDefined` (APIClient/GitWrite.swift), and plan §4 item 49 requires the
* distinction — so the route's own message must reach [GitWriteOutcome.Rejected] verbatim.
*/
@Test
fun `a 401 on git push is the host's own git-credential failure, surfaced verbatim`() = runTest {
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/projects/git/push",
status = 401,
body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray(),
)
val outcome = clientWithToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, "Push authentication required on the host."), outcome)
}
@Test
fun `the route-defined 401 covers the whole git-ops family, not just push`() = runTest {
val body = """{"ok":false,"error":"Push authentication required on the host."}""".toByteArray()
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/stage", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/commit", status = 401, body = body)
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/fetch", status = 401, body = body)
val client = clientWithToken()
assertEquals(401, (client.gitStage("/repo", listOf("a.txt"), true) as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.gitCommit("/repo", "msg") as GitWriteOutcome.Rejected).status)
assertEquals(401, (client.gitFetch("/repo") as GitWriteOutcome.Rejected).status)
}
/**
* F5 · the worktree trio is NOT part of that family. `src/http/worktrees.ts` emits no 401 (403
* kill-switch / 400 / 404 / 500 only) and `src/server.ts:1182-1260` adds none, so the ONLY 401 those
* routes can ever see is the access-token gate. Reading it as a git rejection stranded the user on a
* "worktree failed" message instead of the token flow.
*/
@Test
fun `a 401 on a worktree write is the access-token gate, not a git rejection`() = runTest {
val gateBody = """{"error":"authentication required"}""".toByteArray()
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/worktree", status = 401, body = gateBody)
transport.queueSuccess(method = HttpMethod.DELETE, url = "$BASE/projects/worktree", status = 401, body = gateBody)
transport.queueSuccess(
method = HttpMethod.POST,
url = "$BASE/projects/worktree/prune",
status = 401,
body = gateBody,
)
val client = clientWithToken()
assertEquals(ApiClientError.Unauthorized, errorOf { client.createWorktree("/repo", "feat/x") })
assertEquals(ApiClientError.Unauthorized, errorOf { client.removeWorktree("/repo", "/repo/wt", false) })
assertEquals(ApiClientError.Unauthorized, errorOf { client.pruneWorktrees("/repo") })
}
@Test
fun `a git write with no server message still reports the status, never the token copy`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = "$BASE/projects/git/push", status = 401)
val outcome = clientWithoutToken().gitPush("/repo")
assertEquals(GitWriteOutcome.Rejected(401, null), outcome)
}
}

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,141 @@
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.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.QueueWriteOutcome
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* Status → outcome mapping for the W2 prompt queue, read off the server handlers
* (`src/server.ts` ~605/644/654):
*
* POST: 200 `{length}` → [QueueWriteOutcome.Ok] · 409 `{error:"full"}` → `Full` ·
* 413 → `TooLarge` · 503 `{error:"queue disabled"}` → `Disabled` ·
* 404 `{error:"unknown"|"exited"}` → `SessionGone` · 429 (empty) → `RateLimited` ·
* 400/403/anything else → `Rejected(status, safe error)`.
* DELETE: 200 `{length:0}` → `Ok(0)` · 404 → `SessionGone` · 403 → `Rejected`.
* GET: 200 → snapshot · 404 → [ApiClientError.SessionNotFound].
*
* 429 comes from the shared `QUEUE_RATE_MAX = 20`/min/IP bucket and is a DISTINCT outcome precisely
* so no caller auto-retries into it (the `GitWriteOutcome.RateLimited` precedent).
*/
@DisplayName("W2 queue — status mapping")
class ApiClientQueueTest {
private companion object {
const val BASE = "http://h:3000"
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private val queueUrl = "$BASE/live-sessions/$ID/queue"
private suspend fun errorOf(block: suspend () -> Unit): Throwable? = runCatching { block() }.exceptionOrNull()
private fun queuePost(status: Int, body: String = "") =
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, status = status, body = body.toByteArray())
private fun queueDelete(status: Int, body: String = "") =
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, status = status, body = body.toByteArray())
private suspend fun enqueue(text: String = "do the thing") = client.enqueueFollowup(ID, text, appendEnter = true)
// ── POST ──────────────────────────────────────────────────────────────────────────────────
@Test
fun `a 200 yields Ok carrying the new depth`() = runTest {
queuePost(200, """{"length":3}""")
assertEquals(QueueWriteOutcome.Ok(3), enqueue())
}
@Test
fun `a 200 with a garbled body still succeeds with an unknown depth of zero`() = runTest {
queuePost(200, "not json")
assertEquals(QueueWriteOutcome.Ok(0), enqueue())
}
@Test
fun `409 full 413 too-large and 503 disabled are distinct typed outcomes`() = runTest {
queuePost(409, """{"error":"full"}""")
assertEquals(QueueWriteOutcome.Full, enqueue())
queuePost(413, """{"error":"text too large"}""")
assertEquals(QueueWriteOutcome.TooLarge, enqueue())
queuePost(503, """{"error":"queue disabled"}""")
assertEquals(QueueWriteOutcome.Disabled, enqueue())
}
@Test
fun `404 unknown or exited is SessionGone`() = runTest {
queuePost(404, """{"error":"unknown"}""")
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
queuePost(404, """{"error":"exited"}""")
assertEquals(QueueWriteOutcome.SessionGone, enqueue())
}
@Test
fun `429 from the shared 20-per-minute bucket is RateLimited and nothing is retried`() = runTest {
queuePost(429)
assertEquals(QueueWriteOutcome.RateLimited, enqueue())
// Exactly ONE request went out — a rate-limited enqueue must never auto-retry.
assertEquals(1, transport.recordedRequests.size)
}
@Test
fun `400 and 403 surface Rejected with the server's safe message`() = runTest {
queuePost(400, """{"error":"text must be a non-empty string"}""")
assertEquals(QueueWriteOutcome.Rejected(400, "text must be a non-empty string"), enqueue())
// The Origin guard 403s with an EMPTY body — message degrades to null, never throws.
queuePost(403)
assertEquals(QueueWriteOutcome.Rejected(403, null), enqueue())
}
@Test
fun `an odd non-error status is UnexpectedStatus`() = runTest {
queuePost(302)
assertTrue(errorOf { enqueue() } is ApiClientError.UnexpectedStatus)
}
@Test
fun `an empty prompt is rejected before any network IO`() = runTest {
assertEquals(ApiClientError.QueueTextEmpty, errorOf { client.enqueueFollowup(ID, "", appendEnter = true) })
assertTrue(transport.recordedRequests.isEmpty(), "must not hit the network for an empty prompt")
}
// ── DELETE ────────────────────────────────────────────────────────────────────────────────
@Test
fun `clearQueue maps 200 404 403 and 429`() = runTest {
queueDelete(200, """{"length":0}""")
assertEquals(QueueWriteOutcome.Ok(0), client.clearQueue(ID))
queueDelete(404)
assertEquals(QueueWriteOutcome.SessionGone, client.clearQueue(ID))
queueDelete(403)
assertEquals(QueueWriteOutcome.Rejected(403, null), client.clearQueue(ID))
queueDelete(429)
assertEquals(QueueWriteOutcome.RateLimited, client.clearQueue(ID))
}
// ── GET ───────────────────────────────────────────────────────────────────────────────────
@Test
fun `queue maps 404 to SessionNotFound and a garbled 200 to InvalidResponseBody`() = runTest {
transport.queueSuccess(url = queueUrl, status = 404)
assertEquals(ApiClientError.SessionNotFound, errorOf { client.queue(ID) })
transport.queueSuccess(url = queueUrl, body = "[]".toByteArray())
assertEquals(ApiClientError.InvalidResponseBody, errorOf { client.queue(ID) })
}
}

View File

@@ -0,0 +1,207 @@
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) })
}
/**
* E1 (narrowed, F5) · The 401 split declared AT the route (mirrors iOS `UnauthorizedPolicy`).
*
* **Route-defined 401 = exactly the four `/projects/git/…` writes, and only because of the ONE
* server line that can produce it:** `src/http/git-ops.ts:108` turns a HOST-side git credential
* failure into `{status:401, error:"Push authentication required on the host."}`. That classifier is
* reached only from `stageFiles`/`commit`/`push`/`fetch`.
*
* **The worktree trio is NOT route-defined.** `createWorktree`/`removeWorktree`/`pruneWorktrees`
* land in `src/http/worktrees.ts`, which emits no 401 at all (403 kill-switch / 400 / 404 / 500),
* and `src/server.ts:1182-1260` adds none. Pinning them ROUTE_DEFINED meant that on a token-gated
* host the GATE's 401 surfaced as a git rejection instead of the token flow.
*
* Every other 401 the server can emit belongs to the gate: `src/server.ts:425` (`/auth` invalid),
* `:472` (the gate itself) and `:1453`/`:1463` (the WS upgrade) — none of which is a route here.
*/
@Test
fun `route-defined 401 is exactly the four git-ops writes, everything else is the gate`() {
val gitOpsWrites = listOf(
Endpoints.gitStage("/r", listOf("f"), true),
Endpoints.gitCommit("/r", "m"),
Endpoints.gitPush("/r"),
Endpoints.gitFetch("/r"),
)
val gateRoutes = listOf(
// The worktree trio: guarded writes, but the server has no 401 of its own for them.
Endpoints.createWorktree("/r", "b", null),
Endpoints.removeWorktree("/r", "/r/x", false),
Endpoints.pruneWorktrees("/r"),
Endpoints.liveSessions(),
Endpoints.projects(),
Endpoints.projectLog("/r", null),
Endpoints.killSession(UUID.randomUUID()),
Endpoints.putPrefs(wang.yaojia.webterm.api.models.UiPrefs.create()),
)
assertTrue(gitOpsWrites.all { it.unauthorizedPolicy == UnauthorizedPolicy.ROUTE_DEFINED })
gateRoutes.forEach {
assertEquals(
UnauthorizedPolicy.ACCESS_TOKEN_GATE,
it.unauthorizedPolicy,
"${it.method} ${it.path} has no server-side 401 of its own — a 401 there IS the token gate",
)
}
}
}

View File

@@ -0,0 +1,162 @@
package wang.yaojia.webterm.api.routes
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.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
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 W2 prompt-queue trio
* (`src/server.ts` ~605/644/654):
*
* - `GET /live-sessions/:id/queue` — read-only ⇒ MUST NOT carry Origin;
* - `POST /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin + JSON body;
* - `DELETE /live-sessions/:id/queue` — state-changing ⇒ MUST carry a byte-equal Origin, and (unlike
* `DELETE /projects/worktree`, the precedent that DOES carry a body) MUST NOT carry a body — the
* server handler reads nothing but `:id` and clears the whole queue.
*
* A route reclassified read↔write turns this red instead of silently shipping either a CSWSH hole or a
* write the server 403s.
*
* The enqueued text is RAW KEYBOARD INPUT for a shell (invariant #9): it must survive the JSON body
* **verbatim** — no trim, no escape-rewrite, no Unicode normalisation, and no client-side `\r`
* (`appendEnter` is a flag the SERVER materialises).
*/
@DisplayName("W2 queue routes — shape, Origin policy, verbatim body")
class QueueRouteShapeTest {
private companion object {
const val BASE = "http://192.168.1.5:3000"
const val ORIGIN = "http://192.168.1.5:3000"
val ID: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555")
}
private val transport = FakeHttpTransport()
private val client = ApiClient(HostEndpoint.fromBaseUrl(BASE)!!, transport)
private val queueUrl = "$BASE/live-sessions/$ID/queue"
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")
/** Parse the recorded request body back out of JSON — proves round-trip byte-exactness without
* pinning any particular escaping strategy. */
private fun bodyText(r: HttpRequest): String? =
Json.parseToJsonElement(r.body!!.decodeToString()).jsonObject["text"]?.jsonPrimitive?.content
// ── GET — read-only, no Origin ────────────────────────────────────────────────────────────
@Test
fun `queue is a read-only GET with no Origin and no body`() = runTest {
transport.queueSuccess(url = queueUrl, body = """{"length":2,"items":["a","b"]}""".toByteArray())
val snapshot = client.queue(ID)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertEquals(queueUrl, r.url)
assertReadOnly(r)
assertNull(r.body, "a GET must not carry a body")
assertEquals(2, snapshot.length)
assertEquals(listOf("a", "b"), snapshot.items)
}
// ── POST — guarded, JSON body ─────────────────────────────────────────────────────────────
@Test
fun `enqueueFollowup is a guarded POST with a text-appendEnter body`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, "run the tests", appendEnter = true)
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals(queueUrl, r.url)
assertGuarded(r)
assertEquals(ContentType.JSON, r.headers[HeaderName.CONTENT_TYPE])
assertEquals("""{"text":"run the tests","appendEnter":true}""", r.body?.decodeToString())
}
@Test
fun `appendEnter false is sent verbatim and the client never appends CR itself`() = runTest {
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, "no enter", appendEnter = false)
assertEquals("""{"text":"no enter","appendEnter":false}""", last().body?.decodeToString())
assertEquals("no enter", bodyText(last()))
}
@Test
fun `control characters and CJK survive the body byte-exactly`() = runTest {
// ESC[A (up-arrow), a literal TAB, ETX (Ctrl-C), CR, a stray NUL, CJK, an emoji, and
// deliberate surrounding whitespace that must NOT be trimmed.
val raw = " \u001B[A\t\u0003 \u0000 \u4F60\u597D\uFF0C\u4E16\u754C \uD83C\uDF0F caf\u00E9\r\n "
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
client.enqueueFollowup(ID, raw, appendEnter = false)
assertEquals(raw, bodyText(last()), "queued prompt bytes must travel verbatim (invariant #9)")
}
// ── DELETE — guarded, and (unlike DELETE /projects/worktree) body-less ────────────────────
@Test
fun `clearQueue is a guarded DELETE with NO body`() = runTest {
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
client.clearQueue(ID)
val r = last()
assertEquals(HttpMethod.DELETE, r.method)
assertEquals(queueUrl, r.url)
assertGuarded(r)
assertNull(r.body, "DELETE /live-sessions/:id/queue takes no body (server reads only :id)")
assertFalse(r.headers.containsKey(HeaderName.CONTENT_TYPE), "no body ⇒ no Content-Type")
}
// ── the batch invariant ───────────────────────────────────────────────────────────────────
@Test
fun `only the two writes carry Origin (batch invariant)`() = runTest {
transport.queueSuccess(url = queueUrl, body = """{"length":0,"items":[]}""".toByteArray())
transport.queueSuccess(method = HttpMethod.POST, url = queueUrl, body = """{"length":1}""".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = queueUrl, body = """{"length":0}""".toByteArray())
client.queue(ID)
client.enqueueFollowup(ID, "x", appendEnter = true)
client.clearQueue(ID)
assertReadOnly(transport.recordedRequests[0])
assertGuarded(transport.recordedRequests[1])
assertGuarded(transport.recordedRequests[2])
assertNotNull(transport.recordedRequests[1].body)
}
@Test
fun `session id is serialized lowercase in the path`() = runTest {
val upper = UUID.fromString("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")
val url = "$BASE/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"
transport.queueSuccess(url = url, body = """{"length":0,"items":[]}""".toByteArray())
client.queue(upper)
assertTrue(last().url.endsWith("/live-sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/queue"))
}
}

View File

@@ -0,0 +1,171 @@
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
/**
* Request shape + **Origin-iff-guarded** (plan §4.3 铁律) for the two routes the v0.6 git panel added
* after the last Android commit: `GET /projects/worktree/state` (read) and `POST /projects/git/fetch`
* (write). A route reclassified read↔write turns this red rather than silently shipping either a CSWSH
* hole or a write the server will 403.
*
* Also pins the commit-log boundary arithmetic, which the server's own commit warns "fails in the
* dangerous direction" if shortcut to "the first N rows".
*/
@DisplayName("w6 routes — shape, Origin policy, and the unpushed boundary")
class W6RouteShapeTest {
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()
// ── GET /projects/worktree/state — read-only, no Origin ──────────────────────────────────────────
@Test
fun `worktreeState is a read-only GET with a strict-encoded path and no Origin`() = runTest {
val path = "/home/me/my repo/a+b&c"
transport.queueSuccess(
HttpMethod.GET,
"$BASE/projects/worktree/state?path=%2Fhome%2Fme%2Fmy%20repo%2Fa%2Bb%26c",
200,
body = """{"path":"$path","branch":"wt","dirtyCount":2}""".toByteArray(),
)
val state = client.worktreeState(path)
val r = last()
assertEquals(HttpMethod.GET, r.method)
assertFalse(
r.headers.containsKey(HeaderName.ORIGIN),
"a read must NOT stamp Origin — Origin is the CSWSH marker for state-changing routes only",
)
assertTrue(r.url.contains("%2Bb"), "a bare + would decode to a SPACE in Express's qs parser")
assertEquals(2, state.dirtyCount)
}
// ── POST /projects/git/fetch — guarded, byte-equal Origin, body carries only the path ────────────
@Test
fun `gitFetch is a guarded POST that stamps a byte-equal Origin`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true,"lastFetchMs":42}""".toByteArray())
val outcome = client.gitFetch("/repo")
val r = last()
assertEquals(HttpMethod.POST, r.method)
assertEquals(
ORIGIN,
r.headers[HeaderName.ORIGIN],
"fetch is state-changing (it moves refs/remotes and spawns a network git), so it must be guarded",
)
assertNotNull(r.body, "the path travels in a JSON body")
val body = r.body!!.decodeToString()
assertTrue(body.contains("\"path\""))
assertFalse(
body.contains("remote") || body.contains("refspec"),
"the remote is derived SERVER-side; a client must never be able to aim a fetch at an arbitrary URL",
)
assertEquals(42L, (outcome as GitWriteOutcome.Ok).payload.lastFetchMs)
}
@Test
fun `a fetch that fails leaves lastFetchMs unknown rather than claiming it refreshed`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 200, body = """{"ok":true}""".toByteArray())
val outcome = client.gitFetch("/repo")
assertNull(
(outcome as GitWriteOutcome.Ok).payload.lastFetchMs,
"the server leaves the old value alone on failure, so the UI must keep saying stale",
)
}
@Test
fun `fetch surfaces its own rate limit instead of retrying`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 429, body = """{"error":"slow down"}""".toByteArray())
val outcome = client.gitFetch("/repo")
assertTrue(
outcome is GitWriteOutcome.RateLimited,
"fetch has its OWN bucket so refreshes cannot eat the budget a real push needs",
)
}
@Test
fun `a disabled or Origin-rejected fetch surfaces the server's safe message`() = runTest {
transport.queueSuccess(HttpMethod.POST, "$BASE/projects/git/fetch", 403, body = """{"error":"git ops disabled"}""".toByteArray())
val outcome = client.gitFetch("/repo")
// 403 is overloaded — kill-switch AND Origin failure both return it — so the message is
// surfaced rather than guessed at with a typed variant.
assertEquals("git ops disabled", (outcome as GitWriteOutcome.Rejected).message)
}
// ── the unpushed boundary (w6/G4) ────────────────────────────────────────────────────────────────
@Test
@DisplayName("the boundary sits after the LAST unpushed commit, even when they interleave")
fun boundaryHandlesInterleaving() {
// A backdated merge puts an unpushed commit BELOW a pushed one, because git log is date-ordered.
// "The first N rows" would mark the wrong set and call an unpushed commit pushed.
val log = GitLogResult(
commits = listOf(
commit("aaa", unpushed = true),
commit("bbb", unpushed = false),
commit("ccc", unpushed = true),
commit("ddd", unpushed = false),
),
upstream = "origin/main",
)
assertEquals(2, log.upstreamBoundaryIndex, "after index 2 — the last unpushed one")
assertEquals(2, log.unpushedCount)
}
@Test
@DisplayName("no upstream means NO boundary may be drawn at all")
fun noUpstreamNoBoundary() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = true)), upstream = null)
assertNull(
log.upstreamBoundaryIndex,
"null rather than -1, so a caller cannot mistake 'no boundary' for 'boundary before everything'",
)
}
@Test
@DisplayName("nothing unpushed means no boundary")
fun nothingUnpushed() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = false)), upstream = "origin/main")
assertNull(log.upstreamBoundaryIndex)
assertEquals(0, log.unpushedCount)
}
@Test
@DisplayName("an unknown unpushed flag is not counted as pushed")
fun unknownIsNotPushed() {
val log = GitLogResult(commits = listOf(commit("aaa", unpushed = null)), upstream = "origin/main")
assertEquals(0, log.unpushedCount, "unknown is not 'unpushed'…")
assertNull(log.upstreamBoundaryIndex, "…and it must not invent a boundary either")
}
private fun commit(hash: String, unpushed: Boolean?) =
wang.yaojia.webterm.api.models.CommitLogEntry(hash = hash, at = 1L, subject = "s", unpushed = unpushed)
}

View File

@@ -10,6 +10,8 @@
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android // com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
@@ -17,6 +19,83 @@ plugins {
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
} }
// ─────────────────────────────────────────────────────────────────────────────
// Release signing credentials — NEVER committed.
//
// Looked up in this order, first non-blank wins:
// 1. `android/keystore.properties` (the conventional path; see keystore.properties.example)
// 2. `android/local.properties` (already gitignored — the zero-setup local option)
// 3. `WEBTERM_RELEASE_*` env vars (CI)
//
// DEGRADATION CONTRACT: absent credentials leave debug (and the `benchmark` variant,
// which signs with the debug key) fully working, and make the RELEASE package task fail
// with RELEASE_SIGNING_HELP — never a silent `app-release-unsigned.apk`.
// ─────────────────────────────────────────────────────────────────────────────
val signingPropertyFiles = listOf("keystore.properties", "local.properties")
val signingProperties = Properties().apply {
// Later files must not override earlier ones, so load in reverse and let the
// higher-priority file overwrite.
signingPropertyFiles.reversed()
.map(rootProject::file)
.filter(File::exists)
.forEach { file -> file.inputStream().use(::load) }
}
fun signingCredential(propertyKey: String, envKey: String): String? =
(signingProperties.getProperty(propertyKey) ?: System.getenv(envKey))?.takeIf(String::isNotBlank)
val releaseStoreFile = signingCredential("webterm.release.storeFile", "WEBTERM_RELEASE_STORE_FILE")
val releaseStorePassword = signingCredential("webterm.release.storePassword", "WEBTERM_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingCredential("webterm.release.keyAlias", "WEBTERM_RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingCredential("webterm.release.keyPassword", "WEBTERM_RELEASE_KEY_PASSWORD")
// `storeFile` may be absolute or relative to `android/` — rootProject.file handles both.
val releaseKeystore = releaseStoreFile?.let(rootProject::file)
val hasAllReleaseCredentials =
releaseStoreFile != null &&
releaseStorePassword != null &&
releaseKeyAlias != null &&
releaseKeyPassword != null
val isReleaseSigningConfigured = hasAllReleaseCredentials && releaseKeystore?.exists() == true
// Two distinct failures deserve two distinct messages: "you have not set this up" and
// "you set it up but pointed it at a file that isn't there" have completely different fixes.
val MISSING_KEYSTORE_HELP = """
|:app release signing credentials were found, but the keystore file does not exist:
| ${releaseKeystore?.absolutePath}
|
|Fix `webterm.release.storeFile` (absolute, or relative to the `android/` directory), or
|point WEBTERM_RELEASE_STORE_FILE at the real file. Nothing was signed, and no unsigned
|APK was emitted.
""".trimMargin()
val UNCONFIGURED_SIGNING_HELP = """
|:app release signing is NOT configured — refusing to emit an unsigned release artifact.
|
|Provide the four credentials in ONE of these places (first match wins):
| 1. android/keystore.properties — copy android/keystore.properties.example.
| MAKE SURE `keystore.properties` is in android/.gitignore first.
| 2. android/local.properties — already gitignored; zero extra setup.
| 3. env: WEBTERM_RELEASE_STORE_FILE / _STORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD (CI)
|
|Keys (property form):
| webterm.release.storeFile=<path, relative to android/ or absolute>
| webterm.release.storePassword=<...>
| webterm.release.keyAlias=<...>
| webterm.release.keyPassword=<...>
|
|No keystore yet? Generate one OUTSIDE the repo:
| keytool -genkeypair -v -keystore ~/.webterm/webterm-release.jks \
| -alias webterm -keyalg RSA -keysize 4096 -validity 10000
|Then record its SHA-256 in the App Links assetlinks.json (plan §8).
|
|Unaffected by this failure: `:app:assembleDebug`, `:app:assembleBenchmark`,
|`:app:assembleDebugAndroidTest`, every unit test, and R8 itself (which already ran).
""".trimMargin()
android { android {
namespace = "wang.yaojia.webterm" namespace = "wang.yaojia.webterm"
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for // compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
@@ -28,24 +107,87 @@ android {
applicationId = "wang.yaojia.webterm" applicationId = "wang.yaojia.webterm"
minSdk = 29 minSdk = 29
targetSdk = 35 targetSdk = 35
// ── Versioning policy ────────────────────────────────────────────────
// versionCode: a plain monotonic counter, +1 for EVERY artifact handed to
// anyone (Play track, internal APK, benchmark run that gets archived).
// Never derived from versionName — decoupling them is what lets a hotfix
// ship without renumbering. This is still 1 because no artifact has ever
// left this machine; the first distributed build takes 2.
// versionName: `<server-line>-alphaNN`. The Android client tracks the server's
// 0.1.x line (root package.json is 0.1.0). `-alpha01` is the honest state:
// the P0+P1 surface from ANDROID_CLIENT_PLAN §1 is implemented and JVM-tested,
// but NOTHING in DEVICE_QA_CHECKLIST.md has been signed off on real hardware.
// Drop to `-beta01` when the checklist's A34/A35 blocks pass on a device, and
// to plain `0.1.0` when the whole checklist is ticked.
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0-alpha01"
// Hilt instrumented testing REQUIRES a custom runner: AndroidJUnitRunner is the
// only hook that can swap the app-under-test's Application for the generated
// `HiltTestApplication` (the androidTest manifest cannot — it is merged into the
// TEST apk, not into the app under test). See android/README.md → "Instrumented tests".
testInstrumentationRunner = "wang.yaojia.webterm.HiltTestRunner"
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
signingConfigs {
// Populated ONLY when credentials were found; an empty config would make AGP
// fall back to emitting `app-release-unsigned.apk`, which is exactly the silent
// failure this block exists to prevent (the release buildType leaves
// `signingConfig` null instead, and the guard task below fails the build).
if (isReleaseSigningConfigured) {
create("release") {
storeFile = rootProject.file(releaseStoreFile!!)
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
// v1 (JAR signing) is only needed below API 24 and is the weakest scheme —
// off. v2/v3 are left enabled and apksigner picks what the minSdk range
// actually requires: verified output for minSdk 29 is v1=false, v2=false,
// v3=true (every device that can install this understands v3, so v2 is
// redundant). Do not read v2=false as a misconfiguration.
enableV1Signing = false
enableV2Signing = true
enableV3Signing = true
}
}
}
buildTypes { buildTypes {
debug { debug {
// No applicationId suffix — keep it stable for deep-link testing (A32). // No applicationId suffix — keep it stable for deep-link testing (A32).
} }
release { release {
isMinifyEnabled = false isMinifyEnabled = true
// Resource shrinking requires minification; it is what strips the unused
// CameraX/ML-Kit/Firebase resources the 56 MB unminified APK carried.
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
signingConfig = signingConfigs.findByName("release")
}
// `benchmark` — what :macrobenchmark (A35) measures against. It must match the
// shipping variant in everything that affects performance (non-debuggable, R8'd,
// resource-shrunk) while needing NO release keystore, so it signs with the debug
// key. It is therefore also the variant that proves the R8 pipeline end-to-end
// (`:app:assembleBenchmark` produces a real, installable, minified APK).
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
// Library modules only publish debug/release; resolve `benchmark` to release.
matchingFallbacks += listOf("release")
isDebuggable = false
// Injects <profileable android:shell="true"/> into the merged manifest, which
// macrobenchmark needs to read traces on API 29-30 (implicit from API 31).
// Done here BECAUSE AndroidManifest.xml must not carry a benchmark-only tag.
isProfileable = true
isMinifyEnabled = true
isShrinkResources = true
} }
} }
@@ -114,9 +256,41 @@ dependencies {
testImplementation(libs.bundles.unit.test) testImplementation(libs.bundles.unit.test)
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6) testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)
// ── Instrumented tests (androidTest — device/emulator only; plan §7) ──────────
// Homes A34 (E2E against a real `npm start` host) and the Espresso/Compose half of
// A35. JUnit4 only: AndroidJUnitRunner cannot drive the JUnit5 platform.
androidTestImplementation(libs.bundles.android.instrumented.test)
// Compose UI assertions (gate banner / plan sheet / reconnect-banner precedence /
// session rows / continue-last banner). BOM-managed, same BOM as main.
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
// Supplies the empty host Activity that `createComposeRule()` launches into. Must be
// debugImplementation (it contributes a manifest entry to the app under test).
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Hilt instrumented testing: HiltAndroidRule + the generated HiltTestApplication.
androidTestImplementation(libs.hilt.android.testing)
kspAndroidTest(libs.hilt.compiler)
} }
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules. // Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
// Instrumented (androidTest) tasks are NOT of type Test, so they keep JUnit4.
tasks.withType<Test>().configureEach { tasks.withType<Test>().configureEach {
useJUnitPlatform() useJUnitPlatform()
} }
// ─────────────────────────────────────────────────────────────────────────────
// Release-signing guard.
//
// Hooked as a doFirst on the PACKAGE task (not as a dependency) on purpose: packaging
// runs after dexing, so R8/resource-shrinking have fully completed and been reported by
// the time this fires. An unconfigured machine therefore still gets a real, verifiable
// R8 run — it just cannot produce a distributable artifact.
// ─────────────────────────────────────────────────────────────────────────────
if (!isReleaseSigningConfigured) {
val help = if (hasAllReleaseCredentials) MISSING_KEYSTORE_HELP else UNCONFIGURED_SIGNING_HELP
tasks.matching { it.name == "packageRelease" || it.name == "packageReleaseBundle" }
.configureEach {
doFirst { throw GradleException(help) }
}
}

View File

@@ -1,4 +1,117 @@
# WebTerm :app R8/ProGuard rules. # ─────────────────────────────────────────────────────────────────────────────
# Release is not minified yet (isMinifyEnabled = false); this file exists so the # WebTerm :app R8 keep rules (release + benchmark; both isMinifyEnabled = true).
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for #
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later. # DISCIPLINE: every rule below is justified, and rules that turned out to be
# UNNECESSARY are recorded as such rather than added "just in case". A superfluous
# -keep silently costs size and hides real reachability problems; a MISSING one
# crashes only in production. Both were checked against the merged configuration
# R8 actually used, which is dumped to:
# app/build/outputs/mapping/<variant>/configuration.txt
# Read that file before adding anything here most of our stack already ships its
# own consumer rules, and duplicating them is how this file rots.
#
# ALREADY COVERED BY EMBEDDED CONSUMER RULES do NOT re-declare here:
# · kotlinx.serialization kotlinx-serialization-core-jvm ships
# META-INF/com.android.tools/r8/kotlinx-serialization-{common,r8}.pro, which keeps
# @Serializable classes' `Companion` fields, `serializer(...)`, object `INSTANCE`,
# the `$$serializer.descriptor` field, and RuntimeVisibleAnnotations (the R8
# full-mode fix). Verified present in 1.9.0. Our @Serializable models live in
# :wire-protocol / :api-client; the rules are global, so they are covered.
# · Hilt / Dagger — hilt-android + dagger AARs ship proguard.txt; all injection is
# generated code with compile-time references, no runtime reflection to preserve.
# · Tink — tink-android ships META-INF/proguard/protobuf.pro, keeping <fields> on
# subclasses of the shaded protobuf GeneratedMessageLite (the reflective lite
# runtime). Tink's key managers are registered explicitly by AeadConfig.register(),
# not discovered reflectively, so nothing else needs keeping.
# · FCM firebase-messaging ships consumer rules, AND `.push.FcmService`,
# `.push.DenyBroadcastReceiver`, `.push.AllowTrampolineActivity`, `.MainActivity`
# and `.WebTermApp` are all declared in AndroidManifest.xml, for which AGP
# auto-generates keep rules (build/intermediates/aapt_proguard_file/.../aapt_rules.txt).
# Manifest-declared components therefore need no rule here ever.
# · OkHttp / Okio, Compose, CameraX, ML Kit barcode, DataStore, navigation-compose,
# biometric all ship their own consumer rules.
# · JNI the default proguard-android-optimize.txt already contains
# `-keepclasseswithmembernames class * { native <methods>; }`, which covers the
# Termux emulator's native entry points even though we never fork a subprocess.
# ─────────────────────────────────────────────────────────────────────────────
# ── Termux terminal emulator + view (the ONE thing that genuinely needs a rule) ──
#
# Why this is not "just in case":
# 1. The JitPack artifacts (com.github.termux.termux-app:terminal-{emulator,view},
# v0.118.0) ship NO consumer rules at all — nobody upstream has reasoned about R8
# for these modules, because Termux itself ships unminified.
# 2. :terminal-view deliberately binds to NON-API internals of the pinned version
# (plan §6.1 — TerminalView is `public final`, so we fork rather than subclass):
# · RemoteTerminalHostView writes and reads the public field
# `com.termux.view.TerminalView.mEmulator` directly (installEmulator /
# the TerminalViewClient callbacks / computeVerticalScrollRange).
# · TerminalResizeDriver calls `TerminalRenderer.getFontWidth()` and
# `getFontLineSpacing()` — public accessors verified with `javap -p`, but not
# part of any documented API surface.
# · TerminalScrollGesture reads `mEmulator` and drives KeyHandler.
# Field/method RENAMING alone would survive that (R8 rewrites both sides), but R8
# full mode also does class merging, member inlining and access relaxation across
# a library whose invariants we are already stretching. A wrong outcome here is a
# crash in the app's single core surface the terminal visible ONLY in release.
# 3. The cost is bounded and small: two Apache-2.0 modules, ~120 KB of classes.
#
# So: keep them whole. Revisit only with a device-verified minified build in hand.
-keep class com.termux.terminal.** { *; }
-keep class com.termux.view.** { *; }
# ── ML Kit / Firebase component discovery (reflective no-arg constructors) ───────
#
# OBSERVED, not speculative. Installing the minified `benchmark` APK on an API-35
# emulator and cold-starting it produced, with no rule here:
#
# W ComponentDiscovery: Invalid component registrar.
# Could not instantiate com.google.mlkit.common.internal.CommonComponentRegistrar
# ... at com.google.mlkit.common.internal.MlKitInitProvider.onCreate
# Caused by: java.lang.NoSuchMethodException:
# com.google.mlkit.common.internal.CommonComponentRegistrar.<init> []
# (and the same for com.google.mlkit.vision.common.internal.VisionCommonRegistrar)
#
# Firebase's ComponentDiscovery instantiates registrars named in <meta-data> by
# `getDeclaredConstructor()`. The class names survive, but R8 removed the no-arg
# constructors because nothing in the app calls them. The failure is a WARNING, not a
# crash: the app starts fine and the ML Kit component graph is simply never registered,
# so **QR pairing (A19 barcode scanning) silently stops working in minified builds
# only**. Exactly the release-only breakage this file has to prevent.
#
# `<init>();` (no access modifier) matches any visibility, which is what
# getDeclaredConstructor needs. Scoped to registrars, so it keeps ~a dozen tiny classes.
-keep class * implements com.google.firebase.components.ComponentRegistrar {
<init>();
}
# ── javax.naming does not exist on Android ──────────────────────────────────────
#
# R8 refuses to run without these two, so they are load-bearing for ANY minified build.
# They are NOT a benign suppression — read this before deleting them:
#
# :client-tls CertificateSummary.kt uses `javax.naming.ldap.LdapName` to pull the CN
# out of an RFC2253 DN. That package is JDK-only; it is absent from android.jar and
# from the ART bootclasspath. So on a real device `CertificateSummaryReader.commonName`
# throws NoClassDefFoundError, which is an Error and therefore slips straight through
# that function's `catch (_: Exception)` guard.
#
# The module is pure-JVM and its unit tests run on a real JDK where LdapName DOES
# exist, which is why the green JVM suite never caught it. Tracked as a handoff to
# :client-tls's owner (hand-parse the DN, or catch Throwable). This -dontwarn only
# stops R8 from failing the build over an unresolvable reference; it does not and
# cannot fix the device behaviour.
-dontwarn javax.naming.ldap.LdapName
-dontwarn javax.naming.ldap.Rdn
# ── Crash triage ────────────────────────────────────────────────────────────────
# Keep line numbers so release stack traces are usable, and rename the source-file
# attribute to a single constant so no original filenames leak in the APK. Retrace
# release traces with app/build/outputs/mapping/release/mapping.txt (ARCHIVE IT with
# every distributed artifact without it a crash report is unreadable).
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,28 @@
package wang.yaojia.webterm
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
/**
* The instrumentation runner named by `:app`'s `testInstrumentationRunner`.
*
* A custom runner is **required**, not stylistic: [newApplication] is the only hook that can replace the
* app-under-test's [Application] with the generated [HiltTestApplication]. Setting `android:name` in the
* androidTest manifest cannot do it — that manifest is merged into the *test* APK, not into the app under
* test, so the real [WebTermApp] would still be installed and `@HiltAndroidTest` injection would fail.
*
* `assembleDebugAndroidTest` builds fine without this class (the runner name is only a manifest value);
* an actual instrumentation *run* would fail with `ClassNotFoundException`. See `android/README.md` →
* "Instrumented tests".
*
* Note that installing [HiltTestApplication] replaces [WebTermApp], so anything [WebTermApp] does in
* `onCreate` (push registration, notification channels) does NOT happen under instrumentation. That is
* deliberate — a test must opt into those via Hilt test modules rather than inherit them.
*/
public class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
/**
* **F9 — bad `Origin` on the WS upgrade is rejected. The one non-skippable defence.**
*
* This app hands a full shell to anyone who can reach the port, and Origin validation on the WebSocket
* handshake is the single thing standing between that shell and a malicious page open in the user's
* browser (Cross-Site WebSocket Hijacking). TECH_DOC §7 and plan §8 both call it non-negotiable, and it is
* the only security control in this codebase that cannot be compensated for elsewhere.
*
* A unit test cannot verify it: the check lives in the *server's* upgrade handler, so the only honest proof
* is a real socket carrying a real foreign `Origin` and being refused. That is what this class does, from
* the device, against the real server. It also pins the two neighbouring cases the check must not get
* wrong — a MISSING Origin (default-deny) and the correct Origin (must still work, or the "defence" is
* just a broken client).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class CswshDefenceE2eTest {
/**
* The attack: a page on another origin opens `ws://<lan-ip>:3000/term`. The browser stamps ITS origin,
* which is not in the server's allow-list, and the handshake must be refused with 401 — before any
* frame can be exchanged.
*/
@Test
fun aForeignOriginIsRefusedOnTheWsUpgrade() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host, origin = FOREIGN_ORIGIN)
try {
val failure = socket.failureOrNull()
assertNotNull(
"the handshake must FAIL with a foreign Origin — it opened instead, which is a CSWSH hole",
failure,
)
assertEquals(
"the refusal must be a 401 (src/server.ts writes it before destroying the socket)",
HTTP_UNAUTHORIZED,
socket.handshakeStatusOrNull(),
)
} finally {
socket.cancel()
}
}
/**
* Default-deny: a non-browser client that sends no `Origin` at all must also be refused. `curl` and
* scripts land here, and `isOriginAllowed(undefined, …)` returning false is the single central policy
* point — an allow-if-absent would make the whole check trivially bypassable.
*/
@Test
fun aMissingOriginIsRefusedOnTheWsUpgrade() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host, origin = null)
try {
assertNotNull(
"an absent Origin must be refused (default-deny), not treated as trusted",
socket.failureOrNull(),
)
assertEquals(HTTP_UNAUTHORIZED, socket.handshakeStatusOrNull())
} finally {
socket.cancel()
}
}
/**
* The control: the SAME dial with the endpoint-derived `Origin` (byte-equal to what
* `HostEndpoint.originHeader` produces, and to what `src/http/origin.ts` expects) must open and attach.
* Without this, the two refusals above would also be satisfied by a server that rejects everything.
*/
@Test
fun theEndpointDerivedOriginIsAccepted() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
assertTrue(
"the production Origin must be accepted, got failure=${socket.failureOrNull()}",
socket.failureOrNull() == null,
)
socket.send(ClientMessage.Attach(sessionId = null))
sessionId = (socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached)
.sessionId
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
private companion object {
/** Deliberately not a real host: the point is that it is not in the server's allow-list. */
const val FOREIGN_ORIGIN = "http://evil.example"
const val HTTP_UNAUTHORIZED = 401
}
}

View File

@@ -0,0 +1,185 @@
package wang.yaojia.webterm.e2e
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.MessageCodec
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.WireConstants
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* One live WS connection to a real WebTerm server, driven synchronously so an E2E test reads as a
* transcript.
*
* It deliberately does NOT reuse `:transport-okhttp`'s [wang.yaojia.webterm.transport.OkHttpTermTransport]:
* that class stamps `Origin` from the endpoint by construction, which is exactly the thing the F9 test has
* to be able to falsify. So the upgrade request is built here, and `Origin` is a parameter. Everything
* else that matters IS production code — [MessageCodec] encodes and decodes every frame, so these tests
* are a check of the frozen wire contract against the real server, not of a parallel test codec.
*/
internal class E2eSocket private constructor(
private val opened: CountDownLatch,
private val closed: CountDownLatch,
) : WebSocketListener() {
private val frames = LinkedBlockingQueue<String>()
/**
* Every `output` payload, accumulated as frames ARRIVE rather than as a test pulls them.
*
* This is not an optimisation, it is a correctness requirement. On a re-attach the server replays the
* ring buffer INSIDE `manager.handleAttach`, i.e. it puts the replay `output` frame on the wire BEFORE
* the `attached` confirmation. A reader that scanned the queue for `attached` first would consume and
* discard the replay on the way past — which is exactly how the first run of the replay test reported
* "0 chars" against a server that had sent hundreds of kilobytes.
*/
private val output = StringBuilder()
private val lastFrameAt = java.util.concurrent.atomic.AtomicLong(System.nanoTime())
private val socket = AtomicReference<WebSocket?>(null)
private val failure = AtomicReference<Throwable?>(null)
private val handshakeStatus = AtomicReference<Int?>(null)
private val closeCode = AtomicReference<Int?>(null)
override fun onOpen(webSocket: WebSocket, response: Response) {
handshakeStatus.set(response.code)
socket.set(webSocket)
opened.countDown()
}
override fun onMessage(webSocket: WebSocket, text: String) {
lastFrameAt.set(System.nanoTime())
frames.put(text)
(MessageCodec.decodeServer(text) as? ServerMessage.Output)?.let { frame ->
synchronized(output) { output.append(frame.data) }
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
handshakeStatus.set(response?.code)
failure.set(t)
opened.countDown()
closed.countDown()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
closeCode.set(code)
closed.countDown()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
closeCode.set(code)
webSocket.close(code, reason)
}
// ── driving ──────────────────────────────────────────────────────────────────────────────────
fun send(message: ClientMessage) {
val live = socket.get()
assertNotNull("cannot send $message — the WS never opened", live)
assertTrue("the WS refused to enqueue $message", live!!.send(MessageCodec.encode(message)))
}
/** The next decodable server frame matching [predicate], or fail with what actually arrived. */
fun awaitFrame(what: String, timeoutMs: Long = FRAME_TIMEOUT_MS, predicate: (ServerMessage) -> Boolean): ServerMessage {
val seen = ArrayList<String>()
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
while (System.nanoTime() < deadline) {
val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1)
val raw = frames.poll(remaining, TimeUnit.MILLISECONDS) ?: break
seen += raw.take(RAW_FRAME_LOG_CHARS)
val decoded = MessageCodec.decodeServer(raw)
if (decoded != null && predicate(decoded)) return decoded
}
throw AssertionError(
"no frame matching \"$what\" within $timeoutMs ms. Frames seen: $seen. " +
"failure=${failure.get()} closeCode=${closeCode.get()}",
)
}
/**
* Wait for the wire to go quiet for [quietMs], then take everything `output` has carried since the last
* call. Bounded by [DRAIN_CEILING_MS] so a session that never stops talking cannot hang the suite.
*/
fun drainOutput(quietMs: Long = QUIET_MS): String {
val ceiling = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_CEILING_MS)
while (System.nanoTime() < ceiling) {
val idleMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastFrameAt.get())
if (idleMs >= quietMs) break
Thread.sleep(DRAIN_POLL_MS)
}
return synchronized(output) {
val text = output.toString()
output.setLength(0)
text
}
}
fun awaitClosed(timeoutMs: Long = FRAME_TIMEOUT_MS): Boolean =
closed.await(timeoutMs, TimeUnit.MILLISECONDS)
fun failureOrNull(): Throwable? = failure.get()
fun handshakeStatusOrNull(): Int? = handshakeStatus.get()
fun close() {
socket.get()?.close(NORMAL_CLOSURE, null)
}
fun cancel() {
socket.get()?.cancel()
}
companion object {
const val NORMAL_CLOSURE: Int = 1000
private const val OPEN_TIMEOUT_MS = 8_000L
private const val FRAME_TIMEOUT_MS = 10_000L
private const val QUIET_MS = 700L
private const val DRAIN_CEILING_MS = 30_000L
private const val DRAIN_POLL_MS = 50L
private const val RAW_FRAME_LOG_CHARS = 200
/**
* Dial the host's `/term`. Returns as soon as the handshake resolves either way, so a rejection
* (the F9 case) is observable rather than a hang.
*
* @param origin what to put in the `Origin` header. Defaults to the endpoint-derived value, i.e.
* exactly what production sends; a foreign value is how the CSWSH defence is falsified.
*/
fun dial(
host: WebTermTestHost.ReachableHost,
origin: String? = host.endpoint.originHeader,
): E2eSocket {
val opened = CountDownLatch(1)
val listener = E2eSocket(opened, CountDownLatch(1))
val request = Request.Builder()
.url(host.endpoint.wsUrl)
.apply { origin?.let { header("Origin", it) } }
.build()
host.client.newWebSocket(request, listener)
assertTrue(
"the WS handshake to ${host.endpoint.wsUrl} neither opened nor failed within $OPEN_TIMEOUT_MS ms",
opened.await(OPEN_TIMEOUT_MS, TimeUnit.MILLISECONDS),
)
return listener
}
/** `attach` must be the FIRST frame on every (re)connect (plan §4.1). */
fun attach(host: WebTermTestHost.ReachableHost, sessionId: String?, cwd: String? = null): Pair<E2eSocket, String> {
val socket = dial(host)
socket.send(ClientMessage.Attach(sessionId = sessionId, cwd = cwd))
val attached = socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached
return socket to attached.sessionId
}
/** The soft-reset prefix the server puts in front of a ring-buffer replay — never stripped (§4.1). */
const val REPLAY_PREFIX: String = WireConstants.REPLAY_SOFT_RESET_PREFIX
}
}

View File

@@ -0,0 +1,115 @@
package wang.yaojia.webterm.e2e
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.junit.Assert.assertTrue
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* A permission gate held open on a real server, created exactly the way Claude Code creates one.
*
* `POST /hook/permission` is the hook side-channel: the server holds the HTTP request open until a decision
* arrives (or it times out), then writes the decision JSON as the response — which the hook's `curl` feeds
* back to Claude on stdout. So the *held request itself* is the observable for "is the gate still held", and
* that is the only way to tell a real resolution from a UI that merely stopped showing the banner.
*
* Two server preconditions are worth knowing before reading a failure here:
* - **Loopback only.** `POST /hook/permission` 403s a non-loopback peer. From an emulator this is satisfied
* for free: `10.0.2.2` is forwarded to the host's loopback, so the server sees `127.0.0.1`. Over a LAN
* address it will 403, and [hold] says so rather than timing out mysteriously.
* - **Someone must be watching.** The server only HOLDS when the session has an attached client or a
* registered push target; otherwise it answers `{}` immediately and lets Claude prompt locally. The
* caller therefore attaches first.
*/
internal class HeldGate private constructor(
private val call: Call,
private val completed: CountDownLatch,
private val body: AtomicReference<String?>,
private val failure: AtomicReference<IOException?>,
) {
/** Whether the server has already answered — i.e. the gate is no longer held. */
fun hasResponded(): Boolean = completed.count == 0L
/** The decision body the server finally wrote, or fail if the hold never resolved. */
fun awaitResponse(timeoutMs: Long = RESPONSE_TIMEOUT_MS): String {
val done = completed.await(timeoutMs, TimeUnit.MILLISECONDS)
if (!done) {
call.cancel()
throw AssertionError("the held /hook/permission request never resolved within $timeoutMs ms")
}
failure.get()?.let { throw AssertionError("the held /hook/permission request failed: $it") }
return body.get() ?: ""
}
companion object {
private const val RESPONSE_TIMEOUT_MS = 15_000L
private const val HOLD_SETTLE_MS = 600L
private val JSON = "application/json; charset=utf-8".toMediaType()
/** The tool name the server maps to a plain two-way `tool` gate (anything but `ExitPlanMode`). */
private const val TOOL_NAME = "Bash"
/**
* Start holding a gate for [sessionId]. Returns once the request is in flight and the server has
* had time to either hold it or refuse it — and fails loudly if it refused, because a test that
* proceeded from here would be asserting against a gate that does not exist.
*/
fun hold(host: WebTermTestHost.ReachableHost, sessionId: String): HeldGate {
val completed = CountDownLatch(1)
val body = AtomicReference<String?>(null)
val failure = AtomicReference<IOException?>(null)
val request = Request.Builder()
.url(host.endpoint.baseUrl + "/hook/permission")
.header("x-webterm-session", sessionId)
// `tool_input` is untrusted by contract; a harmless command keeps the derived preview real.
.post(
"""{"tool_name":"$TOOL_NAME","tool_input":{"command":"echo webterm-e2e-gate"}}"""
.toByteArray()
.toRequestBody(JSON),
)
.build()
// Its own client: the hold occupies the connection for as long as the gate lives, and the read
// timeout has to outlast that, which must not be imposed on the rest of the suite.
val holdingClient = host.client.newBuilder()
.readTimeout(RESPONSE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
.build()
val call = holdingClient.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
failure.set(e)
completed.countDown()
}
override fun onResponse(call: Call, response: Response) {
response.use { body.set(it.body?.string() ?: "") }
completed.countDown()
}
})
// Give the server a moment to register the hold (or refuse it outright).
Thread.sleep(HOLD_SETTLE_MS)
val gate = HeldGate(call, completed, body, failure)
if (gate.hasResponded()) {
val answered = body.get().orEmpty().replace(" ", "")
assertTrue(
"""
|POST /hook/permission did not HOLD, so there is no gate to test. It answered immediately
|with "$answered" (failure=${failure.get()}). The two reasons this happens:
| 1. the peer was not loopback (403) — use the emulator's http://10.0.2.2:<port> alias,
| which the host sees as 127.0.0.1, rather than a LAN address;
| 2. nothing was watching the session ({}) — attach a client before holding.
""".trimMargin(),
false,
)
}
return gate
}
}
}

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.ServerMessage
import java.util.UUID
/**
* **A34 — a held permission gate, and what may and may not resolve it.**
*
* A gate is created the way Claude Code creates one: `POST /hook/permission` from the host's own loopback
* (which is what the emulator's `10.0.2.2` alias resolves to on the host side, so the loopback-only guard
* is satisfied without any special setup). The server holds that HTTP request open until a decision
* arrives, and pushes a `status` frame with `pending: true` to every attached client — which is the frame
* the cockpit's gate card renders.
*
* ### An honest boundary: the `/hook/decision` HAPPY path is not automatable
* `/hook/decision` requires the per-decision capability token, and that token leaves the server **only**
* inside a push payload (`pushService.notify(session, 'needs-input', token)`) — never over the WS, never in
* `/live-sessions`. That is deliberate payload minimisation (plan §8), and it means no automated client can
* legitimately obtain it. So this class proves the parts that ARE provable and does not fake the rest:
*
* - a gate really is held, and the held-gate `status` frame really reaches an attached device;
* - `/hook/decision` **refuses** a well-formed but wrong token (403) and a malformed body (400), and the
* gate is still held afterwards — the security half, and the half a bug would most likely break;
* - the gate resolves through the in-app path (`approve` on the WS), observed by the held
* `POST /hook/permission` request finally returning.
*
* The remaining step — a real notification-action POST with a real token — stays on the device-QA checklist
* under Push, where it belongs, because it needs a real FCM delivery. It is NOT quietly asserted here.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class HeldGateE2eTest {
/**
* A wrong capability token must be refused and must leave the gate held. This is the SEC-C1/M1
* contract: the token is the whole authorisation for a decision made from a lock screen, so a
* mismatched, stale or absent one has to be a hard 403.
*/
@Test
fun hookDecisionRefusesAWrongTokenAndLeavesTheGateHeld() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
val gate = HeldGate.hold(host, sessionId)
try {
// The gate must actually be held before the refusal means anything.
val status = socket.awaitFrame("the held-gate status frame") {
it is ServerMessage.Status && it.pending
} as ServerMessage.Status
assertEquals("a plain tool gate must be reported as such", GateKind.TOOL, status.gate)
assertEquals(ClaudeStatus.WAITING, status.status)
val wrongToken = UUID.randomUUID().toString()
val (refused, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"allow","token":"$wrongToken"}""",
origin = host.endpoint.originHeader,
)
assertEquals(
"a mismatched capability token must 403 — it is the only thing authorising a remote decision",
WebTermTestHost.ReachableHost.FORBIDDEN,
refused,
)
val (malformed, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"maybe","token":"$wrongToken"}""",
origin = host.endpoint.originHeader,
)
assertEquals(
"an unknown decision verb must 400, never be coerced into allow",
HTTP_BAD_REQUEST,
malformed,
)
assertTrue(
"the refusals must not have resolved the gate — the hook request must still be held",
!gate.hasResponded(),
)
} finally {
socket.send(ClientMessage.Reject) // release the hold so the server is left clean
gate.awaitResponse()
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
/**
* `/hook/decision` is a GUARDED route, so a foreign `Origin` must 403 before the token is even looked
* at. Otherwise a malicious page could spend a token it somehow observed.
*/
@Test
fun hookDecisionRefusesAForeignOrigin() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
val (status, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"allow","token":"${UUID.randomUUID()}"}""",
origin = FOREIGN_ORIGIN,
)
assertEquals(
"the Origin guard must run before the token check on /hook/decision",
WebTermTestHost.ReachableHost.FORBIDDEN,
status,
)
} finally {
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
/**
* The in-app resolution path, end to end: the attached device sends `approve` (with the mode as a
* TOP-LEVEL key, per plan §4.1) and the held `POST /hook/permission` request returns — which is the
* server telling Claude Code to proceed. Observing that return is the only proof the hold was really
* released rather than merely hidden in the UI.
*/
@Test
fun approveOnTheWireResolvesTheHeldGate() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
val gate = HeldGate.hold(host, sessionId)
try {
socket.awaitFrame("the held-gate status frame") { it is ServerMessage.Status && it.pending }
assertTrue("the hook request must still be held before we decide", !gate.hasResponded())
socket.send(ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS))
val body = gate.awaitResponse()
assertTrue(
"the held hook request must return a decision body, got: $body",
body.isNotEmpty() && body.trimStart().startsWith("{"),
)
assertTrue(
"an approval must not come back as a plain empty object (that is the timeout fallback)",
body.replace(" ", "") != "{}",
)
} finally {
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
private companion object {
const val HTTP_BAD_REQUEST = 400
const val FOREIGN_ORIGIN = "http://evil.example"
}
}

View File

@@ -0,0 +1,256 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.Validation
import wang.yaojia.webterm.wire.WireConstants
import java.util.UUID
/**
* **A34 — instrumented E2E against a real `npm start` host.**
*
* Every test here runs on the device, over a real socket, against this repo's own server. The frames are
* encoded and decoded by production's [wang.yaojia.webterm.wire.MessageCodec], so a drift between the
* frozen client contract and the actual server surfaces here and nowhere else in the suite.
*
* If no host is reachable each test SKIPS with the command needed to make it reachable — see
* [WebTermTestHost]. It never quietly passes.
*
* Every session this class spawns is killed in a `finally`, via the guarded
* `DELETE /live-sessions/:id`, so a run leaves no orphan PTYs behind on the developer's machine.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class SessionRoundTripE2eTest {
/**
* The core loop the whole product rests on: `attach(null)` → `attached` with a server-issued id →
* typed bytes → the shell's own output comes back. Also pins the two contract details a client gets
* wrong most easily: the id must be adopted from the server (never the one we asked for), and Enter is
* `\r`.
*/
@Test
fun attachThenTypeThenOutput_roundTripsThroughARealPty() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
assertTrue(
"the server-issued session id must be a lowercase v4 UUID: $sessionId",
Validation.isValidSessionId(sessionId),
)
assertEquals("…and must parse as a UUID", sessionId, UUID.fromString(sessionId).toString())
// A marker the shell will echo back through the PTY. `\r` (never `\n`) is what Enter sends.
val marker = "webterm-e2e-${UUID.randomUUID()}"
socket.send(ClientMessage.Input("printf '%s\\n' $marker\r"))
val output = socket.awaitFrame("the marker echoed back by the shell") {
it is ServerMessage.Output && it.data.contains(marker)
}
assertTrue("expected an output frame, got $output", output is ServerMessage.Output)
} finally {
socket.close()
host.killQuietly(sessionId)
}
}
/**
* `attach` MUST be the first frame, with the `sessionId` key ALWAYS present (JSON `null` for a new
* session — the server rejects a missing key). Sending something else first must not bind a session:
* the server logs a protocol violation and keeps waiting, so the later `attach` still works. This is
* the ordering guarantee the whole reconnect ladder depends on.
*/
@Test
fun aNonAttachFirstFrameBindsNothing_andTheLaterAttachStillWorks() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(ClientMessage.Input("this must be ignored\r"))
socket.send(ClientMessage.Resize(cols = 100, rows = 40))
socket.send(ClientMessage.Attach(sessionId = null))
val attached = socket.awaitFrame("attached after an out-of-order first frame") {
it is ServerMessage.Attached
} as ServerMessage.Attached
sessionId = attached.sessionId
assertTrue(Validation.isValidSessionId(attached.sessionId))
} finally {
socket.close()
sessionId?.let { host.killQuietly(it) }
}
}
/**
* **F5/F6 — reconnect replays the ring buffer, with no dropped bytes on a multi-MB replay.**
*
* The session is made to produce a large volume of output, the client detaches (which must NOT kill
* the PTY), then re-attaches by id. Every byte of the replay has to arrive: the assertion is not "some
* output came back" but that a *numbered sequence* is present, in order, with no gap — the only way to
* catch a truncated or interleaved replay. `MessageCodec` decodes the replay frame, so this also
* exercises the client's ability to receive a single very large text frame.
*/
@Test
fun reconnectReplaysTheRingBufferWithNoDroppedBytes() {
val host = WebTermTestHost.require()
val (first, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
// seq prints REPLAY_LINES numbered lines; awk pads each to ~PAD_BYTES so the ring holds
// hundreds of KB to a few MB of real PTY output rather than a handful of lines.
first.send(
ClientMessage.Input(
"seq 1 $REPLAY_LINES | awk '{ printf \"%s:\", \$1; " +
"for (i = 0; i < $PAD_REPEATS; i++) printf \"$PAD_CHUNK\"; printf \"\\n\" }'\r",
),
)
first.awaitFrame("the last line of the generated output") {
it is ServerMessage.Output && it.data.contains("$REPLAY_LINES:")
}
first.drainOutput()
// Detach only. The PTY must survive — that is the product's central design point.
first.close()
assertTrue("the first connection never closed", first.awaitClosed())
val stillRunning = host.get("/live-sessions").second
assertTrue(
"the session must survive the detach (PTY lifecycle != WS lifecycle)",
stillRunning.contains(sessionId),
)
val (second, adopted) = E2eSocket.attach(host, sessionId = sessionId)
try {
assertEquals("re-attach must adopt the same id", sessionId, adopted)
val replay = second.drainOutput(quietMs = REPLAY_QUIET_MS)
assertTrue(
"a replay must arrive at all; got ${replay.length} chars",
replay.length > MIN_REPLAY_CHARS,
)
assertTrue(
"the ESC[0m soft-reset prefix must be passed through, not stripped",
replay.contains(WireConstants.REPLAY_SOFT_RESET_PREFIX),
)
// The ring is a fixed-size window, so the OLDEST lines are legitimately gone. What must
// never happen is a HOLE: from the first line present to the last, every number must be
// there. That is what a dropped or reordered chunk would break.
// Anchored at a line start: an unanchored `contains("234:")` would also match inside
// "1234:", which would silently inflate the set and make the contiguity check vacuous.
val present = (1..REPLAY_LINES).filter { replay.contains("\n$it:") }
assertTrue("no numbered line survived the replay at all", present.isNotEmpty())
assertEquals(
"the replay has a hole — lines ${present.first()}..${present.last()} should be contiguous",
(present.first()..present.last()).toList(),
present,
)
assertTrue(
"the newest line must always be in the ring",
replay.contains("\n$REPLAY_LINES:"),
)
} finally {
second.close()
}
} finally {
host.killQuietly(sessionId)
}
}
/**
* A shell that exits ends the session: `exit` is terminal, and the row disappears from
* `/live-sessions`. This is the ordinary exit path — the `-1` spawn-failure path needs a deliberately
* broken server and lives in [SpawnFailureE2eTest].
*/
@Test
fun aShellThatExitsProducesATerminalExitFrame() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
socket.send(ClientMessage.Input("exit $EXPECTED_EXIT_CODE\r"))
val exit = socket.awaitFrame("the exit frame") { it is ServerMessage.Exit } as ServerMessage.Exit
assertEquals("the shell's own exit status must be reported verbatim", EXPECTED_EXIT_CODE, exit.code)
} finally {
socket.close()
host.killQuietly(sessionId)
}
}
/**
* **Kill via the guarded `DELETE /live-sessions/:id`** (the swipe-to-kill action). 204 the first time,
* and **404 the second time counts as success** — "already gone" is the desired end state, which is
* why the client must not treat it as an error (plan §4.3).
*/
@Test
fun killingASessionRemovesItAndASecondKillIsAlreadyGone() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
socket.close()
val (firstStatus, _) = host.delete("/live-sessions/$sessionId")
assertEquals("the first kill must be a 204", WebTermTestHost.ReachableHost.NO_CONTENT, firstStatus)
assertTrue(
"the killed session must be gone from /live-sessions",
!host.get("/live-sessions").second.contains(sessionId),
)
val (secondStatus, _) = host.delete("/live-sessions/$sessionId")
assertEquals(
"a second kill must be 404 (already gone = success), not an error the UI would surface",
WebTermTestHost.ReachableHost.NOT_FOUND,
secondStatus,
)
}
/**
* The Origin 铁律, positive half: the guarded kill route must be REFUSED without a byte-equal `Origin`.
* The negative half (a foreign origin on the WS upgrade, F9) is [CswshDefenceE2eTest].
*/
@Test
fun theGuardedKillRouteRefusesAForeignOrigin() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
socket.close()
try {
val (status, _) = host.delete("/live-sessions/$sessionId", origin = FOREIGN_ORIGIN)
assertEquals(
"a foreign Origin must 403 on a state-changing route",
WebTermTestHost.ReachableHost.FORBIDDEN,
status,
)
assertTrue(
"…and the session must still be alive, i.e. the refusal actually refused",
host.get("/live-sessions").second.contains(sessionId),
)
} finally {
host.killQuietly(sessionId)
}
}
/** Best-effort cleanup: 204 and 404 are both fine, and a transport error must not mask a real failure. */
private fun WebTermTestHost.ReachableHost.killQuietly(sessionId: String) {
runCatching { delete("/live-sessions/$sessionId") }
}
private companion object {
/** ~REPLAY_LINES × (PAD_REPEATS × 64) bytes of PTY output — comfortably past a single WS frame. */
const val REPLAY_LINES = 4_000
const val PAD_REPEATS = 8
const val PAD_CHUNK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const val MIN_REPLAY_CHARS = 100_000
const val REPLAY_QUIET_MS = 2_500L
/** An arbitrary non-zero status, chosen so a coincidental 0 cannot pass the assertion. */
const val EXPECTED_EXIT_CODE = 42
/** Not a real host — the point is that it is not in the server's allow-list. */
const val FOREIGN_ORIGIN = "http://evil.example"
}
}

View File

@@ -0,0 +1,139 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.WireConstants
/**
* **A34 — a host whose shell cannot start, and the spawn-failure banner.**
*
* ## What was expected, and what a real server actually does — MEASURED, 2026-07-30
* Plan §4.1 and `src/server.ts` (M4) say a spawn failure surfaces as `exit(-1)` with a required `reason`:
* `manager.handleAttach` throws, the server sends `exit(-1)` and closes that connection. The client turns
* that into the non-retryable "会话启动失败" banner instead of entering the reconnect ladder.
*
* **That path did not fire.** A server started with `SHELL_PATH=/nonexistent/shell` (port 3112, run against
* this suite on 2026-07-30) answered `attached` and then `exit` with **code 1, not -1**. The reason is
* structural, not a configuration slip: node-pty's `pty.fork()` returns successfully and the `execvp`
* failure happens in the FORKED CHILD, which `_exit(1)`s (`node_modules/node-pty/src/unix/pty.cc`, and the
* same in `spawn-helper.cc` for `chdir`). So nothing throws synchronously in `createSession`, `handleAttach`
* returns a live session, and the failure arrives later as an ordinary child exit. On POSIX, `exit(-1)`
* therefore looks **unreachable through any client-visible route** — which is reported to the plan owner
* rather than papered over here.
*
* ## So this class asserts what is true, and never pretends
* - Over the wire, against the deliberately-broken host: a session whose shell cannot be executed produces
* a **terminal, non-zero `exit`** and does not linger in `/live-sessions`. That is the behaviour a client
* must actually handle, and asserting `-1` here would be asserting a fiction.
* - The `-1` → banner reduction is asserted separately and is labelled as a MODEL assertion, not an
* over-the-wire one, because no reachable server input produces `-1` today. If the server ever gains a
* real `-1` path, the wire test above starts distinguishing the two and this stays correct.
*
* Without `-e webtermSpawnFailureHost` the wire test SKIPS with the exact command to provide one — it is
* never a silent pass.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class SpawnFailureE2eTest {
/**
* A host whose `SHELL_PATH` does not exist must not leave the user with a half-alive session: the
* `exit` frame has to arrive, be non-zero, and the session must be gone afterwards. Anything less and a
* walked-away user would sit on a "connecting…" spinner against a shell that can never run.
*/
@Test
fun aHostWhoseShellCannotStartProducesATerminalNonZeroExit() {
val host = WebTermTestHost.requireSpawnFailureHost()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(ClientMessage.Attach(sessionId = null))
val frame = socket.awaitFrame("either attached or a terminal exit") {
it is ServerMessage.Attached || it is ServerMessage.Exit
}
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
val exit = frame as? ServerMessage.Exit
?: socket.awaitFrame("the exit that follows a failed exec") { it is ServerMessage.Exit }
as ServerMessage.Exit
assertTrue(
"a shell that cannot be executed must produce a NON-ZERO exit, got ${exit.code}",
exit.code != CLEAN_EXIT,
)
sessionId?.let { id ->
assertTrue(
"the dead session must not linger in /live-sessions",
!host.get("/live-sessions").second.contains(id),
)
}
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
/**
* The CLIENT half of the `exit(-1)` contract, asserted on the model rather than over the wire — see the
* class doc for why no reachable server input produces `-1`. It stays here, next to the wire test, so
* the two are read together and the gap is visible instead of forgotten: if the sentinel ever does
* arrive, this is the behaviour it must drive.
*/
@Test
fun theMinusOneSentinelReducesToTheNonRetryableSpawnFailureBanner() {
val banner = BannerModel.Exited(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn /nonexistent/shell ENOENT")
assertTrue("code -1 must be recognised as a spawn failure", banner.isSpawnFailure)
assertTrue("a spawn failure must never show a retry spinner", !banner.showsSpinner)
assertTrue("…and must offer a new session instead", banner.offersNewSession)
assertNotNull("`reason` is what the banner shows the user; it is required on -1", banner.reason)
}
/**
* The neighbouring case a client must NOT confuse with a spawn failure: a bad `cwd` kills the child
* (node-pty's `spawn-helper` `chdir` → `_exit(1)`), which is an ordinary exit. Reporting it as `-1`
* would stop the client offering a retry in a case where retrying elsewhere works.
*/
@Test
fun aBadCwdIsAnOrdinaryExitNotASpawnFailure() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(
ClientMessage.Attach(sessionId = null, cwd = "/webterm-e2e/definitely-does-not-exist"),
)
val frame = socket.awaitFrame("either attached or exit") {
it is ServerMessage.Attached || it is ServerMessage.Exit
}
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
val exit = frame as? ServerMessage.Exit
?: socket.awaitFrame("the child exiting after chdir failed") { it is ServerMessage.Exit }
as ServerMessage.Exit
assertEquals(
"a bad cwd must not be reported as the spawn-failure sentinel — it is the child that died",
false,
exit.code == WireConstants.SPAWN_FAILED_EXIT_CODE,
)
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
private companion object {
const val CLEAN_EXIT = 0
}
}

View File

@@ -0,0 +1,219 @@
package wang.yaojia.webterm.e2e
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.Assume
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Locating (or honestly declining to locate) a real WebTerm server for the A34 instrumented E2E suite.
*
* ### The rule this file exists to enforce
* **A test that cannot tell "passed" from "never ran" is worse than no test.** Every E2E test therefore
* goes through [require], which either hands back a *proven-reachable* host or raises a JUnit assumption
* failure carrying the exact command needed to make it reachable. It never returns a host it has not
* spoken to, and it never lets a test body no-op its way to green.
*
* ### Where the address comes from (never hardcoded, never personal)
* 1. `-e webtermHost <base-url>` — an instrumentation argument. This is how a LAN address is supplied; it
* is read at run time so no host of anyone's ever enters the source tree.
* 2. Otherwise [EMULATOR_HOST_ALIAS], which is not a personal address at all: `10.0.2.2` is the Android
* emulator's fixed platform alias for the host machine's loopback, documented by Google, and it is the
* right default because the server under test is this repo's own `npm start`.
*
* ### Two things that will bite whoever runs this
* - **`Origin`.** The server derives its allow-list from the host's NIC addresses (`src/config.ts`
* `deriveAllowedOrigins`), and `10.0.2.2` is a *guest-side* alias that never appears among them. So the
* three guarded routes 403 unless the server is started with that origin allowed:
* `ALLOWED_ORIGINS=http://10.0.2.2:3000 npm start`. [require] proves this up front rather than letting
* individual tests fail obscurely.
* - **`WEBTERM_TOKEN`.** If the server is token-gated, every route (and the WS upgrade) needs the
* `webterm_auth` cookie. Pass the token with `-e webtermToken <t>` and it is exchanged via `POST /auth`
* into the in-memory jar installed on the one [OkHttpClient] both the REST and WS halves use —
* the same "one client, one jar" shape production relies on. The token is never logged and never written
* anywhere.
*/
internal object WebTermTestHost {
const val ARG_HOST: String = "webtermHost"
const val ARG_TOKEN: String = "webtermToken"
const val ARG_SPAWN_FAILURE_HOST: String = "webtermSpawnFailureHost"
/** The emulator's fixed alias for the host machine's loopback (an Android platform constant). */
const val EMULATOR_HOST_ALIAS: String = "http://10.0.2.2:3000"
private const val CONNECT_TIMEOUT_S = 3L
private const val READ_TIMEOUT_S = 10L
private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404
private val JSON = "application/json; charset=utf-8".toMediaType()
fun argument(name: String): String? =
InstrumentationRegistry.getArguments().getString(name)?.takeIf { it.isNotBlank() }
/**
* A reachable host, or a JUnit assumption failure (reported as SKIPPED, never as a pass) explaining
* exactly how to provide one.
*/
fun require(): ReachableHost {
val baseUrl = argument(ARG_HOST) ?: EMULATOR_HOST_ALIAS
val endpoint = HostEndpoint.fromBaseUrl(baseUrl)
Assume.assumeTrue(
"`-e $ARG_HOST $baseUrl` is not a usable http(s) base URL, so no E2E host could be built.",
endpoint != null,
)
return probe(endpoint!!, argument(ARG_TOKEN))
}
/**
* The optional second host for the "shell cannot start" case. It needs its own deliberately-broken
* instance because no client input can make a healthy host fail to spawn.
*
* MEASURED 2026-07-30: such a host answers `exit` with code **1**, not the documented `-1` sentinel —
* node-pty's `pty.fork()` succeeds and the `execvp` failure happens in the forked child, which
* `_exit(1)`s. See [SpawnFailureE2eTest] for the full finding.
*/
fun requireSpawnFailureHost(): ReachableHost {
val baseUrl = argument(ARG_SPAWN_FAILURE_HOST)
Assume.assumeTrue(
"""
|SKIPPED — no broken-shell host was supplied, so the failed-spawn path could not be exercised.
|Start a second server whose shell does not exist and point the suite at it:
| SHELL_PATH=/nonexistent/shell PORT=3112 ALLOWED_ORIGINS=http://10.0.2.2:3112 npm start
| ./gradlew :app:connectedDebugAndroidTest \
| -Pandroid.testInstrumentationRunnerArguments.$ARG_SPAWN_FAILURE_HOST=http://10.0.2.2:3112
""".trimMargin(),
baseUrl != null,
)
val endpoint = HostEndpoint.fromBaseUrl(baseUrl!!)
Assume.assumeTrue("`-e $ARG_SPAWN_FAILURE_HOST $baseUrl` is not a usable http(s) base URL.", endpoint != null)
return probe(endpoint!!, argument(ARG_TOKEN))
}
private fun probe(endpoint: HostEndpoint, token: String?): ReachableHost {
val cookieJar = MemoryCookieJar()
val client = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_S, TimeUnit.SECONDS)
.pingInterval(0, TimeUnit.MILLISECONDS)
.build()
val host = ReachableHost(endpoint, client)
val firstStatus = try {
host.get("/live-sessions").first
} catch (e: IOException) {
Assume.assumeNoException(
"""
|SKIPPED — no WebTerm server answered at ${endpoint.baseUrl} (${e.javaClass.simpleName}: ${e.message}).
|Start this repo's own server on the machine hosting the emulator, allowing the guest-side
|origin so the three guarded routes are usable:
| ALLOWED_ORIGINS=${endpoint.originHeader} npm start
|or point the suite at a LAN address instead:
| -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://<lan-ip>:3000
""".trimMargin(),
e,
)
error("unreachable") // assumeNoException always throws
}
if (firstStatus == HTTP_UNAUTHORIZED) {
Assume.assumeTrue(
"""
|SKIPPED — ${endpoint.baseUrl} is gated by WEBTERM_TOKEN and no token was supplied. Pass it with
| -Pandroid.testInstrumentationRunnerArguments.$ARG_TOKEN=<the token>
|(the token is only exchanged for the webterm_auth cookie; it is never logged or persisted).
""".trimMargin(),
token != null,
)
val authStatus = host.postJson("/auth", """{"token":${quote(token!!)}}""").first
Assume.assumeTrue(
"SKIPPED — POST /auth at ${endpoint.baseUrl} rejected the supplied token (HTTP $authStatus).",
authStatus == HTTP_NO_CONTENT,
)
}
val (status, body) = host.get("/live-sessions")
Assume.assumeTrue(
"SKIPPED — GET /live-sessions at ${endpoint.baseUrl} answered HTTP $status, so this is not a usable WebTerm host.",
status == HTTP_OK,
)
Assume.assumeTrue(
"SKIPPED — ${endpoint.baseUrl} answered GET /live-sessions with a non-array body, so it is not WebTerm.",
body.trimStart().startsWith("["),
)
return host
}
private fun quote(value: String): String = buildString {
append('"')
for (ch in value) {
when (ch) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
else -> append(ch)
}
}
append('"')
}
/**
* A host that has been *proven* to answer. Exposes only what the E2E tests need, over the one shared
* client (so the auth cookie covers REST and the WS upgrade alike).
*/
internal class ReachableHost(val endpoint: HostEndpoint, val client: OkHttpClient) {
fun get(path: String): Pair<Int, String> = exchange(
Request.Builder().url(endpoint.baseUrl + path).get().build(),
)
/** A GUARDED route: `Origin` stamped byte-equal to [HostEndpoint.originHeader] (plan §4.3). */
fun postJson(path: String, json: String, origin: String? = null): Pair<Int, String> = exchange(
Request.Builder()
.url(endpoint.baseUrl + path)
.post(json.toByteArray().toRequestBody(JSON))
.apply { origin?.let { header("Origin", it) } }
.build(),
)
fun delete(path: String, origin: String = endpoint.originHeader): Pair<Int, String> = exchange(
Request.Builder().url(endpoint.baseUrl + path).delete().header("Origin", origin).build(),
)
private fun exchange(request: Request): Pair<Int, String> =
client.newCall(request).execute().use { it.code to (it.body?.string() ?: "") }
companion object {
const val OK: Int = HTTP_OK
const val NO_CONTENT: Int = HTTP_NO_CONTENT
const val UNAUTHORIZED: Int = HTTP_UNAUTHORIZED
const val FORBIDDEN: Int = HTTP_FORBIDDEN
const val NOT_FOUND: Int = HTTP_NOT_FOUND
}
}
/** Minimal in-memory jar — enough to carry `webterm_auth` across REST calls and the WS upgrade. */
private class MemoryCookieJar : CookieJar {
private val cookies = mutableMapOf<String, Cookie>()
override fun loadForRequest(url: HttpUrl): List<Cookie> = synchronized(cookies) {
cookies.values.filter { it.matches(url) }
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = synchronized(this.cookies) {
cookies.forEach { this.cookies[it.name] = it }
}
}
}

View File

@@ -0,0 +1,188 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **the CRITICAL one: one finger-swipe inside an alternate-screen TUI killed the process.**
*
* Stock `TerminalView.doScroll` (offsets 5679 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll
* into `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and
* `handleKeyCode` offsets 1519 are `mTermSession.getEmulator()` with NO null guard. `mEmulator` is
* guarded; `mTermSession` is not, and it is null forever in this app (plan §6.1). No `TerminalViewClient`
* callback sits anywhere on that path, so installing a client could not help — only taking the gesture
* away from the stock view could.
*
* **Claude Code is an alternate-screen TUI.** That makes this ordinary use, not an edge case: scrolling
* back through a running Claude session was a guaranteed crash. Every test below therefore enters the
* alternate buffer with `ESC [ ? 1 0 4 9 h` FIRST, then drives the real gesture through
* [android.view.View.dispatchTouchEvent] / `dispatchGenericMotionEvent` — the three stock entry points
* (`TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable, `onGenericMotionEvent`).
*
* "It did not crash" is necessary but not sufficient, so each test also pins the bytes: the alternate-
* buffer branch must emit the DECCKM-correct arrow form, the mouse-tracking branch must emit a mouse
* report and NOT arrows, and the fling must be silent (proof the stock runnable never started).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class AlternateScreenScrollRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
harness.awaitAttachSettled()
// ESC [ ? 1 0 4 9 h — enter the alternate screen buffer. This is the state Claude Code, vim,
// htop, less and every git pager run in, and the state that made stock scrolling fatal.
harness.feedAndAwait("\u001b[?1049h", "the alternate screen buffer to be active") {
harness.emulator.isAlternateBufferActive
}
harness.clearSent()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun swipeUpInsideTheAlternateBuffer_doesNotCrashAndEmitsDownArrows() {
val claimed = harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX)
assertTrue(
"the frame must claim the vertical drag — that is what makes stock doScroll unreachable",
claimed,
)
assertTrue("the alternate buffer must still be active", harness.emulator.isAlternateBufferActive)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"a finger moving UP the screen scrolls the TUI forward — DPAD_DOWN in CSI form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001b[B" },
)
}
@Test
fun swipeDownInsideTheAlternateBuffer_doesNotCrashAndEmitsUpArrows() {
val claimed = harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
assertTrue("the frame must claim the vertical drag", claimed)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"a finger moving DOWN the screen scrolls back — DPAD_UP in CSI form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001b[A" },
)
}
/**
* The arrow FORM has to follow the emulator's live DECCKM bit, because that is what stock
* `handleKeyCode` would have read off `mTermSession.getEmulator()`. A TUI in application-cursor mode
* that received `ESC [ A` instead of `ESC O A` would mis-handle the scroll.
*/
@Test
fun scrollUnderApplicationCursorMode_emitsTheSs3ArrowForm() {
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") {
harness.emulator.isCursorKeysApplicationMode
}
harness.clearSent()
harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"application-cursor mode must emit the SS3 form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001bOA" },
)
}
/**
* `doScroll`'s FIRST branch (offsets 2653) is mouse tracking, and it must win over the alternate-
* buffer branch. Getting the order wrong would send arrow keys to a program that asked for mouse
* reports — the terminal would appear to ignore the wheel.
*/
@Test
fun wheelUnderMouseTracking_reportsTheWheelAndSendsNoArrowKeys() {
// ESC [ ? 1 0 0 0 h — button-press mouse tracking (what a mouse-aware TUI enables).
harness.feedAndAwait("\u001b[?1000h", "mouse tracking to be active") {
harness.emulator.isMouseTrackingActive
}
harness.clearSent()
val handled = harness.scrollWheel(up = true)
assertTrue("the frame must claim the mouse wheel", handled)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("the wheel must be reported to the program", frames.isNotEmpty())
assertEquals(
"mouse tracking must suppress the arrow-key branch entirely",
emptyList<ClientMessage.Input>(),
frames.filter { it.data == "\u001b[A" || it.data == "\u001bOA" },
)
assertTrue(
"a wheel report must be an escape sequence, got ${frames.map { it.data.toCharList() }}",
frames.all { it.data.startsWith("\u001b[") },
)
}
/** Without mouse tracking the wheel takes the same alternate-buffer branch a finger drag does. */
@Test
fun wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch() {
val handled = harness.scrollWheel(up = true)
assertTrue("the frame must claim the mouse wheel", handled)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
// Stock onGenericMotionEvent scrolls MOUSE_WHEEL_ROWS (3) rows per notch, one key per row.
assertEquals(
"one wheel notch is three rows",
List(WHEEL_ROWS_PER_NOTCH) { ClientMessage.Input("\u001b[A") },
frames,
)
}
/**
* The third stock entry point is the fling runnable `TerminalView$1$1.run`, which keeps calling
* `doScroll` after the finger leaves. It can only start if `mGestureRecognizer` observed the scroll —
* which is exactly what the intercept prevents. So the observable is silence after the drag ends: a
* live fling would keep emitting arrows (and, before the fix, would crash a beat after the gesture,
* which is what made this bug look intermittent).
*/
@Test
fun theStockFlingNeverStarts_soNoBytesArriveAfterTheFingerLeaves() {
harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX, steps = 2)
harness.drainSent() // the drag's own rows
val afterTheGesture = harness.drainSent(settleMs = FLING_SETTLE_MS)
assertEquals(
"no fling may continue scrolling after ACTION_UP",
emptyList<ClientMessage>(),
afterTheGesture,
)
}
private fun String.toCharList(): List<Int> = map { it.code }
private companion object {
/** Comfortably past the touch slop and several cell heights on any density. */
const val DRAG_DISTANCE_PX = 600f
/** `TerminalScrollGesture.MOUSE_WHEEL_ROWS` — stock scrolls three rows per notch. */
const val WHEEL_ROWS_PER_NOTCH = 3
/** Long enough for a real fling to have produced several frames of scrolling. */
const val FLING_SETTLE_MS = 800L
}
}

View File

@@ -0,0 +1,109 @@
package wang.yaojia.webterm.terminal
import android.view.View
import android.view.autofill.AutofillValue
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **a password manager crashed the app just by offering to fill the terminal.**
*
* Stock `TerminalView.autofill(AutofillValue)` (offsets 720) writes the filled text straight into
* `mTermSession`, which is null forever here, AND the view advertises itself as fillable
* (`getAutofillType()` = `AUTOFILL_TYPE_TEXT`, `getAutofillValue()` non-null). So on any device with an
* autofill service enabled — the default state for most users — focusing the terminal was enough to arm a
* crash, with no user action beyond accepting the fill.
*
* The fix is not a try/catch around `autofill`: it is keeping the whole subtree OUT of the autofill
* structure, so the framework never builds a fillable node for it. `View.isImportantForAutofill()` walks
* the parent chain and stops at the first `*_EXCLUDE_DESCENDANTS`, and that is precisely the gate
* `AutofillManager` consults before it will notify a service about a view — so it is the gate these tests
* assert, on both the frame and the stock child.
*
* **Deliberately NOT tested here:** the end-to-end loop with a real autofill service (which needs an
* installed, user-selected `AutofillService`) stays on the device-QA checklist. What IS provable on an
* emulator is the framework contract that makes the crash unreachable, plus the belt-and-braces
* assertion that a fill delivered anyway puts nothing on the wire — shell input is exactly the content
* that must never enter an autofill structure (plan §8).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalAutofillRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun theTerminalFrameIsExcludedFromTheAutofillStructure() {
val flag = harness.onMain { harness.hostView.importantForAutofill }
val important = harness.onMain { harness.hostView.isImportantForAutofill }
assertEquals(
"the frame must answer IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS",
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
flag,
)
assertFalse("the framework must consider the frame unimportant for autofill", important)
}
/**
* The child is the view that actually carries the crashing `autofill` override, and it is reached
* through the parent-chain walk — which is why the frame overrides the GETTER rather than only
* setting the flag: a later `setImportantForAutofill` cannot quietly re-enable it.
*/
@Test
fun theStockTerminalViewIsExcludedThroughTheParentChain() {
val important = harness.onMain { harness.stockChild.isImportantForAutofill }
assertFalse(
"the stock TerminalView must be unimportant for autofill via the EXCLUDE_DESCENDANTS walk",
important,
)
}
@Test
fun focusingTheTerminalKeepsItExcluded() {
harness.onMain { harness.hostView.requestFocus() }
assertFalse(
"focus must not make the terminal fillable — focus is what triggered the old crash",
harness.onMain { harness.hostView.isImportantForAutofill },
)
assertFalse(
"…nor the stock child",
harness.onMain { harness.stockChild.isImportantForAutofill },
)
}
/**
* Belt and braces: even a fill delivered directly at the frame (bypassing the structure gate) must be
* inert. A password reaching `ClientMessage.Input` would be typed into whatever shell is running.
*/
@Test
fun aFillDeliveredAtTheFrameIsInertAndNeverReachesTheWire() {
harness.onMain { harness.hostView.autofill(AutofillValue.forText("correct-horse-battery-staple")) }
assertEquals(
"an autofilled value must never be sent to the shell",
emptyList<ClientMessage>(),
harness.drainSent(),
)
}
}

View File

@@ -0,0 +1,187 @@
package wang.yaojia.webterm.terminal
import android.view.KeyEvent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **BLOCKER 1: the app died on the first key press.**
*
* `TerminalView.mClient` was never installed, and stock `onKeyDown` dereferences it with no null guard
* (offset 8292 → `mClient.onKeyDown(keyCode, event, mTermSession)`), so *any* key — hardware or
* soft-keyboard — was an instant NPE on the UI thread. Installing a client that returned `false` would
* only have moved the crash one line down, into `mTermSession.write(getCharacters())` (offset 149160),
* because `mTermSession` is null forever here (plan §6.1). The soft-keyboard half was different but no
* better: the stock `TerminalView$2` `InputConnection` routes `commitText` into `inputCodePoint`, which
* early-returns when `mTermSession == null` — everything typed vanished silently.
*
* These tests therefore assert **both** halves of the contract on a real device: nothing crashes, AND the
* exact right bytes reach the wire. A test that only proved "no crash" would pass against the silent
* black hole, which was arguably the worse of the two bugs.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalKeyInputRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
// Discard the attach-time grid push before any assertion looks at the wire.
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
// ── Hardware keys ────────────────────────────────────────────────────────────────────────────
@Test
fun hardwareEnter_sendsCarriageReturnNotLineFeed() {
val consumed = harness.pressKey(KeyEvent.KEYCODE_ENTER)
assertTrue("the terminal must consume Enter, not leak it to the Activity", consumed)
// Repo-wide gotcha: a terminal Enter is CR (0x0D), never LF.
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("Enter"))
}
@Test
fun hardwarePrintableKey_sendsThatCharacter() {
harness.pressKey(KeyEvent.KEYCODE_A)
assertEquals(ClientMessage.Input("a"), harness.awaitSent("the letter a"))
}
@Test
fun hardwareShiftedKey_sendsTheCapital() {
harness.pressKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON)
assertEquals(ClientMessage.Input("A"), harness.awaitSent("Shift+A"))
}
@Test
fun hardwareCtrlC_sendsTheInterruptControlByte() {
harness.pressKey(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
assertEquals(ClientMessage.Input("\u0003"), harness.awaitSent("Ctrl+C"))
}
@Test
fun hardwareBackspace_sendsDel() {
harness.pressKey(KeyEvent.KEYCODE_DEL)
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("Backspace"))
}
/**
* DECCKM (plan §6.3): arrows must go through Termux's `KeyHandler` so application-cursor mode emits
* `ESC O A` rather than `ESC [ A`. Hardcoding the CSI form breaks vim/htop, so the byte form is
* asserted in BOTH modes against the live emulator bit.
*/
@Test
fun arrowUp_followsTheEmulatorsCursorKeyMode() {
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
assertEquals(
"normal cursor mode must emit the CSI form",
ClientMessage.Input("\u001b[A"),
harness.awaitSent("Up in normal cursor mode"),
)
// ESC [ ? 1 h — DECCKM on (what full-screen TUIs set).
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { harness.emulator.isCursorKeysApplicationMode }
harness.clearSent()
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
assertEquals(
"application cursor mode must emit the SS3 form",
ClientMessage.Input("\u001bOA"),
harness.awaitSent("Up in application cursor mode"),
)
}
/**
* BACK must stay the Activity's: `WebTermTerminalViewClient.shouldBackButtonBeMappedToEscape()`
* answers false precisely so stock `onKeyDown` cannot route it into the terminal branch (whose next
* stop is `mTermSession.write`). If this ever returns true the terminal screen becomes a trap the
* user cannot leave.
*/
@Test
fun systemBackKey_isNotSwallowedAndSendsNothing() {
val consumedDown = harness.pressKey(KeyEvent.KEYCODE_BACK)
val consumedUp = harness.releaseKey(KeyEvent.KEYCODE_BACK)
assertFalse("BACK down must fall through to the Activity", consumedDown)
assertFalse("BACK up must fall through to the Activity", consumedUp)
assertEquals("BACK must put no bytes on the wire", emptyList<ClientMessage>(), harness.drainSent())
}
// ── Soft keyboard (the IME seam) ─────────────────────────────────────────────────────────────
@Test
fun softKeyboardIsOffered_anInputConnectionForTheTerminal() {
val connection = harness.openInputConnection()
assertTrue(
"the frame must declare itself a text editor or no IME will attach",
harness.onMain { harness.hostView.onCheckIsTextEditor() },
)
// Proves the connection is OURS, not the stock TerminalView$2 black hole: committing text has to
// reach the wire.
harness.onMain { connection.commitText("ls -la", 1) }
assertEquals(ClientMessage.Input("ls -la"), harness.awaitSent("a soft-keyboard commit"))
}
@Test
fun softKeyboardEnter_sendsCarriageReturn() {
val connection = harness.openInputConnection()
// Some IMEs deliver Enter as a synthesised key event rather than as text.
harness.onMain {
connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
}
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("an IME-synthesised Enter"))
}
@Test
fun softKeyboardBackspace_sendsDel() {
val connection = harness.openInputConnection()
harness.onMain { connection.deleteSurroundingText(1, 0) }
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("an IME backspace"))
}
/**
* Non-ASCII is passed through VERBATIM (invariant #9 — no filtering, no normalisation). CJK is the
* case that matters because it arrives via composition, and it is also the one a byte-oriented
* implementation is most likely to mangle.
*/
@Test
fun softKeyboardCjkCommit_isPassedThroughVerbatim() {
val connection = harness.openInputConnection()
harness.onMain {
connection.setComposingText("中文", 1)
connection.commitText("中文", 1)
}
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertEquals(
"the committed CJK text must reach the wire unmodified",
"中文",
frames.joinToString(separator = "") { it.data },
)
}
}

View File

@@ -0,0 +1,155 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
/**
* REGRESSION for a **defect this suite FOUND, and which is NOT yet fixed** (2026-07-30).
*
* ## The defect
* `RemoteTerminalSession.onResize` calls `emulator.resize(cols, rows)` on its confined *writer* thread.
* The stock `TerminalRenderer` reads that same `TerminalBuffer` on the **UI thread** during `onDraw`, and
* it caches `mEmulator.mColumns` / `mRows` at the top of `render()` before walking the rows. So a regrid
* landing mid-frame tears the buffer out from under the renderer, which then indexes rows that have
* already been reallocated to the other size. Both shapes were observed as FATAL exceptions on the main
* thread on the `webterm` AVD (android-35):
*
* ```
* FATAL EXCEPTION: main
* java.lang.ArrayIndexOutOfBoundsException: length=31; index=31
* at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244)
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104)
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
*
* FATAL EXCEPTION: main
* java.lang.IllegalArgumentException: extRow=19, mScreenRows=18, mActiveTranscriptRows=0
* at com.termux.terminal.TerminalBuffer.externalToInternalRow(TerminalBuffer.java:175)
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:83)
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
* ```
*
* ## Why it matters, and how reliably it reproduces — stated precisely
* The window is not exotic: **every** session attach performs an 80x24 -> real-grid reflow (the server
* spawns the PTY at 80x24 and the client corrects it), and every rotation performs another, both while
* frames are being drawn. `TerminalBuffer.resize` reallocates every transcript row, so at production's
* 10 000-row transcript the mutation takes milliseconds rather than microseconds.
*
* **Reproduction is load-dependent, and a green run here does NOT mean the bug is gone.** Measured on the
* `webterm` AVD:
* - Under load (host memory starved, emulator thrashing) it fired constantly: it aborted roughly one
* instrumentation setup in three, hit an alternating-regrid loop within one or two iterations, and
* surfaced BOTH crash shapes above across about six separate runs.
* - On a freshly rebooted, idle emulator, 60 iterations with a concurrent output burst each pass cleanly:
* the reflow simply finishes inside a gap between frames.
*
* That is the signature of a real, unsynchronised data race rather than a deterministic bug — and load is
* exactly the condition a user meets: a mid-range phone rotating while a busy Claude session streams, or a
* cold start replaying a multi-MB ring buffer. So this test is a **stress probe, not a decider**: it fails
* loudly when it catches the tear and must not be read as an all-clear when it does not. The finding stands
* on the captured stack traces above, not on this test's verdict.
*
* It is invisible to the 900-strong JVM suite for the usual reason - no JVM test draws a `View` - and
* invisible to a manual emulator pass that only reaches the pairing screen, because it needs a bound
* session.
*
* ## The fix (verified here before being reported, and NOT applied — `:terminal-view` is not this
* agent's to edit)
* Do the reflow on the renderer's own thread: inside the confined consumer, wrap the mutation as
* `withContext(mainDispatcher) { emulator.resize(cols, rows) }`. That keeps command ORDER intact (the
* consumer still processes one command at a time, so a resize cannot overtake an append) while making the
* mutation mutually exclusive with `onDraw`. It is also what Termux itself does — its own
* `TerminalView.updateSize()` resizes from the UI thread.
*
* **Control experiment, run on this emulator:** 60 alternating regrids driven from the *main* thread with
* `invalidate()` after each one → zero crashes. The identical loop with the regrid on the confined thread
* → FATAL within two iterations. So the hop is sufficient, and the confinement is the cause.
*
* ## Reading this test
* A FAILURE (a main-thread FATAL that also aborts the instrumentation run, which is what the defect does to
* a user's app) is a positive catch: the bug is still there. A PASS means only that this run did not win the
* race. It deliberately uses the production transcript depth
* ([RemoteTerminalSession.TRANSCRIPT_ROWS]) and a concurrent output burst per iteration, which are the two
* knobs that widen the window. Once the reflow is hopped onto the main thread the test becomes a genuine
* decider, because then no interleaving exists that can tear the buffer.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalResizeDrawRaceRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
// Production scrollback depth on purpose: it is what makes the reallocation — and therefore the
// race window — the real size.
harness = TerminalTestHarness.launch(transcriptRows = RemoteTerminalSession.TRANSCRIPT_ROWS)
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
/**
* Alternate between two grids while forcing a redraw after each change — i.e. what a user does by
* rotating the device, folding a foldable, or dragging a multi-window divider. Every iteration must
* survive; a single torn frame is a dead app.
*/
@Test
fun regriddingWhileTheRendererDraws_neverTearsTheBuffer() {
repeat(REGRID_ITERATIONS) { iteration ->
// Keep the confined writer busy so the reflow is queued behind real appends and lands at an
// unpredictable point relative to the frame pipeline. Without this the reflow tends to complete
// in one gap between frames on a fast, idle device and the tear is missed — the race is
// probabilistic, and this is what makes the probability worth testing.
harness.session.feedRemote(OUTPUT_BURST.toByteArray(Charsets.UTF_8))
val wide = iteration % 2 == 0
harness.layoutTo(
widthPx = if (wide) WIDE_PX else NARROW_PX,
heightPx = if (wide) SHORT_PX else TALL_PX,
)
harness.onMain {
harness.hostView.invalidate()
harness.stockChild.invalidate()
}
harness.awaitTrue("iteration $iteration to reflow the emulator") {
harness.emulator.mColumns > 0 && harness.emulator.mRows > 0
}
}
// Reaching here at all is the assertion: the failure mode is a process-killing FATAL on the main
// thread, not a false return. This last check adds a coherence probe that indexes the buffer the
// same way the renderer does — a torn buffer throws out of it rather than answering.
val readBack = harness.emulator.screen.getSelectedText(
0,
0,
harness.emulator.mColumns - 1,
harness.emulator.mRows - 1,
)
assertNotNull("the buffer must still be readable at the emulator's advertised dims", readBack)
}
private companion object {
/**
* The tear is probabilistic: it needs the reflow to land while a frame is mid-flight, which on an
* idle emulator sometimes never happens. 60 iterations with a concurrent output burst each is what
* made it reproduce, rather than the 24 quiet ones that passed on a freshly booted device.
*/
const val REGRID_ITERATIONS = 60
/** ~8 KB of real terminal output per iteration: enough to keep the confined writer working. */
val OUTPUT_BURST: String = (1..64).joinToString("") { "webterm-race-$it " + "-".repeat(64) + "\r\n" }
const val WIDE_PX = 900
const val NARROW_PX = 500
const val SHORT_PX = 700
const val TALL_PX = 900
}
}

View File

@@ -0,0 +1,225 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **`resize` had zero call sites, so the PTY stayed at 80×24 forever.**
*
* `RemoteTerminalSession.updateSize` existed and was unit-tested, and nothing called it. The stock
* fallback cannot help either: `TerminalView.updateSize()` early-returns when `mTermSession == null`
* (offsets 1825), permanently our case. The visible symptom was every full-screen TUI — Claude Code
* included — rendering into an 80×24 box in the corner of a phone screen, and never reflowing on rotation.
*
* A JVM test cannot catch this class of bug at all: the arithmetic was already correct and tested
* ([wang.yaojia.webterm.terminalview.TerminalGridMath]); what was missing was a real layout pass, real
* cell metrics from a real `TerminalRenderer`, and a real wire. All three exist here.
*
* ### How "the expected grid" is asserted without reading private font metrics
* `TerminalRenderer`'s metrics are only reachable through `:terminal-view`'s `internal` API, and the
* Termux artifact is `implementation`-scoped so its types are off `:app`'s classpath. Rather than
* reflect (which silently rots) the tests assert the **documented formula's own algebra**, which needs no
* metric value: with zero padding, `cols = floor(W / fontWidth)`, so doubling the width must give
* `floor(2W / fontWidth) ∈ {2·cols, 2·cols + 1}` — and nothing else. Same for rows. That pins the grid to
* the plan §6.4 formula rather than merely checking it is "some positive number", and it fails loudly if
* the padding assumption ever stops holding (asserted separately).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalResizeRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun theStockViewHasNoPadding_whichTheGridAlgebraBelowRelies_on() {
val horizontal = harness.onMain { harness.stockChild.paddingLeft }
val vertical = harness.onMain { harness.stockChild.paddingTop }
assertEquals("the grid algebra in this class assumes zero horizontal padding", 0, horizontal)
assertEquals("the grid algebra in this class assumes zero vertical padding", 0, vertical)
}
@Test
fun theFirstLayoutPushesARealGridToTheWireAndToTheEmulator() {
val resize = harness.firstResize()
assertTrue("cols must be a real grid, not the 80x24 default box", resize.cols > 1)
assertTrue("rows must be a real grid, not the 80x24 default box", resize.rows > 1)
assertEquals(
"the local emulator must be reflowed to exactly the dims that went on the wire",
resize.cols to resize.rows,
harness.emulator.mColumns to harness.emulator.mRows,
)
assertTrue(
"the grid must be within the protocol's validated range",
resize.cols in RESIZE_RANGE && resize.rows in RESIZE_RANGE,
)
}
/**
* The §6.4 formula, checked by its own algebra: doubling the pixel box must double the grid to within
* the one cell a `floor` can absorb. A driver that ignored the real metrics (e.g. one that resent the
* default 80×24, or one that divided by a re-measured `Paint`) cannot satisfy this.
*/
@Test
fun doublingTheViewboxDoublesTheGrid_perThePlanFormula() {
val base = harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX * 2, TerminalTestHarness.DEFAULT_HEIGHT_PX * 2)
val doubled = harness.awaitResize("the grid after doubling the viewbox")
assertTrue(
"cols=floor(W/fw) ⇒ floor(2W/fw) must be 2·cols or 2·cols+1, was ${doubled.cols} for base ${base.cols}",
doubled.cols == base.cols * 2 || doubled.cols == base.cols * 2 + 1,
)
assertTrue(
"rows=H/ls ⇒ 2H/ls must be 2·rows or 2·rows+1, was ${doubled.rows} for base ${base.rows}",
doubled.rows == base.rows * 2 || doubled.rows == base.rows * 2 + 1,
)
assertEquals(
"the emulator must follow the wire",
doubled.cols to doubled.rows,
harness.emulator.mColumns to harness.emulator.mRows,
)
}
/**
* A rotation is, to this path, a layout pass with the dimensions swapped — and it must produce a NEW
* grid rather than keep the portrait one (the "does not reflow on rotation" half of the bug).
*/
@Test
fun swappingTheViewboxDimensions_reflowsToATransposedGrid() {
val portrait = harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_HEIGHT_PX, TerminalTestHarness.DEFAULT_WIDTH_PX)
val landscape = harness.awaitResize("the grid after swapping width and height")
assertTrue("a wider box must give more columns", landscape.cols > portrait.cols)
assertTrue("a shorter box must give fewer rows", landscape.rows < portrait.rows)
}
/**
* Layout fires far more often than the grid changes (every IME show/hide, inset change and frame of a
* fold drag). Each one becoming a wire frame would mean a SIGWINCH storm, so an unchanged grid must be
* silent — the dedup that makes the forced re-assert below necessary in the first place.
*/
@Test
fun relayoutAtTheSameSize_sendsNothing() {
harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX, TerminalTestHarness.DEFAULT_HEIGHT_PX)
assertEquals(
"an unchanged grid must not reach the wire",
emptyList<ClientMessage>(),
harness.drainSent(),
)
}
/**
* **The §6.4 double-dedup bug.** A shared PTY has exactly ONE size, so a device returning to the
* foreground has to re-assert dims it has already sent in order to take the size back from whichever
* device wrote last. Two dedups sit in series on that path — `TerminalResizeDriver.lastGrid` and
* `RemoteTerminalSession.lastSentDims` — and BOTH survive a rebind, so an unflagged re-push dies
* silently and the returning device stays letterboxed forever.
*
* Rebinding the session is production's own trigger (`RemoteTerminalView.session`'s setter and
* `bindEmulator` both call `reassertLastGrid()`), so this test performs exactly that and requires the
* identical grid to reach the wire a second time.
*/
@Test
fun rebindingTheSession_reassertsTheSameGridOntoTheWire() {
val before = harness.firstResize()
harness.clearSent()
// What a device-switch / pane-show does: re-bind, which must force the memoised grid through both
// dedups even though not one pixel changed.
harness.onMain { harness.view.session = harness.session }
val reasserted = harness.awaitResize("the forced re-assert after a rebind")
assertEquals(
"the re-assert must carry the SAME grid — it is a claim on the shared PTY size, not a change",
before,
reasserted,
)
}
/**
* The same reclaim through the other production trigger: a brand-new session (a real background →
* foreground cycle bumps the generation and builds a fresh emulator at the server's 80×24 default).
* The view already knows the real grid, so it must push it at the new session immediately rather than
* wait for a layout pass that will never come.
*/
@Test
fun attachingAFreshSession_pushesTheMeasuredGridImmediately() {
val measured = harness.firstResize()
val replacementSent = java.util.concurrent.LinkedBlockingQueue<ClientMessage>()
val replacement = harness.onMain {
RemoteTerminalSession(engineSend = { message -> replacementSent.put(message) })
}
try {
harness.onMain { replacement.attachView(harness.view) }
val pushed = harness.awaitTrueValue("the fresh session to be told the real grid") {
replacementSent.poll()?.let { it as? ClientMessage.Resize }
}
assertEquals(
"a fresh emulator starts at the server's 80x24 default and must be corrected at once",
measured,
pushed,
)
assertEquals(
"the fresh emulator must be reflowed too",
measured.cols to measured.rows,
replacement.emulator.mColumns to replacement.emulator.mRows,
)
} finally {
harness.onMain { replacement.close() }
}
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
/** The attach-time grid push — the first `Resize` the initial layout produced. */
private fun TerminalTestHarness.firstResize(): ClientMessage.Resize = awaitAttachSettled()
private fun TerminalTestHarness.awaitResize(what: String): ClientMessage.Resize {
var last: ClientMessage? = null
repeat(MAX_FRAMES_SCANNED) {
val message = awaitSent(what)
last = message
if (message is ClientMessage.Resize) return message
}
throw AssertionError("no Resize frame arrived while waiting for $what; last frame was $last")
}
private companion object {
/** `WireConstants.RESIZE_RANGE` — the server silently drops anything outside it. */
val RESIZE_RANGE = 1..1000
/** Guards the scan loop from spinning if the wire only ever carries non-Resize frames. */
const val MAX_FRAMES_SCANNED = 8
}
}

View File

@@ -0,0 +1,354 @@
package wang.yaojia.webterm.terminal
import android.app.Activity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.termux.terminal.TerminalEmulator
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.terminalview.RemoteTerminalView
import wang.yaojia.webterm.wire.ClientMessage
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
/**
* The device-side rig for the four 2026-07-30 crash regressions.
*
* ### Why these tests can only exist here
* Every defect this rig covers lived in a real `android.view.View` (`TerminalView.onKeyDown`,
* `doScroll`, `autofill`, `updateSize`). The 900-odd JVM tests could not see any of them because no JVM
* test instantiates a View, inflates an `InputConnection`, dispatches a `MotionEvent` or runs a layout
* pass — which is exactly how three crash-on-first-interaction bugs shipped behind a green suite. So the
* rig builds the REAL [RemoteTerminalView] inside a REAL Activity and drives it through the REAL
* framework entry points. Nothing is faked except the wire: [sent] captures the [ClientMessage]s that
* would have gone to the server, which is the only observable a regression can be asserted against
* without a host.
*
* ### Threading contract
* The View and the session's bind path are main-thread-only, so every mutation goes through
* [onMain]. The test body itself runs on the instrumentation thread, which is what lets [awaitSent] and
* [awaitTrue] block: the confined emulator-writer thread and the main looper both stay free to make
* progress while the test waits. Blocking on the main thread instead would deadlock the very handoff
* under test.
*/
internal class TerminalTestHarness private constructor(
private val scenario: ActivityScenario<ComponentActivity>,
val view: RemoteTerminalView,
val session: RemoteTerminalSession,
val root: FrameLayout,
private val sent: LinkedBlockingQueue<ClientMessage>,
) {
/** The frame that owns focus + the IME contract; the stock Termux view is its only child. */
val hostView: View get() = view.terminalView
/** The stock `com.termux.view.TerminalView`, reachable only as a plain [View]: `:terminal-view`
* scopes the Termux artifact as `implementation`, so its type is off `:app`'s classpath. Every
* assertion this rig needs (`isImportantForAutofill`, padding) is plain `View` API anyway. */
val stockChild: View get() = (hostView as ViewGroup).getChildAt(0)
val emulator: TerminalEmulator get() = session.emulator
// ── Driving the view ─────────────────────────────────────────────────────────────────────────
fun <T> onMain(block: (Activity) -> T): T {
var result: T? = null
var failure: Throwable? = null
val done = CountDownLatch(1)
scenario.onActivity { activity ->
try {
result = block(activity)
} catch (t: Throwable) {
failure = t
} finally {
done.countDown()
}
}
assertTrue("the main thread never ran the block within $MAIN_SYNC_TIMEOUT_MS ms",
done.await(MAIN_SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS))
failure?.let { throw it }
@Suppress("UNCHECKED_CAST")
return result as T
}
/** Resize the hosted view to an exact pixel box and wait for the layout pass to land. */
fun layoutTo(widthPx: Int, heightPx: Int) {
onMain {
hostView.layoutParams = FrameLayout.LayoutParams(widthPx, heightPx)
hostView.requestLayout()
}
awaitTrue("the view never laid out at ${widthPx}x$heightPx") {
onMain { hostView.width == widthPx && hostView.height == heightPx }
}
}
/** Dispatch a key through the framework entry point a physical keyboard uses. */
fun pressKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
val down = KeyEvent(
/* downTime = */ 0L,
/* eventTime = */ 0L,
KeyEvent.ACTION_DOWN,
keyCode,
/* repeat = */ 0,
metaState,
)
hostView.dispatchKeyEvent(down)
}
fun releaseKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
val up = KeyEvent(0L, 0L, KeyEvent.ACTION_UP, keyCode, 0, metaState)
hostView.dispatchKeyEvent(up)
}
/** The soft-keyboard seam: the same `InputConnection` an IME would be handed. */
fun openInputConnection(): InputConnection = onMain {
val editorInfo = EditorInfo()
val connection = hostView.onCreateInputConnection(editorInfo)
assertNotNull("onCreateInputConnection returned null — the IME would have no target", connection)
connection!!
}
/**
* A vertical finger drag, dispatched exactly as the framework would: one DOWN, [steps] MOVEs and one
* UP, all through [View.dispatchTouchEvent] so `onInterceptTouchEvent` runs for real.
*
* @param totalDeltaPx negative moves the finger UP the screen (content scrolls forward), positive
* moves it DOWN (content scrolls back into history).
* @return whether the frame claimed the gesture — true is what makes the crashing stock
* `doScroll` / fling path unreachable rather than merely unlikely.
*/
fun dragVertically(startY: Float, totalDeltaPx: Float, steps: Int = DRAG_STEPS): Boolean {
val x = 10f
var claimed = false
onMain {
val down = motionEvent(MotionEvent.ACTION_DOWN, x, startY)
hostView.dispatchTouchEvent(down)
down.recycle()
for (step in 1..steps) {
val y = startY + totalDeltaPx * step / steps
val move = motionEvent(MotionEvent.ACTION_MOVE, x, y)
// dispatchTouchEvent returns the ViewGroup's verdict; once the frame intercepts, the
// remainder is routed to its own onTouchEvent, which keeps returning true.
claimed = hostView.dispatchTouchEvent(move) || claimed
move.recycle()
}
val up = motionEvent(MotionEvent.ACTION_UP, x, startY + totalDeltaPx)
hostView.dispatchTouchEvent(up)
up.recycle()
}
return claimed
}
/** One external-mouse wheel notch — the second stock entry into the crashing `doScroll`. */
fun scrollWheel(up: Boolean): Boolean = onMain {
val event = wheelEvent(if (up) 1f else -1f)
try {
hostView.dispatchGenericMotionEvent(event)
} finally {
event.recycle()
}
}
// ── Observing ────────────────────────────────────────────────────────────────────────────────
/** The next message that reached the (fake) wire, or fail after [WIRE_TIMEOUT_MS]. */
fun awaitSent(what: String): ClientMessage {
val message = sent.poll(WIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
assertNotNull("nothing reached the wire within $WIRE_TIMEOUT_MS ms while waiting for $what", message)
return message!!
}
/** Every message that reached the wire within [settleMs] (used to assert "and nothing else"). */
fun drainSent(settleMs: Long = SETTLE_MS): List<ClientMessage> {
Thread.sleep(settleMs)
val drained = ArrayList<ClientMessage>()
sent.drainTo(drained)
return drained
}
fun clearSent() {
sent.clear()
}
/** Feed remote bytes and block until the confined writer has applied them. */
fun feedAndAwait(bytes: String, description: String, predicate: () -> Boolean) {
session.feedRemote(bytes.toByteArray(Charsets.UTF_8))
awaitTrue(description, predicate)
}
fun awaitTrue(what: String, predicate: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
while (System.nanoTime() < deadline) {
if (predicate()) return
Thread.sleep(POLL_INTERVAL_MS)
}
assertTrue("timed out after $AWAIT_TIMEOUT_MS ms waiting for: $what", predicate())
}
fun close() {
onMain { session.close() }
scenario.close()
}
private fun motionEvent(action: Int, x: Float, y: Float): MotionEvent =
MotionEvent.obtain(DOWN_TIME, DOWN_TIME, action, x, y, 0)
private fun wheelEvent(vScroll: Float): MotionEvent {
val properties = arrayOf(
MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
},
)
val coords = arrayOf(
MotionEvent.PointerCoords().apply {
x = 10f
y = 10f
setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll)
},
)
return MotionEvent.obtain(
/* downTime = */ DOWN_TIME,
/* eventTime = */ DOWN_TIME,
MotionEvent.ACTION_SCROLL,
/* pointerCount = */ 1,
properties,
coords,
/* metaState = */ 0,
/* buttonState = */ 0,
/* xPrecision = */ 1f,
/* yPrecision = */ 1f,
/* deviceId = */ 0,
/* edgeFlags = */ 0,
android.view.InputDevice.SOURCE_MOUSE,
/* flags = */ 0,
)
}
/**
* Block until the attach-time grid push (80×24 → the real grid) has landed on the wire AND the wire
* has gone quiet, then discard those frames.
*
* Without this, `clearSent()` in a `@Before` races the confined writer: the attach pushes the grid
* asynchronously (twice — `attachView`'s session setter and `bindEmulator` both force a re-assert),
* so a `Resize` can arrive *after* the clear and be mistaken for the first frame a test caused. Two
* of the very first runs of this suite failed exactly that way.
*/
fun awaitAttachSettled(): ClientMessage.Resize {
val grid = awaitTrueValue("the attach-time grid to reach the wire") {
sent.poll() as? ClientMessage.Resize
}
drainSent(settleMs = ATTACH_SETTLE_MS)
return grid
}
fun <T> awaitTrueValue(what: String, produce: () -> T?): T {
var found: T? = null
awaitTrue(what) {
found = produce()
found != null
}
return found!!
}
companion object {
/** The whole terminal viewport for the initial layout; individual tests re-layout as needed. */
const val DEFAULT_WIDTH_PX: Int = 600
const val DEFAULT_HEIGHT_PX: Int = 800
private const val DOWN_TIME = 1_000L
private const val DRAG_STEPS = 8
private const val MAIN_SYNC_TIMEOUT_MS = 5_000L
private const val WIRE_TIMEOUT_MS = 3_000L
private const val AWAIT_TIMEOUT_MS = 5_000L
private const val POLL_INTERVAL_MS = 20L
private const val SETTLE_MS = 300L
private const val ATTACH_SETTLE_MS = 500L
/**
* Build the rig: a [ComponentActivity] (supplied by `ui-test-manifest`), a root frame, the real
* [RemoteTerminalView] added to it at [DEFAULT_WIDTH_PX]×[DEFAULT_HEIGHT_PX], and a
* [RemoteTerminalSession] attached to it.
*
* ### Why the view is laid out BEFORE the session attaches
* This is the §6.6 config-change rebind order (a laid-out view, a session bound to it) rather than
* the cold-start order, and it is chosen for a specific reason: it keeps the attach-time 80×24 →
* real-grid reflow *ahead* of the moment the emulator becomes visible to the stock renderer.
* `attachView` queues the forced re-assert (from the session setter) before the Bind command, and
* the confined consumer is FIFO, so the reallocation completes while no frame can be reading the
* buffer. `bindEmulator`'s second re-assert then carries the same dims, which Termux's
* `TerminalEmulator.resize` short-circuits, so it reallocates nothing.
*
* Doing it the other way round — attach first, let the first layout pass fire afterwards — puts
* the reflow on the confined writer thread while the renderer is already drawing, which is the
* still-unfixed defect [TerminalResizeDrawRaceRegressionTest] documents. That defect is real and
* this ordering does not paper over it: it is exercised deliberately by that test and by every
* relayout in [TerminalResizeRegressionTest]. Ordering it away here simply stops a `:terminal-view`
* defect from killing the process during the *setup* of key, scroll and autofill tests that are
* not about resize at all.
*/
fun launch(transcriptRows: Int = RemoteTerminalSession.TRANSCRIPT_ROWS): TerminalTestHarness {
val sent = LinkedBlockingQueue<ClientMessage>()
val scenario = ActivityScenario.launch(ComponentActivity::class.java)
var view: RemoteTerminalView? = null
var root: FrameLayout? = null
scenario.onActivity { activity ->
val terminal = RemoteTerminalView(activity)
val frame = FrameLayout(activity)
frame.addView(
terminal.terminalView,
FrameLayout.LayoutParams(DEFAULT_WIDTH_PX, DEFAULT_HEIGHT_PX),
)
activity.setContentView(frame)
view = terminal
root = frame
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val terminal = view!!
// Wait for the real layout pass. With no session bound yet, TerminalResizeDriver memoises the
// measured grid and pushes nothing (`session?.updateSize` is a no-op) — so nothing has to be
// reflowed while a frame is in flight.
var laidOut = false
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
while (!laidOut && System.nanoTime() < deadline) {
val done = CountDownLatch(1)
scenario.onActivity { laidOut = terminal.terminalView.width == DEFAULT_WIDTH_PX }
done.countDown()
if (!laidOut) Thread.sleep(POLL_INTERVAL_MS)
}
assertTrue("the terminal never laid out at $DEFAULT_WIDTH_PX px wide", laidOut)
var session: RemoteTerminalSession? = null
scenario.onActivity {
val remote = RemoteTerminalSession(
engineSend = { message -> sent.put(message) },
transcriptRows = transcriptRows,
)
remote.attachView(terminal)
session = remote
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val harness = TerminalTestHarness(scenario, terminal, session!!, root!!, sent)
// Focus is a PRECONDITION for the key path, not a convenience: `ViewGroup.dispatchKeyEvent`
// only calls `super.dispatchKeyEvent` (hence `onKeyDown`) when the group itself is FOCUSED
// and laid out, and it has no focusable children here (`FOCUS_BLOCK_DESCENDANTS`). Production
// satisfies this the same way — `dispatchTouchEvent` claims focus on ACTION_DOWN and
// `showSoftKeyboard()` calls `requestFocus()` — so an unfocused terminal receiving no hardware
// keys is correct framework behaviour, not a defect. Asserting it here keeps a silent focus
// regression from turning the key tests into vacuous passes.
harness.awaitTrue("the terminal frame to take focus") {
harness.onMain { harness.hostView.requestFocus() || harness.hostView.isFocused }
}
return harness
}
}
}

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.GateBanner
import wang.yaojia.webterm.components.PlanGateSheet
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.GateKind
/**
* **Plan §7 Compose UI tests — the gate surfaces.**
*
* These are the two surfaces a remote approval is made from, so the load-bearing contract is not the layout
* (device-QA) but that **each affordance reports the decision it is labelled with, carrying the epoch of the
* gate that was on screen**. The epoch is the stale-guard seam: `GateViewModel.decide` drops a tap whose
* epoch is no longer live, which is what stops a tap landing on a gate the user never saw. A test that only
* checked the copy would let a swapped-callback bug through — approving when the user pressed reject is the
* single worst failure this app can have.
*
* The server-supplied `detail` is also asserted to render, because it must render as INERT text (plan §8) —
* untrusted tool/OSC content with no markdown and no autolink.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class GateSurfacesUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun toolGateBanner_reportsApproveWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(DETAIL).assertIsDisplayed()
compose.onNodeWithText(APPROVE).performClick()
assertEquals(listOf(GateDecision.APPROVE to TOOL_EPOCH), decisions)
}
@Test
fun toolGateBanner_reportsRejectWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(REJECT).performClick()
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
}
/**
* A previewless gate is a NORMAL state, not an error: the server only sends a preview for a held
* Bash/Edit-family tool, and a malformed one decodes to null rather than costing us the gate. So the
* banner must still be fully usable with no preview.
*/
@Test
fun toolGateBanner_withNoPreviewIsStillFullyDecidable() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate.copy(preview = null), onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(APPROVE).assertIsDisplayed()
compose.onNodeWithText(REJECT).performClick()
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
}
/**
* The plan gate is THREE-way, and the third option is the one most easily got wrong: 「继续计划」 keeps
* planning, i.e. it wires to `reject`, not to a second kind of approval.
*/
@Test
fun planGateSheet_offersAllThreeDecisionsWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
PlanGateSheet(gate = planGate, onDecide = { d, e -> decisions += d to e }, onDismiss = {})
}
}
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
compose.onNodeWithText(PLAN_DETAIL).assertIsDisplayed()
compose.onNodeWithText(ACCEPT_EDITS).performClick()
compose.onNodeWithText(KEEP_PLANNING).performClick()
compose.onNodeWithText(APPROVE, useUnmergedTree = false).performClick()
assertEquals(
listOf(
GateDecision.ACCEPT_EDITS to PLAN_EPOCH,
GateDecision.REJECT to PLAN_EPOCH,
GateDecision.APPROVE to PLAN_EPOCH,
),
decisions,
)
}
/**
* Dismissing the sheet (scrim tap / back) must resolve NOTHING — the gate stays held until an explicit
* decision. Silently treating a dismissal as a reject would cancel a user's work behind their back.
*/
@Test
fun planGateSheet_dismissalDecidesNothing() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
var dismissals = 0
compose.setContent {
WebTermTheme {
PlanGateSheet(
gate = planGate,
onDecide = { d, e -> decisions += d to e },
onDismiss = { dismissals += 1 },
)
}
}
// The sheet is on screen and no decision has been reported; the dismiss callback is the only other
// exit, and it is wired to a handler that resolves nothing.
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
assertEquals(emptyList<Pair<GateDecision, Int>>(), decisions)
assertEquals(0, dismissals)
}
private companion object {
const val TOOL_EPOCH = 7
const val PLAN_EPOCH = 11
const val DETAIL = "Bash"
const val PLAN_DETAIL = "Refactor the auth module into 3 files."
const val APPROVE = "批准"
const val REJECT = "拒绝"
const val ACCEPT_EDITS = "批准并自动接受"
const val KEEP_PLANNING = "继续计划"
const val PLAN_TITLE = "批准执行计划"
val toolGate = GateState(kind = GateKind.TOOL, detail = DETAIL, epoch = TOOL_EPOCH)
val planGate = GateState(kind = GateKind.PLAN, detail = PLAN_DETAIL, epoch = PLAN_EPOCH)
}
}

View File

@@ -0,0 +1,150 @@
package wang.yaojia.webterm.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.KeyBar
import wang.yaojia.webterm.designsystem.WebTermTheme
/**
* **Plan §7 Compose UI test — the mobile key-bar, pinned above the IME.**
*
* The key-bar is how a phone produces the keys Claude Code needs and a soft keyboard cannot: Esc, ⇧Tab,
* arrows, Ctrl-chords. Its defining property is that a tap goes **straight to the wire**, bypassing the IME
* entirely (mirroring the web client's `ws.send` bypass) — that is what stops the soft keyboard popping up
* every time the user dismisses a Claude prompt. So the assertions are on the bytes each button emits, not
* on its looks.
*
* What stays device-QA: that the bar is *visually* above the IME. `WindowInsets.ime` reports zero in an
* instrumented test with no keyboard raised, so an assertion about its on-screen position would be
* measuring the absence of a keyboard, not the inset behaviour. The union-with-navigation-bars fallback that
* makes the bar sit above the nav bar when the keyboard is hidden IS exercised here (the bar renders and is
* hittable), and the real above-the-IME check is on the checklist.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class KeyBarUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun escTap_sendsTheEscapeByteDirectly() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("Esc").performClick()
assertEquals(listOf("\u001b"), sent)
}
@Test
fun enterTap_sendsCarriageReturnNotLineFeed() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("").performClick()
// The repo-wide gotcha, on the surface a phone user hits most often.
assertEquals(listOf("\r"), sent)
}
@Test
fun ctrlCTap_sendsTheInterruptControlByte() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("^C").performClick()
assertEquals(listOf("\u0003"), sent)
}
@Test
fun shiftTabTap_sendsTheModeCycleSequence() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("⇧Tab").performClick()
assertEquals(listOf("\u001b[Z"), sent)
}
/**
* The bar is horizontally scrollable precisely so every key stays reachable on a narrow phone; the keys
* past the fold are the ones a layout regression would silently drop. Reaching one by its
* `contentDescription` (which is also its accessibility label) proves it is present and hittable.
*/
@Test
fun everyKeyIsReachable_includingTheOnesPastTheFold() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
// Off-screen keys must be reached the way a user reaches them — by scrolling the bar. A bare
// performClick() on a node outside the viewport fails, which is itself the proof that this key
// really does sit past the fold rather than being trivially present.
compose.onNodeWithContentDescription(SLASH_DESCRIPTION).performScrollTo().performClick()
assertEquals(listOf("/"), sent)
}
@Test
fun tapsAccumulateInOrder_soTwoFastTapsCannotSwap() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("").performClick()
compose.onNodeWithText("").performClick()
compose.onNodeWithText("").performClick()
assertEquals(listOf("\u001b[A", "\u001b[B", "\r"), sent)
}
/**
* A key-bar tap must never route through a text field, or the soft keyboard would open. There is no
* public hook to observe "the IME was not asked to open", but there IS an observable proxy: the bar
* contains no text-input node at all, so nothing on it can request the IME. Combined with the byte
* assertions above (which prove the tap reached the wire), that pins the bypass.
*/
@Test
fun theBarHoldsNoTextInput_soATapCannotRaiseTheKeyboard() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("Esc").assertIsDisplayed()
val editableNodes = compose
.onAllNodes(hasSetTextAction())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
assertTrue("the key-bar must contain no editable text node", editableNodes.isEmpty())
}
private fun setUpKeyBar(sink: MutableList<String>) {
compose.setContent {
WebTermTheme {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
KeyBar(onSend = { bytes -> sink += bytes })
}
}
}
}
private companion object {
/** `KEYBAR_LAYOUT`'s accessibility label for the command-launcher key, which sits past the fold. */
const val SLASH_DESCRIPTION = "Slash — command launcher"
}
}

View File

@@ -0,0 +1,134 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasProgressBarRangeInfo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.FailureReason
import wang.yaojia.webterm.components.ReconnectBanner
import wang.yaojia.webterm.designsystem.WebTermTheme
import kotlin.time.Duration.Companion.seconds
/**
* **Plan §7 Compose UI test — the reconnect banner, and specifically its precedence rule.**
*
* The banner is the only place the app tells a walked-away user *why* their session went quiet, so the
* failure modes it must not have are: showing a retry spinner for something that can never succeed, and
* hiding a terminal state behind a transient one. The reduction itself is JVM-tested; what only a device can
* check is that the rendered surface honours it — the spinner really is absent, the affordance really is
* present and really fires, and the untrusted exit `reason` really renders inert.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class ReconnectBannerUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun hiddenRendersNothingAtAll() {
setUpBanner(BannerModel.Hidden)
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
// A root always exists; what must not exist is any banner content under it.
assertTrue("Hidden must render no banner content", nodes.all { it.children.isEmpty() })
}
@Test
fun reconnectingShowsTheAttemptCountdownAndASpinner() {
setUpBanner(BannerModel.Reconnecting(attempt = 3, countdown = 4.seconds))
compose.onNodeWithText("连接断开,重连中(第 3 次4 秒后重试)").assertIsDisplayed()
assertTrue("a retrying banner must show a spinner", hasSpinner())
}
/**
* The replay-too-large wall is NON-retryable: reconnecting would hit the same wall forever, so a
* spinner here would be a lie that never resolves. The copy has to be actionable and the only way out
* is a new session.
*/
@Test
fun aNonRetryableFailureShowsNoSpinnerAndOffersANewSession() {
var newSessions = 0
setUpBanner(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)) { newSessions += 1 }
assertTrue("a non-retryable wall must NOT show a retry spinner", !hasSpinner())
compose.onNodeWithText("新会话").performClick()
assertEquals(1, newSessions)
}
/**
* `exit(-1)` is a spawn failure, and its `reason` is server-supplied, i.e. untrusted — it must render as
* inert text inside the banner copy, never as a link or markup (plan §8).
*/
@Test
fun aSpawnFailureRendersTheServerReasonInertly() {
setUpBanner(BannerModel.Exited(code = -1, reason = UNTRUSTED_REASON))
compose.onNodeWithText("会话启动失败:$UNTRUSTED_REASON").assertIsDisplayed()
assertTrue("a terminal exit must not show a spinner", !hasSpinner())
compose.onNodeWithText("开新会话").assertIsDisplayed()
}
@Test
fun anOrdinaryExitShowsTheCodeAndTheNewSessionAffordance() {
var newSessions = 0
setUpBanner(BannerModel.Exited(code = 130, reason = null)) { newSessions += 1 }
compose.onNodeWithText("会话已结束(退出码 130").assertIsDisplayed()
compose.onNodeWithText("开新会话").performClick()
assertEquals(1, newSessions)
}
/**
* **The precedence rule, rendered.** A `connecting` event arriving after the session already exited must
* NOT replace the terminal banner with a spinner — otherwise a walked-away user sees "connecting…"
* forever and never learns the session is over. The reduction is asserted and then the reduced model is
* rendered, so copy and rule are checked as one thing.
*/
@Test
fun aTerminalBannerIsStickyOverATransientOne() {
val exited = BannerModel.Exited(code = 1, reason = null)
val afterConnecting = wang.yaojia.webterm.components.bannerModel(
current = exited,
event = Connection(ConnectionState.Connecting),
)
assertEquals("a transient event must not override a terminal banner", exited, afterConnecting)
setUpBanner(afterConnecting)
compose.onNodeWithText("会话已结束(退出码 1").assertIsDisplayed()
assertTrue(!hasSpinner())
}
private fun hasSpinner(): Boolean = compose
.onAllNodes(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate))
.fetchSemanticsNodes(atLeastOneRootRequired = false)
.isNotEmpty()
private fun setUpBanner(model: BannerModel, onNewSession: () -> Unit = {}) {
compose.setContent {
WebTermTheme { ReconnectBanner(model = model, onNewSession = onNewSession) }
}
}
private companion object {
/** Server-supplied and therefore untrusted; it must appear verbatim and inert. */
const val UNTRUSTED_REASON = "spawn /bin/nope ENOENT"
}
}

View File

@@ -0,0 +1,134 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.components.ContinueLastBanner
import wang.yaojia.webterm.components.ContinueLastModel
import wang.yaojia.webterm.components.SessionListRow
import wang.yaojia.webterm.components.continueLastModel
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.viewmodels.SessionRow
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* **Plan §7 Compose UI tests — session-list rows and the continue-last banner.**
*
* Two things only a device can check here. First, that a row renders host-supplied strings **inert**: the
* title arrives from an OSC escape the host (or something running on it) chose, so it must be plain
* [androidx.compose.material3.Text] with no linkify and no markdown (plan §8). Second, that "shown iff there
* is something to show" holds — a `null` model must render literally nothing, because a dead
* continue-affordance that navigates nowhere is worse than no affordance.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class SessionListUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun aRowRendersTheSanitizedTitleAndTheGeometry() {
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = {}) }
}
compose.onNodeWithText(TITLE).assertIsDisplayed()
// The geometry label uses U+00D7 MULTIPLICATION SIGN, not the letter x.
compose.onNodeWithText("161×50", substring = true).assertIsDisplayed()
}
@Test
fun tappingARowOpensThatSession() {
var opened = 0
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = { opened += 1 }) }
}
compose.onNodeWithText(TITLE).performClick()
assertEquals(1, opened)
}
/**
* A hostile OSC title must appear as characters, not as behaviour. `TitleSanitizer` strips bidi and
* zero-width runs upstream (JVM-tested); what this pins is that whatever survives is rendered verbatim
* and is not turned into a link by autolinking.
*/
@Test
fun anUntrustedLookingTitleIsRenderedAsPlainCharacters() {
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = URL_LOOKING_TITLE), onOpen = {}) }
}
compose.onNodeWithText(URL_LOOKING_TITLE).assertIsDisplayed()
}
@Test
fun continueLastBannerIsAbsentWhenThereIsNoLastSession() {
assertEquals("no last session must reduce to no model", null, continueLastModel(null))
compose.setContent {
WebTermTheme { ContinueLastBanner(model = continueLastModel(null), onContinue = {}) }
}
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
assertTrue("a null model must render nothing at all", nodes.all { it.children.isEmpty() })
}
@Test
fun continueLastBannerReOpensTheRememberedSession() {
val remembered = mutableListOf<String>()
compose.setContent {
WebTermTheme {
ContinueLastBanner(
model = ContinueLastModel(LAST_SESSION_ID),
onContinue = { remembered += it },
)
}
}
compose.onNodeWithText("继续上次会话").performClick()
assertEquals(
"the banner must hand back the id it was rendered with, not a re-derived one",
listOf(LAST_SESSION_ID),
remembered,
)
}
private fun row(title: String) = SessionRow(
info = LiveSessionInfo(
id = UUID.fromString(LAST_SESSION_ID),
createdAt = 1_700_000_000_000L,
clientCount = 1,
status = ClaudeStatus.WORKING,
exited = false,
cwd = "/Users/dev/project",
title = title,
cols = 161,
rows = 50,
),
displayStatus = DisplayStatus.Working,
title = title,
isUnread = true,
)
private companion object {
const val TITLE = "claude"
const val URL_LOOKING_TITLE = "https://example.com/not-a-link"
const val LAST_SESSION_ID = "a1b2c3d4-5678-4abc-9def-000000000000"
}
}

View File

@@ -12,13 +12,35 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" /> <uses-feature android:name="android.hardware.camera.any" android:required="false" />
<!-- Cleartext: @xml/network_security_config is the AUTHORITATIVE policy (minSdk 29, so the
platform always reads it and IGNORES android:usesCleartextTraffic). The attribute is set
to true only so the manifest does not state the opposite of what actually ships: bare-LAN
`ws://` must work (plan §6.9). The config is where the real posture lives — cleartext ON
for user-typed LAN addresses (no CIDR syntax exists to scope it), system-only trust
anchors, and cleartext still BLOCKED for *.terminal.yaojia.wang. Read its header before
changing either value; they must stay in agreement. -->
<!-- Backup: OFF, and deliberately not an exclusion list (plan §8 "app-private, uninstall-wiped,
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
files. Nothing in this app has restore value that justifies that risk — re-pairing a host is
a QR scan, and the hosts are LAN-local anyway.
`allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE
transfer is governed by a `dataExtractionRules` <device-transfer> block, which needs an @xml
resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH
sections rather than an exclusion list, so a store added later is excluded by default instead
of leaking until someone remembers it. `fullBackupContent="false"` states the same for the
pre-31 path. Do not set any of these back to true. -->
<application <application
android:name=".WebTermApp" android:name=".WebTermApp"
android:allowBackup="false"
android:label="@string/app_name" android:label="@string/app_name"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.WebTerm" android:theme="@style/Theme.WebTerm"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false"> android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@@ -16,10 +16,15 @@ import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.StatusBadge import wang.yaojia.webterm.designsystem.StatusBadge
import wang.yaojia.webterm.designsystem.WebTermCard import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.session.GateState import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.viewmodels.inertText
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.GateKind import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.PreviewDiffFile
/** /**
* # GateBanner (A22) — the tool-gate two-button approve/reject card. * # GateBanner (A22) — the tool-gate two-button approve/reject card.
@@ -44,6 +49,7 @@ public fun GateBanner(
gate: GateState, gate: GateState,
onDecide: (GateDecision, Int) -> Unit, onDecide: (GateDecision, Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
preview: ApprovalPreview? = null,
) { ) {
WebTermCard(modifier = modifier.fillMaxWidth()) { WebTermCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
@@ -63,6 +69,9 @@ public fun GateBanner(
) )
} }
} }
// WHAT is being approved sits between the label and the buttons, so the decision and the
// evidence are never on different screens. Absent → the name-only bar, unchanged.
ApprovalPreviewBlock(preview)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) { Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) { OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
Text(text = "拒绝") Text(text = "拒绝")
@@ -75,6 +84,124 @@ public fun GateBanner(
} }
} }
// ── W1: the approval preview (shared by GateBanner and PlanGateSheet) ───────────
/** How many diff/command lines a gate surface paints before it says "truncated". The server already
* caps at 40 lines / 4 KB; this is the on-phone reading limit, so the buttons stay reachable. */
private const val PREVIEW_MAX_VISIBLE_LINES = 20
/**
* W1 — render WHAT a held approval would do: the shell command, or the one-file diff.
*
* Everything here is **untrusted** (server-derived from the hook's `tool_input`, which Claude and the
* repo contents influence) and therefore INERT: plain [Text] only — no Markdown, no autolink, no
* `AnnotatedString` link detection (plan §8). A `null` preview renders NOTHING: absent is a normal
* state (plan gates and unknown tools have nothing reviewable), never an error.
*/
@Composable
internal fun ApprovalPreviewBlock(preview: ApprovalPreview?, modifier: Modifier = Modifier) {
if (preview == null) return
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
) {
when (preview) {
is ApprovalPreview.Command -> CommandPreview(preview)
is ApprovalPreview.Diff -> DiffPreview(preview.file)
}
if (preview.truncated) {
Text(
text = GatePreviewCopy.TRUNCATED,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun CommandPreview(preview: ApprovalPreview.Command) {
// Newlines are content in a shell command, so the lines are kept and rendered as lines. The
// sanitizer drops every other control byte (defence in depth over the server's own stripping).
val lines = (preview.inertText() ?: return).lines()
for (line in lines.take(PREVIEW_MAX_VISIBLE_LINES)) {
Text(
text = line,
style = WebTermType.monoTabular(12),
color = MaterialTheme.colorScheme.onSurface,
)
}
if (lines.size > PREVIEW_MAX_VISIBLE_LINES) ClippedNote()
}
@Composable
private fun DiffPreview(file: PreviewDiffFile) {
Text(
text = GatePreviewCopy.diffHeader(file),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (file.binary) {
Text(text = GatePreviewCopy.BINARY, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
return
}
var painted = 0
for (hunk in file.hunks) {
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
DiffLineText(hunk.header, DIFF_KIND_HUNK)
painted++
for (line in hunk.lines) {
if (painted >= PREVIEW_MAX_VISIBLE_LINES) break
DiffLineText(line.text, line.kind)
painted++
}
}
val total = file.hunks.sumOf { it.lines.size + 1 }
if (total > painted) ClippedNote()
}
@Composable
private fun DiffLineText(text: String, kind: String) {
// An UNKNOWN kind still renders (just uncoloured): dropping a line the client cannot classify
// would hide part of what the user is approving.
val color = when (kind) {
DIFF_KIND_ADDED -> WebTermColors.statusWorking
DIFF_KIND_REMOVED -> WebTermColors.statusStuck
DIFF_KIND_HUNK, DIFF_KIND_META -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurface
}
Text(text = text, style = WebTermType.monoTabular(12), color = color, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
@Composable
private fun ClippedNote() {
Text(
text = GatePreviewCopy.CLIPPED,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Wire `DiffLineKind` values (`src/types.ts`) — raw strings, so an unknown kind stays itself. */
private const val DIFF_KIND_ADDED = "added"
private const val DIFF_KIND_REMOVED = "removed"
private const val DIFF_KIND_HUNK = "hunk"
private const val DIFF_KIND_META = "meta"
/** Preview copy (local UI text). */
internal object GatePreviewCopy {
const val TRUNCATED: String = "… 服务端已截断"
const val CLIPPED: String = "… 仅显示前 $PREVIEW_MAX_VISIBLE_LINES 行"
const val BINARY: String = "二进制文件"
fun diffHeader(file: PreviewDiffFile): String {
val path = if (file.oldPath != file.newPath) "${file.oldPath}${file.newPath}" else file.newPath
return "$path +${file.added}/-${file.removed}"
}
}
// ── Preview ───────────────────────────────────────────────────────────────────── // ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "GateBanner") @Preview(name = "GateBanner")
@@ -84,6 +211,7 @@ private fun GateBannerPreview() {
GateBanner( GateBanner(
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3), gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
onDecide = { _, _ -> }, onDecide = { _, _ -> },
preview = ApprovalPreview.Command("rm -rf build/\nnpm run build", truncated = true),
) )
} }
} }

View File

@@ -20,6 +20,7 @@ import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.ApprovalPreview
import wang.yaojia.webterm.wire.GateKind import wang.yaojia.webterm.wire.GateKind
/** /**
@@ -49,6 +50,7 @@ public fun PlanGateSheet(
onDecide: (GateDecision, Int) -> Unit, onDecide: (GateDecision, Int) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
preview: ApprovalPreview? = null,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
@@ -69,6 +71,9 @@ public fun PlanGateSheet(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
// W1: a plan gate usually has nothing reviewable, but when the server DOES send a preview
// (a plan that resolves straight into an Edit/Bash) it belongs above the buttons.
ApprovalPreviewBlock(preview)
Button( Button(
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }, onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View File

@@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import wang.yaojia.webterm.nav.LayoutMode import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.nav.PointerMenuPolicy import wang.yaojia.webterm.nav.PointerMenuPolicy
import wang.yaojia.webterm.viewmodels.SessionRow
import java.util.UUID
/** /**
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu. * # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
@@ -59,7 +61,12 @@ public fun PointerContextMenu(
val density = LocalDensity.current val density = LocalDensity.current
Box( Box(
modifier = modifier.pointerInput(actions) { // Keyed on Unit, NOT on [actions]: the gesture body only records the anchor and opens the menu —
// it never reads [actions] (the DropdownMenu below reads the current list on every composition).
// Keying on the list would restart the pointer handler on every recomposition, because the
// per-entry lambdas in a freshly-built List<ContextMenuAction> are never equal to the previous
// ones — a right-click landing during that restart window would be dropped.
modifier = modifier.pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val event = awaitPointerEvent() val event = awaitPointerEvent()
@@ -95,3 +102,41 @@ public fun PointerContextMenu(
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */ /** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
public data class ContextMenuAction(val label: String, val onClick: () -> Unit) public data class ContextMenuAction(val label: String, val onClick: () -> Unit)
// ── The session-row action set (A26 — the one surface the pointer menu is attached to) ───────────────
/** 打开会话 — the same target a row tap has. */
public const val MENU_LABEL_OPEN: String = "打开会话"
/** 在当前目录开新会话 — `attach(null, cwd)` in the row's own cwd (plan §1 new-session-in-cwd). */
public const val MENU_LABEL_NEW_IN_CWD: String = "在当前目录开新会话"
/** 终止会话 — `DELETE /live-sessions/:id`, the pointer equivalent of swipe-to-kill. */
public const val MENU_LABEL_KILL: String = "终止会话"
/**
* The [PointerContextMenu] entries for one session row — the pure half of the A26 pointer menu (the
* gesture and the placement are device-QA; [PointerMenuPolicy] gates whether the menu exists at all).
*
* Mirrors iOS's pointer menu with ONE deliberate omission: **copy is absent**. Copy-out lives in the
* terminal's own text-selection `ActionMode`, not in a list-row menu, and the row has no selected text to
* copy — advertising it would be a dead entry.
*
* The new-session entry appears only when BOTH a usable cwd is known AND a spawn handler is wired: the
* server may omit `cwd` (or send blank), and `attach(null, cwd)` with no directory is not the same action.
* Every label here is a fixed app string — the server-provided cwd is carried in the CALLBACK only and is
* never rendered into a menu entry (plan §8: untrusted server strings stay out of chrome).
*/
public fun sessionRowMenuActions(
row: SessionRow,
onOpen: (UUID) -> Unit,
onNewSessionInCwd: ((String) -> Unit)?,
onKill: (UUID) -> Unit,
): List<ContextMenuAction> = buildList {
add(ContextMenuAction(MENU_LABEL_OPEN) { onOpen(row.id) })
val cwd = row.info.cwd?.takeIf { it.isNotBlank() }
if (cwd != null && onNewSessionInCwd != null) {
add(ContextMenuAction(MENU_LABEL_NEW_IN_CWD) { onNewSessionInCwd(cwd) })
}
add(ContextMenuAction(MENU_LABEL_KILL) { onKill(row.id) })
}

View File

@@ -0,0 +1,271 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.QueueNotice
import wang.yaojia.webterm.viewmodels.QueueUiState
import wang.yaojia.webterm.viewmodels.QueueViewModel
/**
* # QueueBadge (W2) — the "N queued" pill.
*
* Renders NOTHING for a null (unknown / pre-W2 server) or non-positive depth, so a host without the
* queue feature shows no affordance at all and an empty queue does not shout "0".
*
* The number is always the server's own count — the live `queue` frame or `GET /live-sessions`'s
* `queueLength` — never a locally incremented guess.
*/
@Composable
public fun QueueBadge(depth: Int?, modifier: Modifier = Modifier) {
val pending = depth ?: return
if (pending <= 0) return
Box(
modifier = modifier
.background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(Radius.sm8))
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
) {
Text(
text = "$pending 待发",
style = WebTermType.metaMono, // tabular numerals so the pill does not jitter as N changes
color = MaterialTheme.colorScheme.onSecondaryContainer,
maxLines = 1,
)
}
}
/**
* # QueuePanel (W2) — queue / review / cancel the follow-up prompt FIFO.
*
* The Android counterpart of `public/queue.ts`: a bottom sheet that queues a prompt the server injects
* into the PTY the next time Claude goes idle, lists what is already pending, and offers the ONE cancel
* the server actually has — cancel-ALL. There is deliberately no per-item cancel affordance, because
* `DELETE /live-sessions/:id/queue` clears the whole FIFO and pretending otherwise would lie.
*
* ### The composed text is raw keyboard input (invariant #9)
* The field's contents go to [onSend] **verbatim** — this file never trims, normalises, or appends a
* `\r`. `appendEnter` is a FLAG on the request, so the SERVER materialises the carriage return
* byte-identically to a keystroke. The send action is enabled by [QueueViewModel.canSend], which refuses
* only a genuinely EMPTY string; whitespace is legitimate shell input.
*
* ### Untrusted text (plan §8)
* Queued items are round-tripped through the server, so they are rendered as INERT [Text] — no linkify,
* no Markdown, no `AnnotatedString` autolink — exactly like every other server-derived string. So is the
* [QueueNotice.Rejected] message, which is the server's own already-sanitized `error` field.
*
* Sheet presentation / detents / IME behaviour are device-QA (plan §7); the state machine is
* `QueueViewModelTest`.
*
* @param onSend the composed prompt, verbatim. The panel clears its field only after invoking this.
* @param onClearAll `DELETE …/queue` — cancel every pending entry.
* @param onRefresh re-read the queue (used when the live depth says the cached list is stale).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
public fun QueuePanel(
state: QueueUiState,
onSend: (String) -> Unit,
onClearAll: () -> Unit,
onDismissNotice: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
onRefresh: () -> Unit = {},
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var draft by remember { mutableStateOf("") }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = "排队提示词",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
QueueBadge(depth = state.depth)
}
Text(
text = "Claude 下次空闲时,服务器会按顺序把它们送进终端。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
state.notice?.let { notice ->
QueueNoticeRow(notice = notice, onDismiss = onDismissNotice)
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it }, // VERBATIM: no trim / normalisation anywhere on this path
label = { Text("要排队的提示词") },
enabled = !state.isBusy,
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 6,
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Button(
onClick = {
onSend(draft) // exactly what the user typed
draft = ""
},
enabled = !state.isBusy && QueueViewModel.canSend(draft),
) { Text("加入队列") }
TextButton(onClick = onClearAll, enabled = !state.isBusy && state.depth > 0) {
Text("全部取消")
}
}
HorizontalDivider()
QueuedItems(state = state, onRefresh = onRefresh)
}
}
}
/** The pending prompts, inert. A stale list offers a re-read rather than contradicting the badge. */
@Composable
private fun QueuedItems(state: QueueUiState, onRefresh: () -> Unit) {
if (state.isItemsStale) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = "队列已变化。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onRefresh) { Text("刷新") }
}
return
}
if (state.items.isEmpty()) {
Text(
text = "队列是空的。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = QUEUE_LIST_MAX_HEIGHT),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
itemsIndexed(state.items) { index, item ->
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(
text = "${index + 1}.",
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// INERT: round-tripped through the server, so plain Text only (plan §8).
Text(
text = item,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
}
/**
* One honest line per failure. A 429 says so plainly and offers NO retry button — the bucket is shared
* (20/min/IP), so a retry only burns the budget a real enqueue needs.
*/
@Composable
private fun QueueNoticeRow(notice: QueueNotice, onDismiss: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
.padding(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = queueNoticeText(notice),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text("知道了") }
}
}
/**
* App-authored copy for each outcome. [QueueNotice.Rejected] is the ONE case that surfaces a server
* string, and it is displayed inertly and verbatim (the server already sanitized it); a null message
* falls back to app copy rather than printing "null".
*/
internal fun queueNoticeText(notice: QueueNotice): String = when (notice) {
QueueNotice.Full -> "队列已满,请先取消一条。"
QueueNotice.TooLarge -> "提示词太长了,请缩短后重试。"
QueueNotice.Disabled -> "这台主机关闭了排队功能。"
QueueNotice.SessionGone -> "会话已退出,无法排队。"
// No retry offered: the 20/min bucket is shared with every other client of this host.
QueueNotice.RateLimited -> "操作过于频繁(每分钟上限 20 次),请稍后再手动重试。"
QueueNotice.Unreachable -> "无法连接主机,队列状态未知。"
is QueueNotice.Rejected -> notice.message ?: "主机拒绝了这次操作。"
}
/** Keeps the sheet's buttons reachable no matter how long the queue gets. */
private val QUEUE_LIST_MAX_HEIGHT = Spacing.xxl24 * 8 // 192dp
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "QueueBadge")
@Composable
private fun QueueBadgePreview() {
WebTermTheme {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
QueueBadge(depth = 3)
QueueBadge(depth = 0) // renders nothing
QueueBadge(depth = null) // renders nothing
}
}
}

View File

@@ -2,20 +2,35 @@ package wang.yaojia.webterm.components
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.designsystem.Radius import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme import wang.yaojia.webterm.designsystem.WebTermTheme
@@ -96,6 +111,241 @@ internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean = internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
gateHeld && hasChips gateHeld && hasChips
// ── The palette presenter (production state holder) ─────────────────────────────
/**
* The state holder the terminal screen renders the chips from: one published, immutable
* [chips] snapshot over a [QuickReplyStore].
*
* Every mutation delegates to the store — which is the CRUD authority and returns the new
* list — and republishes exactly what came back, so the UI can never drift from what was
* persisted (no optimistic local copy to reconcile, and a guarded no-op like a blank `add`
* simply republishes the unchanged list). Nothing is published before [load]: an unloaded
* palette shows no chips rather than flashing the defaults over the user's real list.
*
* Plain (not an `androidx.lifecycle.ViewModel`) so it drives under `runTest` virtual time;
* the screen `remember`s one and loads it in a `LaunchedEffect`. Cross-device sync is out of
* scope (plan §1) — this is one device's palette.
*/
public class QuickReplyPalette(private val store: QuickReplyStore) {
private val _chips = MutableStateFlow<List<QuickReplyChip>>(emptyList())
/** The palette in display order; empty until [load] (and after a user "delete all"). */
public val chips: StateFlow<List<QuickReplyChip>> = _chips.asStateFlow()
/** Read the persisted palette (defaults on first ever read) and publish it. */
public suspend fun load() {
_chips.value = store.loadAll()
}
/** Append [text] (blank is a store-level no-op) and republish. */
public suspend fun add(text: String) {
_chips.value = store.add(text)
}
/** Replace the text of [id] and republish (unknown id / blank text → no-op). */
public suspend fun edit(id: String, text: String) {
_chips.value = store.edit(id, text)
}
/** Remove [id] and republish (unknown id → no-op). */
public suspend fun remove(id: String) {
_chips.value = store.remove(id)
}
/** Reorder [from]→[to] and republish (out-of-range → no-op). */
public suspend fun move(from: Int, to: Int) {
_chips.value = store.move(from, to)
}
}
// ── The editor's select / commit reducer (pure, JVM-tested) ─────────────────────
/**
* The editor's whole mutable state: ONE text field that either appends a new chip
* ([selectedId] null) or rewrites the selected one. Immutable — every transition returns a
* fresh snapshot.
*
* A single field (rather than an inline field per row) is deliberate: a per-row bound field
* would write to the store on every keystroke, i.e. one DataStore round-trip per character.
*/
internal data class QuickReplyEditorState(
/** The chip being rewritten, or null while composing a new one. */
val selectedId: String? = null,
/** The field's current text, carried VERBATIM into the commit (whitespace preserved). */
val draft: String = "",
)
/** What committing the current [QuickReplyEditorState] does. */
internal enum class QuickReplyCommit { ADD, EDIT, NONE }
/** Load [chip] into the field for rewriting (its id becomes the commit target). */
internal fun QuickReplyEditorState.selecting(chip: QuickReplyChip): QuickReplyEditorState =
copy(selectedId = chip.id, draft = chip.text)
/** Back to "composing a new chip" with an empty field. */
internal fun QuickReplyEditorState.cleared(): QuickReplyEditorState =
QuickReplyEditorState()
/**
* A blank draft commits NOTHING (mirrors the store's blank guard, so the button can be
* disabled instead of silently no-op-ing); otherwise ADD when nothing is selected, EDIT when
* a chip is.
*/
internal fun QuickReplyEditorState.commitKind(): QuickReplyCommit = when {
draft.isBlank() -> QuickReplyCommit.NONE
selectedId == null -> QuickReplyCommit.ADD
else -> QuickReplyCommit.EDIT
}
// ── The palette editor ──────────────────────────────────────────────────────────
/**
* The editable-palette surface (plan §1 "Quick-reply chips + editable palette"): an
* [AlertDialog] over the store's CRUD. One field composes/rewrites; each row can be selected
* (tap 编辑), reordered (▲/▼) or deleted (✕).
*
* The chip texts are the user's OWN strings — never server data — but they are still rendered
* as plain inert [Text] (no Markdown/autolink), consistent with §8.
*
* Layout/scroll behaviour is device-QA (§7); the select/commit decision is the JVM-tested
* [QuickReplyEditorState] reducer.
*
* @param chips the current palette snapshot (from [QuickReplyPalette.chips]).
* @param onAdd / [onEdit] / [onRemove] / [onMove] the store-backed CRUD callbacks.
* @param onDismiss close the editor.
*/
@Composable
public fun QuickReplyPaletteEditor(
chips: List<QuickReplyChip>,
onAdd: (String) -> Unit,
onEdit: (id: String, text: String) -> Unit,
onRemove: (id: String) -> Unit,
onMove: (from: Int, to: Int) -> Unit,
onDismiss: () -> Unit,
) {
var state by remember { mutableStateOf(QuickReplyEditorState()) }
// A chip removed while selected must not leave a dangling edit target pointing at a gone id.
// DERIVED, not written back during composition (a composition-time state write is a recomposition
// hazard): the field simply falls back to "adding" and keeps whatever was typed.
val effective = if (state.selectedId != null && chips.none { it.id == state.selectedId }) {
state.copy(selectedId = null)
} else {
state
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("快捷回复") },
text = {
Column(
modifier = Modifier.heightIn(max = EDITOR_MAX_HEIGHT_DP.dp),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
QuickReplyEditorField(
state = effective,
onDraftChange = { state = effective.copy(draft = it) },
onCommit = {
when (effective.commitKind()) {
QuickReplyCommit.ADD -> onAdd(effective.draft)
QuickReplyCommit.EDIT -> onEdit(requireNotNull(effective.selectedId), effective.draft)
QuickReplyCommit.NONE -> Unit
}
state = effective.cleared()
},
onCancelSelection = { state = effective.cleared() },
)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
if (chips.isEmpty()) {
Text("还没有快捷回复。", style = MaterialTheme.typography.bodySmall)
}
chips.forEachIndexed { index, chip ->
QuickReplyEditorRow(
chip = chip,
isSelected = chip.id == effective.selectedId,
canMoveUp = index > 0,
canMoveDown = index < chips.lastIndex,
onSelect = { state = effective.selecting(chip) },
onMoveUp = { onMove(index, index - 1) },
onMoveDown = { onMove(index, index + 1) },
onRemove = { onRemove(chip.id) },
)
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("完成") } },
)
}
/** The single compose/rewrite field + its commit action. */
@Composable
private fun QuickReplyEditorField(
state: QuickReplyEditorState,
onDraftChange: (String) -> Unit,
onCommit: () -> Unit,
onCancelSelection: () -> Unit,
) {
OutlinedTextField(
value = state.draft,
onValueChange = onDraftChange,
label = { Text(if (state.selectedId == null) "新增内容" else "修改内容") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(
onClick = onCommit,
enabled = state.commitKind() != QuickReplyCommit.NONE,
) { Text(if (state.selectedId == null) "添加" else "保存") }
if (state.selectedId != null) {
TextButton(onClick = onCancelSelection) { Text("取消") }
}
}
}
/** One palette row: the (inert) text plus select / reorder / delete affordances. */
@Composable
private fun QuickReplyEditorRow(
chip: QuickReplyChip,
isSelected: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onSelect: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onRemove: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = chip.text,
style = MaterialTheme.typography.bodyMedium,
color = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onSelect) { Text("编辑") }
TextButton(onClick = onMoveUp, enabled = canMoveUp) { Text("") }
TextButton(onClick = onMoveDown, enabled = canMoveDown) { Text("") }
TextButton(onClick = onRemove) { Text("") }
}
}
/** Cap on the editor body height so a long palette scrolls inside the dialog. */
private const val EDITOR_MAX_HEIGHT_DP: Int = 320
// ── Preview ───────────────────────────────────────────────────────────────────── // ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "QuickReply (gate held)") @Preview(name = "QuickReply (gate held)")
@@ -109,3 +359,18 @@ private fun QuickReplyPreview() {
) )
} }
} }
@Preview(name = "QuickReply palette editor")
@Composable
private fun QuickReplyPaletteEditorPreview() {
WebTermTheme {
QuickReplyPaletteEditor(
chips = QuickReplyDefaults.chips,
onAdd = {},
onEdit = { _, _ -> },
onRemove = {},
onMove = { _, _ -> },
onDismiss = {},
)
}
}

View File

@@ -79,11 +79,16 @@ public sealed interface BannerModel {
/** /**
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no * A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance. * spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
* actually help — a "新会话" affordance.
*
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
* `ALLOWED_ORIGINS`), which the copy says.
*/ */
public data class Failed(val reason: FailureReason) : BannerModel { public data class Failed(val reason: FailureReason) : BannerModel {
override val showsSpinner: Boolean get() = false override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
} }
/** /**
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
is BannerModel.Failed -> when (model.reason) { is BannerModel.Failed -> when (model.reason) {
FailureReason.REPLAY_TOO_LARGE -> FailureReason.REPLAY_TOO_LARGE ->
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。" "回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
FailureReason.UNAUTHORIZED ->
"服务器拒绝了连接401。请在“配对主机”里重新填写访问令牌若已填写请检查主机的 ALLOWED_ORIGINS。"
} }
is BannerModel.Exited -> is BannerModel.Exited ->
if (model.isSpawnFailure) { if (model.isSpawnFailure) {

View File

@@ -113,6 +113,10 @@ private fun TitleLine(row: SessionRow) {
) { ) {
StatusBadge(status = row.displayStatus) StatusBadge(status = row.displayStatus)
if (row.isUnread) UnreadDot() if (row.isUnread) UnreadDot()
// W2: the pending follow-up depth from `GET /live-sessions`'s `queueLength` (null on a pre-W2
// server → renders nothing). The badge is the cold/list source; an ATTACHED session's live depth
// comes from the `queue` WS frame instead.
QueueBadge(depth = row.info.queueLength)
Text( Text(
text = row.title.ifBlank { fallbackLabel(row.info) }, text = row.title.ifBlank { fallbackLabel(row.info) },
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -202,7 +206,12 @@ private fun SessionListRowPreview() {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
SessionListRow( SessionListRow(
row = SessionRow( row = SessionRow(
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L), info = previewInfo(
status = ClaudeStatus.WORKING,
title = "web-terminal",
lastOutputAt = 2L,
queueLength = 2,
),
displayStatus = DisplayStatus.Working, displayStatus = DisplayStatus.Working,
title = "web-terminal", title = "web-terminal",
isUnread = true, isUnread = true,
@@ -228,6 +237,7 @@ private fun previewInfo(
cols: Int = 161, cols: Int = 161,
rows: Int = 50, rows: Int = 50,
lastOutputAt: Long? = null, lastOutputAt: Long? = null,
queueLength: Int? = null,
): LiveSessionInfo = LiveSessionInfo( ): LiveSessionInfo = LiveSessionInfo(
id = UUID.fromString("11111111-2222-4333-8444-555555555555"), id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
createdAt = 1L, createdAt = 1L,
@@ -240,4 +250,5 @@ private fun previewInfo(
rows = rows, rows = rows,
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L), telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
lastOutputAt = lastOutputAt, lastOutputAt = lastOutputAt,
queueLength = queueLength,
) )

View File

@@ -0,0 +1,136 @@
package wang.yaojia.webterm.di
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okhttp3.CookieJar
import wang.yaojia.webterm.hostregistry.AuthCookieCipher
import wang.yaojia.webterm.hostregistry.AuthCookieStore
import wang.yaojia.webterm.hostregistry.DataStoreAuthCookieStore
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
import wang.yaojia.webterm.tlsandroid.TinkAuthCookieCipher
import wang.yaojia.webterm.transport.AuthCookieJar
import wang.yaojia.webterm.wiring.toRecord
import javax.inject.Singleton
/**
* The optional `WEBTERM_TOKEN` gate's client boundary (server `src/http/auth.ts`).
*
* Three things are wired here, and all three are required for the gate to work at all:
* 1. **[AuthCookieJar] on the ONE shared `OkHttpClient`** (via the [CookieJar] binding [NetworkModule]
* consumes). Both transports share that client, so the `webterm_auth` cookie rides every REST call
* AND the WS upgrade (OkHttp's `BridgeInterceptor` runs for WebSocket calls too). Without this the
* client runs on `CookieJar.NO_COOKIES` and a token-gated host 401s everything.
* 2. **[AuthCookieStore]** so the credential survives a process restart — encrypted at rest.
* 3. **The [AuthCookieCipher] seam** bridging `:host-registry` (which must not depend on Tink) to
* `:client-tls-android`'s [TinkAuthCookieCipher] (which must not depend on `:host-registry`) — the
* same sibling-bridge pattern [NetworkModule] uses for the mTLS `ClientIdentityProvider`.
*
* ### Fail CLOSED
* [DataStoreAuthCookieStore] takes the cipher as a REQUIRED constructor argument precisely so no wiring
* can persist plaintext by omission. If a real cipher cannot be constructed, this module binds
* [InMemoryAuthCookieStore] instead: memory-only persistence (the user re-authenticates after a restart)
* is the correct answer, and writing a full-shell credential in the clear is not an option. A cipher that
* constructs but later fails to seal is handled the same way one level down — the store persists NOTHING
* and drops any previous blob.
*
* ### Threading
* The graph builds nothing expensive: [TinkAuthCookieCipher] defers all AEAD/keystore work to first use,
* and `AppEnvironment.warmUp()` does the first read off `Main`. The persister side must be non-blocking
* because OkHttp calls a `CookieJar` synchronously on the call thread, so the durable write is handed to
* an app-scoped IO scope.
*/
@Module
@InstallIn(SingletonComponent::class)
public object AuthCookieModule {
/**
* The durable home of the `webterm_auth` cookie. Shares the ONE Preferences DataStore with the host
* list / last-session pointer ([StorageModule]) — disjoint keys (`authCookiesSealed` vs `hosts` /
* `lastSessionId.<hostId>`) — but the VALUE here is Tink-AEAD ciphertext, never plaintext.
*/
@Provides
@Singleton
public fun provideAuthCookieStore(
dataStore: DataStore<Preferences>,
@ApplicationContext context: Context,
): AuthCookieStore {
val cipher = buildAuthCookieCipher(context)
if (cipher == null) {
// Fail closed: memory-only rather than a plaintext credential at rest.
Log.w(TAG, "auth-cookie cipher unavailable — the session cookie will not be persisted")
return InMemoryAuthCookieStore()
}
return DataStoreAuthCookieStore(
dataStore = dataStore,
cipher = cipher,
onCipherFailure = ::reportCipherFailure,
)
}
/**
* The ONE jar. Its persister hands each host's changed cookie set to [AuthCookieStore] on an
* app-scoped IO scope — never blocking OkHttp's call thread on DataStore/Tink I/O. The scope lives for
* the process (there is no later moment at which a pending credential write should be abandoned).
*/
@Provides
@Singleton
public fun provideAuthCookieJar(store: AuthCookieStore): AuthCookieJar {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
return AuthCookieJar(
persister = { hostKey, cookies ->
scope.launch {
runCatching { store.replaceHost(hostKey, cookies.map { it.toRecord(hostKey) }) }
.onFailure(::reportPersistFailure)
}
},
)
}
/** What [NetworkModule] installs on the shared client — the same instance as [provideAuthCookieJar]. */
@Provides
@Singleton
public fun provideCookieJar(jar: AuthCookieJar): CookieJar = jar
/**
* The 4-line adapter [TinkAuthCookieCipher]'s KDoc prescribes: it matches [AuthCookieCipher]'s shape
* exactly but cannot declare it (sibling modules), so `:app` bridges the two.
*
* Returns null if the cipher cannot even be CONSTRUCTED. Today's implementation defers all keystore
* work to first use and so does not throw here; the guard exists because the alternative to "no
* cipher" must always be "no persistence", never "plaintext persistence".
*/
private fun buildAuthCookieCipher(context: Context): AuthCookieCipher? =
runCatching {
val tink = TinkAuthCookieCipher(context)
object : AuthCookieCipher {
override fun seal(plaintext: String): String = tink.seal(plaintext)
override fun open(sealed: String): String = tink.open(sealed)
}
}.getOrNull()
/**
* A seal/open failure means a credential was dropped instead of stored (or a stored one could not be
* read). Reported by TYPE only: neither the plaintext nor the ciphertext may reach a log.
*/
private fun reportCipherFailure(error: Throwable) {
Log.w(TAG, "auth-cookie at-rest crypto failed (${error::class.simpleName}) — nothing was persisted")
}
/** A durable-write failure leaves the cookie in memory only; same logging discipline. */
private fun reportPersistFailure(error: Throwable) {
Log.w(TAG, "auth-cookie persist failed (${error::class.simpleName}) — cookie kept in memory only")
}
private const val TAG: String = "WebTermAuthCookie"
}

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.AccessTokenStore
import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore
import wang.yaojia.webterm.wire.AccessTokenSource
import javax.inject.Singleton
/**
* Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as
* the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only
* [AccessTokenSource] the transports consume.
*
* It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material:
* [StorageModule]'s DataStore holds only non-secret UI state, while this store is
* Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s
* cert store, with its own keyset so the two secrets never share a key).
*
* Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible
* to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free
* (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap.
*/
@Module
@InstallIn(SingletonComponent::class)
public object AuthModule {
@Provides
@Singleton
public fun provideAccessTokenStore(
@ApplicationContext context: Context,
): AccessTokenStore = TinkAccessTokenStore(context)
/** The transports' read seam — the SAME instance the pairing screen wrote to. */
@Provides
@Singleton
public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store
}

View File

@@ -5,6 +5,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import wang.yaojia.webterm.transport.ClientIdentity import wang.yaojia.webterm.transport.ClientIdentity
import wang.yaojia.webterm.transport.ClientIdentityProvider import wang.yaojia.webterm.transport.ClientIdentityProvider
@@ -12,6 +13,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory
import wang.yaojia.webterm.transport.OkHttpHttpTransport import wang.yaojia.webterm.transport.OkHttpHttpTransport
import wang.yaojia.webterm.transport.OkHttpTermTransport import wang.yaojia.webterm.transport.OkHttpTermTransport
import wang.yaojia.webterm.tlsandroid.IdentityRepository import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HttpTransport import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport import wang.yaojia.webterm.wire.TermTransport
import javax.inject.Singleton import javax.inject.Singleton
@@ -27,6 +29,11 @@ import javax.inject.Singleton
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no * re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
* client rebuild (R4). The provider is read ONCE, when the client is built. * client rebuild (R4). The provider is read ONCE, when the client is built.
* *
* ### The WEBTERM_TOKEN cookie jar
* The shared client also carries the ONE [CookieJar] provided by [AuthCookieModule], so the optional
* access-token gate applies uniformly to REST and to the WS upgrade. See that module for the at-rest
* (Tink AEAD) custody of the cookie.
*
* ### Breaking the construction cycle (A11's frozen constructor) * ### Breaking the construction cycle (A11's frozen constructor)
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the * `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared * client needs the repository's SSL material — a cycle. It is broken with an explicit shared
@@ -53,22 +60,40 @@ public object NetworkModule {
ClientIdentity(material.sslSocketFactory, material.trustManager) ClientIdentity(material.sslSocketFactory, material.trustManager)
} }
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */ /**
* The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool] and
* carrying the ONE [CookieJar] ([AuthCookieModule]).
*
* The cookie jar is what makes the optional `WEBTERM_TOKEN` gate work: a gated host answers pairing
* with `Set-Cookie: webterm_auth=…` and then requires that cookie on every remote route AND on the WS
* upgrade. Because both transports share this client, installing the jar HERE covers both at once
* (OkHttp's `BridgeInterceptor` runs for WebSocket calls). Leaving it at `CookieJar.NO_COOKIES` — as
* this provider did before — makes the whole token feature inert.
*/
@Provides @Provides
@Singleton @Singleton
public fun provideOkHttpClient( public fun provideOkHttpClient(
identityProvider: ClientIdentityProvider, identityProvider: ClientIdentityProvider,
connectionPool: ConnectionPool, connectionPool: ConnectionPool,
cookieJar: CookieJar,
): OkHttpClient = ): OkHttpClient =
OkHttpClientFactory.create(identityProvider) OkHttpClientFactory.create(identityProvider, cookieJar)
.newBuilder() .newBuilder()
.connectionPool(connectionPool) .connectionPool(connectionPool)
.build() .build()
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */ /**
* WS transport over the shared client (it derives a streaming-tuned variant sharing the pool).
*
* [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=<t>` — the
* REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1).
*/
@Provides @Provides
@Singleton @Singleton
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client) public fun provideTermTransport(
client: OkHttpClient,
tokens: AccessTokenSource,
): TermTransport = OkHttpTermTransport(client, tokens)
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */ /** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
@Provides @Provides

View File

@@ -1,11 +1,19 @@
package wang.yaojia.webterm.di package wang.yaojia.webterm.di
import com.google.firebase.messaging.FirebaseMessaging
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.suspendCancellableCoroutine
import wang.yaojia.webterm.push.PushRegistrar
import wang.yaojia.webterm.push.PushRegistrarTokenSink import wang.yaojia.webterm.push.PushRegistrarTokenSink
import wang.yaojia.webterm.push.PushTokenSink import wang.yaojia.webterm.push.PushTokenSink
import wang.yaojia.webterm.wiring.HostPushUnregister
import wang.yaojia.webterm.wiring.PushTokenSource
import javax.inject.Singleton
import kotlin.coroutines.resume
/** /**
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with * Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
@@ -13,10 +21,52 @@ import wang.yaojia.webterm.push.PushTokenSink
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)` * `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar] * — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
* (self-heal). This is the intended optional-collaborator handoff (no mutable global). * (self-heal). This is the intended optional-collaborator handoff (no mutable global).
*
* It also binds the two seams the HOST-REMOVAL path needs
* ([HostPushUnregister] + [PushTokenSource]): `PushRegistrar.unregisterHost` had zero call sites, so a
* removed host kept this device's token and kept pushing for a host the user had dropped.
*/ */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
public interface PushModule { public interface PushModule {
@Binds @Binds
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
public companion object {
/**
* `DELETE /push/fcm-token` for ONE host — the FIRST caller of [PushRegistrar.unregisterHost],
* which was defined and never invoked, so a removed host kept this device's token forever.
*
* Routed through the registrar rather than a fresh `ApiClient` so the removal inherits its
* already-tested best-effort discipline (a `CancellationException` rethrown, any other failure
* swallowed with the token never logged).
*/
@Provides
@Singleton
public fun provideHostPushUnregister(
registrar: PushRegistrar,
): HostPushUnregister = HostPushUnregister { host, token -> registrar.unregisterHost(host, token) }
/**
* The current FCM registration token. Suspends on Firebase's own `Task` rather than blocking, and
* answers `null` for every degraded case (no `google-services.json`, uninitialised `FirebaseApp`,
* a failed fetch) so a push-less build still removes hosts cleanly. The token is returned to the
* caller and NEVER logged (plan §8).
*/
@Provides
@Singleton
public fun providePushTokenSource(): PushTokenSource = PushTokenSource {
suspendCancellableCoroutine { continuation ->
val task = runCatching { FirebaseMessaging.getInstance().token }.getOrNull()
if (task == null) {
continuation.resume(null)
return@suspendCancellableCoroutine
}
task
.addOnSuccessListener { token -> continuation.resume(token?.takeIf { it.isNotEmpty() }) }
.addOnFailureListener { continuation.resume(null) }
.addOnCanceledListener { continuation.resume(null) }
}
}
}
} }

View File

@@ -12,15 +12,22 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.DataStoreHostStore import wang.yaojia.webterm.hostregistry.DataStoreHostStore
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
import wang.yaojia.webterm.hostregistry.DataStoreSessionWatermarkStore
import wang.yaojia.webterm.hostregistry.HostStore import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.hostregistry.SessionWatermarkStore
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore. * Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key * The host list ([HostStore], key `"hosts"`), the per-host last-session id ([LastSessionStore], key
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret * `"lastSessionId.<hostId>"`) and the unread watermarks ([SessionWatermarkStore], key
* material (the device cert) never lives here — that is the Tink store in [TlsModule]. * `"unreadWatermarks"`) use disjoint keys, so they safely share one DataStore file. Secret material (the
* device cert, the `webterm_auth` cookie) never lives here in the clear — those are the Tink-sealed
* stores in [TlsModule] / [AuthCookieModule].
*
* Two `DataStore` instances over one file THROW, so every store here takes the single [provideDataStore]
* singleton; a new store means a new KEY, never a new file.
*/ */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -43,5 +50,16 @@ public object StorageModule {
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore = public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
DataStoreLastSessionStore(dataStore) DataStoreLastSessionStore(dataStore)
/**
* The durable home of the session-list unread watermarks (`sessionId → last-seen ms`). Until this was
* wired the ledger was memory-only, so the unread dot reset on every process death — precisely when
* it matters, since the product premise is walking away and coming back.
*/
@Provides
@Singleton
public fun provideSessionWatermarkStore(
dataStore: DataStore<Preferences>,
): SessionWatermarkStore = DataStoreSessionWatermarkStore(dataStore)
private const val DATASTORE_NAME: String = "webterm" private const val DATASTORE_NAME: String = "webterm"
} }

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