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:
Yaojia Wang
2026-07-04 21:53:41 +02:00
parent 2ab93c9682
commit 95438cdc12
32 changed files with 3772 additions and 4 deletions

View 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
}

View 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
}
}

View 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)"
}
}
}

View 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
}
}

View 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)
}

View File

@@ -0,0 +1,313 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// T-iOS-8 · ****plan §4
/// - `LiveSessionInfo` src/types.ts:246-256`telemetry` / null
/// - `status` `.unknown`
/// - crash body "?"
/// - `TimelineEvent` WireProtocol `decodeList` `class`
struct ModelDecodingTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
private static let fullSessionJSON = """
{"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":2,\
"status":"working","exited":false,"cwd":"/Users/dev/proj","cols":120,"rows":40,\
"telemetry":{"contextUsedPct":42.5,"costUsd":1.25,"linesAdded":10,"linesRemoved":2,\
"model":"Opus","effort":"high",\
"pr":{"number":7,"url":"https://github.com/x/y/pull/7","reviewState":"APPROVED"},\
"rate":{"fiveHourPct":12.5,"sevenDayPct":40.0},"at":1720000005000}}
"""
private static let noTelemetrySessionJSON = """
{"id":"\(sessionIdString)","createdAt":1720000000000,"clientCount":0,\
"status":"idle","exited":false,"cwd":null,"cols":80,"rows":24}
"""
private func makeClient(_ http: FakeHTTPTransport) throws -> APIClient {
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
return APIClient(endpoint: endpoint, http: http)
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func fetchSessions(body: String) async throws -> [LiveSessionInfo] {
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data(body.utf8))
return try await client.liveSessions()
}
// MARK: - LiveSessionInfo
@Test("LiveSessionInfo (src/types.ts:246-256 , telemetry)")
func liveSessionInfoDecodesFullSample() async throws {
// Act
let sessions = try await fetchSessions(body: "[\(Self.fullSessionJSON)]")
// Assert
#expect(sessions.count == 1)
let session = try #require(sessions.first)
#expect(session.id == UUID(uuidString: Self.sessionIdString))
#expect(session.createdAt == 1_720_000_000_000)
#expect(session.clientCount == 2)
#expect(session.status == .working)
#expect(session.exited == false)
#expect(session.cwd == "/Users/dev/proj")
#expect(session.cols == 120)
#expect(session.rows == 40)
let telemetry = try #require(session.telemetry)
#expect(telemetry.contextUsedPct == 42.5)
#expect(telemetry.costUsd == 1.25)
#expect(telemetry.linesAdded == 10)
#expect(telemetry.linesRemoved == 2)
#expect(telemetry.model == "Opus")
#expect(telemetry.effort == "high")
#expect(telemetry.pr?.number == 7)
#expect(telemetry.rate?.fiveHourPct == 12.5)
#expect(telemetry.at == 1_720_000_005_000)
// 与 memberwise 构造的期望值整体相等(Equatable 全字段往返)
let expected = LiveSessionInfo(
id: try #require(UUID(uuidString: Self.sessionIdString)),
createdAt: 1_720_000_000_000,
clientCount: 2,
status: .working,
exited: false,
cwd: "/Users/dev/proj",
cols: 120,
rows: 40,
telemetry: StatusTelemetry(
contextUsedPct: 42.5, costUsd: 1.25, linesAdded: 10, linesRemoved: 2,
model: "Opus", effort: "high",
pr: PrInfo(number: 7, url: "https://github.com/x/y/pull/7", reviewState: "APPROVED"),
rate: RateInfo(fiveHourPct: 12.5, sevenDayPct: 40.0),
at: 1_720_000_005_000
)
)
#expect(session == expected)
}
@Test("LiveSessionInfo telemetry 缺失/cwd 为 null → 解码为 nil,其余字段完整")
func liveSessionInfoToleratesMissingTelemetryAndNullCwd() async throws {
// Act
let sessions = try await fetchSessions(body: "[\(Self.noTelemetrySessionJSON)]")
// Assert
let session = try #require(sessions.first)
#expect(session.telemetry == nil)
#expect(session.cwd == nil)
#expect(session.status == .idle)
#expect(session.cols == 80)
#expect(session.rows == 24)
}
@Test("未知 status 字符串 → .unknown(不丢弃整个会话条目)")
func unknownStatusStringMapsToUnknownWithoutDroppingEntry() async throws {
// Arrange
let body = Self.noTelemetrySessionJSON
.replacingOccurrences(of: #""status":"idle""#, with: #""status":"hyperdrive""#)
// Act
let sessions = try await fetchSessions(body: "[\(body)]")
// Assert
#expect(sessions.count == 1)
#expect(sessions.first?.status == .unknown)
}
@Test("畸形条目(非对象/坏 UUID/缺必填)逐条丢弃,合法条目保留,不 crash")
func malformedEntriesAreDroppedWhileValidOnesSurvive() async throws {
// Arrange
let badUUID = Self.noTelemetrySessionJSON
.replacingOccurrences(of: Self.sessionIdString, with: "not-a-uuid")
let body = "[\(Self.fullSessionJSON),42,\(badUUID),{\"id\":\"x\"}]"
// Act
let sessions = try await fetchSessions(body: body)
// Assert
#expect(sessions.count == 1)
#expect(sessions.first?.id == UUID(uuidString: Self.sessionIdString))
}
@Test("非数组 body(HTML/对象)→ invalidResponseBody 显式错误(探针的 端口对吗 信号)")
func nonArrayBodyThrowsInvalidResponseBody() async throws {
// Act + Assert HTML()
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await self.fetchSessions(body: "<html><body>router admin</body></html>")
}
// Act + Assert JSON
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await self.fetchSessions(body: #"{"error":"nope"}"#)
}
}
// MARK: - TimelineEvent WireProtocol helper
@Test("TimelineEvent 未知 class 值 → 该条丢弃不 crash(服务器视为不可信)")
func unknownTimelineClassEntriesAreDroppedNotCrashed() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
let body = """
[{"at":1,"class":"tool","toolName":"Bash","label":"ran Bash"},\
{"at":2,"class":"quantum","label":"??"},\
{"at":3,"class":"waiting","label":"waiting for approval"}]
"""
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
body: Data(body.utf8)
)
// Act
let events = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString)))
// Assert
#expect(events.count == 2)
#expect(events.map(\.class) == ["tool", "waiting"])
#expect(events.first?.toolName == "Bash")
}
@Test("events 404 → sessionNotFound")
func events404ThrowsSessionNotFound() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
status: 404
)
// Act + Assert
await #expect(throws: APIClientError.sessionNotFound) {
_ = try await client.events(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
}
// MARK: - SessionPreview / UiConfig
@Test("SessionPreview 全字段解码(GET /live-sessions/:id/preview 形状)")
func sessionPreviewDecodesAllFields() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
let body = #"{"id":"\#(Self.sessionIdString)","cols":100,"rows":30,"data":"hello"}"#
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
body: Data(body.utf8)
)
// Act
let preview = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
// Assert
let expected = SessionPreview(
id: try #require(UUID(uuidString: Self.sessionIdString)),
cols: 100, rows: 30, data: "hello"
)
#expect(preview == expected)
}
@Test("preview 200 但 body 非预期形状 → invalidResponseBody")
func previewGarbageBodyThrowsInvalidResponseBody() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
body: Data("not json".utf8)
)
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
}
@Test("preview 404 → sessionNotFound")
func preview404ThrowsSessionNotFound() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
status: 404
)
// Act + Assert
await #expect(throws: APIClientError.sessionNotFound) {
_ = try await client.preview(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
}
@Test("UiConfig 解码 allowAutoMode(GET /config/ui,src/types.ts:503)")
func uiConfigDecodesAllowAutoMode() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(
url: try routeURL("/config/ui"),
body: Data(#"{"allowAutoMode":true}"#.utf8)
)
// Act
let config = try await client.uiConfig()
// Assert
#expect(config == UiConfig(allowAutoMode: true))
}
@Test("uiConfig 200 但 body 非预期形状 → invalidResponseBody")
func uiConfigGarbageBodyThrowsInvalidResponseBody() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(url: try routeURL("/config/ui"), body: Data("[]".utf8))
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await client.uiConfig()
}
}
@Test("liveSessions 非 200(500) → unexpectedStatus(500)")
func liveSessions500ThrowsUnexpectedStatus() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(http)
await http.queueSuccess(url: try routeURL("/live-sessions"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await client.liveSessions()
}
}
// MARK: -
@Test("decisionRejected 话术明示 token 已过期/已被处理(RED 清单:403 → token 过期话术)")
func decisionRejectedMessageMentionsTokenExpiry() {
#expect(APIClientError.decisionRejected.message.contains("过期"))
}
@Test("APIClientError 每个 case 都有非空 UI 话术(plan §4:错误处理全面显式)")
func everyAPIClientErrorCaseHasNonEmptyMessage() {
// Arrange
let allCases: [APIClientError] = [
.invalidRequest, .invalidResponseBody, .sessionNotFound,
.forbidden, .decisionRejected, .rateLimited, .unexpectedStatus(500),
]
// Act + Assert
for errorCase in allCases {
#expect(!errorCase.message.isEmpty)
}
#expect(APIClientError.unexpectedStatus(500).message.contains("500"))
}
}

View File

@@ -0,0 +1,387 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
@testable import APIClient
/// T-iOS-8 · plan §3.4
/// `GET /live-sessions` Origin +
/// WS attach(null) adopt attached ** DELETE kill Origin**
/// `PairingError` clock/deadlineFakeClock
///
/// §3.4 2026-07-04 `Result<HostEndpoint, PairingError>`
///Host{id,name} T-iOS-12 VM `@testable`
/// `runPairingProbeCore` FakeClock
/// `runPairingProbe`ContinuousClock + Tunables.pairingProbeTimeout
/// deadline 线
struct PairingProbeTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
private static let attachedFrame =
#"{"type":"attached","sessionId":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}"#
private struct Fixture {
let endpoint: HostEndpoint
let http: FakeHTTPTransport
let ws: FakeTransport
let liveSessionsURL: URL
let killURL: URL
}
private func makeFixture(base: String = PairingProbeTests.base) throws -> Fixture {
let baseURL = try #require(URL(string: base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
return Fixture(
endpoint: endpoint,
http: FakeHTTPTransport(),
ws: FakeTransport(),
liveSessionsURL: try #require(URL(string: base + "/live-sessions")),
killURL: try #require(URL(string: base + "/live-sessions/\(Self.sessionIdString)"))
)
}
/// timeout: nil race,
private func runProbe(_ fixture: Fixture, timeout: Duration? = nil,
clock: FakeClock = FakeClock()) async -> Result<HostEndpoint, PairingError> {
await runPairingProbeCore(
endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws,
clock: clock, timeout: timeout
)
}
// MARK: -
@Test("探针①连接拒绝 → hostUnreachable,且不触碰 WS(不发任何升级请求)")
func stepOneConnectionRefusedMapsToHostUnreachable() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueFailure(
url: fixture.liveSessionsURL, error: URLError(.cannotConnectToHost)
)
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.hostUnreachable) = result else {
Issue.record("expected hostUnreachable, got \(result)")
return
}
#expect(await fixture.ws.connectAttempts.isEmpty)
}
@Test("探针①返回 HTML → httpOkButNotWebTerminal(端口对吗?)")
func stepOneHTMLBodyMapsToNotWebTerminal() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
url: fixture.liveSessionsURL,
body: Data("<html><body>router admin</body></html>".utf8)
)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
}
@Test("探针① 404 → httpOkButNotWebTerminal(HTTP 有应答但不是 web-terminal)")
func stepOne404MapsToNotWebTerminal() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, status: 404)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
}
@Test("探针① ATS 拦明文(-1022) → atsBlocked(host)")
func stepOneATSBlockMapsToAtsBlocked() async throws {
// Arrange
let fixture = try makeFixture()
let ats = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorAppTransportSecurityRequiresSecureConnection
)
await fixture.http.queueFailure(url: fixture.liveSessionsURL, error: ats)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.atsBlocked(host: "192.168.1.5")))
}
@Test("探针① POSIX Network-down + LAN IP → localNetworkDenied(本地网络权限被拒)")
func stepOnePosixNetworkDownOnLANMapsToLocalNetworkDenied() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueFailure(
url: fixture.liveSessionsURL, error: Self.networkDownError()
)
// Act + Assert
#expect(await runProbe(fixture) == .failure(.localNetworkDenied))
}
@Test("探针① POSIX Network-down + 公网 host → hostUnreachable(不误报本地网络权限)")
func stepOnePosixNetworkDownOnPublicHostMapsToHostUnreachable() async throws {
// Arrange
let publicBase = "http://203.0.113.7:3000"
let fixture = try makeFixture(base: publicBase)
await fixture.http.queueFailure(
url: fixture.liveSessionsURL, error: Self.networkDownError()
)
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.hostUnreachable) = result else {
Issue.record("expected hostUnreachable, got \(result)")
return
}
}
@Test("探针① TLS 失败(-1200) → tlsFailure;URLSession 层超时(-1001) → timeout")
func stepOneTLSAndURLTimeoutClassification() async throws {
// Arrange TLS
let tlsFixture = try makeFixture()
await tlsFixture.http.queueFailure(
url: tlsFixture.liveSessionsURL, error: URLError(.secureConnectionFailed)
)
// Arrange URLSession
let timeoutFixture = try makeFixture()
await timeoutFixture.http.queueFailure(
url: timeoutFixture.liveSessionsURL, error: URLError(.timedOut)
)
// Act + Assert
#expect(await runProbe(tlsFixture) == .failure(.tlsFailure))
#expect(await runProbe(timeoutFixture) == .failure(.timeout))
}
// MARK: -
@Test("探针② WS 升级被拒(401) → originRejected,hint 含 ALLOWED_ORIGINS=<拨号 origin>")
func stepTwoUpgradeRejectionMapsToOriginRejectedWithActionableHint() async throws {
// Arrange , (401 )
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.ws.scriptConnectFailure()
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected, got \(result)")
return
}
#expect(hint.contains("ALLOWED_ORIGINS=http://192.168.1.5:3000"))
#expect(!hint.contains(":443"))
}
@Test("originRejected hint 对 https 默认端口不写 :443(与拨号 URL 一致,无端口迷信)")
func originRejectedHintOmitsDefaultPortForHTTPS() async throws {
// Arrange
let fixture = try makeFixture(base: "https://mac.tailnet.ts.net")
await fixture.http.queueSuccess(
url: try #require(URL(string: "https://mac.tailnet.ts.net/live-sessions")),
body: Data("[]".utf8)
)
await fixture.ws.scriptConnectFailure()
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.originRejected(let hint)) = result else {
Issue.record("expected originRejected, got \(result)")
return
}
#expect(hint.contains("ALLOWED_ORIGINS=https://mac.tailnet.ts.net"))
#expect(!hint.contains(":443"))
}
@Test("探针②流在 attached 前结束 → httpOkButNotWebTerminal(升级已成功,非 Origin 问题)")
func stepTwoStreamEndingBeforeAttachedMapsToNotWebTerminal() async throws {
// Arrange finish connect
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.ws.finishFrames()
// Act + Assert
#expect(await runProbe(fixture) == .failure(.httpOkButNotWebTerminal))
}
// MARK: - + kill
@Test("探针全通 → success,attach 首帧 sessionId 为显式 null,attached 后必发带 Origin 的 kill(不留孤儿会话)")
func fullProbeSuccessKillsProbeSessionWithOrigin() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act
let result = await runProbe(fixture)
// Assert
#expect(result == .success(fixture.endpoint))
// Assert attach(null) WS ,sessionId null
let sentFrames = await fixture.ws.sentFrames
#expect(sentFrames.count == 1)
let frame = try #require(sentFrames.first)
let object = try #require(
try JSONSerialization.jsonObject(with: Data(frame.utf8)) as? [String: Any]
)
#expect(object["type"] as? String == "attach")
#expect(object["sessionId"] is NSNull)
// Assert kill attached , Origin
let requests = await fixture.http.recordedRequests
#expect(requests.count == 2)
let kill = try #require(requests.last)
#expect(kill.httpMethod == "DELETE")
#expect(kill.url == fixture.killURL)
#expect(kill.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
// Assert WS ()
#expect(await fixture.ws.closeCallCount == 1)
}
@Test("公开包装 runPairingProbe:全通 → success(endpoint)(§3.4 裁定签名;默认 deadline 走 Tunables.pairingProbeTimeout,快路径不触发)")
func publicWrapperHappyPathReturnsEndpoint() async throws {
// Arrange ;fakes ,ContinuousClock race
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act
let result = await runPairingProbe(
endpoint: fixture.endpoint, http: fixture.http, ws: fixture.ws
)
// Assert
#expect(result == .success(fixture.endpoint))
#expect(await fixture.ws.closeCallCount == 1)
}
@Test("attached 前收到 output 帧仍成功(不可信服务器容忍:跳过非 attached 帧)")
func outputFrameBeforeAttachedIsSkippedAndProbeSucceeds() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 204)
await fixture.ws.emit(frame: #"{"type":"output","data":"\u001b[0mreplay"}"#)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act + Assert
#expect(await runProbe(fixture) == .success(fixture.endpoint))
}
@Test("kill 被 G 守卫拒(403) → originRejected(HTTP 侧 Origin 校验也是配对必验项)")
func killRejectedByOriginGuardMapsToOriginRejected() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 403)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act
let result = await runProbe(fixture)
// Assert
guard case .failure(.originRejected) = result else {
Issue.record("expected originRejected, got \(result)")
return
}
}
@Test("kill 返回 404(会话已自行退出) → 仍算全通(目标态就是会话不存在)")
func killReturning404StillCountsAsSuccess() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
await fixture.http.queueSuccess(method: "DELETE", url: fixture.killURL, status: 404)
await fixture.ws.emit(frame: Self.attachedFrame)
// Act + Assert
#expect(await runProbe(fixture) == .success(fixture.endpoint))
}
// MARK: - classify POSIX
@Test("POSIX ETIMEDOUT → timeout;POSIX ECONNREFUSED → hostUnreachable")
func posixTimeoutAndRefusedClassification() async throws {
// Arrange
let timeoutFixture = try makeFixture()
await timeoutFixture.http.queueFailure(
url: timeoutFixture.liveSessionsURL,
error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ETIMEDOUT.rawValue))
)
let refusedFixture = try makeFixture()
await refusedFixture.http.queueFailure(
url: refusedFixture.liveSessionsURL,
error: NSError(domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ECONNREFUSED.rawValue))
)
// Act
let timeoutResult = await runProbe(timeoutFixture)
let refusedResult = await runProbe(refusedFixture)
// Assert
#expect(timeoutResult == .failure(.timeout))
guard case .failure(.hostUnreachable) = refusedResult else {
Issue.record("expected hostUnreachable, got \(refusedResult)")
return
}
}
@Test("LAN/私网 host 判定表:RFC1918+link-local+loopback+CGNAT+.local 为真,公网/畸形为假")
func privateOrLocalHostPredicateTable() {
// Assert LAN (localNetworkDenied )
let lanHosts = [
"localhost", "mac-mini.local", "10.0.0.5", "172.20.10.1",
"192.168.0.9", "169.254.1.1", "100.100.1.1", "127.0.0.1",
]
for host in lanHosts {
#expect(PairingError.isPrivateOrLocalHost(host), "\(host) 应判为私网级")
}
// Assert LAN()
let publicHosts = [
"8.8.8.8", "203.0.113.7", "mac.tailnet.ts.net",
"256.1.1.1", "172.32.0.1", "100.128.0.1", "1.2.3",
]
for host in publicHosts {
#expect(!PairingError.isPrivateOrLocalHost(host), "\(host) 不应判为私网级")
}
}
// MARK: - FakeClock
@Test("超时:探针卡在等待 attached 时,注入的 FakeClock 到期 → .timeout")
func probeTimesOutViaInjectedClockDeadline() async throws {
// Arrange ; attached
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: fixture.liveSessionsURL, body: Data("[]".utf8))
let clock = FakeClock()
let probeTimeout: Duration = .seconds(10)
// Act deadline sleeper (),
async let result = runProbe(fixture, timeout: probeTimeout, clock: clock)
await clock.waitForSleepers(count: 1)
clock.advance(by: probeTimeout)
// Assert
#expect(await result == .failure(.timeout))
}
// MARK: - fixtures
private static func networkDownError() -> NSError {
let posixDown = NSError(
domain: NSPOSIXErrorDomain, code: Int(POSIXErrorCode.ENETDOWN.rawValue)
)
return NSError(
domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet,
userInfo: [NSUnderlyingErrorKey: posixDown]
)
}
}

View File

@@ -0,0 +1,250 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// T-iOS-8 · Origin-iff-G plan §3.4/§5.1
/// `Origin` header **iff** G
/// RO GETliveSessions/preview/events/uiConfig Origin
/// GkillSession/hookDecision**** `endpoint.originHeader`
/// RO G
struct RequestBuilderTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
private static let emptyArrayBody = Data("[]".utf8)
private static let previewBody = Data(
#"{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cols":80,"rows":24,"data":"hi"}"#.utf8
)
private static let uiConfigBody = Data(#"{"allowAutoMode":false}"#.utf8)
private func makeEndpoint(_ base: String = RequestBuilderTests.base) throws -> HostEndpoint {
let url = try #require(URL(string: base))
return try #require(HostEndpoint(baseURL: url))
}
private func routeURL(_ path: String, base: String = RequestBuilderTests.base) throws -> URL {
try #require(URL(string: base + path))
}
private func makeSessionId() throws -> UUID {
try #require(UUID(uuidString: Self.sessionIdString))
}
// MARK: - RO iff-G Origin
@Test("Origin iff-G(RO 侧):liveSessions/preview/events/uiConfig 四个 RO GET 一律不带 Origin")
func readOnlyEndpointsNeverCarryOriginHeader() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
let id = try makeSessionId()
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/preview"),
body: Self.previewBody
)
await http.queueSuccess(
url: try routeURL("/live-sessions/\(Self.sessionIdString)/events"),
body: Self.emptyArrayBody
)
await http.queueSuccess(url: try routeURL("/config/ui"), body: Self.uiConfigBody)
// Act
_ = try await client.liveSessions()
_ = try await client.preview(id: id)
_ = try await client.events(id: id)
_ = try await client.uiConfig()
// Assert
let requests = await http.recordedRequests
#expect(requests.count == 4)
for request in requests {
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
}
}
// MARK: - G iff-G Origin
@Test("Origin iff-G(G 侧):killSession 为 DELETE /live-sessions/:id(小写 UUID),Origin 逐字符等于 endpoint.originHeader")
func killSessionCarriesByteEqualOriginHeader() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
let id = try makeSessionId()
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
await http.queueSuccess(method: "DELETE", url: url, status: 204)
// Act
try await client.killSession(id: id)
// Assert
let requests = await http.recordedRequests
#expect(requests.count == 1)
let request = try #require(requests.first)
#expect(request.httpMethod == "DELETE")
#expect(request.url == url) // UUID
#expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
}
@Test("Origin iff-G(G 侧):hookDecision 为 POST /hook/decision,Origin 逐字符等于 endpoint.originHeader")
func hookDecisionCarriesByteEqualOriginHeader() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
let url = try routeURL("/hook/decision")
await http.queueSuccess(method: "POST", url: url, status: 204)
// Act
try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1")
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == url)
#expect(request.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
}
@Test("G 端点 Origin 对 https 默认端口省略 :443(无端口迷信,plan §5.1)")
func httpsDefaultPortOriginOmitsPort443() async throws {
// Arrange
let base = "https://mac.tailnet.ts.net"
let endpoint = try makeEndpoint(base)
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
let url = try routeURL("/live-sessions/\(Self.sessionIdString)", base: base)
await http.queueSuccess(method: "DELETE", url: url, status: 204)
// Act
try await client.killSession(id: try makeSessionId())
// Assert
let origin = try #require(
await http.recordedRequests.first?.value(forHTTPHeaderField: "Origin")
)
#expect(origin == "https://mac.tailnet.ts.net")
#expect(!origin.contains(":443"))
}
// MARK: - hookDecision body
@Test("hookDecision body 形状恰为 {sessionId,decision,token} 且 Content-Type 为 JSON(服务器限 4KB body、10 次/分/IP)")
func hookDecisionBodyShapeIsExactlySessionIdDecisionToken() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 204)
// Act
try await client.hookDecision(sessionId: try makeSessionId(), decision: .allow, token: "tok-1")
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
let body = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(Set(object.keys) == Set(["sessionId", "decision", "token"]))
#expect(object["sessionId"] as? String == Self.sessionIdString) // UUID
#expect(object["decision"] as? String == "allow")
#expect(object["token"] as? String == "tok-1")
}
@Test("hookDecision 403 → decisionRejected 显式错误(token 已过期/已被处理,src/server.ts:522-525)")
func hookDecision403ThrowsExplicitTokenRejectedError() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 403)
// Act + Assert
await #expect(throws: APIClientError.decisionRejected) {
try await client.hookDecision(
sessionId: try self.makeSessionId(), decision: .deny, token: "expired"
)
}
}
@Test("hookDecision 429 → rateLimited(≤10 次/分/IP,src/server.ts:72)")
func hookDecision429ThrowsRateLimited() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 429)
// Act + Assert
await #expect(throws: APIClientError.rateLimited) {
try await client.hookDecision(
sessionId: try self.makeSessionId(), decision: .allow, token: "tok"
)
}
}
@Test("hookDecision 400(非法 body) → unexpectedStatus(400)")
func hookDecision400ThrowsUnexpectedStatus() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/hook/decision"), status: 400)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(400)) {
try await client.hookDecision(
sessionId: try self.makeSessionId(), decision: .allow, token: "tok"
)
}
}
@Test("killSession 意外状态码(500) → unexpectedStatus(500)")
func killSession500ThrowsUnexpectedStatus() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
await http.queueSuccess(method: "DELETE", url: url, status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
try await client.killSession(id: try self.makeSessionId())
}
}
@Test("killSession 404 → sessionNotFound(会话已退出/被清理)")
func killSession404ThrowsSessionNotFound() async throws {
// Arrange
let endpoint = try makeEndpoint()
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
let url = try routeURL("/live-sessions/\(Self.sessionIdString)")
await http.queueSuccess(method: "DELETE", url: url, status: 404)
// Act + Assert
await #expect(throws: APIClientError.sessionNotFound) {
try await client.killSession(id: try self.makeSessionId())
}
}
@Test("baseURL 带尾斜杠时请求 URL 归一化(不产生 //live-sessions)")
func trailingSlashBaseURLNormalizesRequestPath() async throws {
// Arrange
let endpoint = try makeEndpoint("http://192.168.1.5:3000/")
let http = FakeHTTPTransport()
let client = APIClient(endpoint: endpoint, http: http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Self.emptyArrayBody)
// Act
_ = try await client.liveSessions()
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.url?.absoluteString == "http://192.168.1.5:3000/live-sessions")
}
}