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:
@@ -306,6 +306,7 @@ public struct AwayDigest: Sendable, Equatable {
|
||||
| `telemetryStaleTtlMs` | 30_000 | 镜像 public/tabs.ts:45 `STATUSLINE_TTL_MS`(= 服务器默认 src/config.ts:63;服务器侧 env 可覆盖——iOS 固化默认值,主机改配置时会漂移,已接受) |
|
||||
| `digestFadeDelay` | 8 s | T-iOS-14 digest 自动淡出 |
|
||||
| `titleMaxLength` | 256 | T-iOS-23 OSC 标题净化上限 |
|
||||
| `pairingProbeTimeout` | 10 s | 配对探针整体 deadline(§3.4 契约裁定新增;两步探针任一步挂起超此时限 → `.timeout`) |
|
||||
| `maxWSMessageBytes` | 16 MiB(`16 * 1024 * 1024`) | ≥ 6 × 默认 SCROLLBACK_BYTES(2 MiB) + 帧包络。**耦合警示(写进 doc comment)**:回放单帧 ≈ SCROLLBACK_BYTES × JSON 转义系数(1–6×,控制字节→`\uXXXX`,src/protocol.ts:186);SCROLLBACK_BYTES 为服务器 env 可调且客户端运行时不可知——超限 → 不可重试 `.replayTooLarge`(§3.2 / T-iOS-9/10) |
|
||||
|
||||
### 3.3 `HostRegistry`
|
||||
@@ -356,7 +357,11 @@ public enum PairingError: Error, Equatable { // [S:hybrid] 错误分类学,逐
|
||||
case tlsFailure, timeout
|
||||
}
|
||||
public func runPairingProbe(endpoint: HostEndpoint, http: any HTTPTransport,
|
||||
ws: any TermTransport) async -> Result<Host, PairingError>
|
||||
ws: any TermTransport) async -> Result<HostEndpoint, PairingError>
|
||||
// 【契约裁定 2026-07-04,T-iOS-8 BLOCKED 上报】原冻结签名返回 Result<Host,…>,但 Host 是 §3.3
|
||||
// HostRegistry 类型,APIClient 依赖边只有 WireProtocol(§1 叶子包零耦合)——签名自相矛盾。
|
||||
// 裁定:探针职责=验证 endpoint,返回 HostEndpoint;Host{id,name} 由 T-iOS-12 PairingViewModel
|
||||
// 构造后入 store(id/name 本就非探针所知)。超时经 Tunables.pairingProbeTimeout(§3.2.1 新增行)。
|
||||
// 探针两步:①GET /live-sessions(无 Origin,验可达+形状) ②WS attach(null)+立即 kill 往返
|
||||
//(带 Origin,验 isOriginAllowed 精确匹配)。任何失败 → 映射到 PairingError,UI 内联显示。
|
||||
// 注:扫码来源的 endpoint 必须先经 T-iOS-12 的"确认 host"步——用户未确认前不得调用本探针(探针①就会联网)。
|
||||
@@ -585,7 +590,7 @@ W5 验收(report-only, 并行)
|
||||
- [ ] `hookDecision` body 形状 `{sessionId,decision,token}`;403 → 显式错误(token 过期话术)
|
||||
- [ ] 探针①失败分支:连接拒绝 → `hostUnreachable`;返回 HTML → `httpOkButNotWebTerminal`
|
||||
- [ ] 探针②失败分支:WS 401 → `originRejected(hint:…)`(hint 含 `ALLOWED_ORIGINS=<scheme>://<拨号 host>[:port]`,与 App 连接的 URL 一致——不含任何 ":443 迷信")
|
||||
- [ ] 探针全通 → `Result.success(Host)`;探针成功路径里 attach(null) 后必发 kill(不留孤儿会话)
|
||||
- [ ] 探针全通 → `Result.success(HostEndpoint)`(契约裁定:Host 由 T-iOS-12 VM 构造,见 §3.4 注);探针成功路径里 attach(null) 后必发 kill(不留孤儿会话)
|
||||
- [ ] 超时(FakeClock)→ `.timeout`
|
||||
- **Steps(实现, GREEN)**: [ ] §3.4 签名 [ ] 服务器约束写进 doc comment(按端点精确):`hookDecision` body ≤ 4 KB(src/server.ts:503)、≤10 次/分/IP(src/server.ts:72,504-508)——P1 增量端点的限额归 T-iOS-38
|
||||
- **Accept**: `swift test --package-path ios/Packages/APIClient` 全绿;覆盖率 ≥ 80%
|
||||
|
||||
@@ -32,7 +32,13 @@
|
||||
- **T-iOS-3 WireProtocol(冻结)**: §3.1 全类型 + Tunables §3.2.1 全表;**59 tests 全绿、覆盖率 100%**(llvm-cov 226/226);300 轮 roundtrip property + 500 轮 fuzz(decodeServer 永不 crash);**跨实现实测**: Swift encoder 输出经 tsx 喂给真 `src/protocol.ts` parseClientMessage → 13 帧+mode 顶层恢复全 PASS。自此冻结。
|
||||
- **T-iOS-4 TestSupport**: FakeTransport(actor,scripted failures+按连接记帧)/FakeClock(class+锁,`waitForSleepers` 确定性栅栏,零真实等待)/FakeHTTPTransport(记录 headers 供 Origin-iff-G 断言);3 smoke 全绿。
|
||||
- **verify(独立 agent 复跑)**: 模拟器构建 exit 0;59+3 tests;覆盖率 100%;spike 6 tests+1 known issue;`git status` 零越界。
|
||||
- 待续: W1(T-iOS-5/6/7/8 并行)→ W2 → W3 → W4 → W5。**偏差(orchestrator 决策)**: W1+ 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + SwiftPM .build 锁自然串行化构建,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
|
||||
- **[x] W1 叶子包(T-iOS-5/6/7/8,4 builders;编排 [5→6 串行] ∥ 7 ∥ 8——5/6 同在 SessionCore 包,主树并行会互坏编译)**:
|
||||
- **T-iOS-5 ReconnectMachine+PingScheduler**: 纯 reducer,§3.2 签名逐字;backoff 1s→…→30s 封顶镜像 terminal-session.ts(连上才归零,retry/foregrounded 不重置阶梯——防狂按打穿);Ping 25s/连续 2 miss→connectionLost/取消与断线显式区分。16 测,0 真实等待,文件级覆盖 100%/95%。
|
||||
- **T-iOS-6 GateState+AwayDigest**: epoch 语义逐行镜像 terminal-session.ts:306-311(上升沿 +1、持续帧刷新 detail、下降沿不重置计数器);`canDecide(epoch:)` 防误批;affordance 映射逐字对齐 tabs.ts:345-347(绝不发 raw auto);digest 词表与 `TimelineEvent.knownClasses` 有相等性绊线测试;防御:乱序容忍、Date→ms clamp 不 trap。25 测,两文件 100%。
|
||||
- **T-iOS-7 HostRegistry**: §3.3 契约;KeychainHostStore 经 SecItemShim 缝(swift test 打 fake;真 shim kSecUseDataProtectionKeychain+AfterFirstUnlockThisDeviceOnly,属性字典单点构造+逐字单测);InMemoryHostStore 入 Sources 供 App-VM 测试。30 测,own-Sources 覆盖 88.1%。
|
||||
- **T-iOS-8 APIClient+探针**: 44 测先全绿后 **`[!] BLOCKED` 正确上报冻结契约自相矛盾**——§3.4 `runPairingProbe → Result<Host,…>` 中 Host 是 §3.3 HostRegistry 类型,而 APIClient 依赖边仅 WireProtocol(§1 零耦合),签名无法编译(两轮 review 均漏)。**裁定(orchestrator,三选一取 c)**: 探针职责=验证 endpoint → 返回 `Result<HostEndpoint,…>`,Host{id,name} 由 T-iOS-12 VM 构造(id/name 本非探针所知);同时按法定路径加 `Tunables.pairingProbeTimeout`(10s,§3.2.1 新增行+绊线断言)。裁定已写回 PLAN §3.4/§3.2.1/T-iOS-8,公开包装+冒烟测试由 orchestrator 落地。Origin **iff**-G 入测试名;探针②成功后必带 Origin kill 不留孤儿;403 kill→originRejected(HTTP 侧 G 守卫也是配对必验项)。45 测,own-Sources 覆盖 98.1%。
|
||||
- **W1 验证(orchestrator 亲验)**: 5 包 178 tests 全绿(59+3+41+30+45);覆盖率(own-Sources 口径)SessionCore 99.1%/HostRegistry 88.1%/APIClient 98.1%,全过 80% 门;`git status` 全部在 Owns 内零越界。**发现归 T-iOS-16**: §9 覆盖率命令的 ignore regex 不排静态链入的依赖包源码,CI 接线时须按包过滤(本次已按正确口径亲算)。
|
||||
- 待续: W2(T-iOS-9→10,同包串行)→ W3 → W4 → W5。**偏差(orchestrator 决策,W1 起生效)**: 并行 builder 不用 git worktree(Workflow worktree 合并回主树增加失败面),改为主树直跑——文件互斥 + 同包任务串行化,禁 agent 碰 git;每波末独立 verify + orchestrator commit 兜底。
|
||||
|
||||
### ✅ iOS 客户端实施计划 — `docs/PLAN_IOS_CLIENT.md`(规划完成 — 2026-07-04,main,未提交;纯文档,零代码改动)
|
||||
**多 agent 编排 = 一个 ultracode `Workflow`(66 agents,7 阶段)**:5 读者并行(wire contract 逐 file:line / 功能矩阵 / relay 现状 / 模板规范 / iOS 平台联网调研)→ 3 架构师独立竞标(full-native / WKWebView-hybrid / phased)→ 3 评委多视角打分(UX/工程/安全,**phased 26.5 胜出**,native 23.5、hybrid 16.5,21 条 mustSteal 嫁接)→ 综合简报 → 写手出稿(843 行)→ **4 评审交叉验证**(对照 src/ 源码逐条核实 / iOS API 联网核查 / 安全 / 计划质量)→ 每条 finding 对抗式 verify(CRITICAL/HIGH 双票)→ fixer 应用。
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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/deadline(FakeClock,零真实等待)。
|
||||
///
|
||||
/// 注:§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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 GET(liveSessions/preview/events/uiConfig)一律不带 Origin;
|
||||
/// G(killSession/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")
|
||||
}
|
||||
}
|
||||
23
ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift
Normal file
23
ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// A paired web-terminal host (frozen contract, plan §3.3). Immutable
|
||||
/// snapshot: identity (`id`) and dial info (`endpoint`) are fixed at creation;
|
||||
/// "renaming" a host means upserting a new value with the same `id`.
|
||||
///
|
||||
/// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module;
|
||||
/// downstream test/app targets that import Foundation should alias
|
||||
/// `typealias Host = HostRegistry.Host` or qualify.
|
||||
public struct Host: Sendable, Equatable, Codable, Identifiable {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
/// Single point of Origin/wsURL derivation — from WireProtocol, never
|
||||
/// redeclared here (plan §6 rule 1).
|
||||
public let endpoint: HostEndpoint
|
||||
|
||||
public init(id: UUID, name: String, endpoint: HostEndpoint) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.endpoint = endpoint
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// Frozen contract (plan §3.3). Implementations: `KeychainHostStore` (real)
|
||||
/// and `InMemoryHostStore` (in-Sources double for this package's tests and
|
||||
/// the App-layer VM tests). Immutable style: mutations return the NEW
|
||||
/// collection instead of mutating shared state in place.
|
||||
public protocol HostStore: Sendable {
|
||||
func loadAll() async throws -> [Host]
|
||||
/// Insert, or replace the host with the same `id` (position preserved).
|
||||
/// Returns the new collection.
|
||||
func upsert(_ host: Host) async throws -> [Host]
|
||||
/// Removing an unknown `id` is an explicit no-op: returns the unchanged
|
||||
/// collection, never throws for "not found".
|
||||
func remove(id: UUID) async throws -> [Host]
|
||||
}
|
||||
|
||||
// Pure collection transforms shared by all HostStore implementations (DRY);
|
||||
// never mutate `self`, always return a fresh array.
|
||||
extension [Host] {
|
||||
func upserting(_ host: Host) -> [Host] {
|
||||
contains(where: { $0.id == host.id })
|
||||
? map { $0.id == host.id ? host : $0 }
|
||||
: self + [host]
|
||||
}
|
||||
|
||||
func removing(id: UUID) -> [Host] {
|
||||
filter { $0.id != id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// In-memory `HostStore`. Lives in Sources (not Tests) on purpose — plan
|
||||
/// T-iOS-7: it doubles for the keychain store in this package's contract
|
||||
/// tests AND in the App layer's ViewModel tests (which import HostRegistry).
|
||||
///
|
||||
/// Actor-isolated so concurrent upserts/removes serialize; the state is a
|
||||
/// value-type snapshot replaced wholesale on every change (no in-place
|
||||
/// mutation of shared references).
|
||||
public actor InMemoryHostStore: HostStore {
|
||||
private var hosts: [Host]
|
||||
|
||||
public init(hosts: [Host] = []) {
|
||||
self.hosts = hosts
|
||||
}
|
||||
|
||||
public func loadAll() async throws -> [Host] {
|
||||
hosts
|
||||
}
|
||||
|
||||
public func upsert(_ host: Host) async throws -> [Host] {
|
||||
let updated = hosts.upserting(host)
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
public func remove(id: UUID) async throws -> [Host] {
|
||||
let updated = hosts.removing(id: id)
|
||||
hosts = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Explicit error taxonomy for keychain-backed persistence (plan T-iOS-7
|
||||
/// "错误码显式映射"). `errSecDuplicateItem` and `errSecItemNotFound` are
|
||||
/// BRANCHES (add→update fallback / empty list / idempotent delete), never
|
||||
/// surfaced as errors.
|
||||
public enum KeychainHostStoreError: Error, Equatable {
|
||||
/// -34018 `errSecMissingEntitlement`: the data-protection keychain is
|
||||
/// unavailable to unsigned binaries (e.g. `swift test`) — a signal to
|
||||
/// inject a fake `SecItemShim`, not a user-facing failure.
|
||||
case missingEntitlement
|
||||
/// Stored payload is not a decodable `[Host]` — treated as an explicit
|
||||
/// error, never a crash (defensive at the storage boundary).
|
||||
case corruptedData
|
||||
/// `[Host]` failed to JSON-encode (practically unreachable; kept for a
|
||||
/// complete, explicit taxonomy on the write path).
|
||||
case encodingFailed
|
||||
case unexpectedStatus(OSStatus)
|
||||
|
||||
init(status: OSStatus) {
|
||||
self = status == errSecMissingEntitlement ? .missingEntitlement : .unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keychain-backed `HostStore`: the whole host list is ONE generic-password
|
||||
/// item (JSON `[Host]`) under `service`/`account`. An actor so the
|
||||
/// read-modify-write in `upsert`/`remove` serializes.
|
||||
///
|
||||
/// All SecItem dictionaries come from `KeychainItemSpec` — the §5.3 single
|
||||
/// source; building an attribute dict anywhere else is a review CRITICAL.
|
||||
public actor KeychainHostStore: HostStore {
|
||||
public static let defaultService = "com.yaojia.webterm.host-registry"
|
||||
public static let defaultAccount = "hosts"
|
||||
|
||||
private let shim: any SecItemShim
|
||||
private let spec: KeychainItemSpec
|
||||
|
||||
public init(
|
||||
shim: any SecItemShim = LiveSecItemShim(),
|
||||
service: String = KeychainHostStore.defaultService,
|
||||
account: String = KeychainHostStore.defaultAccount
|
||||
) {
|
||||
self.shim = shim
|
||||
self.spec = KeychainItemSpec(service: service, account: account)
|
||||
}
|
||||
|
||||
public func loadAll() async throws -> [Host] {
|
||||
try readHosts()
|
||||
}
|
||||
|
||||
public func upsert(_ host: Host) async throws -> [Host] {
|
||||
let updated = try readHosts().upserting(host)
|
||||
try persist(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
public func remove(id: UUID) async throws -> [Host] {
|
||||
let current = try readHosts()
|
||||
let updated = current.removing(id: id)
|
||||
guard updated.count != current.count else {
|
||||
return current // explicit no-op: unknown id, nothing persisted
|
||||
}
|
||||
try persist(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
// MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec)
|
||||
|
||||
private func readHosts() throws -> [Host] {
|
||||
let (status, data) = shim.copyMatching(spec.copyQuery())
|
||||
if status == errSecItemNotFound { return [] }
|
||||
guard status == errSecSuccess else { throw KeychainHostStoreError(status: status) }
|
||||
guard let data, let hosts = try? JSONDecoder().decode([Host].self, from: data) else {
|
||||
throw KeychainHostStoreError.corruptedData
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
private func persist(_ hosts: [Host]) throws {
|
||||
if hosts.isEmpty {
|
||||
try deleteItem()
|
||||
return
|
||||
}
|
||||
let data = try encodeHosts(hosts)
|
||||
let addStatus = shim.add(spec.addAttributes(data: data))
|
||||
if addStatus == errSecSuccess { return }
|
||||
guard addStatus == errSecDuplicateItem else {
|
||||
throw KeychainHostStoreError(status: addStatus)
|
||||
}
|
||||
let updateStatus = shim.update(
|
||||
query: spec.baseQuery(),
|
||||
attributesToUpdate: spec.updateAttributes(data: data)
|
||||
)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainHostStoreError(status: updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItem() throws {
|
||||
let status = shim.delete(spec.baseQuery())
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainHostStoreError(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeHosts(_ hosts: [Host]) throws -> Data {
|
||||
do {
|
||||
return try JSONEncoder().encode(hosts)
|
||||
} catch {
|
||||
throw KeychainHostStoreError.encodingFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
/// Frozen contract (plan §3.3). NON-SECRET per-host UI state only — anything
|
||||
/// sensitive belongs in `KeychainHostStore` (§5.3 split: Keychain = secrets,
|
||||
/// UserDefaults = prefs).
|
||||
public protocol LastSessionStore: Sendable {
|
||||
func lastSessionId(host: UUID) -> UUID?
|
||||
func setLastSessionId(_ id: UUID?, host: UUID)
|
||||
}
|
||||
|
||||
/// UserDefaults-backed implementation with an injectable suite for tests.
|
||||
/// `@unchecked Sendable`: `UserDefaults` carries no Sendable annotation in
|
||||
/// the SDK but is documented thread-safe ("NSUserDefaults is thread-safe"),
|
||||
/// and this wrapper adds no mutable state of its own.
|
||||
public struct UserDefaultsLastSessionStore: LastSessionStore, @unchecked Sendable {
|
||||
private static let keyPrefix = "lastSessionId."
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public func lastSessionId(host: UUID) -> UUID? {
|
||||
guard let raw = defaults.string(forKey: Self.key(host)) else { return nil }
|
||||
// Stored value crosses a storage boundary — validate, never trust:
|
||||
// garbage reads back as nil instead of crashing or misrouting.
|
||||
return UUID(uuidString: raw)
|
||||
}
|
||||
|
||||
public func setLastSessionId(_ id: UUID?, host: UUID) {
|
||||
guard let id else {
|
||||
defaults.removeObject(forKey: Self.key(host))
|
||||
return
|
||||
}
|
||||
defaults.set(id.uuidString, forKey: Self.key(host))
|
||||
}
|
||||
|
||||
private static func key(_ host: UUID) -> String {
|
||||
keyPrefix + host.uuidString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Testability seam over the four `SecItem*` C calls (plan §3.3 / T-iOS-7).
|
||||
///
|
||||
/// Why a seam: unsigned `swift test` binaries get `errSecMissingEntitlement`
|
||||
/// (-34018) from the data-protection keychain, so unit tests inject a fake
|
||||
/// conforming to this protocol and assert the store's dictionaries verbatim;
|
||||
/// the live shim only runs inside the signed simulator/app pass (T-iOS-15/16).
|
||||
public protocol SecItemShim: Sendable {
|
||||
func add(_ attributes: [String: any Sendable]) -> OSStatus
|
||||
func update(query: [String: any Sendable],
|
||||
attributesToUpdate: [String: any Sendable]) -> OSStatus
|
||||
func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?)
|
||||
func delete(_ query: [String: any Sendable]) -> OSStatus
|
||||
}
|
||||
|
||||
/// Live passthrough to Security.framework. Deliberately logic-free: every
|
||||
/// attribute — including the §5.3-mandated protection keys — is built by
|
||||
/// `KeychainItemSpec` below, so there is exactly ONE place to audit and to
|
||||
/// unit-test. Adding attribute logic here would split that single source.
|
||||
public struct LiveSecItemShim: SecItemShim {
|
||||
public init() {}
|
||||
|
||||
public func add(_ attributes: [String: any Sendable]) -> OSStatus {
|
||||
SecItemAdd(attributes as [String: Any] as CFDictionary, nil)
|
||||
}
|
||||
|
||||
public func update(query: [String: any Sendable],
|
||||
attributesToUpdate: [String: any Sendable]) -> OSStatus {
|
||||
SecItemUpdate(query as [String: Any] as CFDictionary,
|
||||
attributesToUpdate as [String: Any] as CFDictionary)
|
||||
}
|
||||
|
||||
public func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) {
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as [String: Any] as CFDictionary, &result)
|
||||
return (status, result as? Data)
|
||||
}
|
||||
|
||||
public func delete(_ query: [String: any Sendable]) -> OSStatus {
|
||||
SecItemDelete(query as [String: Any] as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
/// §5.3 single source of truth for every keychain dictionary the store sends
|
||||
/// through the shim. One wrong attribute here = credentials leave the device
|
||||
/// with backups — KeychainHostStoreTests asserts these dictionaries verbatim
|
||||
/// (exact key sets), and the signed simulator pass (T-iOS-15/16) verifies the
|
||||
/// real item's attributes end-to-end.
|
||||
struct KeychainItemSpec: Sendable {
|
||||
let service: String
|
||||
let account: String
|
||||
|
||||
/// Identifies the item in every call. `kSecUseDataProtectionKeychain`
|
||||
/// pins the iOS-style data-protection keychain on macOS too (on iOS it
|
||||
/// is implicit); omitting it would silently fall back to the legacy
|
||||
/// file keychain on Mac and void the §5.3 semantics.
|
||||
func baseQuery() -> [String: any Sendable] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword as String,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
]
|
||||
}
|
||||
|
||||
func copyQuery() -> [String: any Sendable] {
|
||||
merged(baseQuery(), [
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne as String,
|
||||
])
|
||||
}
|
||||
|
||||
/// `AfterFirstUnlockThisDeviceOnly`: readable in background after first
|
||||
/// unlock (reconnect/UI needs), never migrates to another device or into
|
||||
/// backups; no `kSecAttrSynchronizable` → never iCloud-synced (§5.3).
|
||||
func addAttributes(data: Data) -> [String: any Sendable] {
|
||||
merged(baseQuery(), updateAttributes(data: data))
|
||||
}
|
||||
|
||||
/// Update re-asserts `kSecAttrAccessible` so a pre-existing item can
|
||||
/// never retain a weaker protection class than the current policy.
|
||||
func updateAttributes(data: Data) -> [String: any Sendable] {
|
||||
[
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
}
|
||||
|
||||
private func merged(
|
||||
_ base: [String: any Sendable],
|
||||
_ extra: [String: any Sendable]
|
||||
) -> [String: any Sendable] {
|
||||
base.merging(extra) { _, new in new }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
import HostRegistry
|
||||
|
||||
/// In-memory keychain double behind the `SecItemShim` seam (plan §3.3):
|
||||
/// unsigned `swift test` binaries get errSecMissingEntitlement (-34018) from
|
||||
/// the real data-protection keychain, so store logic is tested against this.
|
||||
///
|
||||
/// Behavioral single-slot model (the store uses exactly one service+account):
|
||||
/// - add: duplicate if a value is stored; otherwise stores kSecValueData
|
||||
/// - update: itemNotFound when empty; otherwise replaces the stored value
|
||||
/// - copyMatching: itemNotFound when empty; otherwise returns the stored value
|
||||
/// - delete: itemNotFound when empty; otherwise clears
|
||||
///
|
||||
/// `force*` knobs override the next status/result (forced statuses do NOT
|
||||
/// touch storage — the fake stays dumb). All calls are recorded verbatim so
|
||||
/// tests can assert the §5.3 attribute dictionaries character-for-character.
|
||||
final class FakeSecItemShim: SecItemShim, Sendable {
|
||||
struct UpdateCall: Sendable {
|
||||
let query: [String: any Sendable]
|
||||
let attributes: [String: any Sendable]
|
||||
}
|
||||
|
||||
private struct State: Sendable {
|
||||
var storedData: Data?
|
||||
var addCalls: [[String: any Sendable]] = []
|
||||
var updateCalls: [UpdateCall] = []
|
||||
var copyCalls: [[String: any Sendable]] = []
|
||||
var deleteCalls: [[String: any Sendable]] = []
|
||||
var forcedAddStatus: OSStatus?
|
||||
var forcedUpdateStatus: OSStatus?
|
||||
var forcedCopyStatus: OSStatus?
|
||||
var forcedDeleteStatus: OSStatus?
|
||||
var forcedCopyData: Data?
|
||||
}
|
||||
|
||||
private let state = OSAllocatedUnfairLock(initialState: State())
|
||||
|
||||
// MARK: - SecItemShim
|
||||
|
||||
func add(_ attributes: [String: any Sendable]) -> OSStatus {
|
||||
state.withLock { s in
|
||||
s.addCalls.append(attributes)
|
||||
if let forced = s.forcedAddStatus { return forced }
|
||||
guard s.storedData == nil else { return errSecDuplicateItem }
|
||||
s.storedData = attributes[kSecValueData as String] as? Data
|
||||
return errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
func update(query: [String: any Sendable],
|
||||
attributesToUpdate: [String: any Sendable]) -> OSStatus {
|
||||
state.withLock { s in
|
||||
s.updateCalls.append(UpdateCall(query: query, attributes: attributesToUpdate))
|
||||
if let forced = s.forcedUpdateStatus { return forced }
|
||||
guard s.storedData != nil else { return errSecItemNotFound }
|
||||
s.storedData = attributesToUpdate[kSecValueData as String] as? Data
|
||||
return errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) {
|
||||
state.withLock { s in
|
||||
s.copyCalls.append(query)
|
||||
if let forced = s.forcedCopyStatus { return (forced, nil) }
|
||||
if let forcedData = s.forcedCopyData { return (errSecSuccess, forcedData) }
|
||||
guard let data = s.storedData else { return (errSecItemNotFound, nil) }
|
||||
return (errSecSuccess, data)
|
||||
}
|
||||
}
|
||||
|
||||
func delete(_ query: [String: any Sendable]) -> OSStatus {
|
||||
state.withLock { s in
|
||||
s.deleteCalls.append(query)
|
||||
if let forced = s.forcedDeleteStatus { return forced }
|
||||
guard s.storedData != nil else { return errSecItemNotFound }
|
||||
s.storedData = nil
|
||||
return errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test controls
|
||||
|
||||
func forceAddStatus(_ status: OSStatus) { state.withLock { $0.forcedAddStatus = status } }
|
||||
func forceUpdateStatus(_ status: OSStatus) { state.withLock { $0.forcedUpdateStatus = status } }
|
||||
func forceCopyStatus(_ status: OSStatus) { state.withLock { $0.forcedCopyStatus = status } }
|
||||
func forceDeleteStatus(_ status: OSStatus) { state.withLock { $0.forcedDeleteStatus = status } }
|
||||
func forceCopyData(_ data: Data) { state.withLock { $0.forcedCopyData = data } }
|
||||
|
||||
// MARK: - Recorded calls
|
||||
|
||||
var recordedAdds: [[String: any Sendable]] { state.withLock { $0.addCalls } }
|
||||
var recordedUpdates: [UpdateCall] { state.withLock { $0.updateCalls } }
|
||||
var recordedCopies: [[String: any Sendable]] { state.withLock { $0.copyCalls } }
|
||||
var recordedDeletes: [[String: any Sendable]] { state.withLock { $0.deleteCalls } }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
import HostRegistry
|
||||
|
||||
@Test("Host Codable 往返:id/name/endpoint 全保真(originHeader 重派生一致)")
|
||||
func hostCodableRoundTripPreservesAllFields() throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443")
|
||||
|
||||
// Act
|
||||
let data = try JSONEncoder().encode(host)
|
||||
let decoded = try JSONDecoder().decode(Host.self, from: data)
|
||||
|
||||
// Assert
|
||||
#expect(decoded == host)
|
||||
#expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443")
|
||||
}
|
||||
|
||||
@Test("Identifiable:Host.id 即协议 id")
|
||||
func hostIdentifiableIdIsItsOwnId() {
|
||||
// Arrange / Act
|
||||
let host = Fixtures.makeHost()
|
||||
|
||||
// Assert
|
||||
let identifiable: any Identifiable<UUID> = host
|
||||
#expect(identifiable.id == host.id)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import HostRegistry
|
||||
|
||||
// HostStore protocol contract, tested against the in-Sources InMemory double
|
||||
// (plan T-iOS-7: "用 InMemory 替身测协议契约").
|
||||
|
||||
@Test("upsert 新 host:返回的新集合含它,loadAll 一致")
|
||||
func upsertNewHostAppearsInReturnedCollectionAndLoadAll() async throws {
|
||||
// Arrange
|
||||
let store = InMemoryHostStore()
|
||||
let host = Fixtures.makeHost()
|
||||
|
||||
// Act
|
||||
let result = try await store.upsert(host)
|
||||
|
||||
// Assert
|
||||
#expect(result == [host])
|
||||
#expect(try await store.loadAll() == [host])
|
||||
}
|
||||
|
||||
@Test("同 id 再 upsert:替换不重复,且保持原位置")
|
||||
func upsertSameIdReplacesWithoutDuplicatingAndKeepsPosition() async throws {
|
||||
// Arrange
|
||||
let first = Fixtures.makeHost(name: "old-name")
|
||||
let second = Fixtures.makeHost(name: "other", urlString: "https://mac.ts.net")
|
||||
let store = InMemoryHostStore(hosts: [first, second])
|
||||
let renamed = Host(id: first.id, name: "new-name", endpoint: first.endpoint)
|
||||
|
||||
// Act
|
||||
let result = try await store.upsert(renamed)
|
||||
|
||||
// Assert
|
||||
#expect(result == [renamed, second])
|
||||
#expect(try await store.loadAll() == [renamed, second])
|
||||
}
|
||||
|
||||
@Test("remove 已存在 id:集合不再含它")
|
||||
func removeExistingIdDropsItFromCollection() async throws {
|
||||
// Arrange
|
||||
let keep = Fixtures.makeHost(name: "keep")
|
||||
let doomed = Fixtures.makeHost(name: "doomed", urlString: "http://10.0.0.7:3000")
|
||||
let store = InMemoryHostStore(hosts: [keep, doomed])
|
||||
|
||||
// Act
|
||||
let result = try await store.remove(id: doomed.id)
|
||||
|
||||
// Assert
|
||||
#expect(result == [keep])
|
||||
#expect(try await store.loadAll() == [keep])
|
||||
}
|
||||
|
||||
@Test("remove 不存在的 id:集合不变、不 throw(显式返回原集合)")
|
||||
func removeUnknownIdReturnsUnchangedCollectionWithoutThrowing() async throws {
|
||||
// Arrange
|
||||
let host = Fixtures.makeHost()
|
||||
let store = InMemoryHostStore(hosts: [host])
|
||||
|
||||
// Act
|
||||
let result = try await store.remove(id: UUID())
|
||||
|
||||
// Assert
|
||||
#expect(result == [host])
|
||||
#expect(try await store.loadAll() == [host])
|
||||
}
|
||||
|
||||
@Test("init 种子集合:loadAll 原样返回")
|
||||
func initSeedsCollectionVerbatim() async throws {
|
||||
// Arrange
|
||||
let hosts = [Fixtures.makeHost(name: "a"), Fixtures.makeHost(name: "b")]
|
||||
|
||||
// Act
|
||||
let store = InMemoryHostStore(hosts: hosts)
|
||||
|
||||
// Assert
|
||||
#expect(try await store.loadAll() == hosts)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
import HostRegistry
|
||||
|
||||
// KeychainHostStore logic against the fake SecItemShim (plan T-iOS-7:
|
||||
// add/update/delete 走对分支、错误码显式映射;真 keychain 归 xcodebuild 模拟器测试)。
|
||||
|
||||
// MARK: - Branch selection
|
||||
|
||||
@Test("upsert 新 host:走 add 分支,不碰 update")
|
||||
func upsertNewHostTakesAddBranchOnly() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
|
||||
// Act
|
||||
let result = try await store.upsert(host)
|
||||
|
||||
// Assert
|
||||
#expect(result == [host])
|
||||
#expect(shim.recordedAdds.count == 1)
|
||||
#expect(shim.recordedUpdates.isEmpty)
|
||||
#expect(try await store.loadAll() == [host])
|
||||
}
|
||||
|
||||
@Test("已有条目再 upsert:add 撞 errSecDuplicateItem → 走 update 分支")
|
||||
func upsertOntoExistingItemFallsBackToUpdateBranch() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let first = Fixtures.makeHost(name: "first")
|
||||
_ = try await store.upsert(first)
|
||||
let second = Fixtures.makeHost(name: "second", urlString: "http://10.0.0.9:3000")
|
||||
|
||||
// Act
|
||||
let result = try await store.upsert(second)
|
||||
|
||||
// Assert
|
||||
#expect(result == [first, second])
|
||||
#expect(shim.recordedUpdates.count == 1)
|
||||
#expect(try await store.loadAll() == [first, second])
|
||||
}
|
||||
|
||||
@Test("同 id 再 upsert:替换不重复(keychain 持久化后仍只一条)")
|
||||
func upsertSameIdReplacesInsteadOfDuplicating() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost(name: "old")
|
||||
_ = try await store.upsert(host)
|
||||
let renamed = Host(id: host.id, name: "new", endpoint: host.endpoint)
|
||||
|
||||
// Act
|
||||
let result = try await store.upsert(renamed)
|
||||
|
||||
// Assert
|
||||
#expect(result == [renamed])
|
||||
#expect(try await store.loadAll() == [renamed])
|
||||
}
|
||||
|
||||
@Test("remove 到空集合:走 delete 分支清掉整个 item")
|
||||
func removeLastHostTakesDeleteBranch() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
|
||||
// Act
|
||||
let result = try await store.remove(id: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(result.isEmpty)
|
||||
#expect(shim.recordedDeletes.count == 1)
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
}
|
||||
|
||||
@Test("remove 不存在的 id:集合不变、不 throw、零持久化调用")
|
||||
func removeUnknownIdIsExplicitNoOpWithoutPersisting() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
let addsBefore = shim.recordedAdds.count
|
||||
let updatesBefore = shim.recordedUpdates.count
|
||||
|
||||
// Act
|
||||
let result = try await store.remove(id: UUID())
|
||||
|
||||
// Assert
|
||||
#expect(result == [host])
|
||||
#expect(shim.recordedAdds.count == addsBefore)
|
||||
#expect(shim.recordedUpdates.count == updatesBefore)
|
||||
#expect(shim.recordedDeletes.isEmpty)
|
||||
}
|
||||
|
||||
@Test("空 keychain loadAll:errSecItemNotFound 映射为空集合而非错误")
|
||||
func loadAllMapsItemNotFoundToEmptyCollection() async throws {
|
||||
// Arrange
|
||||
let store = KeychainHostStore(shim: FakeSecItemShim())
|
||||
|
||||
// Act / Assert
|
||||
#expect(try await store.loadAll().isEmpty)
|
||||
}
|
||||
|
||||
@Test("delete 撞 errSecItemNotFound:视为成功(幂等删除)")
|
||||
func deleteItemNotFoundIsTreatedAsSuccess() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
shim.forceDeleteStatus(errSecItemNotFound)
|
||||
|
||||
// Act
|
||||
let result = try await store.remove(id: host.id)
|
||||
|
||||
// Assert
|
||||
#expect(result.isEmpty)
|
||||
}
|
||||
|
||||
@Test("持久化跨实例:同一 shim 上的新 store 读回全部 host")
|
||||
func persistedHostsSurviveAcrossStoreInstances() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let a = Fixtures.makeHost(name: "a")
|
||||
let b = Fixtures.makeHost(name: "b", urlString: "https://mac.ts.net")
|
||||
let writer = KeychainHostStore(shim: shim)
|
||||
_ = try await writer.upsert(a)
|
||||
_ = try await writer.upsert(b)
|
||||
|
||||
// Act
|
||||
let reader = KeychainHostStore(shim: shim)
|
||||
|
||||
// Assert
|
||||
#expect(try await reader.loadAll() == [a, b])
|
||||
}
|
||||
|
||||
// MARK: - Explicit error-code mapping
|
||||
|
||||
@Test("add 撞 -34018 errSecMissingEntitlement:映射为 .missingEntitlement")
|
||||
func addMissingEntitlementMapsToTypedError() async {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
shim.forceAddStatus(errSecMissingEntitlement)
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: KeychainHostStoreError.missingEntitlement) {
|
||||
_ = try await store.upsert(Fixtures.makeHost())
|
||||
}
|
||||
}
|
||||
|
||||
@Test("copyMatching 意外错误码:映射为 .unexpectedStatus(原码)")
|
||||
func copyUnexpectedStatusMapsToTypedErrorWithCode() async {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
shim.forceCopyStatus(errSecInteractionNotAllowed)
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecInteractionNotAllowed)) {
|
||||
_ = try await store.loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("update 分支失败:映射为 .unexpectedStatus(原码)")
|
||||
func updateFailureMapsToTypedErrorWithCode() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
_ = try await store.upsert(Fixtures.makeHost())
|
||||
shim.forceUpdateStatus(errSecAuthFailed)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecAuthFailed)) {
|
||||
_ = try await store.upsert(Fixtures.makeHost(name: "second"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("delete 分支意外错误码:映射为 .unexpectedStatus(原码)")
|
||||
func deleteFailureMapsToTypedErrorWithCode() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
shim.forceDeleteStatus(errSecAuthFailed)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: KeychainHostStoreError.unexpectedStatus(errSecAuthFailed)) {
|
||||
_ = try await store.remove(id: host.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("keychain 里的数据不是合法 [Host] JSON:映射为 .corruptedData,不 crash")
|
||||
func corruptKeychainPayloadMapsToTypedError() async {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
shim.forceCopyData(Data("not-json".utf8))
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(throws: KeychainHostStoreError.corruptedData) {
|
||||
_ = try await store.loadAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - §5.3 attribute dictionaries, asserted verbatim
|
||||
|
||||
@Test("§5.3 add 属性字典逐字校验:data-protection + AfterFirstUnlockThisDeviceOnly,无 synchronizable,无多余键")
|
||||
func addAttributeDictionaryIsExactlyThePolicySet() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test")
|
||||
|
||||
// Act
|
||||
_ = try await store.upsert(Fixtures.makeHost())
|
||||
|
||||
// Assert
|
||||
let attrs = try #require(shim.recordedAdds.first)
|
||||
#expect(attrs[kSecClass as String] as? String == kSecClassGenericPassword as String)
|
||||
#expect(attrs[kSecAttrService as String] as? String == "svc.test")
|
||||
#expect(attrs[kSecAttrAccount as String] as? String == "acct.test")
|
||||
#expect(attrs[kSecUseDataProtectionKeychain as String] as? Bool == true)
|
||||
#expect(attrs[kSecAttrAccessible as String] as? String
|
||||
== kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
|
||||
#expect(attrs[kSecAttrSynchronizable as String] == nil)
|
||||
#expect(attrs[kSecValueData as String] is Data)
|
||||
#expect(attrs.count == 6) // 白名单外零多余键 —— verbatim 断言
|
||||
}
|
||||
|
||||
@Test("§5.3 update 分支字典:query 带 data-protection 不带数据;attributes 重申 accessible + 新数据")
|
||||
func updateDictionariesReassertProtectionPolicy() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
_ = try await store.upsert(Fixtures.makeHost(name: "first"))
|
||||
|
||||
// Act
|
||||
_ = try await store.upsert(Fixtures.makeHost(name: "second", urlString: "http://10.1.1.1:3000"))
|
||||
|
||||
// Assert
|
||||
let call = try #require(shim.recordedUpdates.first)
|
||||
#expect(call.query[kSecClass as String] as? String == kSecClassGenericPassword as String)
|
||||
#expect(call.query[kSecUseDataProtectionKeychain as String] as? Bool == true)
|
||||
#expect(call.query[kSecValueData as String] == nil)
|
||||
#expect(call.query.count == 4) // class + service + account + data-protection
|
||||
#expect(call.attributes[kSecAttrAccessible as String] as? String
|
||||
== kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String)
|
||||
#expect(call.attributes[kSecValueData as String] is Data)
|
||||
#expect(call.attributes.count == 2)
|
||||
}
|
||||
|
||||
@Test("§5.3 copy 查询字典:base + returnData + matchLimitOne,共 6 键")
|
||||
func copyQueryDictionaryIsExactlyThePolicySet() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
|
||||
// Act
|
||||
_ = try await store.loadAll()
|
||||
|
||||
// Assert
|
||||
let query = try #require(shim.recordedCopies.first)
|
||||
#expect(query[kSecClass as String] as? String == kSecClassGenericPassword as String)
|
||||
#expect(query[kSecUseDataProtectionKeychain as String] as? Bool == true)
|
||||
#expect(query[kSecReturnData as String] as? Bool == true)
|
||||
#expect(query[kSecMatchLimit as String] as? String == kSecMatchLimitOne as String)
|
||||
#expect(query.count == 6)
|
||||
}
|
||||
|
||||
@Test("§5.3 delete 查询字典:带 data-protection 的 base query,共 4 键")
|
||||
func deleteQueryDictionaryIsExactlyTheBaseQuery() async throws {
|
||||
// Arrange
|
||||
let shim = FakeSecItemShim()
|
||||
let store = KeychainHostStore(shim: shim)
|
||||
let host = Fixtures.makeHost()
|
||||
_ = try await store.upsert(host)
|
||||
|
||||
// Act
|
||||
_ = try await store.remove(id: host.id)
|
||||
|
||||
// Assert
|
||||
let query = try #require(shim.recordedDeletes.first)
|
||||
#expect(query[kSecClass as String] as? String == kSecClassGenericPassword as String)
|
||||
#expect(query[kSecUseDataProtectionKeychain as String] as? Bool == true)
|
||||
#expect(query.count == 4)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import HostRegistry
|
||||
|
||||
// UserDefaultsLastSessionStore over an injectable, per-test-unique suite
|
||||
// (plan §3.3: 非机密;lastSessionId 存取/清除)。
|
||||
|
||||
@Test("lastSessionId 存取:set 后读回同一 UUID")
|
||||
func setThenGetReturnsSameSessionId() throws {
|
||||
// Arrange
|
||||
let suiteName = Fixtures.makeSuiteName()
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = UserDefaultsLastSessionStore(defaults: defaults)
|
||||
let host = UUID()
|
||||
let session = UUID()
|
||||
|
||||
// Act
|
||||
store.setLastSessionId(session, host: host)
|
||||
|
||||
// Assert
|
||||
#expect(store.lastSessionId(host: host) == session)
|
||||
}
|
||||
|
||||
@Test("未设置过的 host:lastSessionId 返回 nil")
|
||||
func unknownHostReturnsNil() throws {
|
||||
// Arrange
|
||||
let suiteName = Fixtures.makeSuiteName()
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = UserDefaultsLastSessionStore(defaults: defaults)
|
||||
|
||||
// Act / Assert
|
||||
#expect(store.lastSessionId(host: UUID()) == nil)
|
||||
}
|
||||
|
||||
@Test("set nil:清除既有 lastSessionId")
|
||||
func settingNilClearsStoredSessionId() throws {
|
||||
// Arrange
|
||||
let suiteName = Fixtures.makeSuiteName()
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = UserDefaultsLastSessionStore(defaults: defaults)
|
||||
let host = UUID()
|
||||
store.setLastSessionId(UUID(), host: host)
|
||||
|
||||
// Act
|
||||
store.setLastSessionId(nil, host: host)
|
||||
|
||||
// Assert
|
||||
#expect(store.lastSessionId(host: host) == nil)
|
||||
}
|
||||
|
||||
@Test("不同 host 键隔离:互不覆盖")
|
||||
func distinctHostsDoNotCollide() throws {
|
||||
// Arrange
|
||||
let suiteName = Fixtures.makeSuiteName()
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = UserDefaultsLastSessionStore(defaults: defaults)
|
||||
let hostA = UUID()
|
||||
let hostB = UUID()
|
||||
let sessionA = UUID()
|
||||
let sessionB = UUID()
|
||||
|
||||
// Act
|
||||
store.setLastSessionId(sessionA, host: hostA)
|
||||
store.setLastSessionId(sessionB, host: hostB)
|
||||
|
||||
// Assert
|
||||
#expect(store.lastSessionId(host: hostA) == sessionA)
|
||||
#expect(store.lastSessionId(host: hostB) == sessionB)
|
||||
}
|
||||
|
||||
@Test("UserDefaults 中的非 UUID 垃圾值:读回 nil 而非 crash(防御性解析)")
|
||||
func garbageStoredValueReadsBackAsNil() throws {
|
||||
// Arrange — discover the store's key via the suite's own domain, then poison it
|
||||
let suiteName = Fixtures.makeSuiteName()
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
let store = UserDefaultsLastSessionStore(defaults: defaults)
|
||||
let host = UUID()
|
||||
store.setLastSessionId(UUID(), host: host)
|
||||
let key = try #require(defaults.persistentDomain(forName: suiteName)?.keys.first)
|
||||
|
||||
// Act
|
||||
defaults.set("not-a-uuid", forKey: key)
|
||||
|
||||
// Assert
|
||||
#expect(store.lastSessionId(host: host) == nil)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
import HostRegistry
|
||||
|
||||
/// On macOS `Foundation.Host` (NSHost) shadows the package type in test
|
||||
/// files that import Foundation — pin resolution for the whole test target.
|
||||
typealias Host = HostRegistry.Host
|
||||
|
||||
/// Shared fixtures for HostRegistryTests. Pure helpers only — no state.
|
||||
enum Fixtures {
|
||||
/// Builds a valid Host; traps loudly if the fixture URL is malformed
|
||||
/// (a broken fixture must fail the suite, not silently skip assertions).
|
||||
static func makeHost(
|
||||
name: String = "mac-studio",
|
||||
urlString: String = "http://192.168.1.5:3000",
|
||||
id: UUID = UUID()
|
||||
) -> Host {
|
||||
guard let url = URL(string: urlString), let endpoint = HostEndpoint(baseURL: url) else {
|
||||
fatalError("test fixture URL invalid: \(urlString)")
|
||||
}
|
||||
return Host(id: id, name: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
/// Unique UserDefaults suite name per test — isolates parallel tests.
|
||||
static func makeSuiteName() -> String {
|
||||
"HostRegistryTests." + UUID().uuidString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
/// The server's `TimelineClass` vocabulary (src/types.ts:423, produced by
|
||||
/// src/session/timeline.ts CLASS_MAP). Kept in lockstep with
|
||||
/// `TimelineEvent.knownClasses` (WireProtocol) — a sync test asserts equality.
|
||||
/// Internal (not public API): consumers see counts, not class strings.
|
||||
enum TimelineClassName {
|
||||
static let tool = "tool"
|
||||
static let waiting = "waiting"
|
||||
static let done = "done"
|
||||
static let stuck = "stuck"
|
||||
static let user = "user"
|
||||
}
|
||||
|
||||
/// "What happened while I was away" summary (T-iOS-6, plan §3.2 — frozen
|
||||
/// shape). Reduced once after a reconnect from the session's activity timeline
|
||||
/// (`GET /live-sessions/:id/events`, src/types.ts:428-433) and rendered above
|
||||
/// the terminal (T-iOS-14).
|
||||
public struct AwayDigest: Sendable, Equatable {
|
||||
/// Events with class `tool` at/after `since` (PreToolUse + PostToolUse).
|
||||
public let toolRuns: Int
|
||||
/// Events with class `waiting` (approval requests / notifications).
|
||||
public let waitingCount: Int
|
||||
/// Any `done` event seen (Stop / SessionEnd).
|
||||
public let sawDone: Bool
|
||||
/// Any `stuck` event seen (A5 silent-too-long derivation).
|
||||
public let sawStuck: Bool
|
||||
/// The newest events (post-`since`), oldest→newest, truncated to `limit`.
|
||||
public let recent: [TimelineEvent]
|
||||
|
||||
/// The all-zero digest: nothing happened → UI skips rendering entirely.
|
||||
public static let empty = AwayDigest(
|
||||
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: [])
|
||||
|
||||
public init(
|
||||
toolRuns: Int, waitingCount: Int, sawDone: Bool, sawStuck: Bool,
|
||||
recent: [TimelineEvent]
|
||||
) {
|
||||
self.toolRuns = toolRuns
|
||||
self.waitingCount = waitingCount
|
||||
self.sawDone = sawDone
|
||||
self.sawStuck = sawStuck
|
||||
self.recent = recent
|
||||
}
|
||||
|
||||
/// True when there is nothing to show (the UI's "don't render" signal).
|
||||
public var isEmpty: Bool { self == .empty }
|
||||
|
||||
/// Folds a timeline into a digest. PURE function — no I/O, no clock.
|
||||
///
|
||||
/// - `events`: `/events` entries (untrusted server input; order not assumed
|
||||
/// — entries are stably re-ordered by `at` before truncation).
|
||||
/// - `since`: the moment the user left; strictly-earlier events are
|
||||
/// excluded, an event AT the instant still counts.
|
||||
/// - `limit`: max `recent` entries kept (newest win); `<= 0` keeps none.
|
||||
public static func reduce(events: [TimelineEvent], since: Date, limit: Int) -> AwayDigest {
|
||||
let sinceMs = clampedEpochMs(since)
|
||||
let away = events
|
||||
.filter { $0.at >= sinceMs }
|
||||
.sorted { $0.at < $1.at }
|
||||
guard !away.isEmpty else { return .empty }
|
||||
|
||||
return AwayDigest(
|
||||
toolRuns: away.filter { $0.class == TimelineClassName.tool }.count,
|
||||
waitingCount: away.filter { $0.class == TimelineClassName.waiting }.count,
|
||||
sawDone: away.contains { $0.class == TimelineClassName.done },
|
||||
sawStuck: away.contains { $0.class == TimelineClassName.stuck },
|
||||
recent: Array(away.suffix(max(0, limit))))
|
||||
}
|
||||
|
||||
/// `Date` → epoch-ms `Int`, clamped: `Int(_: Double)` TRAPS on overflow, and
|
||||
/// `since` is caller input (e.g. `.distantFuture`) — clamp instead of crash.
|
||||
private static func clampedEpochMs(_ date: Date) -> Int {
|
||||
let ms = date.timeIntervalSince1970 * 1_000
|
||||
guard ms < Double(Int.max) else { return .max }
|
||||
guard ms > Double(Int.min) else { return .min }
|
||||
return Int(ms)
|
||||
}
|
||||
}
|
||||
119
ios/Packages/SessionCore/Sources/SessionCore/GateState.swift
Normal file
119
ios/Packages/SessionCore/Sources/SessionCore/GateState.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
import WireProtocol
|
||||
|
||||
/// One held permission gate as the UI should render it (T-iOS-6, plan §3.2 —
|
||||
/// frozen shape). Produced by `GateTracker` from `status` frames; `nil` gate
|
||||
/// (via `SessionEvent.gate(nil)`) means "gate lifted".
|
||||
///
|
||||
/// `epoch` is the stale-decision guard (mirrors the web client's
|
||||
/// `pendingEpochValue`, public/terminal-session.ts:98-102): it increments only
|
||||
/// on a pending false→true rising edge, and every approve/reject carries the
|
||||
/// epoch it was tapped against — a stale epoch is dropped so a slow tap can
|
||||
/// never approve the NEXT gate (防误批新 gate).
|
||||
public struct GateState: Sendable, Equatable {
|
||||
/// `plan` → three-way affordances; `tool` → two-way (public/tabs.ts:334-350).
|
||||
public let kind: GateKind
|
||||
/// Server-supplied context (tool name for tool gates); untrusted display text.
|
||||
public let detail: String?
|
||||
/// Rising-edge counter; see type doc.
|
||||
public let epoch: Int
|
||||
|
||||
public init(kind: GateKind, detail: String?, epoch: Int) {
|
||||
self.kind = kind
|
||||
self.detail = detail
|
||||
self.epoch = epoch
|
||||
}
|
||||
}
|
||||
|
||||
extension GateState {
|
||||
/// One user-facing gate action. Pure affordance DATA — display strings live
|
||||
/// in the App layer; the wire mapping mirrors public/tabs.ts:334-350.
|
||||
public enum Affordance: Sendable, Equatable {
|
||||
/// Plan gate: "Approve + Auto" → `approve(mode: .acceptEdits)`.
|
||||
case approveAuto
|
||||
/// Plan gate: "Approve + Review" → `approve(mode: .default)`.
|
||||
case approveReview
|
||||
/// Plan gate: "Keep Planning" → `reject` (stays in plan mode).
|
||||
case keepPlanning
|
||||
/// Tool gate: "Approve" → `approve(mode: nil)`.
|
||||
case approve
|
||||
/// Tool gate: "Reject" → `reject`.
|
||||
case reject
|
||||
|
||||
/// The exact wire message the web client sends for this affordance
|
||||
/// (public/tabs.ts:345-347: plan three-way only ever sends
|
||||
/// acceptEdits / default / reject — never raw `auto`, plan §3.1 note).
|
||||
public var clientMessage: ClientMessage {
|
||||
switch self {
|
||||
case .approveAuto: return .approve(mode: .acceptEdits)
|
||||
case .approveReview: return .approve(mode: .default)
|
||||
case .keepPlanning: return .reject
|
||||
case .approve: return .approve(mode: nil)
|
||||
case .reject: return .reject
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The action set for this gate: plan → three-way, tool → two-way.
|
||||
public var affordances: [Affordance] {
|
||||
switch kind {
|
||||
case .plan: return [.approveAuto, .approveReview, .keepPlanning]
|
||||
case .tool: return [.approve, .reject]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure reducer that folds `status` frames' `(pending, gate, detail)` into the
|
||||
/// current `GateState?` (T-iOS-6). Mirrors public/terminal-session.ts:306-311:
|
||||
///
|
||||
/// - pending false→true rising edge → epoch +1, new gate held;
|
||||
/// - sustained pending → SAME epoch, kind/detail refresh from the latest frame
|
||||
/// (the server is the source of truth; reattach re-sync stays correct);
|
||||
/// - pending true→false falling edge → gate cleared, epoch counter retained;
|
||||
/// - `gate == nil` while pending → treated as a tool gate, because an
|
||||
/// unrecognized gate value decodes as nil (ServerMessage) and the pending
|
||||
/// signal must never be lost (web: `gate ?? null` → tool buttons).
|
||||
///
|
||||
/// PURE state machine: `reduce` never mutates the receiver — it returns a new
|
||||
/// immutable snapshot. No I/O, no clock; the caller (SessionEngine, T-iOS-10)
|
||||
/// feeds frames in and emits `SessionEvent.gate(current)` on change.
|
||||
public struct GateTracker: Sendable, Equatable {
|
||||
public static let initial = GateTracker(current: nil, lastEpoch: 0)
|
||||
|
||||
/// The gate currently held server-side, or `nil` when none.
|
||||
public let current: GateState?
|
||||
|
||||
/// Highest epoch ever issued; NOT reset on falling edges so decisions
|
||||
/// against a resolved gate can never match a later one.
|
||||
private let lastEpoch: Int
|
||||
|
||||
private init(current: GateState?, lastEpoch: Int) {
|
||||
self.current = current
|
||||
self.lastEpoch = lastEpoch
|
||||
}
|
||||
|
||||
/// Feeds one `status` frame's gate-relevant fields; returns the next snapshot.
|
||||
public func reduce(pending: Bool, gate: GateKind?, detail: String?) -> GateTracker {
|
||||
guard pending else {
|
||||
// Falling edge (or still idle): gate lifted, epoch counter retained.
|
||||
return GateTracker(current: nil, lastEpoch: lastEpoch)
|
||||
}
|
||||
let kind = gate ?? .tool
|
||||
if let held = current {
|
||||
// Sustained pending: same epoch, refresh kind/detail from the frame.
|
||||
let refreshed = GateState(kind: kind, detail: detail, epoch: held.epoch)
|
||||
return GateTracker(current: refreshed, lastEpoch: lastEpoch)
|
||||
}
|
||||
// Rising edge: mint the next epoch (the ONLY place it increments).
|
||||
let nextEpoch = lastEpoch + 1
|
||||
let risen = GateState(kind: kind, detail: detail, epoch: nextEpoch)
|
||||
return GateTracker(current: risen, lastEpoch: nextEpoch)
|
||||
}
|
||||
|
||||
/// True iff a decision tapped against `epoch` may be sent NOW: a gate must
|
||||
/// be held and its epoch must match. Stale epoch or no gate → drop the
|
||||
/// decision (防误批新 gate).
|
||||
public func canDecide(epoch: Int) -> Bool {
|
||||
guard let held = current else { return false }
|
||||
return held.epoch == epoch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import WireProtocol
|
||||
|
||||
/// WS keep-alive pacer (T-iOS-5, plan §3.2 / §3.2.1).
|
||||
///
|
||||
/// `URLSessionWebSocketTask` has NO automatic ping (plan §1), so without this
|
||||
/// a half-dead connection "looks connected" forever. `run` sends an explicit
|
||||
/// ping every `interval` (default `Tunables.pingInterval` = 25 s) on the
|
||||
/// injected clock.
|
||||
///
|
||||
/// Miss policy (`Tunables.pongMissLimit` = 2): one missed pong is tolerated;
|
||||
/// the moment `pongMissLimit` CONSECUTIVE pings go unanswered the connection
|
||||
/// is declared dead and `run` returns `.connectionLost` — the caller
|
||||
/// (SessionEngine, T-iOS-10) then feeds `.disconnected` into
|
||||
/// `ReconnectMachine`. Any answered ping resets the consecutive-miss counter.
|
||||
///
|
||||
/// Zero real timers: the clock is injected (`any Clock<Duration>`), so tests
|
||||
/// drive a `FakeClock` and never wait wall-clock time.
|
||||
public struct PingScheduler: Sendable {
|
||||
/// How `run` ended.
|
||||
public enum Outcome: Sendable, Equatable {
|
||||
/// `Tunables.pongMissLimit` consecutive pings went unanswered — treat
|
||||
/// the transport as disconnected.
|
||||
case connectionLost
|
||||
/// The surrounding task was cancelled (normal teardown, e.g. explicit
|
||||
/// close or transport swap) — NOT a connectivity verdict.
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// Ping period. Frozen default: `Tunables.pingInterval` (25 s).
|
||||
public let interval: Duration
|
||||
|
||||
public init(interval: Duration = Tunables.pingInterval) {
|
||||
self.interval = interval
|
||||
}
|
||||
|
||||
/// Loops until the connection dies or the task is cancelled: sleep one
|
||||
/// `interval` on `clock`, then await `sendPing`.
|
||||
///
|
||||
/// `sendPing` returns `true` iff the pong arrived — the transport adapter
|
||||
/// owns ping/pong resolution (e.g. racing `URLSessionWebSocketTask`'s
|
||||
/// `pongReceiveHandler` against its own deadline, T-iOS-9); `false` = miss.
|
||||
public func run(
|
||||
clock: any Clock<Duration>,
|
||||
sendPing: @Sendable () async -> Bool
|
||||
) async -> Outcome {
|
||||
var consecutiveMisses = 0
|
||||
while true {
|
||||
do {
|
||||
try await clock.sleep(for: interval, tolerance: nil)
|
||||
} catch {
|
||||
// Clock.sleep only throws CancellationError (FakeClock mirrors
|
||||
// ContinuousClock semantics) — normal teardown, not a failure.
|
||||
return .cancelled
|
||||
}
|
||||
let isPongReceived = await sendPing()
|
||||
consecutiveMisses = isPongReceived ? 0 : consecutiveMisses + 1
|
||||
if consecutiveMisses >= Tunables.pongMissLimit {
|
||||
return .connectionLost
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Pure reconnect back-off reducer (T-iOS-5, plan §3.2).
|
||||
///
|
||||
/// Mirrors the web client's behaviour in `public/terminal-session.ts`:
|
||||
/// - line 106: first retry delay is 1 s,
|
||||
/// - lines 352-361 (`scheduleReconnect`): the CURRENT delay is scheduled,
|
||||
/// then doubled and capped at 30 s (`Math.min(delay * 2, 30_000)`),
|
||||
/// - line 248: a successful open resets the delay to 1 s.
|
||||
/// Ladder: 1s → 2s → 4s → 8s → 16s → 30s → 30s …
|
||||
///
|
||||
/// PURE state machine: `reduce` never mutates the receiver — it returns a new
|
||||
/// immutable snapshot plus the single side-effect the caller (SessionEngine,
|
||||
/// T-iOS-10) must perform. No clock, no timers here; timing lives with the
|
||||
/// caller, which is what makes this fully testable with zero real waits.
|
||||
public struct ReconnectMachine: Sendable, Equatable {
|
||||
/// First retry delay after a disconnect
|
||||
/// (mirrors `public/terminal-session.ts:106` — `reconnectDelay = 1000`).
|
||||
static let initialRetryDelay: Duration = .seconds(1)
|
||||
|
||||
/// Back-off ceiling
|
||||
/// (mirrors `public/terminal-session.ts:356` — `Math.min(delay * 2, 30_000)`).
|
||||
static let maxRetryDelay: Duration = .seconds(30)
|
||||
|
||||
/// Doubling factor per failed attempt (same mirror line as the ceiling).
|
||||
static let backoffMultiplier = 2
|
||||
|
||||
public enum Input: Sendable, Equatable {
|
||||
case connected
|
||||
case disconnected
|
||||
case retryTimerFired
|
||||
case foregrounded
|
||||
case userRetry
|
||||
}
|
||||
|
||||
public enum Effect: Sendable, Equatable {
|
||||
case connectNow
|
||||
case scheduleRetry(after: Duration)
|
||||
case none
|
||||
}
|
||||
|
||||
public static let initial = ReconnectMachine(nextRetryDelay: initialRetryDelay)
|
||||
|
||||
/// Delay the NEXT `.disconnected` will schedule.
|
||||
private let nextRetryDelay: Duration
|
||||
|
||||
private init(nextRetryDelay: Duration) {
|
||||
self.nextRetryDelay = nextRetryDelay
|
||||
}
|
||||
|
||||
/// Feeds one input and returns `(next snapshot, effect to perform)`.
|
||||
///
|
||||
/// - `.connected`: back-off resets to the initial 1 s ladder rung; no effect.
|
||||
/// - `.disconnected`: schedule a retry after the current delay; the next
|
||||
/// snapshot carries the doubled (capped) delay.
|
||||
/// - `.retryTimerFired` / `.foregrounded` / `.userRetry`: connect NOW.
|
||||
/// Foreground/user-initiated attempts deliberately do NOT reset the
|
||||
/// ladder — only a successful `.connected` does, so mashing retry can
|
||||
/// never turn back-off into hammering.
|
||||
public func reduce(_ input: Input) -> (ReconnectMachine, Effect) {
|
||||
switch input {
|
||||
case .connected:
|
||||
return (.initial, .none)
|
||||
case .disconnected:
|
||||
let doubled = nextRetryDelay * Self.backoffMultiplier
|
||||
let next = ReconnectMachine(nextRetryDelay: min(doubled, Self.maxRetryDelay))
|
||||
return (next, .scheduleRetry(after: nextRetryDelay))
|
||||
case .retryTimerFired, .foregrounded, .userRetry:
|
||||
return (self, .connectNow)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import SessionCore
|
||||
|
||||
/// T-iOS-6 · AwayDigest — "what happened while I was away" reducer (plan §3.2).
|
||||
/// Input is the `GET /live-sessions/:id/events` timeline (src/types.ts:428-433);
|
||||
/// class vocabulary is the server's `TimelineClass` union (src/types.ts:423,
|
||||
/// produced by src/session/timeline.ts CLASS_MAP): tool/waiting/done/stuck/user.
|
||||
@Suite("AwayDigest")
|
||||
struct AwayDigestTests {
|
||||
// MARK: - helpers
|
||||
|
||||
/// Chronology anchor: all test events sit at `leftAt + offset` ms.
|
||||
private static let leftAt = Date(timeIntervalSince1970: 1_000)
|
||||
private static let leftAtMs = 1_000_000
|
||||
private static let defaultLimit = 10
|
||||
|
||||
private static func event(
|
||||
atOffsetMs offset: Int,
|
||||
class className: String,
|
||||
toolName: String? = nil,
|
||||
label: String = "activity"
|
||||
) -> TimelineEvent {
|
||||
TimelineEvent(at: leftAtMs + offset, class: className, toolName: toolName, label: label)
|
||||
}
|
||||
|
||||
// MARK: - counting
|
||||
|
||||
@Test("counts tool runs, waiting events, and done across the event list")
|
||||
func countsToolWaitingAndDoneAcrossEvents() {
|
||||
// Arrange: 3 tool + 1 waiting + 1 done (task spec's canonical example).
|
||||
let events = [
|
||||
Self.event(atOffsetMs: 1, class: "tool", toolName: "Bash", label: "ran Bash"),
|
||||
Self.event(atOffsetMs: 2, class: "tool", toolName: "Edit", label: "edited with Edit"),
|
||||
Self.event(atOffsetMs: 3, class: "waiting", label: "waiting for approval"),
|
||||
Self.event(atOffsetMs: 4, class: "tool", toolName: "Write", label: "edited with Write"),
|
||||
Self.event(atOffsetMs: 5, class: "done", label: "done"),
|
||||
]
|
||||
|
||||
// Act
|
||||
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit)
|
||||
|
||||
// Assert
|
||||
#expect(digest.toolRuns == 3)
|
||||
#expect(digest.waitingCount == 1)
|
||||
#expect(digest.sawDone == true)
|
||||
#expect(digest.sawStuck == false)
|
||||
}
|
||||
|
||||
@Test("a stuck event sets sawStuck")
|
||||
func stuckEventSetsSawStuck() {
|
||||
// Arrange
|
||||
let events = [Self.event(atOffsetMs: 1, class: "stuck", label: "stuck")]
|
||||
|
||||
// Act
|
||||
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit)
|
||||
|
||||
// Assert
|
||||
#expect(digest.sawStuck == true)
|
||||
#expect(digest.sawDone == false)
|
||||
}
|
||||
|
||||
@Test("user events appear in recent but count toward no metric")
|
||||
func userEventsAppearInRecentButCountNothing() {
|
||||
// Arrange
|
||||
let userEvent = Self.event(atOffsetMs: 1, class: "user", label: "user input")
|
||||
|
||||
// Act
|
||||
let digest = AwayDigest.reduce(events: [userEvent], since: Self.leftAt, limit: Self.defaultLimit)
|
||||
|
||||
// Assert
|
||||
#expect(digest.toolRuns == 0)
|
||||
#expect(digest.waitingCount == 0)
|
||||
#expect(digest.sawDone == false)
|
||||
#expect(digest.sawStuck == false)
|
||||
#expect(digest.recent == [userEvent])
|
||||
}
|
||||
|
||||
// MARK: - since filter
|
||||
|
||||
@Test("events strictly earlier than since are excluded from counts and recent")
|
||||
func eventsBeforeSinceAreExcluded() {
|
||||
// Arrange: one stale tool run before leaving, one fresh after.
|
||||
let stale = Self.event(atOffsetMs: -1, class: "tool", label: "ran Bash")
|
||||
let boundary = Self.event(atOffsetMs: 0, class: "waiting", label: "waiting for approval")
|
||||
let fresh = Self.event(atOffsetMs: 1, class: "tool", label: "ran Edit")
|
||||
|
||||
// Act
|
||||
let digest = AwayDigest.reduce(
|
||||
events: [stale, boundary, fresh], since: Self.leftAt, limit: Self.defaultLimit)
|
||||
|
||||
// Assert: strictly-earlier excluded; the exact leave instant still counts.
|
||||
#expect(digest.toolRuns == 1)
|
||||
#expect(digest.waitingCount == 1)
|
||||
#expect(digest.recent == [boundary, fresh])
|
||||
}
|
||||
|
||||
@Test("all events before since yield the all-zero digest")
|
||||
func allEventsBeforeSinceYieldAllZeroDigest() {
|
||||
// Arrange
|
||||
let events = [
|
||||
Self.event(atOffsetMs: -2, class: "tool", label: "ran Bash"),
|
||||
Self.event(atOffsetMs: -1, class: "done", label: "done"),
|
||||
]
|
||||
|
||||
// Act
|
||||
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: Self.defaultLimit)
|
||||
|
||||
// Assert: UI can skip rendering entirely.
|
||||
#expect(digest == .empty)
|
||||
#expect(digest.isEmpty == true)
|
||||
}
|
||||
|
||||
// MARK: - limit / recent
|
||||
|
||||
@Test("limit truncates recent to the most recent events in chronological order")
|
||||
func limitTruncatesRecentToMostRecentEvents() {
|
||||
// Arrange
|
||||
let events = (1...5).map { Self.event(atOffsetMs: $0, class: "tool", label: "ran \($0)") }
|
||||
|
||||
// Act
|
||||
let digest = AwayDigest.reduce(events: events, since: Self.leftAt, limit: 2)
|
||||
|
||||
// Assert: newest two, still oldest→newest.
|
||||
#expect(digest.recent == [events[3], events[4]])
|
||||
#expect(digest.toolRuns == 5)
|
||||
}
|
||||
|
||||
@Test("out-of-order server input is ordered by timestamp before truncating recent")
|
||||
func outOfOrderEventsAreOrderedByTimestampForRecent() {
|
||||
// Arrange: the server is an untrusted input source — order is not a given.
|
||||
let oldest = Self.event(atOffsetMs: 1, class: "tool", label: "ran 1")
|
||||
let middle = Self.event(atOffsetMs: 2, class: "waiting", label: "waiting")
|
||||
let newest = Self.event(atOffsetMs: 3, class: "done", label: "done")
|
||||
|
||||
// Act
|
||||
let digest = AwayDigest.reduce(
|
||||
events: [newest, oldest, middle], since: Self.leftAt, limit: 2)
|
||||
|
||||
// Assert
|
||||
#expect(digest.recent == [middle, newest])
|
||||
}
|
||||
|
||||
@Test("zero or negative limit yields empty recent while counts still compute")
|
||||
func zeroOrNegativeLimitYieldsEmptyRecentButCountsStillComputed() {
|
||||
// Arrange
|
||||
let events = [Self.event(atOffsetMs: 1, class: "tool", label: "ran Bash")]
|
||||
|
||||
// Act
|
||||
let zero = AwayDigest.reduce(events: events, since: Self.leftAt, limit: 0)
|
||||
let negative = AwayDigest.reduce(events: events, since: Self.leftAt, limit: -3)
|
||||
|
||||
// Assert
|
||||
#expect(zero.recent.isEmpty)
|
||||
#expect(zero.toolRuns == 1)
|
||||
#expect(negative.recent.isEmpty)
|
||||
#expect(negative.toolRuns == 1)
|
||||
}
|
||||
|
||||
// MARK: - empty / extreme inputs
|
||||
|
||||
@Test("empty events yield the all-zero digest")
|
||||
func emptyEventsYieldAllZeroDigest() {
|
||||
// Arrange / Act
|
||||
let digest = AwayDigest.reduce(events: [], since: Self.leftAt, limit: Self.defaultLimit)
|
||||
|
||||
// Assert
|
||||
#expect(digest == AwayDigest(
|
||||
toolRuns: 0, waitingCount: 0, sawDone: false, sawStuck: false, recent: []))
|
||||
#expect(digest.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("extreme since dates never crash the ms conversion")
|
||||
func extremeSinceDatesDoNotCrash() {
|
||||
// Arrange
|
||||
let events = [Self.event(atOffsetMs: 1, class: "tool", label: "ran Bash")]
|
||||
|
||||
// Act
|
||||
let future = AwayDigest.reduce(events: events, since: .distantFuture, limit: 5)
|
||||
let past = AwayDigest.reduce(events: events, since: .distantPast, limit: 5)
|
||||
let overflow = AwayDigest.reduce(
|
||||
events: events, since: Date(timeIntervalSince1970: .greatestFiniteMagnitude), limit: 5)
|
||||
|
||||
// Assert
|
||||
#expect(future.isEmpty == true)
|
||||
#expect(past.toolRuns == 1)
|
||||
#expect(overflow.isEmpty == true)
|
||||
}
|
||||
|
||||
// MARK: - vocabulary sync
|
||||
|
||||
@Test("digest class vocabulary stays in sync with WireProtocol's known classes")
|
||||
func classVocabularyStaysInSyncWithWireProtocol() {
|
||||
// Arrange: the local names must be exactly the server's TimelineClass
|
||||
// union as frozen in WireProtocol (src/types.ts:423).
|
||||
let local: Set<String> = [
|
||||
TimelineClassName.tool, TimelineClassName.waiting, TimelineClassName.done,
|
||||
TimelineClassName.stuck, TimelineClassName.user,
|
||||
]
|
||||
|
||||
// Act / Assert
|
||||
#expect(local == TimelineEvent.knownClasses)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import SessionCore
|
||||
|
||||
/// T-iOS-6 · GateState + GateTracker — permission-gate reducer (plan §3.2).
|
||||
/// Mirrors the web client's stale-gate guard (public/terminal-session.ts:94-311):
|
||||
/// epoch increments ONLY on a pending false→true rising edge; a decision carrying
|
||||
/// a stale epoch is dropped so a slow tap can never approve the NEXT gate.
|
||||
@Suite("GateState + GateTracker")
|
||||
struct GateStateTests {
|
||||
// MARK: - epoch semantics (rising edge only)
|
||||
|
||||
@Test("pending false→true rising edge creates a gate with epoch +1")
|
||||
func risingEdgeCreatesGateWithIncrementedEpoch() {
|
||||
// Arrange
|
||||
let tracker = GateTracker.initial
|
||||
|
||||
// Act
|
||||
let next = tracker.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
|
||||
// Assert
|
||||
#expect(tracker.current == nil)
|
||||
#expect(next.current == GateState(kind: .tool, detail: "Bash", epoch: 1))
|
||||
}
|
||||
|
||||
@Test("sustained pending frames keep the same epoch")
|
||||
func sustainedPendingKeepsSameEpoch() {
|
||||
// Arrange
|
||||
let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
|
||||
// Act: two continuation frames while the same gate stays held.
|
||||
let afterOne = held.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
let afterTwo = afterOne.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
|
||||
// Assert
|
||||
#expect(afterOne.current?.epoch == 1)
|
||||
#expect(afterTwo.current?.epoch == 1)
|
||||
}
|
||||
|
||||
@Test("continuation frames refresh detail from the latest frame without bumping epoch")
|
||||
func sustainedPendingRefreshesDetailFromLatestFrame() {
|
||||
// Arrange (mirrors terminal-session.ts:310 — pendingTool follows msg.detail)
|
||||
let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
|
||||
// Act
|
||||
let refreshed = held.reduce(pending: true, gate: .tool, detail: "Edit")
|
||||
|
||||
// Assert
|
||||
#expect(refreshed.current == GateState(kind: .tool, detail: "Edit", epoch: 1))
|
||||
}
|
||||
|
||||
@Test("pending true→false falling edge clears the gate")
|
||||
func fallingEdgeClearsGate() {
|
||||
// Arrange
|
||||
let held = GateTracker.initial.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
|
||||
// Act
|
||||
let cleared = held.reduce(pending: false, gate: nil, detail: nil)
|
||||
|
||||
// Assert
|
||||
#expect(cleared.current == nil)
|
||||
}
|
||||
|
||||
@Test("a second rising edge increments the epoch again")
|
||||
func secondRisingEdgeIncrementsEpochAgain() {
|
||||
// Arrange: gate 1 held, then resolved.
|
||||
let cleared = GateTracker.initial
|
||||
.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
.reduce(pending: false, gate: nil, detail: nil)
|
||||
|
||||
// Act: gate 2 arrives.
|
||||
let second = cleared.reduce(pending: true, gate: .plan, detail: nil)
|
||||
|
||||
// Assert
|
||||
#expect(second.current == GateState(kind: .plan, detail: nil, epoch: 2))
|
||||
}
|
||||
|
||||
// MARK: - stale-epoch decisions are dropped (防误批新 gate)
|
||||
|
||||
@Test("a decision carrying a stale epoch is dropped")
|
||||
func staleEpochDecisionIsDropped() {
|
||||
// Arrange: gate 1 resolved, gate 2 now held (epoch 2).
|
||||
let tracker = GateTracker.initial
|
||||
.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
.reduce(pending: false, gate: nil, detail: nil)
|
||||
.reduce(pending: true, gate: .tool, detail: "Write")
|
||||
|
||||
// Act / Assert: the user's slow tap against gate 1 must NOT approve gate 2.
|
||||
#expect(tracker.canDecide(epoch: 1) == false)
|
||||
}
|
||||
|
||||
@Test("a decision carrying the current epoch is accepted")
|
||||
func currentEpochDecisionIsAccepted() {
|
||||
// Arrange
|
||||
let tracker = GateTracker.initial
|
||||
.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
.reduce(pending: false, gate: nil, detail: nil)
|
||||
.reduce(pending: true, gate: .tool, detail: "Write")
|
||||
|
||||
// Act / Assert
|
||||
#expect(tracker.canDecide(epoch: 2) == true)
|
||||
}
|
||||
|
||||
@Test("a decision when no gate is held is dropped even with the last-issued epoch")
|
||||
func decisionWithNoGateHeldIsDropped() {
|
||||
// Arrange: gate 1 was resolved server-side before the tap landed.
|
||||
let cleared = GateTracker.initial
|
||||
.reduce(pending: true, gate: .tool, detail: "Bash")
|
||||
.reduce(pending: false, gate: nil, detail: nil)
|
||||
|
||||
// Act / Assert
|
||||
#expect(cleared.canDecide(epoch: 1) == false)
|
||||
}
|
||||
|
||||
// MARK: - affordances (plan → three-way, tool → two-way)
|
||||
|
||||
@Test("plan gate offers the three-way affordance set")
|
||||
func planGateOffersThreeWayAffordances() {
|
||||
// Arrange
|
||||
let gate = GateState(kind: .plan, detail: nil, epoch: 1)
|
||||
|
||||
// Act / Assert (mirrors public/tabs.ts:343-350)
|
||||
#expect(gate.affordances == [.approveAuto, .approveReview, .keepPlanning])
|
||||
}
|
||||
|
||||
@Test("tool gate offers the two-way affordance set")
|
||||
func toolGateOffersTwoWayAffordances() {
|
||||
// Arrange
|
||||
let gate = GateState(kind: .tool, detail: "Bash", epoch: 1)
|
||||
|
||||
// Act / Assert (mirrors public/tabs.ts:334-339)
|
||||
#expect(gate.affordances == [.approve, .reject])
|
||||
}
|
||||
|
||||
@Test("pending frame without a gate kind defaults to a tool gate")
|
||||
func pendingWithoutGateKindDefaultsToToolGate() {
|
||||
// Arrange: an unrecognized/absent gate decodes as nil (ServerMessage) —
|
||||
// the pending signal must never be lost (web: gate ?? null → tool buttons).
|
||||
let tracker = GateTracker.initial
|
||||
|
||||
// Act
|
||||
let held = tracker.reduce(pending: true, gate: nil, detail: "Bash")
|
||||
|
||||
// Assert
|
||||
#expect(held.current?.kind == .tool)
|
||||
#expect(held.current?.affordances == [.approve, .reject])
|
||||
}
|
||||
|
||||
@Test("affordances map to the exact wire messages the web client sends")
|
||||
func affordancesMapToWireMessagesMirroringWebClient() {
|
||||
// Arrange / Act / Assert (public/tabs.ts:345-347: acceptEdits / default / reject)
|
||||
#expect(GateState.Affordance.approveAuto.clientMessage == .approve(mode: .acceptEdits))
|
||||
#expect(GateState.Affordance.approveReview.clientMessage == .approve(mode: .default))
|
||||
#expect(GateState.Affordance.keepPlanning.clientMessage == .reject)
|
||||
#expect(GateState.Affordance.approve.clientMessage == .approve(mode: nil))
|
||||
#expect(GateState.Affordance.reject.clientMessage == .reject)
|
||||
}
|
||||
|
||||
// MARK: - purity
|
||||
|
||||
@Test("reduce is pure: same input gives same output and never mutates the receiver")
|
||||
func reduceIsPureAndDoesNotMutateReceiver() {
|
||||
// Arrange
|
||||
let tracker = GateTracker.initial
|
||||
let snapshotBefore = tracker
|
||||
|
||||
// Act: reduce twice with the identical input on the SAME value.
|
||||
let first = tracker.reduce(pending: true, gate: .plan, detail: "d")
|
||||
let second = tracker.reduce(pending: true, gate: .plan, detail: "d")
|
||||
|
||||
// Assert
|
||||
#expect(first == second)
|
||||
#expect(tracker == snapshotBefore)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
@testable import SessionCore
|
||||
|
||||
/// T-iOS-5 · PingScheduler — keep-alive pacer (plan §3.2 / §3.2.1).
|
||||
/// 25s ping cadence (`Tunables.pingInterval`); 1 missed pong tolerated,
|
||||
/// `Tunables.pongMissLimit` (2) CONSECUTIVE misses → connection declared lost.
|
||||
/// All timing on FakeClock — zero real waits.
|
||||
@Suite("PingScheduler")
|
||||
struct PingSchedulerTests {
|
||||
/// Scripted pong ledger: pops one result per ping, records the call count.
|
||||
/// Runs out of script → answers `true` (pong received).
|
||||
private actor PongScript {
|
||||
private var results: [Bool]
|
||||
private(set) var pingCount = 0
|
||||
|
||||
init(_ results: [Bool]) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func nextResult() -> Bool {
|
||||
pingCount += 1
|
||||
guard !results.isEmpty else { return true }
|
||||
return results.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `run` on a FakeClock and parks it at its first sleep.
|
||||
private static func startScheduler(
|
||||
clock: FakeClock,
|
||||
script: PongScript,
|
||||
interval: Duration = Tunables.pingInterval
|
||||
) async -> Task<PingScheduler.Outcome, Never> {
|
||||
let scheduler = PingScheduler(interval: interval)
|
||||
let task = Task {
|
||||
await scheduler.run(clock: clock) { await script.nextResult() }
|
||||
}
|
||||
await clock.waitForSleepers(count: 1)
|
||||
return task
|
||||
}
|
||||
|
||||
/// Fires one ping tick: advance past the interval, then wait until the
|
||||
/// scheduler is parked in its NEXT sleep (proof the tick fully processed).
|
||||
private static func fireTickAndAwaitNextSleep(clock: FakeClock) async {
|
||||
clock.advance(by: Tunables.pingInterval)
|
||||
await clock.waitForSleepers(count: 1)
|
||||
}
|
||||
|
||||
@Test("default interval is Tunables.pingInterval (25s)")
|
||||
func defaultIntervalIsTunablesPingInterval() {
|
||||
// Arrange / Act
|
||||
let scheduler = PingScheduler()
|
||||
|
||||
// Assert
|
||||
#expect(scheduler.interval == Tunables.pingInterval)
|
||||
#expect(scheduler.interval == .seconds(25))
|
||||
}
|
||||
|
||||
@Test("sends one ping per interval tick while pongs keep arriving")
|
||||
func sendsOnePingPerIntervalTickWhenPongsArrive() async {
|
||||
// Arrange
|
||||
let clock = FakeClock()
|
||||
let script = PongScript([true, true, true])
|
||||
let task = await Self.startScheduler(clock: clock, script: script)
|
||||
|
||||
// Act: three full interval ticks.
|
||||
await Self.fireTickAndAwaitNextSleep(clock: clock)
|
||||
await Self.fireTickAndAwaitNextSleep(clock: clock)
|
||||
await Self.fireTickAndAwaitNextSleep(clock: clock)
|
||||
|
||||
// Assert: exactly one ping per tick, none before the first interval.
|
||||
#expect(await script.pingCount == 3)
|
||||
task.cancel()
|
||||
#expect(await task.value == .cancelled)
|
||||
}
|
||||
|
||||
@Test("does not ping before the first interval has elapsed")
|
||||
func doesNotPingBeforeFirstIntervalElapses() async {
|
||||
// Arrange
|
||||
let clock = FakeClock()
|
||||
let script = PongScript([])
|
||||
let task = await Self.startScheduler(clock: clock, script: script)
|
||||
|
||||
// Act: advance just short of the interval.
|
||||
clock.advance(by: Tunables.pingInterval - .seconds(1))
|
||||
|
||||
// Assert: still parked, zero pings sent.
|
||||
#expect(clock.pendingSleeperCount == 1)
|
||||
#expect(await script.pingCount == 0)
|
||||
task.cancel()
|
||||
#expect(await task.value == .cancelled)
|
||||
}
|
||||
|
||||
@Test("tolerates a single missed pong and keeps the connection alive")
|
||||
func toleratesSingleMissedPongAndKeepsPinging() async {
|
||||
// Arrange: miss one pong, then recover.
|
||||
let clock = FakeClock()
|
||||
let script = PongScript([false, true])
|
||||
let task = await Self.startScheduler(clock: clock, script: script)
|
||||
|
||||
// Act: tick #1 misses — scheduler must survive and park again;
|
||||
// tick #2 gets its pong.
|
||||
await Self.fireTickAndAwaitNextSleep(clock: clock)
|
||||
await Self.fireTickAndAwaitNextSleep(clock: clock)
|
||||
|
||||
// Assert: both pings sent, run still alive (it re-parked after each).
|
||||
#expect(await script.pingCount == 2)
|
||||
task.cancel()
|
||||
#expect(await task.value == .cancelled)
|
||||
}
|
||||
|
||||
@Test("two consecutive missed pongs signal connectionLost")
|
||||
func twoConsecutiveMissedPongsSignalConnectionLost() async {
|
||||
// Arrange
|
||||
let clock = FakeClock()
|
||||
let script = PongScript([false, false])
|
||||
let task = await Self.startScheduler(clock: clock, script: script)
|
||||
|
||||
// Act: miss #1 (tolerated) …
|
||||
await Self.fireTickAndAwaitNextSleep(clock: clock)
|
||||
// … miss #2 == Tunables.pongMissLimit → run must finish by itself.
|
||||
clock.advance(by: Tunables.pingInterval)
|
||||
|
||||
// Assert
|
||||
#expect(await task.value == .connectionLost)
|
||||
#expect(await script.pingCount == Tunables.pongMissLimit)
|
||||
|
||||
// Assert: a dead scheduler never pings again.
|
||||
clock.advance(by: Tunables.pingInterval)
|
||||
#expect(await script.pingCount == Tunables.pongMissLimit)
|
||||
}
|
||||
|
||||
@Test("a pong between misses resets the counter so non-consecutive misses never disconnect")
|
||||
func pongBetweenMissesResetsConsecutiveMissCounter() async {
|
||||
// Arrange: miss, pong, miss, pong — never two misses in a row.
|
||||
let clock = FakeClock()
|
||||
let script = PongScript([false, true, false, true])
|
||||
let task = await Self.startScheduler(clock: clock, script: script)
|
||||
|
||||
// Act
|
||||
for _ in 0..<4 {
|
||||
await Self.fireTickAndAwaitNextSleep(clock: clock)
|
||||
}
|
||||
|
||||
// Assert: still alive after 4 ticks (it re-parked after the last one).
|
||||
#expect(await script.pingCount == 4)
|
||||
task.cancel()
|
||||
#expect(await task.value == .cancelled)
|
||||
}
|
||||
|
||||
@Test("cancellation while sleeping ends run as cancelled, not connectionLost")
|
||||
func cancellationWhileSleepingEndsRunAsCancelled() async {
|
||||
// Arrange
|
||||
let clock = FakeClock()
|
||||
let script = PongScript([])
|
||||
let task = await Self.startScheduler(clock: clock, script: script)
|
||||
|
||||
// Act: tear down mid-sleep (normal close path).
|
||||
task.cancel()
|
||||
|
||||
// Assert
|
||||
#expect(await task.value == .cancelled)
|
||||
#expect(await script.pingCount == 0)
|
||||
}
|
||||
|
||||
@Test("honors a custom interval instead of the default")
|
||||
func honorsCustomIntervalInsteadOfDefault() async {
|
||||
// Arrange
|
||||
let customInterval: Duration = .seconds(5)
|
||||
let clock = FakeClock()
|
||||
let script = PongScript([true])
|
||||
let task = await Self.startScheduler(
|
||||
clock: clock, script: script, interval: customInterval
|
||||
)
|
||||
|
||||
// Act: one custom-interval tick.
|
||||
clock.advance(by: customInterval)
|
||||
await clock.waitForSleepers(count: 1)
|
||||
|
||||
// Assert
|
||||
#expect(await script.pingCount == 1)
|
||||
task.cancel()
|
||||
#expect(await task.value == .cancelled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Testing
|
||||
@testable import SessionCore
|
||||
|
||||
/// T-iOS-5 · ReconnectMachine — pure back-off reducer (plan §3.2).
|
||||
/// Mirrors public/terminal-session.ts:106,248,352-361: retry delays
|
||||
/// 1s → 2s → 4s → 8s → 16s → 30s (capped), reset to 1s on `connected`.
|
||||
@Suite("ReconnectMachine")
|
||||
struct ReconnectMachineTests {
|
||||
/// Delay ladder the web client produces (public/terminal-session.ts:356).
|
||||
private static let expectedBackoffLadder: [Duration] = [
|
||||
.seconds(1), .seconds(2), .seconds(4), .seconds(8),
|
||||
.seconds(16), .seconds(30), .seconds(30),
|
||||
]
|
||||
|
||||
/// Drives `count` disconnect→timer-fired cycles from `.initial` and
|
||||
/// collects every `.scheduleRetry(after:)` delay along the way.
|
||||
private static func scheduledDelays(disconnectCount: Int) -> [Duration] {
|
||||
var machine = ReconnectMachine.initial
|
||||
var delays: [Duration] = []
|
||||
for _ in 0..<disconnectCount {
|
||||
let (afterDisconnect, effect) = machine.reduce(.disconnected)
|
||||
if case .scheduleRetry(let after) = effect {
|
||||
delays.append(after)
|
||||
}
|
||||
let (afterFire, _) = afterDisconnect.reduce(.retryTimerFired)
|
||||
machine = afterFire
|
||||
}
|
||||
return delays
|
||||
}
|
||||
|
||||
/// Returns the machine state after `count` disconnect→timer-fired cycles.
|
||||
private static func machineAfterDisconnects(_ count: Int) -> ReconnectMachine {
|
||||
var machine = ReconnectMachine.initial
|
||||
for _ in 0..<count {
|
||||
let (afterDisconnect, _) = machine.reduce(.disconnected)
|
||||
let (afterFire, _) = afterDisconnect.reduce(.retryTimerFired)
|
||||
machine = afterFire
|
||||
}
|
||||
return machine
|
||||
}
|
||||
|
||||
@Test("disconnect sequence schedules 1s,2s,4s,8s,16s then caps at 30s")
|
||||
func disconnectSequenceFollowsDoublingLadderCappedAt30s() {
|
||||
// Arrange / Act
|
||||
let delays = Self.scheduledDelays(disconnectCount: Self.expectedBackoffLadder.count)
|
||||
|
||||
// Assert
|
||||
#expect(delays == Self.expectedBackoffLadder)
|
||||
}
|
||||
|
||||
@Test("every disconnected input produces a scheduleRetry effect")
|
||||
func disconnectedAlwaysSchedulesARetry() {
|
||||
// Arrange
|
||||
let disconnectCount = Self.expectedBackoffLadder.count
|
||||
|
||||
// Act
|
||||
let delays = Self.scheduledDelays(disconnectCount: disconnectCount)
|
||||
|
||||
// Assert: no cycle dropped its retry (delays collected == cycles run).
|
||||
#expect(delays.count == disconnectCount)
|
||||
}
|
||||
|
||||
@Test("connected resets backoff so the next disconnect starts at 1s again")
|
||||
func connectedResetsBackoffToOneSecond() {
|
||||
// Arrange: escalate well into the ladder first.
|
||||
let escalated = Self.machineAfterDisconnects(4)
|
||||
|
||||
// Act
|
||||
let (afterConnected, connectEffect) = escalated.reduce(.connected)
|
||||
let (_, retryEffect) = afterConnected.reduce(.disconnected)
|
||||
|
||||
// Assert
|
||||
#expect(connectEffect == .none)
|
||||
#expect(afterConnected == .initial)
|
||||
#expect(retryEffect == .scheduleRetry(after: .seconds(1)))
|
||||
}
|
||||
|
||||
@Test("connected while already at initial state is a no-op")
|
||||
func connectedOnInitialStateIsNoOp() {
|
||||
// Arrange
|
||||
let machine = ReconnectMachine.initial
|
||||
|
||||
// Act
|
||||
let (next, effect) = machine.reduce(.connected)
|
||||
|
||||
// Assert
|
||||
#expect(next == .initial)
|
||||
#expect(effect == .none)
|
||||
}
|
||||
|
||||
@Test("retryTimerFired produces connectNow")
|
||||
func retryTimerFiredProducesConnectNow() {
|
||||
// Arrange
|
||||
let (waiting, _) = ReconnectMachine.initial.reduce(.disconnected)
|
||||
|
||||
// Act
|
||||
let (_, effect) = waiting.reduce(.retryTimerFired)
|
||||
|
||||
// Assert
|
||||
#expect(effect == .connectNow)
|
||||
}
|
||||
|
||||
@Test("foregrounded produces immediate connectNow without waiting for the timer")
|
||||
func foregroundedProducesImmediateConnectNow() {
|
||||
// Arrange: mid-backoff (next disconnect would schedule 4s).
|
||||
let waiting = Self.machineAfterDisconnects(2)
|
||||
|
||||
// Act
|
||||
let (afterForeground, effect) = waiting.reduce(.foregrounded)
|
||||
|
||||
// Assert: connect right now, and the backoff ladder is NOT reset —
|
||||
// only a successful `connected` resets it.
|
||||
#expect(effect == .connectNow)
|
||||
#expect(afterForeground == waiting)
|
||||
let (_, retryEffect) = afterForeground.reduce(.disconnected)
|
||||
#expect(retryEffect == .scheduleRetry(after: .seconds(4)))
|
||||
}
|
||||
|
||||
@Test("userRetry produces immediate connectNow without waiting for the timer")
|
||||
func userRetryProducesImmediateConnectNow() {
|
||||
// Arrange
|
||||
let waiting = Self.machineAfterDisconnects(3)
|
||||
|
||||
// Act
|
||||
let (afterRetry, effect) = waiting.reduce(.userRetry)
|
||||
|
||||
// Assert
|
||||
#expect(effect == .connectNow)
|
||||
#expect(afterRetry == waiting)
|
||||
}
|
||||
|
||||
@Test("reduce is pure: same input gives same output and never mutates the receiver")
|
||||
func reduceIsPureAndDoesNotMutateOriginalSnapshot() {
|
||||
// Arrange
|
||||
let machine = ReconnectMachine.initial
|
||||
let snapshotBefore = machine
|
||||
|
||||
// Act: reduce twice with the identical input on the SAME value.
|
||||
let (firstNext, firstEffect) = machine.reduce(.disconnected)
|
||||
let (secondNext, secondEffect) = machine.reduce(.disconnected)
|
||||
|
||||
// Assert: deterministic output, untouched original.
|
||||
#expect(firstNext == secondNext)
|
||||
#expect(firstEffect == secondEffect)
|
||||
#expect(machine == snapshotBefore)
|
||||
#expect(machine == .initial)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ public enum Tunables {
|
||||
/// host/attacker-controlled input).
|
||||
public static let titleMaxLength = 256
|
||||
|
||||
/// Overall pairing-probe deadline (§3.4 contract ruling, 2026-07-04): if
|
||||
/// either probe step hangs past this, the probe resolves `.timeout`.
|
||||
public static let pairingProbeTimeout: Duration = .seconds(10)
|
||||
|
||||
/// `URLSessionWebSocketTask.maximumMessageSize`. The default (1 MiB) is too
|
||||
/// small: ring-buffer replay arrives as ONE full-snapshot frame
|
||||
/// (src/session/session.ts:165-171) and JSON escaping inflates control
|
||||
@@ -37,7 +41,8 @@ public enum Tunables {
|
||||
/// COUPLING WARNING: SCROLLBACK_BYTES is a server env knob the client
|
||||
/// cannot discover at runtime (no config handshake). If the host raises it
|
||||
/// past ~2.7 MB (or replay is extremely escape-dense), `receive()` fails
|
||||
/// with NSPOSIXErrorDomain code 40 (ENOBUFS "Message too long") — that MUST
|
||||
/// with NSPOSIXErrorDomain code 40 (EMSGSIZE "Message too long"; 55
|
||||
/// ENOBUFS as fallback classification — T-iOS-2 spike measured) — that MUST
|
||||
/// be classified as the non-retryable `.replayTooLarge` failure and never
|
||||
/// fed into the backoff reconnect loop (plan §3.2 / T-iOS-9/10).
|
||||
public static let maxWSMessageBytes = 16 * 1024 * 1024
|
||||
|
||||
@@ -13,6 +13,7 @@ func tunablesMatchFrozenValueTable() {
|
||||
#expect(Tunables.telemetryStaleTtlMs == 30_000) // public/tabs.ts:45 STATUSLINE_TTL_MS
|
||||
#expect(Tunables.digestFadeDelay == .seconds(8))
|
||||
#expect(Tunables.titleMaxLength == 256)
|
||||
#expect(Tunables.pairingProbeTimeout == .seconds(10)) // §3.4 契约裁定 2026-07-04
|
||||
#expect(Tunables.maxWSMessageBytes == 16 * 1024 * 1024)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user