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
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 deletions

View File

@@ -46,8 +46,16 @@ Setup: `local.properties` → `sdk.dir=/usr/local/share/android-commandlinetools
| HostRegistry | `:host-registry` | Android (DataStore) | ✅ built |
| SwiftTerm host view | `:terminal-view` | Android (Termux wrap) | ✅ built |
| 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 |
> `:transport-okhttp` (A7) holds the OkHttp `TermTransport`/`HttpTransport`
> implementations that the two iOS `URLSession*Transport`s consolidate into
> (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
@@ -76,8 +84,45 @@ written yet.
`:wire-protocol` is the **frozen shared contract** (Android analogue of
`src/types.ts` + WireProtocol) — `ClientMessage`/`ServerMessage`, `MessageCodec`,
`Validation`, `WireConstants`, `HostEndpoint` (the single Origin/wsURL derivation),
and the `TermTransport` / `HttpTransport` / `PingableTermTransport` boundary
interfaces. New wire types are added **only** here (a coordination point).
`AuthCookie`/`AccessTokenRule`/`AccessTokenSource` (the single access-token-cookie
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
@@ -111,8 +156,9 @@ JUnit Platform (`tasks.test { useJUnitPlatform() }`).
```
> Testing target: **≥80% Kover coverage** on the pure modules (`:wire-protocol`,
> `:session-core`, `:api-client`, `:client-tls` pure half). TDD, immutable data,
> small focused files — same discipline as the rest of the repo.
> `:session-core`, `:api-client`, `:client-tls` pure half — the four modules that
> 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
@@ -199,8 +245,7 @@ Before adding a rule, read the merged configuration R8 actually used:
`hilt-android-testing` with `kspAndroidTest`) and `testInstrumentationRunner` is set to
`wang.yaojia.webterm.HiltTestRunner`.
⚠️ **That runner class does not exist yet** — it is the first file the A34 author must write,
into `app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt`:
`app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt` supplies it:
```kotlin
class HiltTestRunner : AndroidJUnitRunner() {
@@ -215,6 +260,24 @@ replace the app-under-test's `Application` with the generated `HiltTestApplicati
*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)
The pure JVM modules need only a JDK + Gradle. The **Android-framework** modules