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:
@@ -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。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user