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,430 @@
import APIClient
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · Access-token UX in the pairing flow (ios-completion §1.1).
///
/// Every branch of the frozen contract is pinned here, because three of the four
/// `POST /auth` outcomes are NOT errors and must be told apart:
/// 204+Set-Cookie = save it · 204 without = this host has no auth (save nothing)
/// · 401 = wrong token · 429 = back off.
@MainActor
@Suite("Pairing access token")
struct PairingTokenViewModelTests {
// MARK: - Scripted probe (records what the VM asked for, in order)
private actor ProbeRecorder {
/// `(endpoint, token)` per host-verification call the token argument is
/// what proves the candidate reaches the probe.
private(set) var verifyCalls: [(origin: String, token: String?)] = []
private(set) var validateCalls: [String] = []
private var verifyResults: [Result<HostEndpoint, PairingError>]
private var validateResults:
[Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
init(
verifyResults: [Result<HostEndpoint, PairingError>] = [],
validateResults: [Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
= []
) {
self.verifyResults = verifyResults
self.validateResults = validateResults
}
func verify(
_ endpoint: HostEndpoint, _ token: AccessToken?
) -> Result<HostEndpoint, PairingError> {
verifyCalls = verifyCalls + [(endpoint.originHeader, token?.rawValue)]
guard let next = verifyResults.first else { return .success(endpoint) }
verifyResults = Array(verifyResults.dropFirst())
return next
}
func validate(
_ token: AccessToken
) -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
validateCalls = validateCalls + [token.rawValue]
guard let next = validateResults.first else { return .success(.valid) }
validateResults = Array(validateResults.dropFirst())
return next
}
}
private static let base = "http://192.168.1.5:3000"
/// 32 chars from the frozen charset shape-valid, so any rejection in a test
/// comes from the server outcome, not from validation.
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
private func makeViewModel(
store: any HostStore,
recorder: ProbeRecorder
) -> PairingViewModel {
PairingViewModel(
store: store,
probe: PairingViewModel.Probe(
verifyHost: { endpoint, token in await recorder.verify(endpoint, token) },
validateToken: { _, token in await recorder.validate(token) }
)
)
}
/// Drive the VM to the token prompt the way the user gets there: the host
/// answers 401, which the probe reports as `originRejected` with the
/// both-remedies hint.
private func viewModelAtTokenPrompt(
store: any HostStore = InMemoryHostStore(),
recorder: ProbeRecorder
) async -> PairingViewModel {
let viewModel = makeViewModel(store: store, recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
viewModel.beginAccessTokenEntry()
// Loud, not silent: a recorder whose first verification does NOT fail
// with a 401 would leave the VM elsewhere and make every assertion below
// vacuous.
if case .awaitingToken = viewModel.phase {} else {
Issue.record("harness expected .awaitingToken, got \(viewModel.phase)")
}
return viewModel
}
// MARK: - 401 prompt
@Test("401 配对失败 → 提供「输入访问令牌」,进入令牌输入态")
func unauthorizedFailureOffersTokenPrompt() async throws {
// The hint text itself is the probe's (pinned in
// PairingProbeUnauthorizedTests against the REAL probe); here it stands
// in verbatim, because the VM must surface it unchanged.
let hint = "主机以 401 拒绝了这次配对……请输入访问令牌后重试;"
+ "或在主机上设置 ALLOWED_ORIGINS=\(Self.base) 后重启 web-terminal。"
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: hint))])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.action == .enterAccessToken)
// The ambiguity is spelled out: BOTH remedies, since the server answers
// 401 for a bad token and a rejected Origin alike.
#expect(failure.message.contains("访问令牌"))
#expect(failure.message.contains("ALLOWED_ORIGINS=\(Self.base)"))
viewModel.beginAccessTokenEntry()
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("非 401 失败不给令牌入口beginAccessTokenEntry 无副作用)")
func nonUnauthorizedFailureHasNoTokenEntry() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.timeout)])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
viewModel.beginAccessTokenEntry()
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.action == .retry)
}
// MARK: - Shape validation happens BEFORE any network I/O
@Test("令牌太短 → 本地就拒(不发 /auth话术只带长度不带令牌")
func tooShortTokenIsRejectedLocally() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken("short")
#expect(await recorder.validateCalls.isEmpty)
let rejection = try #require(viewModel.tokenRejection)
#expect(rejection.contains("\(5)"))
#expect(!rejection.contains("short")) // never echo the value
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected to stay in .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("令牌含非法字符 → 本地就拒,不发 /auth")
func invalidCharactersRejectedLocally() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken("abcdefghijklmnop qrstuv")
#expect(await recorder.validateCalls.isEmpty)
#expect(viewModel.tokenRejection?.isEmpty == false)
}
// MARK: - The four §1.1 outcomes
@Test("204+Set-Cookievalid→ 带令牌重跑探针,主机连令牌一起入库")
func validTokenIsSavedWithTheHost() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))], // first, unauthenticated
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// The re-verification carried the candidate token (2nd verify call).
let verifyCalls = await recorder.verifyCalls
#expect(verifyCalls.count == 2)
#expect(verifyCalls.first?.token == nil)
#expect(verifyCalls.last?.token == Self.goodToken)
// and the persisted record has it (Keychain in production).
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.accessToken?.rawValue == Self.goodToken)
let stored = try await store.loadAll()
#expect(stored.count == 1)
#expect(stored.first?.accessToken?.rawValue == Self.goodToken)
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
}
@Test("204 但没有 Set-Cookie该主机未启用鉴权→ 绝不保存令牌,也不声称已认证")
func authDisabledNeverPersistsAToken() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.authDisabled)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// Still in the prompt, told the truth, and NOTHING was stored.
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
#expect(viewModel.tokenRejection == PairingCopy.tokenNotRequired)
#expect(try await store.loadAll().isEmpty)
// No second verification either re-probing unauthenticated would just
// reproduce the same failure (the cause is not the token).
#expect(await recorder.verifyCalls.count == 1)
}
@Test("authDisabled 之后即使配对成功也不带令牌入库(候选已被清掉)")
func authDisabledClearsTheCandidate() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.authDisabled)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// Back to confirm, then a (now succeeding) retry of the whole flow.
viewModel.cancelAccessTokenEntry()
await viewModel.confirmConnect()
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.accessToken == nil)
#expect(await recorder.verifyCalls.last?.token == nil)
}
@Test("401令牌错→ 留在输入态给出「令牌不正确」,不入库")
func invalidTokenKeepsThePrompt() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.invalidToken)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
#expect(viewModel.tokenRejection == PairingCopy.tokenInvalid)
#expect(try await store.loadAll().isEmpty)
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("429 → 「稍后再试」话术,不入库")
func rateLimitedKeepsThePrompt() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.rateLimited)]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
#expect(viewModel.tokenRejection == PairingCopy.tokenRateLimited)
}
@Test("/auth 传输失败 → 网络话术(不吞错、不假装令牌错)")
func transportFailureIsSurfaced() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.failure(.unreachable("Connection refused"))]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
let rejection = try #require(viewModel.tokenRejection)
#expect(rejection.contains("Connection refused"))
#expect(rejection != PairingCopy.tokenInvalid)
}
// MARK: - Recovering an already-paired host
@Test("对同一 origin 再配对一次 = 原地更新(复用 id、不重复添加、补上令牌")
func repairingSameOriginUpdatesInPlace() async throws {
// Arrange: a host paired before the server enabled WEBTERM_TOKEN.
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
let existing = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
_ = try await store.upsert(existing)
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
// Act
await viewModel.submitAccessToken(Self.goodToken)
// Assert: ONE host, same id (every lastSessionId/watermark keyed by it
// survives), original name kept, token now present.
let hosts = try await store.loadAll()
#expect(hosts.count == 1)
#expect(hosts.first?.id == existing.id)
#expect(hosts.first?.name == "书房 Mac")
#expect(hosts.first?.accessToken?.rawValue == Self.goodToken)
}
@Test("原地更新不会因为「用户没改名字」而把主机名覆盖成 IP")
func repairingKeepsUserChosenName() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
_ = try await store.upsert(
HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
)
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.name == "书房 Mac")
}
@Test("重新配对时用户改了名字 → 采用新名字")
func repairingAdoptsEditedName() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
_ = try await store.upsert(
HostRegistry.Host(id: UUID(), name: "旧名字", endpoint: endpoint)
)
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
viewModel.hostName = "客厅 Mac"
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.name == "客厅 Mac")
}
@Test("重新配对(未输新令牌)不会丢掉已保存的令牌")
func repairingPreservesStoredToken() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
let token = try AccessToken(validating: Self.goodToken)
_ = try await store.upsert(HostRegistry.Host(
id: UUID(), name: "书房 Mac", endpoint: endpoint, accessToken: token
))
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.accessToken == token)
}
// MARK: - Secret hygiene
@Test("取消令牌输入 → 回到确认页,候选令牌被丢弃")
func cancellingTokenEntryDropsTheCandidate() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
viewModel.cancelAccessTokenEntry()
guard case .confirming = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(viewModel.tokenRejection == nil)
await viewModel.confirmConnect()
#expect(await recorder.verifyCalls.last?.token == nil)
}
@Test("换一个配对目标不会继承上一个目标的令牌")
func newTargetDoesNotInheritTheToken() async throws {
let recorder = ProbeRecorder(validateResults: [.success(.valid)])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect() // succeeds paired
// A fresh VM state is what the sheet gives on re-entry; here we assert the
// reset path itself.
viewModel.cancel()
viewModel.submitManualURL("http://192.168.1.9:3000")
await viewModel.confirmConnect()
#expect(await recorder.verifyCalls.last?.token == nil)
#expect(await recorder.verifyCalls.last?.origin == "http://192.168.1.9:3000")
}
@Test("令牌绝不出现在任何可观察状态里(阶段/话术/主机描述)")
func tokenNeverLeaksIntoObservableState() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(!String(describing: viewModel.phase).contains(Self.goodToken))
#expect(!host.description.contains(Self.goodToken))
#expect(host.description.contains("hasAccessToken: true")) // presence only
#expect(viewModel.tokenRejection == nil)
}
}