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.
This commit is contained in:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -4,12 +4,32 @@ import WireProtocol
/// T-iOS-15 · Production `HTTPTransport` (the WireProtocol seam's doc reserves
/// the URLSession wrapper for the production side; no package owns it, so the
/// assembly layer provides it). Deliberately logic-free: `APIClient` builds
/// every request including the Origin-iff-G rule (plan §3.4 ) and this
/// type only performs the exchange. Adding ANY header/URL logic here would
/// bypass that single audited point (review CRITICAL).
/// assembly layer provides it). Deliberately logic-free about ROUTING:
/// `APIClient` builds every request including the Origin-iff-G rule (plan §3.4
/// ) and this type only performs the exchange. Adding URL or Origin logic
/// here would bypass that single audited point (review CRITICAL).
///
/// C1 · The ONE exception is the access-token `Cookie` (ios-completion §1.1),
/// and it is deliberate:
/// - the token is **unconditional** every request, RO and G alike so it has
/// no interaction with the conditional Origin rule it must never replace;
/// - it is **per host**, resolved from the request's own origin, whereas
/// `APIClient` instances are built ad hoc all over the App layer (list poll,
/// previews, projects, diffs, push registration) with no access to the
/// Keychain. Stamping at the shared transport is what makes "every request
/// carries the token" true by construction instead of per-call-site;
/// - it is resolved LAZILY per request from `tokenForOrigin` the same
/// no-relaunch pattern as the mTLS `identityProvider` below so a token typed
/// mid-run applies to the very next request.
///
/// A request that ALREADY carries a `Cookie` is left untouched: the pairing probe
/// authenticates with a *candidate* token that is not in the store yet, and
/// overwriting it here would silently unauthenticate the probe.
struct URLSessionHTTPTransport: HTTPTransport {
private let session: URLSession
/// Resolves the stored access token for a request's origin (see type doc).
/// `@Sendable`, async: the store is an actor over the Keychain.
private let tokenForOrigin: @Sendable (String) async -> String?
/// Strong reference to the mTLS delegate. URLSession retains its delegate
/// until invalidated, but this ephemeral session is never explicitly
/// invalidated, so holding it here documents the ownership and keeps the
@@ -18,6 +38,7 @@ struct URLSessionHTTPTransport: HTTPTransport {
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
/// provider, so behaviour is identical to capturing the identity directly.
/// No token source no `Cookie` is ever added (LAN zero-config default).
init(identity: ClientIdentity? = nil) {
self.init(identityProvider: { identity })
}
@@ -37,16 +58,20 @@ struct URLSessionHTTPTransport: HTTPTransport {
/// contain printed secrets and `.shared`'s default URLCache writes
/// responses to disk. Ephemeral keeps them memory-only, matching the WS
/// transport and the privacy-shade posture.
init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
init(
identityProvider: @escaping @Sendable () -> ClientIdentity?,
tokenForOrigin: @escaping @Sendable (String) async -> String? = { _ in nil }
) {
let delegate = LazyClientTLSSessionDelegate(identityProvider: identityProvider)
self.tlsDelegate = delegate
self.tokenForOrigin = tokenForOrigin
self.session = URLSession(
configuration: .ephemeral, delegate: delegate, delegateQueue: nil
)
}
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await session.data(for: request)
let (data, response) = try await session.data(for: await authenticated(request))
guard let httpResponse = response as? HTTPURLResponse else {
// http(s)-only endpoints (HostEndpoint validates) always produce
// an HTTPURLResponse; anything else is a transport-level anomaly.
@@ -54,6 +79,50 @@ struct URLSessionHTTPTransport: HTTPTransport {
}
return (data, httpResponse)
}
/// Stamp `Cookie: webterm_auth=<t>` for the request's own origin (C1).
/// Immutable style: returns a NEW request, never mutates the caller's.
///
/// `internal`, not private: this is the whole behaviour `send` adds, and it
/// is testable with zero network the alternative would be leaving the one
/// line that carries a credential unverified.
func authenticated(_ request: URLRequest) async -> URLRequest {
guard let origin = AccessTokenCookie.origin(of: request),
let token = await tokenForOrigin(origin) else {
return request
}
return AccessTokenCookie.stamped(request, token: token)
}
}
/// The single point where an access token becomes a request header (App layer).
/// Pure and static so the rules are unit-testable without any network.
enum AccessTokenCookie {
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30) the same literal the packages
/// pin; both of their `AuthCookie` helpers are package-internal, so the App
/// layer needs its own single definition rather than a fourth ad-hoc string.
static let name = "webterm_auth"
static let header = "Cookie"
/// The request's origin in `HostEndpoint.originHeader` form, via the frozen
/// single derivation point (default ports omitted, scheme/host lowercased)
/// never hand-assembled here. nil for a non-http(s) or host-less URL.
static func origin(of request: URLRequest) -> String? {
guard let url = request.url, let endpoint = HostEndpoint(baseURL: url) else {
return nil
}
return endpoint.originHeader
}
/// A copy of `request` carrying the token cookie unless it already carries
/// a `Cookie`, in which case the existing one wins (the pairing probe's
/// candidate token must not be overwritten by the stored one).
static func stamped(_ request: URLRequest, token: String) -> URLRequest {
guard request.value(forHTTPHeaderField: header) == nil else { return request }
var authenticated = request
authenticated.setValue("\(name)=\(token)", forHTTPHeaderField: header)
return authenticated
}
}
/// C-iOS-2 (MEDIUM no-relaunch fix) · Session-level mTLS delegate that resolves