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

@@ -0,0 +1,176 @@
import APIClient
import Foundation
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · The pairing probe against a token-gated host, driven through the REAL
/// `runPairingProbe` over `FakeHTTPTransport` + `FakeTransport`.
///
/// Two things are pinned:
/// 1. a 401 is no longer misreported as "Origin rejected" with the token gate
/// live, 401 has TWO causes and one status code, so the copy must name both;
/// 2. the candidate token really travels: `Cookie: webterm_auth=<t>` on every
/// HTTP leg (RO GET and guarded DELETE alike, §1.1 the cookie is orthogonal
/// to `Origin`, which only the DELETE carries).
///
/// These live in the App test target because APIClient's own test suite is B1's
/// file ownership; the probe function under test is the package's public one.
@Suite("Pairing probe · 401 and the token cookie")
struct PairingProbeUnauthorizedTests {
private static let base = "http://192.168.1.5:3000"
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
private static let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
private func endpoint() throws -> HostEndpoint {
let url = try #require(URL(string: Self.base))
return try #require(HostEndpoint(baseURL: url))
}
/// Script the whole happy path: RO list WS attach guarded kill.
private func scriptHappyPath(
http: FakeHTTPTransport, ws: FakeTransport
) async throws {
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
status: 204
)
}
// MARK: - 401 both remedies
@Test("RO 探针收到 401 → 不是「不是 web-terminal」而是给出令牌+ALLOWED_ORIGINS 两条补救")
func readOnly401NamesBothRemedies() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
status: 401,
body: Data(#"{"error":"unauthorized"}"#.utf8)
)
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected(hint:), got \(result)")
return
}
#expect(hint.contains("401"))
#expect(hint.contains("访问令牌"))
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
// The WS leg is never reached, so no PTY is spawned on a host that
// refuses us.
#expect(await ws.connectAttempts.isEmpty)
}
@Test("WS 升级被拒(无法归类)→ 同一条两因两解的话术")
func upgradeRejectionNamesBothRemedies() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.scriptConnectFailure()
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected(hint:), got \(result)")
return
}
#expect(hint.contains("访问令牌"))
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
#expect(!hint.contains(":443"))
}
@Test("守卫 DELETE 收到 401而非 403→ 同样是两因两解,不报「主机不可达」")
func guardedDelete401NamesBothRemedies() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
await http.queueSuccess(
url: try #require(URL(string: "\(Self.base)/live-sessions")),
body: Data("[]".utf8)
)
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
await http.queueSuccess(
method: "DELETE",
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
status: 401
)
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected(hint:), got \(result)")
return
}
#expect(hint.contains("访问令牌"))
}
// MARK: - The candidate token actually travels
@Test("带候选令牌的探针:两条 HTTP 腿都带 Cookie: webterm_auth且只读腿仍不带 Origin")
func tokenTravelsOnBothHTTPLegs() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
try await scriptHappyPath(http: http, ws: ws)
let result = await runPairingProbe(
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
)
guard case .success = result else {
Issue.record("expected success, got \(result)")
return
}
let requests = await http.recordedRequests
#expect(requests.count == 2)
for request in requests {
#expect(
request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)",
"every probe leg must carry the token cookie"
)
}
// Origin-iff-G is untouched by the token (§1.1: orthogonal).
let readOnly = try #require(requests.first)
let guarded = try #require(requests.last)
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil)
#expect(guarded.value(forHTTPHeaderField: "Origin") == Self.base)
}
@Test("没有候选令牌 → 一个 Cookie 头都不加LAN 零配置逐字不变)")
func noTokenMeansNoCookieHeader() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
try await scriptHappyPath(http: http, ws: ws)
_ = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
for request in await http.recordedRequests {
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
}
}
@Test("令牌不出现在 URL 里(绝不进查询串/日志)")
func tokenNeverAppearsInAURL() async throws {
let http = FakeHTTPTransport()
let ws = FakeTransport()
try await scriptHappyPath(http: http, ws: ws)
_ = await runPairingProbe(
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
)
for request in await http.recordedRequests {
#expect(request.url?.absoluteString.contains(Self.token) == false)
}
}
}