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

@@ -28,21 +28,33 @@ import WireProtocol
/// deadline (the transport's own timeouts still apply). The production
/// default is a UI-layer decision (T-iOS-12); a shared
/// `Tunables.pairingProbeTimeout` would be a T-iOS-3 contract addition.
/// - accessToken: candidate `WEBTERM_TOKEN` for a host that gates its HTTP
/// surface (C1 over B1, ios-completion §1.1). It is stamped as
/// `Cookie: webterm_auth=` on the probe's TWO HTTP legs by `APIClient`;
/// the WS leg's cookie comes from the transport the caller passes (the
/// pairing flow builds a probe-scoped transport carrying the same
/// candidate), because `TermTransport.connect` is a frozen contract with no
/// credential parameter. `nil` = probe unauthenticated (the LAN default).
func runPairingProbeCore(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport,
clock: any Clock<Duration>,
timeout: Duration?
timeout: Duration?,
accessToken: String? = nil
) async -> Result<HostEndpoint, PairingError> {
guard let timeout else {
return await performProbe(endpoint: endpoint, http: http, ws: ws)
return await performProbe(
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
)
}
return await withTaskGroup(
of: Result<HostEndpoint, PairingError>.self
) { group in
group.addTask {
await performProbe(endpoint: endpoint, http: http, ws: ws)
await performProbe(
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
)
}
group.addTask {
// Cancellation (probe won) also lands here; the value is discarded.
@@ -63,14 +75,16 @@ func runPairingProbeCore(
public func runPairingProbe(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport
ws: any TermTransport,
accessToken: String? = nil
) async -> Result<HostEndpoint, PairingError> {
await runPairingProbeCore(
endpoint: endpoint,
http: http,
ws: ws,
clock: ContinuousClock(),
timeout: Tunables.pairingProbeTimeout
timeout: Tunables.pairingProbeTimeout,
accessToken: accessToken
)
}
@@ -79,23 +93,31 @@ public func runPairingProbe(
private func performProbe(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport
ws: any TermTransport,
accessToken: String?
) async -> Result<HostEndpoint, PairingError> {
let api = APIClient(endpoint: endpoint, http: http)
let api = APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
// Reachability + shape. Any HTTP-level answer that isn't the
// /live-sessions array shape means "some other service" ?
do {
_ = try await api.liveSessions()
} catch APIClientError.unauthorized {
// C1 · 401 on the RO leg is NOT "some other service": this host gates
// its HTTP surface and our candidate token was absent or wrong. The
// status alone cannot say which of the two gates rejected us, so the
// copy offers both remedies (see `unauthorizedPairingHint`).
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
} catch is APIClientError {
return .failure(.httpOkButNotWebTerminal)
} catch {
return .failure(PairingError.classify(error, endpoint: endpoint))
}
// WS upgrade the server's ONLY upgrade-reject path is the Origin 401
// (src/server.ts:646-651), so after passed, an unrecognizable connect
// failure is classified as originRejected.
// WS upgrade the server rejects an upgrade with 401 from TWO gates:
// the Origin/CSWSH check and then the `webterm_auth` cookie
// (src/server.ts:1367-1379). After passed, an unrecognizable connect
// failure is one of those two, so the fallback names both.
let connection: TransportConnection
do {
connection = try await ws.connect(to: endpoint)
@@ -103,7 +125,7 @@ private func performProbe(
return .failure(PairingError.classify(
error, endpoint: endpoint,
unrecognizedFallback: .originRejected(
hint: PairingError.originRejectedHint(for: endpoint)
hint: unauthorizedPairingHint(for: endpoint)
)
))
}
@@ -152,9 +174,14 @@ private func killProbeSession(
} catch APIClientError.sessionNotFound {
// Already gone (exited between attach and kill) the goal state.
} catch APIClientError.forbidden {
// 403 is UNAMBIGUOUS: only the guarded-HTTP Origin check answers 403
// (src/server.ts:332-339) the token gate answers 401. So this one
// keeps the pure Origin copy.
return .failure(.originRejected(
hint: PairingError.originRejectedHint(for: endpoint)
))
} catch APIClientError.unauthorized {
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
} catch let apiError as APIClientError {
return .failure(.hostUnreachable(underlying: apiError.message))
} catch {
@@ -162,3 +189,27 @@ private func killProbeSession(
}
return .success(endpoint)
}
// MARK: - The ambiguous 401 (C1 · fixes the pre-token "Origin rejected" verdict)
/// Copy for a **401** met during pairing.
///
/// Before the access token existed, 401 had exactly one cause on this path, so
/// the probe reported `originRejected` with Origin-only copy. With
/// `WEBTERM_TOKEN` live there are TWO causes and one status code: the server
/// checks Origin first and the `webterm_auth` cookie second both write 401
/// (src/server.ts:1367-1379; the RO HTTP gate likewise, src/server.ts:459).
/// A client provably cannot tell them apart, so guessing one remedy sends half
/// the users chasing the wrong knob. Both are named, token first (it is the
/// one the user can fix from the phone).
///
/// The `ALLOWED_ORIGINS=` value is still derived from `endpoint.originHeader`
/// the single point (plan §5.1), never hand-assembled, default ports omitted.
/// The error case stays `originRejected` because `PairingError` is a frozen
/// contract (§3.4) and its payload is exactly "the hint the UI shows verbatim".
func unauthorizedPairingHint(for endpoint: HostEndpoint) -> String {
"主机以 401 拒绝了这次配对,而两种原因会得到同一个状态码:"
+ "① 该主机启用了访问令牌WEBTERM_TOKEN本次配对没带或带错了令牌——请输入访问令牌后重试"
+ "② 主机的来源白名单不含本 App 拨号的地址——请在主机上设置 "
+ "ALLOWED_ORIGINS=\(endpoint.originHeader)(与 App 连接的 URL 完全一致)后重启 web-terminal。"
}