feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
This commit is contained in:
137
ios/Packages/APIClient/Sources/APIClient/APIClient.swift
Normal file
137
ios/Packages/APIClient/Sources/APIClient/APIClient.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// Typed client for the server's HTTP surface (frozen contract, plan §3.4).
|
||||
///
|
||||
/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: only the two G (state-changing)
|
||||
/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do.
|
||||
/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is
|
||||
/// single-point derived by `HostEndpoint` (never hand-assembled).
|
||||
///
|
||||
/// The server is an UNTRUSTED input source at this boundary (plan §4): bodies
|
||||
/// are decoded tolerantly (malformed entries dropped), statuses are mapped to
|
||||
/// explicit `APIClientError`s, and nothing here ever crashes on bad input.
|
||||
public struct APIClient: Sendable {
|
||||
public let endpoint: HostEndpoint
|
||||
private let http: any HTTPTransport
|
||||
|
||||
public init(endpoint: HostEndpoint, http: any HTTPTransport) {
|
||||
self.endpoint = endpoint
|
||||
self.http = http
|
||||
}
|
||||
|
||||
// MARK: - RO (read-only — NO Origin header)
|
||||
|
||||
/// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every
|
||||
/// device polls (`Tunables.listPollInterval` cadence, T-iOS-13).
|
||||
public func liveSessions() async throws -> [LiveSessionInfo] {
|
||||
let (data, response) = try await perform(Endpoints.liveSessions())
|
||||
guard response.statusCode == HTTPStatus.ok else {
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
return try LiveSessionInfo.decodeList(from: data)
|
||||
}
|
||||
|
||||
/// `GET /live-sessions/:id/preview` (src/server.ts:314-326) — ring-buffer
|
||||
/// tail for a read-only thumbnail; no attach, no client registered.
|
||||
public func preview(id: UUID) async throws -> SessionPreview {
|
||||
let (data, response) = try await perform(Endpoints.preview(id: id))
|
||||
try Self.requireOK(response)
|
||||
guard let preview = try? JSONDecoder().decode(SessionPreview.self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
/// `GET /live-sessions/:id/events` (src/server.ts:528-540) — the A4
|
||||
/// activity timeline. Decoded via WireProtocol's tolerant helper: unknown
|
||||
/// `class` entries are dropped, a non-array body yields `[]` (the server
|
||||
/// itself replies `[]` when timeline capture is disabled).
|
||||
public func events(id: UUID) async throws -> [TimelineEvent] {
|
||||
let (data, response) = try await perform(Endpoints.events(id: id))
|
||||
try Self.requireOK(response)
|
||||
return TimelineEvent.decodeList(from: data)
|
||||
}
|
||||
|
||||
/// `GET /config/ui` (src/server.ts:609-612) — `{allowAutoMode}`. Reserved
|
||||
/// for a future permission-mode switcher (plan §3.4); the plan-gate
|
||||
/// three-way UI does not consume it.
|
||||
public func uiConfig() async throws -> UiConfig {
|
||||
let (data, response) = try await perform(Endpoints.uiConfig())
|
||||
try Self.requireOK(response)
|
||||
guard let config = try? JSONDecoder().decode(UiConfig.self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// MARK: - G (state-changing — Origin required, byte-equal)
|
||||
|
||||
/// `DELETE /live-sessions/:id` (src/server.ts:354-358). 403 = the Origin
|
||||
/// guard rejected us (src/server.ts:332-339); 404 = session already gone.
|
||||
public func killSession(id: UUID) async throws {
|
||||
let (_, response) = try await perform(Endpoints.killSession(id: id))
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.noContent:
|
||||
return
|
||||
case HTTPStatus.notFound:
|
||||
throw APIClientError.sessionNotFound
|
||||
case HTTPStatus.forbidden:
|
||||
throw APIClientError.forbidden
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /hook/decision` (src/server.ts:503-525) — resolve a held remote
|
||||
/// approval with a single-use capability `token` (arrives via push payload
|
||||
/// only; NEVER persist it, plan §5.3). Server limits: body ≤ 4 KB
|
||||
/// (src/server.ts:503), ≤ 10 requests/min/IP (src/server.ts:72,504-508).
|
||||
/// 403 → `.decisionRejected` (token expired / mismatched / already used).
|
||||
public func hookDecision(sessionId: UUID, decision: HookDecision, token: String) async throws {
|
||||
let route = try Endpoints.hookDecision(
|
||||
sessionId: sessionId, decision: decision, token: token
|
||||
)
|
||||
let (_, response) = try await perform(route)
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.noContent:
|
||||
return
|
||||
case HTTPStatus.forbidden:
|
||||
throw APIClientError.decisionRejected
|
||||
case HTTPStatus.tooManyRequests:
|
||||
throw APIClientError.rateLimited
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let request = route.urlRequest(for: endpoint) else {
|
||||
throw APIClientError.invalidRequest
|
||||
}
|
||||
return try await http.send(request)
|
||||
}
|
||||
|
||||
/// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`.
|
||||
private static func requireOK(_ response: HTTPURLResponse) throws {
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
return
|
||||
case HTTPStatus.notFound:
|
||||
throw APIClientError.sessionNotFound
|
||||
default:
|
||||
throw APIClientError.unexpectedStatus(response.statusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
|
||||
private enum HTTPStatus {
|
||||
static let ok = 200
|
||||
static let noContent = 204
|
||||
static let forbidden = 403
|
||||
static let notFound = 404
|
||||
static let tooManyRequests = 429
|
||||
}
|
||||
131
ios/Packages/APIClient/Sources/APIClient/Endpoints.swift
Normal file
131
ios/Packages/APIClient/Sources/APIClient/Endpoints.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// HTTP method whitelist for the six frozen endpoints (plan §3.4).
|
||||
enum HTTPMethod: String, Sendable {
|
||||
case get = "GET"
|
||||
case post = "POST"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
|
||||
/// Whether a route mutates server state — THE security split (plan §3.4/§5.1):
|
||||
/// `Origin` is stamped **iff** `.guarded`.
|
||||
enum OriginPolicy: Sendable, Equatable {
|
||||
/// Read-only GET — MUST NOT carry Origin. If the server ever reclassifies
|
||||
/// a RO route as guarded, integration tests go red instead of passing by
|
||||
/// coincidence (§3.4 铁律).
|
||||
case readOnly
|
||||
/// State-changing — MUST carry `Origin: endpoint.originHeader`, byte-equal;
|
||||
/// the server rejects missing/foreign Origin with 403 (src/server.ts:332-339).
|
||||
case guarded
|
||||
}
|
||||
|
||||
/// Header/content-type names used by the builder (no magic strings inline).
|
||||
enum HeaderName {
|
||||
static let origin = "Origin"
|
||||
static let contentType = "Content-Type"
|
||||
}
|
||||
|
||||
enum ContentTypeValue {
|
||||
static let json = "application/json"
|
||||
}
|
||||
|
||||
/// One buildable API route — an immutable snapshot; building never mutates.
|
||||
struct APIRoute: Sendable, Equatable {
|
||||
let method: HTTPMethod
|
||||
let path: String
|
||||
let originPolicy: OriginPolicy
|
||||
let body: Data?
|
||||
|
||||
/// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
|
||||
/// the path is REPLACED and query/fragment/credentials are dropped — the
|
||||
/// same derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
|
||||
/// happens HERE and only here (single point; hand-stamping elsewhere is a
|
||||
/// review CRITICAL, plan §5.1).
|
||||
func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
|
||||
guard var components = URLComponents(
|
||||
url: endpoint.baseURL, resolvingAgainstBaseURL: true
|
||||
) else { return nil }
|
||||
components.path = path
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
guard let url = components.url else { return nil }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
if originPolicy == .guarded {
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin)
|
||||
}
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType)
|
||||
}
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
/// Builders for the six frozen endpoints (plan §3.4). Route table
|
||||
/// (verified against src/server.ts):
|
||||
/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
|
||||
/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609)
|
||||
/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
|
||||
enum Endpoints {
|
||||
static func liveSessions() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
|
||||
static func preview(id: UUID) -> APIRoute {
|
||||
APIRoute(
|
||||
method: .get, path: "/live-sessions/\(pathId(id))/preview",
|
||||
originPolicy: .readOnly, body: nil
|
||||
)
|
||||
}
|
||||
|
||||
static func events(id: UUID) -> APIRoute {
|
||||
APIRoute(
|
||||
method: .get, path: "/live-sessions/\(pathId(id))/events",
|
||||
originPolicy: .readOnly, body: nil
|
||||
)
|
||||
}
|
||||
|
||||
static func uiConfig() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/config/ui", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
|
||||
static func killSession(id: UUID) -> APIRoute {
|
||||
APIRoute(
|
||||
method: .delete, path: "/live-sessions/\(pathId(id))",
|
||||
originPolicy: .guarded, body: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Body is exactly `{sessionId,decision,token}`. Server-enforced limits
|
||||
/// (they are the server's, not ours — documented for callers):
|
||||
/// body ≤ 4 KB (src/server.ts:503) and ≤ 10 requests/min/IP
|
||||
/// (src/server.ts:72,504-508).
|
||||
static func hookDecision(
|
||||
sessionId: UUID, decision: HookDecision, token: String
|
||||
) throws -> APIRoute {
|
||||
let body = try JSONEncoder().encode(HookDecisionBody(
|
||||
sessionId: pathId(sessionId), decision: decision.rawValue, token: token
|
||||
))
|
||||
return APIRoute(
|
||||
method: .post, path: "/hook/decision", originPolicy: .guarded, body: body
|
||||
)
|
||||
}
|
||||
|
||||
/// Server session ids are lowercase `crypto.randomUUID()` strings and
|
||||
/// `:id` route params are matched as EXACT strings — always serialize
|
||||
/// lowercase (same rule as `MessageCodec`'s attach encoding).
|
||||
private static func pathId(_ id: UUID) -> String {
|
||||
id.uuidString.lowercased()
|
||||
}
|
||||
|
||||
private struct HookDecisionBody: Encodable {
|
||||
let sessionId: String
|
||||
let decision: String
|
||||
let token: String
|
||||
}
|
||||
}
|
||||
187
ios/Packages/APIClient/Sources/APIClient/Models.swift
Normal file
187
ios/Packages/APIClient/Sources/APIClient/Models.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// MARK: - LiveSessionInfo
|
||||
|
||||
/// One running (or just-exited) server session from `GET /live-sessions`
|
||||
/// (read-only discovery — NO Origin header, src/server.ts:257-259). Mirrors
|
||||
/// `src/types.ts:246-256` field-for-field; `telemetry` is `?: … | null` there
|
||||
/// and stays optional here.
|
||||
///
|
||||
/// Decoding is tolerant at the UNTRUSTED server boundary (plan §4):
|
||||
/// - identity/geometry (`id`/`createdAt`/`clientCount`/`exited`/`cols`/`rows`)
|
||||
/// are required — an entry missing them is dropped by `decodeList`;
|
||||
/// - an unrecognized `status` string maps to `.unknown` — a future server
|
||||
/// status value must not make a running session invisible in the list;
|
||||
/// - wrong-typed/absent `cwd`/`telemetry` degrade to `nil`, never fail the entry.
|
||||
public struct LiveSessionInfo: Sendable, Equatable {
|
||||
public let id: UUID
|
||||
/// Server `Date.now()` at spawn (ms since epoch).
|
||||
public let createdAt: Int
|
||||
/// Devices currently attached (JOIN/mirror semantics, src/session/manager.ts).
|
||||
public let clientCount: Int
|
||||
public let status: ClaudeStatus
|
||||
public let exited: Bool
|
||||
public let cwd: String?
|
||||
/// Current PTY size (latest-writer-wins on the server).
|
||||
public let cols: Int
|
||||
public let rows: Int
|
||||
/// Latest statusLine telemetry, if any (B2).
|
||||
public let telemetry: StatusTelemetry?
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
createdAt: Int,
|
||||
clientCount: Int,
|
||||
status: ClaudeStatus,
|
||||
exited: Bool,
|
||||
cwd: String?,
|
||||
cols: Int,
|
||||
rows: Int,
|
||||
telemetry: StatusTelemetry?
|
||||
) {
|
||||
self.id = id
|
||||
self.createdAt = createdAt
|
||||
self.clientCount = clientCount
|
||||
self.status = status
|
||||
self.exited = exited
|
||||
self.cwd = cwd
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
self.telemetry = telemetry
|
||||
}
|
||||
}
|
||||
|
||||
extension LiveSessionInfo: Decodable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, createdAt, clientCount, status, exited, cwd, cols, rows, telemetry
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
createdAt = try container.decode(Int.self, forKey: .createdAt)
|
||||
clientCount = try container.decode(Int.self, forKey: .clientCount)
|
||||
exited = try container.decode(Bool.self, forKey: .exited)
|
||||
cols = try container.decode(Int.self, forKey: .cols)
|
||||
rows = try container.decode(Int.self, forKey: .rows)
|
||||
// Unknown/missing status → .unknown, never drop the whole entry.
|
||||
let rawStatus = try? container.decode(String.self, forKey: .status)
|
||||
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
|
||||
// Optional/nullable fields: wrong type degrades to nil (tolerant).
|
||||
cwd = try? container.decode(String.self, forKey: .cwd)
|
||||
telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry)
|
||||
}
|
||||
|
||||
/// Decode a `/live-sessions` response body.
|
||||
/// - Non-JSON / non-array top level → `APIClientError.invalidResponseBody`
|
||||
/// (the pairing probe maps this to `httpOkButNotWebTerminal` — "端口对吗?").
|
||||
/// - Malformed elements are dropped one by one, mirroring
|
||||
/// `TimelineEvent.decodeList`'s per-element tolerance.
|
||||
static func decodeList(from data: Data) throws -> [LiveSessionInfo] {
|
||||
guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else {
|
||||
throw APIClientError.invalidResponseBody
|
||||
}
|
||||
return entries.compactMap(\.value)
|
||||
}
|
||||
|
||||
/// Per-element tolerance shim: a malformed element becomes `nil` instead of
|
||||
/// failing the whole array decode (same pattern as WireProtocol's helper).
|
||||
private struct LossyEntry: Decodable {
|
||||
let value: LiveSessionInfo?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? LiveSessionInfo(from: decoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SessionPreview
|
||||
|
||||
/// `GET /live-sessions/:id/preview` response (src/server.ts:314-326): the tail
|
||||
/// of the session's ring buffer for a read-only thumbnail. `data` is opaque
|
||||
/// ANSI/UTF-8 — feed it to a terminal, never parse it. Read-only → NO Origin.
|
||||
public struct SessionPreview: Sendable, Equatable, Decodable {
|
||||
public let id: UUID
|
||||
public let cols: Int
|
||||
public let rows: Int
|
||||
public let data: String
|
||||
|
||||
public init(id: UUID, cols: Int, rows: Int, data: String) {
|
||||
self.id = id
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UiConfig
|
||||
|
||||
/// `GET /config/ui` response (src/server.ts:609-612, src/types.ts:503).
|
||||
/// Reserved for a future permission-mode switcher (plan §3.4) — it filters the
|
||||
/// high-risk raw `auto` mode by `allowAutoMode`; the plan-gate three-way UI
|
||||
/// does NOT consume this.
|
||||
public struct UiConfig: Sendable, Equatable, Decodable {
|
||||
public let allowAutoMode: Bool
|
||||
|
||||
public init(allowAutoMode: Bool) {
|
||||
self.allowAutoMode = allowAutoMode
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HookDecision
|
||||
|
||||
/// The two verdicts `POST /hook/decision` accepts — anything else is a 400
|
||||
/// (src/server.ts:513-517).
|
||||
public enum HookDecision: String, Sendable, Equatable, CaseIterable {
|
||||
case allow, deny
|
||||
}
|
||||
|
||||
// MARK: - APIClientError
|
||||
|
||||
/// Typed failures for `APIClient` calls (explicit error handling, plan §4).
|
||||
/// Transport-level errors thrown by `HTTPTransport.send` propagate UNwrapped —
|
||||
/// `PairingError.classify` reads their NSError shape during pairing.
|
||||
public enum APIClientError: Error, Equatable, Sendable {
|
||||
/// The request could not be built from the endpoint (should never happen
|
||||
/// for a validated `HostEndpoint`; surfaced instead of force-unwrapping).
|
||||
case invalidRequest
|
||||
/// A 2xx arrived but the body is not the endpoint's shape. For
|
||||
/// `/live-sessions` this is the pairing probe's "the port speaks HTTP but
|
||||
/// is not web-terminal" signal.
|
||||
case invalidResponseBody
|
||||
/// 404 on a `/live-sessions/:id/*` route — the session is gone
|
||||
/// (exited / reaped / already killed).
|
||||
case sessionNotFound
|
||||
/// 403 from a G route's Origin guard (src/server.ts:332-339).
|
||||
case forbidden
|
||||
/// 403 from `POST /hook/decision` (src/server.ts:522-525): the capability
|
||||
/// token is missing / mismatched / STALE — tokens are single-use and
|
||||
/// expiring by design (SEC-C1/M1). User-facing 话术 in `message`.
|
||||
case decisionRejected
|
||||
/// 429: `POST /hook/decision` is limited to 10 requests/min/IP
|
||||
/// (src/server.ts:72,504-508 — fixed server security policy).
|
||||
case rateLimited
|
||||
/// Any other non-success status code.
|
||||
case unexpectedStatus(Int)
|
||||
|
||||
/// User-facing message (UI 层可直接展示的话术,plan §4 错误处理).
|
||||
public var message: String {
|
||||
switch self {
|
||||
case .invalidRequest:
|
||||
"无法构造请求(主机地址异常)。"
|
||||
case .invalidResponseBody:
|
||||
"服务器响应不是预期格式——端口对吗?"
|
||||
case .sessionNotFound:
|
||||
"会话已不存在(可能已退出或被清理)。"
|
||||
case .forbidden:
|
||||
"服务器拒绝了此来源(Origin 校验未通过)。"
|
||||
case .decisionRejected:
|
||||
"审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。"
|
||||
case .rateLimited:
|
||||
"操作过于频繁(服务器限 10 次/分钟),请稍后再试。"
|
||||
case .unexpectedStatus(let status):
|
||||
"服务器返回了意外状态码 \(status)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
187
ios/Packages/APIClient/Sources/APIClient/PairingError.swift
Normal file
187
ios/Packages/APIClient/Sources/APIClient/PairingError.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// Error taxonomy for the pairing probe (frozen contract, plan §3.4
|
||||
/// `[S:hybrid]`) — each case maps one probe failure mode to actionable UI copy
|
||||
/// (T-iOS-12 renders these inline).
|
||||
public enum PairingError: Error, Equatable, Sendable {
|
||||
/// iOS Local Network permission denied: connects fail with POSIX
|
||||
/// "Network is down" (ENETDOWN) while the target is a LAN/private host
|
||||
/// (plan §5.2). UI: deep-link to 设置 → 隐私 → 本地网络.
|
||||
case localNetworkDenied
|
||||
/// Nothing answered (connection refused / no route / DNS failure…).
|
||||
case hostUnreachable(underlying: String)
|
||||
/// The port speaks HTTP but `GET /live-sessions` did not return the
|
||||
/// web-terminal shape (e.g. an HTML admin page) — "端口对吗?".
|
||||
case httpOkButNotWebTerminal
|
||||
/// The WS upgrade (or the guarded kill round-trip) was rejected — the
|
||||
/// host's Origin whitelist does not contain our dialed origin. `hint`
|
||||
/// carries the exact `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]` line
|
||||
/// (default ports omitted — the server normalizes both sides via
|
||||
/// `new URL()`, so there is NO ":443 迷信", plan §5.1).
|
||||
case originRejected(hint: String)
|
||||
/// ATS blocked cleartext HTTP to a host outside the app's CIDR exception
|
||||
/// list (NSURLError -1022, plan §5.2).
|
||||
case atsBlocked(host: String)
|
||||
/// TLS negotiation / certificate failure on an https/wss target.
|
||||
case tlsFailure
|
||||
/// The injected probe deadline elapsed (or the transport timed out).
|
||||
case timeout
|
||||
}
|
||||
|
||||
extension PairingError {
|
||||
/// Actionable copy for `originRejected` — always derived from the SINGLE
|
||||
/// origin source (`endpoint.originHeader`), never hand-assembled.
|
||||
static func originRejectedHint(for endpoint: HostEndpoint) -> String {
|
||||
"服务器拒绝了这个来源。请在主机上设置 ALLOWED_ORIGINS=\(endpoint.originHeader)"
|
||||
+ "(与 App 连接的 URL 完全一致)后重启 web-terminal,再重试配对。"
|
||||
}
|
||||
|
||||
/// Classify a transport-level error thrown by `HTTPTransport.send` /
|
||||
/// `TermTransport.connect` into the probe taxonomy. Walks the NSError
|
||||
/// underlying chain (bounded) so wrapped POSIX causes are seen.
|
||||
///
|
||||
/// - Parameter unrecognizedFallback: used by probe step ② — after step ①
|
||||
/// proved the host reachable AND web-terminal-shaped, an upgrade failure
|
||||
/// with no recognizable network cause is, by elimination, the server's
|
||||
/// Origin 401 (its only upgrade-reject path, src/server.ts:646-651).
|
||||
static func classify(
|
||||
_ error: any Error,
|
||||
endpoint: HostEndpoint,
|
||||
unrecognizedFallback: PairingError? = nil
|
||||
) -> PairingError {
|
||||
let chain = underlyingChain(of: error as NSError)
|
||||
let host = endpoint.baseURL.host ?? ""
|
||||
if chain.contains(where: isATSBlockError) {
|
||||
return .atsBlocked(host: host)
|
||||
}
|
||||
if chain.contains(where: isTimeoutError) {
|
||||
return .timeout
|
||||
}
|
||||
if chain.contains(where: isTLSError) {
|
||||
return .tlsFailure
|
||||
}
|
||||
if chain.contains(where: isPosixNetworkDownError) {
|
||||
// plan §5.2: Local-Network-denied presents as POSIX "Network is
|
||||
// down" — but only claim it for LAN-class targets.
|
||||
return isPrivateOrLocalHost(host)
|
||||
? .localNetworkDenied
|
||||
: .hostUnreachable(underlying: describe(error))
|
||||
}
|
||||
if chain.contains(where: isConnectivityError) {
|
||||
return .hostUnreachable(underlying: describe(error))
|
||||
}
|
||||
return unrecognizedFallback ?? .hostUnreachable(underlying: describe(error))
|
||||
}
|
||||
|
||||
// MARK: - Error-shape predicates
|
||||
|
||||
private static func isATSBlockError(_ error: NSError) -> Bool {
|
||||
error.domain == NSURLErrorDomain
|
||||
&& error.code == NSURLErrorAppTransportSecurityRequiresSecureConnection
|
||||
}
|
||||
|
||||
private static func isTimeoutError(_ error: NSError) -> Bool {
|
||||
(error.domain == NSURLErrorDomain && error.code == NSURLErrorTimedOut)
|
||||
|| (error.domain == NSPOSIXErrorDomain
|
||||
&& error.code == Int(POSIXErrorCode.ETIMEDOUT.rawValue))
|
||||
}
|
||||
|
||||
private static let tlsErrorCodes: Set<Int> = [
|
||||
NSURLErrorSecureConnectionFailed,
|
||||
NSURLErrorServerCertificateHasBadDate,
|
||||
NSURLErrorServerCertificateUntrusted,
|
||||
NSURLErrorServerCertificateHasUnknownRoot,
|
||||
NSURLErrorServerCertificateNotYetValid,
|
||||
NSURLErrorClientCertificateRejected,
|
||||
NSURLErrorClientCertificateRequired,
|
||||
]
|
||||
|
||||
private static func isTLSError(_ error: NSError) -> Bool {
|
||||
error.domain == NSURLErrorDomain && tlsErrorCodes.contains(error.code)
|
||||
}
|
||||
|
||||
private static func isPosixNetworkDownError(_ error: NSError) -> Bool {
|
||||
error.domain == NSPOSIXErrorDomain
|
||||
&& error.code == Int(POSIXErrorCode.ENETDOWN.rawValue)
|
||||
}
|
||||
|
||||
private static let connectivityURLErrorCodes: Set<Int> = [
|
||||
NSURLErrorCannotConnectToHost,
|
||||
NSURLErrorCannotFindHost,
|
||||
NSURLErrorDNSLookupFailed,
|
||||
NSURLErrorNetworkConnectionLost,
|
||||
NSURLErrorNotConnectedToInternet,
|
||||
]
|
||||
|
||||
private static let connectivityPosixCodes: Set<Int> = [
|
||||
Int(POSIXErrorCode.ECONNREFUSED.rawValue),
|
||||
Int(POSIXErrorCode.EHOSTUNREACH.rawValue),
|
||||
Int(POSIXErrorCode.EHOSTDOWN.rawValue),
|
||||
Int(POSIXErrorCode.ENETUNREACH.rawValue),
|
||||
]
|
||||
|
||||
private static func isConnectivityError(_ error: NSError) -> Bool {
|
||||
(error.domain == NSURLErrorDomain && connectivityURLErrorCodes.contains(error.code))
|
||||
|| (error.domain == NSPOSIXErrorDomain && connectivityPosixCodes.contains(error.code))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Bounded walk of `NSUnderlyingErrorKey` (defensive: never trust error
|
||||
/// graphs not to cycle).
|
||||
private static let maxUnderlyingDepth = 8
|
||||
|
||||
private static func underlyingChain(of error: NSError) -> [NSError] {
|
||||
var chain: [NSError] = []
|
||||
var current: NSError? = error
|
||||
while let nsError = current, chain.count < maxUnderlyingDepth {
|
||||
chain.append(nsError)
|
||||
current = nsError.userInfo[NSUnderlyingErrorKey] as? NSError
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
private static func describe(_ error: any Error) -> String {
|
||||
(error as NSError).localizedDescription
|
||||
}
|
||||
|
||||
/// LAN-class targets for the `localNetworkDenied` mapping: RFC1918,
|
||||
/// link-local 169.254/16, loopback, CGNAT 100.64/10 (Tailscale), and
|
||||
/// mDNS `.local` names. (T-iOS-12's warning TIERS are separate logic —
|
||||
/// this predicate only gates the Local-Network-permission diagnosis.)
|
||||
static func isPrivateOrLocalHost(_ host: String) -> Bool {
|
||||
let lowered = host.lowercased()
|
||||
if lowered == "localhost" || lowered.hasSuffix(".local") {
|
||||
return true
|
||||
}
|
||||
guard let octets = ipv4Octets(lowered) else {
|
||||
return false
|
||||
}
|
||||
switch (octets[0], octets[1]) {
|
||||
case (10, _), (127, _): // RFC1918 10/8 · loopback 127/8
|
||||
return true
|
||||
case (192, 168), (169, 254): // RFC1918 192.168/16 · link-local
|
||||
return true
|
||||
case (172, 16...31): // RFC1918 172.16/12
|
||||
return true
|
||||
case (100, 64...127): // CGNAT 100.64/10 (Tailscale)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static let ipv4OctetCount = 4
|
||||
private static let ipv4OctetRange = 0...255
|
||||
|
||||
private static func ipv4Octets(_ host: String) -> [Int]? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == ipv4OctetCount else { return nil }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == ipv4OctetCount,
|
||||
octets.allSatisfy({ ipv4OctetRange.contains($0) })
|
||||
else { return nil }
|
||||
return octets
|
||||
}
|
||||
}
|
||||
164
ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift
Normal file
164
ios/Packages/APIClient/Sources/APIClient/PairingProbe.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// Two-step pairing probe (plan §3.4):
|
||||
/// ① `GET /live-sessions` — NO Origin (reachability + web-terminal shape).
|
||||
/// ② WS `attach(null)` → adopt `attached` → close → **immediately
|
||||
/// `DELETE /live-sessions/:id` WITH Origin** (verifies `isOriginAllowed` on
|
||||
/// both the upgrade and the guarded-HTTP side, and never leaks the probe's
|
||||
/// orphan session).
|
||||
///
|
||||
/// Callers MUST run this only after the user confirmed a scanned host
|
||||
/// (T-iOS-12) — step ① already talks to the network and step ② spawns a PTY
|
||||
/// on the target machine.
|
||||
///
|
||||
/// CONTRACT RULING (orchestrator, 2026-07-04 — resolves the T-iOS-8 BLOCKED):
|
||||
/// §3.4's original `Result<Host, …>` payload was self-contradictory (`Host` is
|
||||
/// a §3.3 HostRegistry type; this package depends only on WireProtocol, §1/§6
|
||||
/// zero leaf-coupling). Amended §3.4: the probe's job is validating an
|
||||
/// endpoint, so the success payload IS the probed `HostEndpoint`; constructing
|
||||
/// `Host{id,name}` belongs to the pairing UI (T-iOS-12 PairingViewModel —
|
||||
/// id/name are not the probe's to know). Public deadline comes from
|
||||
/// `Tunables.pairingProbeTimeout` (§3.2.1 addition, same ruling).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - clock: injected for deterministic deadline tests (`FakeClock`);
|
||||
/// production passes `ContinuousClock()`.
|
||||
/// - timeout: overall probe deadline → `.timeout`. `nil` = no app-level
|
||||
/// 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.
|
||||
func runPairingProbeCore(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
ws: any TermTransport,
|
||||
clock: any Clock<Duration>,
|
||||
timeout: Duration?
|
||||
) async -> Result<HostEndpoint, PairingError> {
|
||||
guard let timeout else {
|
||||
return await performProbe(endpoint: endpoint, http: http, ws: ws)
|
||||
}
|
||||
return await withTaskGroup(
|
||||
of: Result<HostEndpoint, PairingError>.self
|
||||
) { group in
|
||||
group.addTask {
|
||||
await performProbe(endpoint: endpoint, http: http, ws: ws)
|
||||
}
|
||||
group.addTask {
|
||||
// Cancellation (probe won) also lands here; the value is discarded.
|
||||
try? await clock.sleep(for: timeout, tolerance: nil)
|
||||
return .failure(.timeout)
|
||||
}
|
||||
guard let first = await group.next() else {
|
||||
return .failure(.timeout)
|
||||
}
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
/// Public probe entry (§3.4 as amended by the 2026-07-04 contract ruling).
|
||||
/// Wall-clock deadline `Tunables.pairingProbeTimeout` on `ContinuousClock`;
|
||||
/// tests drive the deterministic core via an injected `FakeClock`.
|
||||
public func runPairingProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
ws: any TermTransport
|
||||
) async -> Result<HostEndpoint, PairingError> {
|
||||
await runPairingProbeCore(
|
||||
endpoint: endpoint,
|
||||
http: http,
|
||||
ws: ws,
|
||||
clock: ContinuousClock(),
|
||||
timeout: Tunables.pairingProbeTimeout
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Probe body
|
||||
|
||||
private func performProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
ws: any TermTransport
|
||||
) async -> Result<HostEndpoint, PairingError> {
|
||||
let api = APIClient(endpoint: endpoint, http: http)
|
||||
|
||||
// ① Reachability + shape. Any HTTP-level answer that isn't the
|
||||
// /live-sessions array shape means "some other service" → 端口对吗?
|
||||
do {
|
||||
_ = try await api.liveSessions()
|
||||
} 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.
|
||||
let connection: TransportConnection
|
||||
do {
|
||||
connection = try await ws.connect(to: endpoint)
|
||||
} catch {
|
||||
return .failure(PairingError.classify(
|
||||
error, endpoint: endpoint,
|
||||
unrecognizedFallback: .originRejected(
|
||||
hint: PairingError.originRejectedHint(for: endpoint)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
let adoption = await adoptAttachedSession(over: connection)
|
||||
await connection.close()
|
||||
switch adoption {
|
||||
case .failure(let failure):
|
||||
return .failure(failure)
|
||||
case .success(let sessionId):
|
||||
return await killProbeSession(id: sessionId, api: api, endpoint: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `attach(null)` (explicit JSON `"sessionId":null` — the server requires
|
||||
/// the key) and wait for the server-issued `attached` id, skipping any other
|
||||
/// or undecodable frames (untrusted server; only `attached` matters here).
|
||||
private func adoptAttachedSession(
|
||||
over connection: TransportConnection
|
||||
) async -> Result<UUID, PairingError> {
|
||||
do {
|
||||
try await connection.send(MessageCodec.encode(.attach(sessionId: nil, cwd: nil)))
|
||||
for try await frame in connection.frames {
|
||||
if case .attached(let sessionId) = MessageCodec.decodeServer(frame) {
|
||||
return .success(sessionId)
|
||||
}
|
||||
}
|
||||
// Upgrade succeeded, so this is NOT an Origin problem: a socket that
|
||||
// closes/errors before speaking our protocol is not web-terminal.
|
||||
return .failure(.httpOkButNotWebTerminal)
|
||||
} catch {
|
||||
return .failure(.httpOkButNotWebTerminal)
|
||||
}
|
||||
}
|
||||
|
||||
/// The guarded kill round-trip is part of pairing verification itself
|
||||
/// (`DELETE` exercises the HTTP-side Origin guard that `hookDecision` will
|
||||
/// need later) — AND it guarantees the probe leaves no orphan session behind.
|
||||
private func killProbeSession(
|
||||
id: UUID,
|
||||
api: APIClient,
|
||||
endpoint: HostEndpoint
|
||||
) async -> Result<HostEndpoint, PairingError> {
|
||||
do {
|
||||
try await api.killSession(id: id)
|
||||
} catch APIClientError.sessionNotFound {
|
||||
// Already gone (exited between attach and kill) — the goal state.
|
||||
} catch APIClientError.forbidden {
|
||||
return .failure(.originRejected(
|
||||
hint: PairingError.originRejectedHint(for: endpoint)
|
||||
))
|
||||
} catch let apiError as APIClientError {
|
||||
return .failure(.hostUnreachable(underlying: apiError.message))
|
||||
} catch {
|
||||
return .failure(PairingError.classify(error, endpoint: endpoint))
|
||||
}
|
||||
return .success(endpoint)
|
||||
}
|
||||
Reference in New Issue
Block a user