feat(ios): access-token support + git-panel endpoints across the package layer

APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four
distinct outcomes from the frozen contract, Cookie/Accept landed at the same
single header choke point that already enforces Origin-iff-G, plus the whole
project-ops surface the server has had since late July and iOS consumed none of:
/projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/
fetch, worktree create/remove/prune, GET /sessions, follow-up queue.

HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain
under the existing SecItemShim conventions (device-only, never synchronizable),
charset/length validated at the boundary, old token-less records still decode.

SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from
the same point that writes Origin, and a 401 handshake is a terminal
.unauthorized -- never entering the backoff loop, since retrying one wrong
shared token is a brute-force generator against the server's 10/min limiter.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent c4f8b5b47f
commit 850531fd07
33 changed files with 4191 additions and 106 deletions

View File

@@ -0,0 +1,42 @@
/// The ONE place SessionCore turns a configured access token (`WEBTERM_TOKEN`)
/// into an HTTP request header (B3 · ios-completion §1.1, FROZEN).
///
/// Why hand-written and not `HTTPCookieStorage`: the native client already KNOWS
/// the token, and `URLSession`'s cookie jar behaves inconsistently on a
/// WebSocket upgrade (and is barely testable there). A hand-written header is
/// the same pattern as the hand-written `Origin` (plan §5.1) and can be pinned
/// by pure unit tests. `Set-Cookie` is never parsed.
///
/// SECURITY: the token is secret material. It exists here only long enough to be
/// handed to `URLRequest.setValue`; it is never logged, never interpolated into a
/// URL/query, and never carried by any error this module throws.
enum AuthCookie {
/// The server's `AUTH_COOKIE_NAME` (src/http/auth.ts:30).
static let name = "webterm_auth"
/// `webterm_auth=<token>` for a well-formed token; `nil` when no token is
/// configured OR the configured value is off-policy.
///
/// Off-policy OMIT rather than throw: a host with auth DISABLED ignores
/// the cookie entirely, so a junk stored token must not break a working
/// connection. When the host DOES enforce auth, the omitted cookie yields
/// the server's 401 `TermTransportError.unauthorized` the UI asks for a
/// correct token. Omitting a secret is never a leak.
static func headerValue(token: String?) -> String? {
guard let token, isValidToken(token) else { return nil }
return "\(name)=\(token)"
}
/// True iff `candidate` satisfies the server's token policy: a byte-identical
/// port of `WEBTERM_TOKEN_RE` (src/config.ts:178) URL/cookie-safe charset,
/// 16512 chars. A token the server would reject at startup can never be a
/// valid credential, and enforcing the charset HERE is also what keeps a
/// hostile stored value from injecting CRLF into the request headers.
///
/// The literal is built per call (as in `WireProtocol.Validation`): `Regex`
/// is not `Sendable`, so it cannot be hoisted into a `static let`.
static func isValidToken(_ candidate: String) -> Bool {
let tokenPolicy = /^[A-Za-z0-9._~+\/=-]{16,512}$/
return candidate.wholeMatch(of: tokenPolicy) != nil
}
}

View File

@@ -19,7 +19,8 @@ import WireProtocol
/// frame goes through `MessageCodec.decodeServer`; nil dropped + counted,
/// never a crash.
/// - Terminal states never re-enter backoff: `exit` (session over, including
/// spawn failure -1, M4) and `.replayTooLarge` (deterministic failure).
/// spawn failure -1, M4), `.replayTooLarge` (deterministic failure), and
/// `.unauthorized` (401 on the upgrade B3 · ios-completion §1.1).
///
/// Sizing (documented decision, plan §7 T-iOS-10): the server spawns every PTY
/// at 80×24 (src/server.ts:714-717) and the frozen `open` signature carries no
@@ -324,9 +325,9 @@ public actor SessionEngine {
eventsContinuation.finish()
return false
}
if let error, isReplayTooLarge(error) {
if let error, let reason = Self.terminalFailure(for: error) {
hasFailedTerminally = true
emit(.connection(.failed(.replayTooLarge))) // NEVER backoff (plan §1)
emit(.connection(.failed(reason))) // NEVER backoff (plan §1 / §1.1)
eventsContinuation.finish()
return false
}
@@ -470,8 +471,17 @@ public actor SessionEngine {
generation == gen
}
private func isReplayTooLarge(_ error: any Error) -> Bool {
(error as? TermTransportError) == .replayTooLarge
/// The NON-retryable transport classifications, in ONE place. Retrying
/// either is pointless: `replayTooLarge` fails deterministically forever
/// (plan §1) and `unauthorized` (401 on the upgrade Origin or access
/// token, ios-completion §1.1) does not become correct by waiting, while a
/// retry storm on 401 reads as a brute-force attempt. `nil` = retryable.
private static func terminalFailure(for error: any Error) -> FailureReason? {
switch error as? TermTransportError {
case .replayTooLarge: return .replayTooLarge
case .unauthorized: return .unauthorized
case .sendAfterClose, .none: return nil
}
}
private func emit(_ event: SessionEvent) {

View File

@@ -57,4 +57,12 @@ public enum FailureReason: Sendable, Equatable {
/// enters backoff. UI copy: lower the server's SCROLLBACK_BYTES or raise
/// the client cap (plan §3.2.1 coupling warning).
case replayTooLarge
/// The WS upgrade was rejected with **401** (B3 · ios-completion §1.1):
/// either the host's Origin whitelist does not contain our dialed origin or
/// the access token is missing/wrong (src/server.ts:1367-1379 checks Origin
/// first, then the `webterm_auth` cookie the status is the same, so the UI
/// must offer BOTH remedies). Never retried: the credential does not become
/// correct by waiting, and a retry storm on 401 is a brute-force signal.
/// UI copy: re-enter the access token, or fix `ALLOWED_ORIGINS` on the host.
case unauthorized
}

View File

@@ -26,6 +26,15 @@ public enum TermTransportError: Error, Equatable, Sendable {
/// backoff loop (plan §1 / §3.2.1 coupling warning), otherwise the retry
/// is deterministic and infinite.
case replayTooLarge
/// The WS upgrade was answered **401** (B3 · ios-completion §1.1). Both
/// server-side upgrade gates write that status the Origin/CSWSH check
/// first, then the access-token cookie (src/server.ts:1367-1379) and
/// neither becomes true by waiting. NON-RETRYABLE, exactly like
/// `replayTooLarge`: the engine surfaces `connection(.failed(.unauthorized))`
/// and must never feed it into the backoff loop, because retrying a wrong
/// token forever is useless AND indistinguishable from a brute-force probe.
/// Carries NO payload the token must never ride on an error.
case unauthorized
/// `send` on a connection that already terminated (client `close()`,
/// server close frame, or transport error). Mirrors
/// `FakeTransportError.sendAfterClose` (TestSupport).
@@ -74,6 +83,8 @@ protocol ConnectionPinger: Sendable {
/// snapshot frame every task gets `Tunables.maxWSMessageBytes` (16 MiB).
/// - `receive()` delivers ONE message per call the loop re-arms forever.
/// - No automatic ping `ConnectionPinger` (driven by `PingScheduler`).
/// - Access token: a hand-written `Cookie: webterm_auth=<t>` next to (never
/// instead of) `Origin` B3 · ios-completion §1.1, see `AuthCookie`.
public struct URLSessionTermTransport: TermTransport {
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
private let maxMessageBytes: Int
@@ -86,19 +97,32 @@ public struct URLSessionTermTransport: TermTransport {
/// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil`
/// result is inert there.
private let identityProvider: @Sendable () -> ClientIdentity?
/// B3 · access token, resolved LAZILY once per `connect` for the same
/// no-relaunch reason as `identityProvider`: a token entered (or rotated)
/// mid-run takes effect on the NEXT connection. `nil` no `Cookie` header
/// at all, so LAN zero-config hosts are byte-identical to before.
/// The value is passed straight to `AuthCookie` never logged, never stored.
private let tokenProvider: @Sendable () -> String?
/// Fixed-identity convenience (snapshot callers / tests): wraps a constant
/// provider, so behaviour is identical to capturing the identity directly.
public init(identity: ClientIdentity? = nil) {
self.init(identityProvider: { identity })
public init(
identity: ClientIdentity? = nil,
tokenProvider: @escaping @Sendable () -> String? = { nil }
) {
self.init(identityProvider: { identity }, tokenProvider: tokenProvider)
}
/// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the
/// provider is re-consulted on every `connect`, so a nilinstalled
/// transition is picked up without relaunch.
public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) {
public init(
identityProvider: @escaping @Sendable () -> ClientIdentity?,
tokenProvider: @escaping @Sendable () -> String? = { nil }
) {
self.init(
maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider
maxMessageBytes: Tunables.maxWSMessageBytes,
identityProvider: identityProvider, tokenProvider: tokenProvider
)
}
@@ -107,15 +131,20 @@ public struct URLSessionTermTransport: TermTransport {
/// through `init()` / `init(identityProvider:)` and the frozen
/// `Tunables.maxWSMessageBytes`.
init(maxMessageBytes: Int, identity: ClientIdentity? = nil) {
self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity })
self.init(
maxMessageBytes: maxMessageBytes, identityProvider: { identity },
tokenProvider: { nil }
)
}
init(
maxMessageBytes: Int,
identityProvider: @escaping @Sendable () -> ClientIdentity?
identityProvider: @escaping @Sendable () -> ClientIdentity?,
tokenProvider: @escaping @Sendable () -> String?
) {
self.maxMessageBytes = maxMessageBytes
self.identityProvider = identityProvider
self.tokenProvider = tokenProvider
}
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
@@ -123,12 +152,12 @@ public struct URLSessionTermTransport: TermTransport {
}
/// Internal: concrete-typed connect (tests assert task configuration;
/// `connectPingable` builds on it). The identity is resolved HERE, per
/// connect, from `identityProvider` (no-relaunch pickup).
/// `connectPingable` builds on it). Identity AND token are resolved HERE,
/// per connect, from their providers (no-relaunch pickup).
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
try await WSConnection.open(
endpoint: endpoint, maxMessageBytes: maxMessageBytes,
identity: identityProvider()
identity: identityProvider(), token: tokenProvider()
)
}
}
@@ -185,11 +214,13 @@ final class WSConnection: NSObject, @unchecked Sendable {
/// handshake (didOpen / didCompleteWithError no receive-error guessing),
/// then start the re-arming receive loop.
static func open(
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil,
token: String? = nil
) async throws -> WSConnection {
let connection = WSConnection()
connection.configure(
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity
endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity,
token: token
)
try await connection.performHandshake()
connection.startReceiveLoop()
@@ -209,21 +240,52 @@ final class WSConnection: NSObject, @unchecked Sendable {
// MARK: Setup
private func configure(
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?
endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?, token: String?
) {
// Set BEFORE building the task/resume: the mTLS challenge can only fire
// after `task.resume()`, so this write happens-before any read of it.
self.identity = identity
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
var request = URLRequest(url: endpoint.wsURL)
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
let task = session.webSocketTask(with: request)
let session = URLSession(
configuration: Self.sessionConfiguration(), delegate: self, delegateQueue: nil
)
let task = session.webSocketTask(
with: Self.upgradeRequest(endpoint: endpoint, token: token)
)
task.maximumMessageSize = maxMessageBytes
self.session = session
self.task = task
}
/// THE single choke point for upgrade request headers.
///
/// - `Origin`: SINGLE source of truth = `endpoint.originHeader` (plan §5.1)
/// always sent.
/// - `Cookie`: `webterm_auth=<token>` when a token is configured
/// (B3 · ios-completion §1.1). ADDITIVE and ORTHOGONAL: the server checks
/// Origin first and the cookie second (src/server.ts:1367-1379), so the
/// cookie never replaces Origin. Absent/off-policy token header omitted
/// (see `AuthCookie.headerValue`).
private static func upgradeRequest(endpoint: HostEndpoint, token: String?) -> URLRequest {
var request = URLRequest(url: endpoint.wsURL)
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
if let cookie = AuthCookie.headerValue(token: token) {
request.setValue(cookie, forHTTPHeaderField: "Cookie")
}
return request
}
/// Ephemeral, with the cookie jar switched OFF: §1.1 freezes the
/// hand-written `Cookie` header as the ONLY authority, so no `Set-Cookie`
/// from any host can override it, outlive the connection, or park the secret
/// in shared storage.
private static func sessionConfiguration() -> URLSessionConfiguration {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpShouldSetCookies = false
configuration.httpCookieAcceptPolicy = .never
configuration.httpCookieStorage = nil
return configuration
}
private func performHandshake() async throws {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, any Error>) in
@@ -331,6 +393,34 @@ final class WSConnection: NSObject, @unchecked Sendable {
}
}
/// HTTP status both server-side upgrade gates answer with: the Origin/CSWSH
/// check, then the access-token cookie (src/server.ts:1367-1379).
private static let unauthorizedStatusCode = 401
/// The upgrade never opened decide what `connect` throws.
///
/// A **401** is non-retryable by construction (see
/// `TermTransportError.unauthorized`), so it becomes the typed error the
/// engine treats as terminal. EVERYTHING else is rethrown VERBATIM, because
/// `PairingError.classify` (T-iOS-8) keys off the underlying POSIX/NSURLError
/// identity to tell localNetworkDenied / atsBlocked / tlsFailure apart
/// wrapping those would break that taxonomy (see `TermTransportError` doc).
private static func handshakeFailure(task: URLSessionTask, error: (any Error)?) -> any Error {
if httpStatusCode(of: task) == unauthorizedStatusCode {
return TermTransportError.unauthorized
}
return error ?? URLError(.cannotConnectToHost)
}
/// The HTTP status of a rejected upgrade: `URLSessionWebSocketTask` fails the
/// task on a non-101 answer and leaves the `HTTPURLResponse` on `task`
/// (mutation-verified against a scripted 401 vs 500 in
/// `URLSessionTermTransportTests`; without it the 401 arrives as the opaque
/// NSURLErrorDomain -1011 that WOULD enter the backoff loop).
private static func httpStatusCode(of task: URLSessionTask) -> Int? {
(task.response as? HTTPURLResponse)?.statusCode
}
/// T-iOS-2 spike (measured on a real server): exceeding
/// `maximumMessageSize` fails `receive()` with NSPOSIXErrorDomain
/// EMSGSIZE(40) "Message too long" not a 1009 close. ENOBUFS(55) is the
@@ -399,10 +489,7 @@ extension WSConnection: URLSessionWebSocketDelegate {
didCompleteWithError error: (any Error)?
) {
if let handshake = takeHandshakeContinuation() {
// Handshake never opened. Rethrow the UNDERLYING error verbatim
// PairingError.classify (T-iOS-8) depends on its POSIX/NSURLError
// identity (see TermTransportError doc).
handshake.resume(throwing: error ?? URLError(.cannotConnectToHost))
handshake.resume(throwing: Self.handshakeFailure(task: task, error: error))
session.finishTasksAndInvalidate()
return
}