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,346 @@
import Foundation
import HostRegistry
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · The app's single read path for per-host access tokens, plus the one
/// header-stamping point (`AccessTokenCookie`).
///
/// E1 · The WS cases are the important ones. SessionCore's `tokenProvider` is
/// `() -> String?` no endpoint, no await so C1 answered it with "the token
/// every paired host shares", which is *nil* for the deployment the token
/// exists for (a tokened tunnel host next to an open LAN host): the upgrade then
/// omitted the cookie, the server 401'd, and `.failed(.unauthorized)` is
/// terminal with no in-app remedy, since re-entering the correct token left
/// the fleet just as mixed. The answer is now resolved PER HOST
/// (`wsToken(for:)`), exactly like the HTTP path and like Android's
/// `tokens.tokenFor(endpoint)`.
@Suite("Access-token source")
struct AccessTokenSourceTests {
private static let tokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private static let tokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
}
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
return HostRegistry.Host(
id: UUID(),
name: base,
endpoint: try #require(HostEndpoint(baseURL: url)),
accessToken: try token.map { try AccessToken(validating: $0) }
)
}
private func makeStore(_ hosts: [HostRegistry.Host]) async throws -> InMemoryHostStore {
let store = InMemoryHostStore()
for host in hosts {
_ = try await store.upsert(host)
}
return store
}
// MARK: - Per-origin resolution (the HTTP path)
@Test("按 origin 取到该主机的令牌;其它 origin 与无令牌主机都返回 nil")
func resolvesTokenByOrigin() async throws {
let store = try await makeStore([
try makeHost("http://192.168.1.5:3000", token: Self.tokenA),
try makeHost("http://192.168.1.9:3000", token: nil),
])
let source = AccessTokenSource(store: store)
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == Self.tokenA)
#expect(await source.token(forOrigin: "http://192.168.1.9:3000") == nil)
#expect(await source.token(forOrigin: "http://192.168.1.99:3000") == nil)
}
@Test("https 默认端口按 originHeader 规范化匹配(不写 :443")
func matchesNormalizedHTTPSOrigin() async throws {
let store = try await makeStore([
try makeHost("https://mac.tailnet.ts.net", token: Self.tokenA),
])
let source = AccessTokenSource(store: store)
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net") == Self.tokenA)
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net:443") == nil)
}
@Test("存储读取失败 → nil降级成「无令牌」不让 App 崩或卡住)")
func storeFailureDegradesToNoToken() async throws {
let source = AccessTokenSource(store: ThrowingHostStore())
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == nil)
}
// MARK: - The per-host WS answer (E1 · replaces `hostIndependentToken`)
@Test("混合机群:有令牌的那台拿到自己的令牌,没令牌的那台拿 nil旧解析对两台都给 nil")
func resolvesPerHostTokenInAMixedFleet() async throws {
let tokened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let open = try makeHost("http://192.168.1.9:3000", token: nil)
let source = AccessTokenSource(store: try await makeStore([tokened, open]))
_ = await source.token(forOrigin: tokened.endpoint.originHeader) //
#expect(source.wsToken(for: tokened) == Self.tokenA)
#expect(source.wsToken(for: open) == nil)
}
@Test("两台令牌不同 → 各拿各的,绝不把 A 的令牌发给 B")
func neverHandsOneHostsTokenToAnother() async throws {
let hostA = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let hostB = try makeHost("http://192.168.1.9:3000", token: Self.tokenB)
let source = AccessTokenSource(store: try await makeStore([hostA, hostB]))
_ = await source.token(forOrigin: hostA.endpoint.originHeader)
#expect(source.wsToken(for: hostA) == Self.tokenA)
#expect(source.wsToken(for: hostB) == Self.tokenB)
}
@Test("快照还没热(刚启动就开终端)→ 回落到该主机记录,绝不因竞态漏掉 Cookie")
func fallsBackToTheHostRecordBeforeAnyStoreRead() async throws {
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let source = AccessTokenSource(store: try await makeStore([host]))
// No `token(forOrigin:)` call yet the snapshot is empty.
#expect(source.wsToken(for: host) == Self.tokenA)
}
@Test("令牌轮换后:下一次连接用存储里的新值,而不是打开终端时的旧值")
func picksUpARotatedTokenOnTheNextConnect() async throws {
let opened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let store = try await makeStore([opened])
let source = AccessTokenSource(store: store)
let rotated = HostRegistry.Host(
id: opened.id, name: opened.name, endpoint: opened.endpoint,
accessToken: try AccessToken(validating: Self.tokenB)
)
_ = try await store.upsert(rotated)
_ = await source.token(forOrigin: opened.endpoint.originHeader) // HTTP
#expect(source.wsToken(for: opened) == Self.tokenB)
}
@Test("存储读取失败 → 回落到主机记录(打开时的值),不是别的主机的令牌")
func wsTokenSurvivesAStoreFailure() async throws {
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
let source = AccessTokenSource(store: ThrowingHostStore())
#expect(await source.token(forOrigin: host.endpoint.originHeader) == nil)
#expect(source.wsToken(for: host) == Self.tokenA)
}
@Test("完全没有令牌的机群 → nilLAN 零配置逐字不变,不带 Cookie")
func tokenlessFleetYieldsNoCookie() async throws {
let host = try makeHost("http://192.168.1.5:3000", token: nil)
let source = AccessTokenSource(store: try await makeStore([host]))
_ = await source.token(forOrigin: host.endpoint.originHeader)
#expect(source.wsToken(for: host) == nil)
}
}
/// E1 · The wiring that the per-host answer depends on: a terminal's WS
/// transport is built for THE host it dials, not once for the whole app.
@MainActor
@Suite("Per-host WS transport wiring")
struct PerHostTermTransportTests {
/// `@Sendable`-safe recorder for the hosts the factory was asked about.
private final class HostRecorder: @unchecked Sendable {
private let lock = NSLock()
private var hosts: [HostRegistry.Host] = []
func record(_ host: HostRegistry.Host) {
lock.withLock { hosts.append(host) }
}
var recorded: [HostRegistry.Host] { lock.withLock { hosts } }
}
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
let url = try #require(URL(string: base))
return HostRegistry.Host(
id: UUID(), name: base,
endpoint: try #require(HostEndpoint(baseURL: url)),
accessToken: try token.map { try AccessToken(validating: $0) }
)
}
private func makeEnvironment(
shared: FakeTransport,
factory: (@Sendable (HostRegistry.Host) -> any TermTransport)? = nil
) throws -> AppEnvironment {
let defaults = try #require(UserDefaults(suiteName: "PerHostTermTransportTests"))
return AppEnvironment(
hostStore: InMemoryHostStore(),
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
http: FakeHTTPTransport(),
termTransport: shared,
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
termTransportFactory: factory
)
}
@Test("未注入工厂 → 回落到共享传输(既有 App 层测试装配零回归)")
func fallsBackToTheSharedTransport() throws {
let shared = FakeTransport()
let environment = try makeEnvironment(shared: shared)
let resolved = environment.makeTermTransport(for: try makeHost("http://192.168.1.5:3000", token: nil))
#expect(resolved as? FakeTransport === shared)
}
@Test("注入工厂 → 每台主机各建一个传输,且工厂拿到的就是那台主机(含其令牌)")
func buildsOneTransportPerHost() throws {
let recorder = HostRecorder()
let environment = try makeEnvironment(
shared: FakeTransport(),
factory: { host in
recorder.record(host)
return FakeTransport()
}
)
let tokened = try makeHost("http://192.168.1.5:3000", token: String(repeating: "a", count: 32))
let open = try makeHost("http://192.168.1.9:3000", token: nil)
_ = environment.makeTermTransport(for: tokened)
_ = environment.makeTermTransport(for: open)
#expect(recorder.recorded.map(\.id) == [tokened.id, open.id])
#expect(recorder.recorded.first?.accessToken == tokened.accessToken)
}
@Test("TerminalSessionController 用自己那台主机建传输(终端 401 的根因就在这里)")
func controllerBuildsItsTransportForItsOwnHost() throws {
let recorder = HostRecorder()
let environment = try makeEnvironment(
shared: FakeTransport(),
factory: { host in
recorder.record(host)
return FakeTransport()
}
)
let host = try makeHost("http://192.168.1.5:3000", token: String(repeating: "b", count: 32))
_ = TerminalSessionController(
host: host, sessionId: nil, environment: environment,
onPendingChanged: { _, _ in }
)
#expect(recorder.recorded.map(\.id) == [host.id])
}
}
/// C1 · The App layer's one place where a token becomes a header.
@Suite("Access-token cookie stamping")
struct AccessTokenCookieTests {
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
private func request(_ url: String) throws -> URLRequest {
URLRequest(url: try #require(URL(string: url)))
}
@Test("请求 URL → originHeader 形式的 origin路径/查询串被忽略)")
func derivesOriginFromRequestURL() throws {
let withPath = try request("http://192.168.1.5:3000/live-sessions/abc/preview?x=1")
#expect(AccessTokenCookie.origin(of: withPath) == "http://192.168.1.5:3000")
}
@Test("https 默认端口不写 :443与服务器的 URL 规范化一致)")
func omitsDefaultHTTPSPort() throws {
let secure = try request("https://mac.tailnet.ts.net/live-sessions")
#expect(AccessTokenCookie.origin(of: secure) == "https://mac.tailnet.ts.net")
}
@Test("非 http(s) / 无 host 的请求 → nil不可能是本 App 的主机)")
func rejectsNonHTTPRequests() throws {
#expect(AccessTokenCookie.origin(of: try request("ftp://192.168.1.5/x")) == nil)
#expect(AccessTokenCookie.origin(of: URLRequest(url: URL(fileURLWithPath: "/tmp"))) == nil)
}
@Test("盖上 Cookie: webterm_auth=<t>,且返回新请求(不原地改调用方的请求)")
func stampsCookieImmutably() throws {
let original = try request("http://192.168.1.5:3000/live-sessions")
let stamped = AccessTokenCookie.stamped(original, token: Self.token)
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(original.value(forHTTPHeaderField: "Cookie") == nil)
}
@Test("已经带 Cookie 的请求原样通过(配对探针的候选令牌不能被覆盖)")
func neverOverwritesAnExistingCookie() throws {
var candidate = try request("http://192.168.1.5:3000/live-sessions")
candidate.setValue("webterm_auth=candidate-token-value", forHTTPHeaderField: "Cookie")
let stamped = AccessTokenCookie.stamped(candidate, token: Self.token)
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate-token-value")
}
@Test("Cookie 名与服务器 AUTH_COOKIE_NAME 逐字一致")
func cookieNameMatchesTheServer() {
#expect(AccessTokenCookie.name == "webterm_auth")
}
// MARK: - The transport choke point itself (no network involved)
@Test("传输层给每个请求按 origin 盖上令牌 CookieRO GET 也一样)")
func transportStampsEveryRequest() async throws {
let transport = URLSessionHTTPTransport(
identityProvider: { nil },
tokenForOrigin: { origin in
origin == "http://192.168.1.5:3000" ? Self.token : nil
}
)
let readOnly = try request("http://192.168.1.5:3000/live-sessions")
let stamped = await transport.authenticated(readOnly)
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
}
@Test("没有该 origin 的令牌 → 请求逐字不变LAN 零配置主机不受影响)")
func transportLeavesTokenlessOriginsAlone() async throws {
let transport = URLSessionHTTPTransport(
identityProvider: { nil }, tokenForOrigin: { _ in nil }
)
let plain = try request("http://192.168.1.9:3000/live-sessions")
let result = await transport.authenticated(plain)
#expect(result.value(forHTTPHeaderField: "Cookie") == nil)
#expect(result.allHTTPHeaderFields == plain.allHTTPHeaderFields)
}
@Test("请求已带 Cookie配对探针的候选令牌→ 传输层不覆盖")
func transportNeverOverwritesTheProbeCandidate() async throws {
let transport = URLSessionHTTPTransport(
identityProvider: { nil }, tokenForOrigin: { _ in Self.token }
)
var candidate = try request("http://192.168.1.5:3000/live-sessions")
candidate.setValue("webterm_auth=candidate", forHTTPHeaderField: "Cookie")
let result = await transport.authenticated(candidate)
#expect(result.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate")
}
}