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

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

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

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

View File

@@ -3,23 +3,46 @@ 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).
/// **Origin plan §3.4/§5.1**: `Origin: endpoint.originHeader` is
/// stamped **iff** the route is G (state-changing); RO GETs never carry it.
/// Stamping lives in ONE place `APIRoute.urlRequest(for:accessToken:)` and
/// the value is single-point derived by `HostEndpoint` (never hand-assembled).
/// The optional access-token `Cookie` is stamped at that same single point and is
/// **orthogonal**: it never replaces Origin (ios-completion §1.1).
///
/// 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 struct APIClient: Sendable, CustomStringConvertible, CustomDebugStringConvertible {
public let endpoint: HostEndpoint
private let http: any HTTPTransport
/// The host's optional shared access token (`WEBTERM_TOKEN`, ios-completion
/// §1.1). SECRET: private, never printed (see `description`), never put in a
/// URL, only ever leaving as a `Cookie` header stamped in `APIRoute`.
/// nil = the host has no token configured (LAN zero-config).
private let accessToken: String?
public init(endpoint: HostEndpoint, http: any HTTPTransport) {
public init(endpoint: HostEndpoint, http: any HTTPTransport, accessToken: String? = nil) {
self.endpoint = endpoint
self.http = http
self.accessToken = accessToken
}
/// Whether this client carries an access token PRESENCE only. There is
/// deliberately no getter for the value: the token leaves this type solely
/// as a `Cookie` header (plan §5: never log, never a URL, never a report).
public var hasAccessToken: Bool { accessToken != nil }
/// Redacted on purpose: the default reflection-based description of a
/// struct holding a secret would print it into any log line that
/// interpolates the client.
public var description: String {
"APIClient(origin: \(endpoint.originHeader), accessToken: "
+ (accessToken == nil ? "none)" : "<redacted>)")
}
public var debugDescription: String { description }
// MARK: - RO (read-only NO Origin header)
/// `GET /live-sessions` (src/server.ts:257-259) the discovery list every
@@ -107,11 +130,35 @@ public struct APIClient: Sendable {
// MARK: - Internals (shared with the P1 feature files, T-iOS-38)
/// The ONE request choke point. Two structural guarantees live here:
/// - a configured token is shape-validated before it can reach a header
/// (`.malformedToken`, fail-fast never silently send an unauthenticated
/// request and let the user read the 401 as "server is down");
/// - a 401 becomes the typed `.unauthorized` (ios-completion §1.1) for every
/// route except the two families that define their own 401
/// (`UnauthorizedPolicy.routeDefined`).
func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) {
guard let request = route.urlRequest(for: endpoint) else {
let token = try validatedAccessToken()
guard let request = route.urlRequest(for: endpoint, accessToken: token) else {
throw APIClientError.invalidRequest
}
return try await http.send(request)
let (data, response) = try await http.send(request)
if response.statusCode == HTTPStatus.unauthorized,
route.unauthorizedPolicy == .accessTokenGate {
throw APIClientError.unauthorized
}
return (data, response)
}
/// nil when no token is configured; throws `.malformedToken` when one is
/// configured but violates the frozen charset/length rule (which is also
/// what makes CRLF header injection impossible).
private func validatedAccessToken() throws -> String? {
guard let accessToken else { return nil }
guard AccessTokenRule.isWellFormed(accessToken) else {
throw APIClientError.malformedToken
}
return accessToken
}
/// 200 ok; 404 `.sessionNotFound`; anything else `.unexpectedStatus`.
@@ -125,6 +172,44 @@ public struct APIClient: Sendable {
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
/// The `/projects/*` three-prong contract, shared by `detail`/`log`/`pr`
/// (src/server.ts:1033-1042 and friends): `path` missing/empty 400,
/// non-git dir 404, read failure 500. `notFound` is a parameter because
/// `/projects/worktree/state`'s 404 means "not a worktree", not "no project".
static func requireGitReadOK(
_ response: HTTPURLResponse, notFound: APIClientError = .projectNotFound
) throws {
switch response.statusCode {
case HTTPStatus.ok:
return
case HTTPStatus.badRequest:
throw APIClientError.projectPathInvalid
case HTTPStatus.notFound:
throw notFound
case HTTPStatus.internalServerError:
throw APIClientError.gitDataUnavailable
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
/// Mirror of the server's own `path` guard, applied BEFORE any network I/O
/// (validate at the boundary, plan §4) every `/projects/*` route rejects
/// an empty path with 400, so there is nothing to learn from the round trip.
static func requireNonEmptyPath(_ path: String) throws {
guard !path.isEmpty else {
throw APIClientError.projectPathInvalid
}
}
/// Decode a single JSON object body, or `.invalidResponseBody`.
static func decodeObject<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
guard let value = try? JSONDecoder().decode(T.self, from: data) else {
throw APIClientError.invalidResponseBody
}
return value
}
}
/// Named HTTP status codes used by the client (no magic numbers, plan §4).
@@ -132,8 +217,12 @@ enum HTTPStatus {
static let ok = 200
static let noContent = 204
static let badRequest = 400
static let unauthorized = 401
static let forbidden = 403
static let notFound = 404
static let conflict = 409
static let payloadTooLarge = 413
static let tooManyRequests = 429
static let internalServerError = 500
static let serviceUnavailable = 503
}

View File

@@ -0,0 +1,132 @@
import Foundation
import WireProtocol
// B1 · optional shared access token (`WEBTERM_TOKEN`) ios-completion §1.1
// FROZEN contract, cross-checked against `src/http/auth.ts` + `src/server.ts`.
//
// Server facts:
// | cookie name | `webterm_auth` | auth.ts:30 |
// | login endpoint | `POST /auth` | server.ts:393 |
// | request body | `{"token":"<t>"}` + JSON C-T | server.ts:396 |
// | `Accept` | MUST NOT contain `text/html` | server.ts:366 |
// | valid | 204 **with** `Set-Cookie` | server.ts:412 |
// | wrong token | 401 `{"error":"invalid token"}` | server.ts:418 |
// | rate limited | 429 (10/min/IP) | server.ts:399 |
// | auth DISABLED | 204 **without** `Set-Cookie` | server.ts:404 |
//
// IMPLEMENTATION DECISION (frozen): a native client KNOWS its token, so it
// hand-writes `Cookie: webterm_auth=<t>` and NEVER parses `Set-Cookie` nor
// relies on a cookie jar (URLSession/OkHttp jars behave inconsistently on a WS
// upgrade and are hard to test). Hand-written header == the same pattern as the
// hand-written `Origin`, pinned by pure-function unit tests.
//
// HONEST BOUNDARY (src/http/auth.ts header): the token is a bar-raiser, NOT a
// TLS substitute. On bare `ws://`/`http://` it travels in cleartext and is
// replayable by a LAN sniffer; it only meaningfully hardens the TLS-terminated
// relay/tunnel path.
/// The auth cookie, single-point (name + value assembly).
enum AuthCookie {
/// `AUTH_COOKIE_NAME` (src/http/auth.ts:30).
static let name = "webterm_auth"
/// Response header the probe reads. The client checks its PRESENCE only and
/// never parses its value the token it would echo is already known.
static let setCookieHeader = "Set-Cookie"
/// `webterm_auth=<token>`. Callers MUST pass a token that already satisfies
/// `AccessTokenRule.isWellFormed` the charset check is what guarantees no
/// CR/LF (header injection) and no `;` (cookie splitting) can appear here.
static func headerValue(for token: String) -> String {
"\(name)=\(token)"
}
}
/// The frozen token shape: 16512 characters from `[A-Za-z0-9._~+/=-]`
/// (CLAUDE.md / `src/config.ts` the server REFUSES TO START with anything
/// else, so a shape-violating token can never be the right one).
enum AccessTokenRule {
static let minLength = 16
static let maxLength = 512
/// URL/cookie-safe set, byte-for-byte the server's `[A-Za-z0-9._~+/=-]`.
private static let allowed = Set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-"
)
static func isWellFormed(_ token: String) -> Bool {
(minLength...maxLength).contains(token.count) && token.allSatisfy(allowed.contains)
}
}
/// The four outcomes of the `POST /auth` pairing-time probe (ios-completion
/// §1.1). They are RESULTS, not errors: three of the four are perfectly normal
/// states the pairing UI must tell apart.
public enum AccessTokenProbeResult: Sendable, Equatable {
/// 204 **with** `Set-Cookie` the token is correct; persist it (Keychain).
case valid
/// 204 **without** `Set-Cookie` this host has auth DISABLED. It is NOT
/// "authenticated": nothing was verified and nothing should be persisted.
case authDisabled
/// 401 wrong token.
case invalidToken
/// 429 10 attempts/min/IP exceeded; ask the user to wait.
case rateLimited
}
extension Endpoints {
static let authPath = "/auth"
/// `POST /auth` with body `{"token":}`.
///
/// - `.guarded`: this is a state-changing POST (it mints a session cookie),
/// so it stamps `Origin` like every other write. The server does not
/// Origin-check `/auth` today keeping the rule uniform costs one header
/// and avoids a special case that would rot the moment it does.
/// - `.routeDefined`: this route's own 401 means "wrong token", not "the
/// gate rejected you" the probe maps it to `.invalidToken`.
static func auth(token: String) throws -> APIRoute {
APIRoute(
method: .post, path: authPath, originPolicy: .guarded,
body: try JSONEncoder().encode(AuthTokenBody(token: token)),
unauthorizedPolicy: .routeDefined
)
}
private struct AuthTokenBody: Encodable {
let token: String
}
}
extension APIClient {
/// One-shot pairing-time probe of a candidate access token
/// (`POST /auth`, ios-completion §1.1).
///
/// The token travels ONLY in the JSON body never in a URL query (the
/// server's `?token=` bootstrap exists for browsers, which strip it from
/// history afterwards; a native client has no reason to put a secret in a
/// URL that lands in logs). A shape-violating candidate is rejected here,
/// before any network I/O (`.malformedToken`).
///
/// Returns one of the four frozen outcomes; any other status throws
/// `.unexpectedStatus` rather than guessing.
public func probeAccessToken(_ token: String) async throws -> AccessTokenProbeResult {
guard AccessTokenRule.isWellFormed(token) else {
throw APIClientError.malformedToken
}
let (_, response) = try await perform(try Endpoints.auth(token: token))
switch response.statusCode {
case HTTPStatus.noContent:
// THE distinction the whole feature hinges on: a 204 without a
// Set-Cookie means the host never enabled auth. Reporting that as
// "authenticated" would persist a token that gates nothing and
// teach the user the host is protected when it is not.
return response.value(forHTTPHeaderField: AuthCookie.setCookieHeader) == nil
? .authDisabled : .valid
case HTTPStatus.unauthorized:
return .invalidToken
case HTTPStatus.tooManyRequests:
return .rateLimited
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
}

View File

@@ -21,10 +21,28 @@ enum OriginPolicy: Sendable, Equatable {
case guarded
}
/// How a 401 on this route must be READ (ios-completion §1.1). The access-token
/// gate answers 401 for any unauthed request (src/server.ts:459 `authGate` step
/// 5), so on almost every route 401 means "token missing/wrong" typed
/// `.unauthorized`. Two route families define their OWN 401 and must not be
/// swallowed by that rule declared here, at the route, so the exception is
/// visible instead of hidden in a call site.
enum UnauthorizedPolicy: Sendable, Equatable {
/// Default a 401 can only be the access-token gate.
case accessTokenGate
/// The route owns its 401: `POST /auth`'s wrong-token answer
/// (src/server.ts:418) and the git-write family, where the server
/// CLASSIFIES a host-side git credential failure as 401
/// (src/http/git-ops.ts:108 "Push authentication required on the host.").
case routeDefined
}
/// Header/content-type names used by the builder (no magic strings inline).
enum HeaderName {
static let origin = "Origin"
static let contentType = "Content-Type"
static let accept = "Accept"
static let cookie = "Cookie"
}
enum ContentTypeValue {
@@ -41,28 +59,43 @@ struct APIRoute: Sendable, Equatable {
/// nil for no query. Percent-encoding happens ONCE, in the route builder
/// (T-iOS-38: `/projects/detail?path=`) never at call sites.
let percentEncodedQuery: String?
/// See `UnauthorizedPolicy`. Defaults to the gate reading.
let unauthorizedPolicy: UnauthorizedPolicy
init(
method: HTTPMethod,
path: String,
originPolicy: OriginPolicy,
body: Data?,
percentEncodedQuery: String? = nil
percentEncodedQuery: String? = nil,
unauthorizedPolicy: UnauthorizedPolicy = .accessTokenGate
) {
self.method = method
self.path = path
self.originPolicy = originPolicy
self.body = body
self.percentEncodedQuery = percentEncodedQuery
self.unauthorizedPolicy = unauthorizedPolicy
}
/// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
/// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
/// (dropped when nil), 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? {
/// derivation philosophy as `HostEndpoint.wsURL`.
///
/// **Every header this client sends is stamped HERE and only here** (single
/// point; hand-stamping elsewhere is a review CRITICAL, plan §5.1):
/// - `Origin` **iff** `.guarded` the security split;
/// - `Cookie: webterm_auth=<t>` iff a token is configured ORTHOGONAL to
/// the Origin rule (ios-completion §1.1: the token never REPLACES Origin,
/// both travel together, on RO and G alike);
/// - `Accept: application/json` always an `Accept` containing `text/html`
/// makes the server treat the request as a browser navigation and answer
/// 302/login instead of 401/204 (src/server.ts:366-369,459).
///
/// `accessToken` MUST already be shape-validated (`AccessTokenRule`); the
/// charset check is what makes a header-injection value impossible here.
func urlRequest(for endpoint: HostEndpoint, accessToken: String? = nil) -> URLRequest? {
guard var components = URLComponents(
url: endpoint.baseURL, resolvingAgainstBaseURL: true
) else { return nil }
@@ -78,9 +111,15 @@ struct APIRoute: Sendable, Equatable {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.accept)
if originPolicy == .guarded {
request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin)
}
if let accessToken {
request.setValue(
AuthCookie.headerValue(for: accessToken), forHTTPHeaderField: HeaderName.cookie
)
}
if let body {
request.httpBody = body
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType)
@@ -89,17 +128,24 @@ struct APIRoute: Sendable, Equatable {
}
}
/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 ). 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)
/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293)
/// · `GET /prefs` (:273)
/// - G `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20
/// shape, mirrors `/push/subscribe` :461-498)
/// P1 builders live beside their feature models: `ApnsToken.swift`,
/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner).
/// Builders for the frozen endpoints (plan §3.4 · T-iOS-38 P1 · B1 ). Route
/// table (verified against src/server.ts at the line numbers shown):
/// - RO `GET /live-sessions` (:485) · `GET /live-sessions/:id/preview` (:585)
/// · `GET /live-sessions/:id/events` (:984) · `GET /config/ui` (:1320)
/// · `GET /projects` (:507) · `GET /projects/detail?path=` (:564)
/// · `GET /prefs` (:518) · `GET /projects/log?path=[&n=]` (:1030)
/// · `GET /projects/pr?path=` (:1058)
/// · `GET /projects/worktree/state?path=` (:542) · `GET /sessions` (:479)
/// - G `DELETE /live-sessions/:id` (:689) · `POST /hook/decision` (:959)
/// · `PUT /prefs` (:523) · `POST|DELETE /push/apns-token` (:871/:892)
/// · `POST /auth` (:393) · `POST /live-sessions/:id/queue` (:605)
/// · `POST /projects/git/stage|commit|push|fetch` (:1184/:1219/:1256/:1290)
/// · `POST /projects/worktree` (:1095) · `DELETE /projects/worktree` (:1124)
/// · `POST /projects/worktree/prune` (:1154)
/// Builders live beside their feature models: `ApnsToken.swift`,
/// `Projects.swift`, `Prefs.swift`, `AccessToken.swift`, `GitLog.swift`,
/// `PrStatus.swift`, `WorktreeState.swift`, `GitWrite.swift`, `History.swift`,
/// `FollowupQueue.swift`.
enum Endpoints {
/// Strict RFC 3986 unreserved set everything else gets percent-encoded.
/// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is
@@ -108,6 +154,21 @@ enum Endpoints {
static let unreservedCharacters = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
/// THE single percent-encoding choke point for every query value in this
/// package (`?path=`, and anything added later). nil = not encodable
/// callers surface `.invalidRequest` instead of building a broken URL.
static func percentEncode(_ value: String) -> String? {
value.addingPercentEncoding(withAllowedCharacters: unreservedCharacters)
}
/// `path=<strictly-encoded>` the query shared by every `/projects/*` read
/// route (`detail`, `log`, `pr`, `worktree/state`). One builder, so a new
/// route cannot re-introduce the `+`-decodes-to-space class of bug.
static func pathQuery(_ path: String) -> String? {
percentEncode(path).map { "path=\($0)" }
}
static func liveSessions() -> APIRoute {
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
}
@@ -155,7 +216,7 @@ enum Endpoints {
/// 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 {
static func pathId(_ id: UUID) -> String {
id.uuidString.lowercased()
}

View File

@@ -0,0 +1,83 @@
import Foundation
import WireProtocol
// B1 · `POST /live-sessions/:id/queue` (src/server.ts:605-643) **G**.
// State-changing (it causes shell input on the next idle) Origin guard + per-IP
// rate limit. Body: `{text, appendEnter?}`.
//
// BYTE-SHUTTLE (the project's central invariant): the bytes are stored and later
// injected VERBATIM. The server never parses them as a shell command, and
// neither does this client `text` is passed through untouched, exactly like a
// keystroke. The FE owns the Enter decision (`appendEnter` a trailing `\r`,
// 0x0D never `\n`), so the stored entry is byte-identical to what typing it
// would have produced.
/// `POST /live-sessions/:id/queue` 200 body the queue's new depth.
struct QueueDepth: Decodable {
let length: Int
}
extension Endpoints {
/// `POST /live-sessions/:id/queue` G. `appendEnter` is always serialized
/// (the server reads `=== true`, so an explicit `false` is honest and
/// symmetric rather than relying on an absent-key default).
static func enqueueFollowup(
sessionId: UUID, text: String, appendEnter: Bool
) throws -> APIRoute {
APIRoute(
method: .post, path: "/live-sessions/\(pathId(sessionId))/queue",
originPolicy: .guarded,
body: try JSONEncoder().encode(
FollowupBody(text: text, appendEnter: appendEnter)
)
)
}
private struct FollowupBody: Encodable {
let text: String
let appendEnter: Bool
}
}
extension APIClient {
/// Enqueue a follow-up prompt, fired into the PTY on the session's next idle
/// (w2). Returns the queue's NEW depth.
///
/// G `Origin` byte-equal. Server-enforced limits (theirs, documented for
/// callers): body 16 KB, `text` + optional `\r` `queueItemMaxBytes`
/// ( 413), depth `queueMaxItems` ( 409, never a silent drop), per-IP
/// rate limit ( 429), `QUEUE_ENABLED=0` ( 503).
/// An empty `text` is rejected before any network I/O (mirrors the 400 rule).
@discardableResult
public func enqueueFollowup(
sessionId: UUID, text: String, appendEnter: Bool
) async throws -> Int {
guard !text.isEmpty else {
throw APIClientError.queueTextInvalid
}
let route = try Endpoints.enqueueFollowup(
sessionId: sessionId, text: text, appendEnter: appendEnter
)
let (data, response) = try await perform(route)
switch response.statusCode {
case HTTPStatus.ok:
return try Self.decodeObject(QueueDepth.self, from: data).length
case HTTPStatus.badRequest:
throw APIClientError.queueTextInvalid
case HTTPStatus.forbidden:
throw APIClientError.forbidden
case HTTPStatus.notFound:
throw APIClientError.sessionNotFound
case HTTPStatus.conflict:
throw APIClientError.queueFull
case HTTPStatus.payloadTooLarge:
throw APIClientError.queueTextTooLarge
case HTTPStatus.tooManyRequests:
throw APIClientError.rateLimited
case HTTPStatus.serviceUnavailable:
throw APIClientError.queueDisabled
default:
throw APIClientError.unexpectedStatus(response.statusCode)
}
}
}

View File

@@ -0,0 +1,110 @@
import Foundation
import WireProtocol
// B1 · `GET /projects/log?path=[&n=]` (src/server.ts:1030-1056) RO, NO Origin.
// Response = `src/types.ts:757-772` `GitLogResult`. Always 200 on a valid git
// dir (a git failure degrades to an empty commit list server-side); `path`
// missing 400, non-git dir 404, read failure 500.
/// One commit from `git log` (src/types.ts:757-763). `hash` and `at` are
/// REQUIRED a commit without them cannot be rendered or opened, so the entry
/// is dropped while its siblings survive. `at` = `%ct * 1000` (epoch ms, an
/// integer unlike the `stat()`-derived timestamps elsewhere).
///
/// Every field is INERT display text: render as plain text, never autolink,
/// never pass to a shell.
public struct CommitLogEntry: Sendable, Equatable {
public let hash: String
public let at: Int
public let subject: String
/// w6/G4: reachable from HEAD but not from `@{u}`. nil = the server did not
/// say (no upstream to compare against) which is NOT the same as `false`.
public let unpushed: Bool?
public init(hash: String, at: Int, subject: String = "", unpushed: Bool? = nil) {
self.hash = hash
self.at = at
self.subject = subject
self.unpushed = unpushed
}
}
extension CommitLogEntry: Decodable {
private enum CodingKeys: String, CodingKey {
case hash, at, subject, unpushed
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
hash = try container.decode(String.self, forKey: .hash)
at = try container.decode(Int.self, forKey: .at)
subject = (try? container.decode(String.self, forKey: .subject)) ?? ""
unpushed = try? container.decode(Bool.self, forKey: .unpushed)
}
}
/// `GET /projects/log` result (src/types.ts:765-772).
public struct GitLogResult: Sendable, Equatable {
public let commits: [CommitLogEntry]
/// More commits exist beyond the server's cap.
public let truncated: Bool
/// w6/G4: upstream short name, used to label the pushed/unpushed boundary.
/// nil nothing to compare against, so NO boundary may be drawn.
public let upstream: String?
public init(commits: [CommitLogEntry], truncated: Bool = false, upstream: String? = nil) {
self.commits = commits
self.truncated = truncated
self.upstream = upstream
}
}
extension GitLogResult: Decodable {
private enum CodingKeys: String, CodingKey {
case commits, truncated, upstream
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
commits = LossyList.decode(CommitLogEntry.self, in: container, forKey: .commits)
truncated = (try? container.decode(Bool.self, forKey: .truncated)) ?? false
upstream = try? container.decode(String.self, forKey: .upstream)
}
}
extension Endpoints {
/// Mirror of `src/http/git-log.ts:32` `GIT_LOG_MAX` the server's `?n=`
/// clamp ceiling. Clamping client-side too keeps the URL honest about what
/// will come back (the server re-clamps regardless).
static let gitLogMaxCount = 50
static let gitLogMinCount = 1
/// `GET /projects/log?path=[&n=]` RO, no Origin. nil = `path` could not be
/// percent-encoded. A nil `n` omits the parameter (server default applies).
static func gitLog(path: String, n: Int?) -> APIRoute? {
guard var query = pathQuery(path) else { return nil }
if let n {
query += "&n=\(min(max(n, gitLogMinCount), gitLogMaxCount))"
}
return APIRoute(
method: .get, path: "/projects/log", originPolicy: .readOnly,
body: nil, percentEncodedQuery: query
)
}
}
extension APIClient {
/// `GET /projects/log?path=[&n=]` the repo's recent commits. RO no
/// Origin. `n` is clamped to `1...50`; nil leaves it to the server.
/// 400/404/500 `.projectPathInvalid` / `.projectNotFound` /
/// `.gitDataUnavailable`; an empty path is rejected before any network I/O.
public func gitLog(path: String, n: Int? = nil) async throws -> GitLogResult {
try Self.requireNonEmptyPath(path)
guard let route = Endpoints.gitLog(path: path, n: n) else {
throw APIClientError.invalidRequest
}
let (data, response) = try await perform(route)
try Self.requireGitReadOK(response)
return try Self.decodeObject(GitLogResult.self, from: data)
}
}

View File

@@ -0,0 +1,377 @@
import Foundation
import WireProtocol
// B1 · the seven **G** (state-changing) git/worktree routes the highest-risk
// channel in the app. Every one of them: `Origin` (CSRF) `gitOpsEnabled` /
// `worktreeEnabled` kill-switch (403) per-IP rate limit (429)
// `isValidGitDir` three-prong (404). Sources:
// POST /projects/git/stage src/server.ts:1184-1218 {path,files,stage}
// POST /projects/git/commit src/server.ts:1219-1255 {path,message}
// POST /projects/git/push src/server.ts:1256-1289 {path}
// POST /projects/git/fetch src/server.ts:1290-1319 {path}
// POST /projects/worktree src/server.ts:1095-1123 {path,branch[,base]}
// DELETE /projects/worktree src/server.ts:1124-1153 {path,worktreePath,force}
// POST /projects/worktree/prune src/server.ts:1154-1183 {path}
//
// The remote/branch/refspec of push and fetch are ALWAYS derived server-side
// this client cannot point them at an arbitrary URL, and never force-pushes.
// Failure bodies carry the server's already-classified, already-sanitized `error`
// string only (never raw git stderr, SEC-M10): the client shows it verbatim.
/// Outcome of one guarded git write. Three shapes, because the server gives
/// exactly three:
/// - `.ok` 200 with the route's payload;
/// - `.rejected` a 4xx/5xx carrying the server's SAFE `error` message, to be
/// displayed INERTLY. **403 is overloaded** (the Origin guard AND the feature
/// kill-switch both answer 403) and the client cannot tell them apart by
/// status, so it surfaces the message instead of inventing a typed variant;
/// - `.rateLimited` 429. Do NOT auto-retry (that is what the limiter is for).
public enum GitWriteOutcome<Payload: Sendable & Equatable>: Sendable, Equatable {
case ok(Payload)
case rejected(status: Int, message: String?)
case rateLimited
}
/// A guarded write's 200 payload. `degraded` is what a 200 with a missing or
/// garbled body decodes to: the write ALREADY HAPPENED, so a bad body must not
/// be reported as a failure and the fallback must not be a crash path either.
protocol GitWritePayload: Decodable, Sendable, Equatable {
static var degraded: Self { get }
}
// MARK: - Per-route 200 payloads
/// `POST /projects/git/stage` `{ok,staged,count}`.
public struct StageResult: Sendable, Equatable, Decodable {
/// true = files were staged (`git add`); false = unstaged (`git restore --staged`).
public let staged: Bool
public let count: Int
public init(staged: Bool = false, count: Int = 0) {
self.staged = staged
self.count = count
}
private enum CodingKeys: String, CodingKey { case staged, count }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
staged = (try? container.decode(Bool.self, forKey: .staged)) ?? false
count = LossyNumber.int(in: container, forKey: .count) ?? 0
}
}
/// `POST /projects/git/commit` `{ok,commit}` (short sha; `""` is possible).
public struct CommitResult: Sendable, Equatable, Decodable {
public let commit: String
public init(commit: String = "") {
self.commit = commit
}
private enum CodingKeys: String, CodingKey { case commit }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
commit = (try? container.decode(String.self, forKey: .commit)) ?? ""
}
}
/// `POST /projects/git/push` `{ok,branch,remote}` (both server-derived).
public struct PushResult: Sendable, Equatable, Decodable {
public let branch: String?
public let remote: String?
public init(branch: String? = nil, remote: String? = nil) {
self.branch = branch
self.remote = remote
}
private enum CodingKeys: String, CodingKey { case branch, remote }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
branch = try? container.decode(String.self, forKey: .branch)
remote = try? container.decode(String.self, forKey: .remote)
}
}
/// `POST /projects/git/fetch` `{ok,remote,lastFetchMs}`. `lastFetchMs` is the
/// post-fetch `FETCH_HEAD` mtime fractional (Double), same as `SyncState`.
/// nil means the mtime could not be read: the UI must NOT then claim the
/// `behind` count was freshly verified.
public struct FetchResult: Sendable, Equatable, Decodable {
public let remote: String?
public let lastFetchMs: Double?
public init(remote: String? = nil, lastFetchMs: Double? = nil) {
self.remote = remote
self.lastFetchMs = lastFetchMs
}
private enum CodingKeys: String, CodingKey { case remote, lastFetchMs }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
remote = try? container.decode(String.self, forKey: .remote)
lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs)
}
}
/// `POST /projects/worktree` `{ok,path,branch}` (git's canonical values).
public struct CreateWorktreeResult: Sendable, Equatable, Decodable {
public let path: String?
public let branch: String?
public init(path: String? = nil, branch: String? = nil) {
self.path = path
self.branch = branch
}
private enum CodingKeys: String, CodingKey { case path, branch }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
path = try? container.decode(String.self, forKey: .path)
branch = try? container.decode(String.self, forKey: .branch)
}
}
/// `DELETE /projects/worktree` `{ok,path}` (the canonical path removed).
public struct RemoveWorktreeResult: Sendable, Equatable, Decodable {
public let path: String?
public init(path: String? = nil) {
self.path = path
}
private enum CodingKeys: String, CodingKey { case path }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
path = try? container.decode(String.self, forKey: .path)
}
}
/// `POST /projects/worktree/prune` `{ok,pruned}`. An empty list means
/// "nothing to prune" the route is idempotent.
public struct PruneWorktreesResult: Sendable, Equatable, Decodable {
public let pruned: [String]
public init(pruned: [String] = []) {
self.pruned = pruned
}
private enum CodingKeys: String, CodingKey { case pruned }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
pruned = (try? container.decode([String].self, forKey: .pruned)) ?? []
}
}
// All-defaults fallbacks, declared next to nothing else so they stay one line
// each and cannot drift from the memberwise defaults above.
extension StageResult: GitWritePayload { static var degraded: Self { .init() } }
extension CommitResult: GitWritePayload { static var degraded: Self { .init() } }
extension PushResult: GitWritePayload { static var degraded: Self { .init() } }
extension FetchResult: GitWritePayload { static var degraded: Self { .init() } }
extension CreateWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
extension RemoveWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
extension PruneWorktreesResult: GitWritePayload { static var degraded: Self { .init() } }
/// Shape of a failure body: the worktree routes emit `{error}`, git-ops
/// `{ok:false,error}`. Both carry `error` as a SAFE string.
private struct GitErrorBody: Decodable {
let error: String?
}
// MARK: - Request bodies (frozen field-for-field against src/server.ts)
private struct StageBody: Encodable {
let path: String
let files: [String]
let stage: Bool
}
private struct CommitBody: Encodable {
let path: String
let message: String
}
/// `{path}` push · fetch · worktree prune all take exactly this.
private struct RepoPathBody: Encodable {
let path: String
}
private struct CreateWorktreeBody: Encodable {
let path: String
let branch: String
/// Omitted from the JSON when nil the server reads a missing `base` as
/// "branch from HEAD", and an explicit `null`/`""` is NOT the same thing.
let base: String?
}
private struct RemoveWorktreeBody: Encodable {
let path: String
let worktreePath: String
let force: Bool
}
// MARK: - Routes
extension Endpoints {
/// Every guarded git write shares this shape: JSON body + `Origin`, and
/// `.routeDefined` 401 `src/http/git-ops.ts:108` classifies a HOST-side
/// git credential failure as 401 ("Push authentication required on the
/// host."), which must not be mistaken for the access-token gate telling us
/// to enter a token.
private static func gitWriteRoute<Body: Encodable>(
_ method: HTTPMethod, _ path: String, _ body: Body
) throws -> APIRoute {
APIRoute(
method: method, path: path, originPolicy: .guarded,
body: try JSONEncoder().encode(body),
unauthorizedPolicy: .routeDefined
)
}
static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute {
try gitWriteRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage))
}
static func gitCommit(path: String, message: String) throws -> APIRoute {
try gitWriteRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message))
}
static func gitPush(path: String) throws -> APIRoute {
try gitWriteRoute(.post, "/projects/git/push", RepoPathBody(path: path))
}
static func gitFetch(path: String) throws -> APIRoute {
try gitWriteRoute(.post, "/projects/git/fetch", RepoPathBody(path: path))
}
static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute {
try gitWriteRoute(
.post, "/projects/worktree",
CreateWorktreeBody(path: path, branch: branch, base: base)
)
}
/// DELETE **with** a JSON body the server reads `express.json` here
/// (src/server.ts:1124), so this is the wire shape, unusual as it looks.
static func removeWorktree(path: String, worktreePath: String, force: Bool) throws -> APIRoute {
try gitWriteRoute(
.delete, "/projects/worktree",
RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force)
)
}
static func pruneWorktrees(path: String) throws -> APIRoute {
try gitWriteRoute(.post, "/projects/worktree/prune", RepoPathBody(path: path))
}
}
// MARK: - Client calls
extension APIClient {
/// Stage (`stage: true` `git add`) or unstage (`false`
/// `git restore --staged`) specific files. The file list is capped and
/// realpath-contained server-side; body limit 64 KB.
public func gitStage(
path: String, files: [String], stage: Bool
) async throws -> GitWriteOutcome<StageResult> {
try await performGitWrite(path: path, StageResult.self) {
try Endpoints.gitStage(path: path, files: files, stage: stage)
}
}
/// Commit the STAGED changes. `message` is length-capped server-side and
/// passed as a single `-m <msg>` argv never a shell string, never a
/// pathspec. An empty message comes back as `.rejected(400, )`.
public func gitCommit(
path: String, message: String
) async throws -> GitWriteOutcome<CommitResult> {
try await performGitWrite(path: path, CommitResult.self) {
try Endpoints.gitCommit(path: path, message: message)
}
}
/// Push the current branch to its existing upstream, or `-u <sole-remote>
/// <branch>` when it has none. Never a force-push; the remote is derived
/// server-side. Tighter rate limit than stage/commit (network-bound).
public func gitPush(path: String) async throws -> GitWriteOutcome<PushResult> {
try await performGitWrite(path: path, PushResult.self) {
try Endpoints.gitPush(path: path)
}
}
/// Refresh remote-tracking refs (`refs/remotes` only never a pull) so the
/// panel's `behind` stops being a stale guess.
public func gitFetch(path: String) async throws -> GitWriteOutcome<FetchResult> {
try await performGitWrite(path: path, FetchResult.self) {
try Endpoints.gitFetch(path: path)
}
}
/// Create a git worktree (`base` nil branch from HEAD). The only
/// write-to-disk feature; gated by `WORKTREE_ENABLED` (403 when off).
public func createWorktree(
path: String, branch: String, base: String?
) async throws -> GitWriteOutcome<CreateWorktreeResult> {
try await performGitWrite(path: path, CreateWorktreeResult.self) {
try Endpoints.createWorktree(path: path, branch: branch, base: base)
}
}
/// Remove a worktree. Destructive: a dirty worktree needs `force: true`
/// (otherwise the server answers 409 with a safe message), and the main
/// worktree can never be removed (400).
public func removeWorktree(
path: String, worktreePath: String, force: Bool
) async throws -> GitWriteOutcome<RemoveWorktreeResult> {
try await performGitWrite(path: path, RemoveWorktreeResult.self) {
try Endpoints.removeWorktree(path: path, worktreePath: worktreePath, force: force)
}
}
/// Prune stale worktree registrations (idempotent an empty `pruned` list
/// means there was nothing to reclaim).
public func pruneWorktrees(path: String) async throws -> GitWriteOutcome<PruneWorktreesResult> {
try await performGitWrite(path: path, PruneWorktreesResult.self) {
try Endpoints.pruneWorktrees(path: path)
}
}
/// The ONE place a guarded git write is executed and its status mapped, so
/// all seven routes cannot drift apart: empty path rejected before any
/// network I/O 200 decoded (a garbled payload degrades to defaults rather
/// than throwing the write already happened) 429 `.rateLimited`
/// everything else `.rejected` with the server's safe message.
private func performGitWrite<Payload: GitWritePayload>(
path: String,
_ payload: Payload.Type,
route build: () throws -> APIRoute
) async throws -> GitWriteOutcome<Payload> {
try Self.requireNonEmptyPath(path)
let (data, response) = try await perform(try build())
switch response.statusCode {
case HTTPStatus.ok:
let decoded = try? JSONDecoder().decode(Payload.self, from: data)
return .ok(decoded ?? Payload.degraded)
case HTTPStatus.tooManyRequests:
return .rateLimited
default:
return .rejected(
status: response.statusCode, message: Self.decodeGitError(from: data)
)
}
}
/// The server's SAFE `error` string, or nil when the body is empty/
/// unparseable the client never invents a reason.
private static func decodeGitError(from data: Data) -> String? {
(try? JSONDecoder().decode(GitErrorBody.self, from: data))?.error
}
}

View File

@@ -0,0 +1,90 @@
import Foundation
import WireProtocol
// B1 · `GET /sessions` (src/server.ts:479-481) RO, NO Origin. Response =
// `src/http/history.ts:13-19` `HistorySession[]`: the host's most recently
// modified Claude Code session files, i.e. the `claude --resume` picker's data
// (T-iOS-32).
//
// SECURITY (Sec H3, accepted upstream risk documented, not introduced here):
// this route is UNAUTHENTICATED on a token-less host and returns session cwds
// plus the first ~120 chars of each first prompt. That matches the app's threat
// model (the whole app hands a shell to anyone who can reach the port; deploy
// behind Tailscale) but it means the CLIENT must treat every field as INERT
// display text: never autolink, never interpolate into a shell command.
/// One past Claude Code session (src/http/history.ts:13-19).
///
/// `id` stays a **String**, not a UUID: it is the `.jsonl` filename stem the
/// host will pass to `claude --resume <id>` verbatim. Parsing it as a UUID would
/// buy nothing and would DROP any session whose file was renamed while the id
/// still resumes fine. Required + non-empty, though: an entry without one cannot
/// be resumed, so it is dropped rather than rendered as a dead row.
public struct HistorySession: Sendable, Equatable {
public let id: String
/// The session's working directory (`""` when the jsonl had none).
public let cwd: String
/// Last cwd segment, for display (`"unknown"` server-side when cwd is empty).
public let project: String
/// The jsonl's mtime in ms. `fs.stat().mtimeMs` is FRACTIONAL on a real host
/// (e.g. `1785390645813.5327`), so this is a Double decoding it as Int
/// would fail and silently drop every entry (src/http/history.ts:105).
public let mtimeMs: Double
/// First user prompt, whitespace-collapsed and truncated to 120 chars
/// server-side. INERT text.
public let preview: String
public init(id: String, cwd: String = "", project: String = "", mtimeMs: Double, preview: String = "") {
self.id = id
self.cwd = cwd
self.project = project
self.mtimeMs = mtimeMs
self.preview = preview
}
}
extension HistorySession: Decodable {
private enum CodingKeys: String, CodingKey {
case id, cwd, project, mtimeMs, preview
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let rawId = try container.decode(String.self, forKey: .id)
guard !rawId.isEmpty else {
throw DecodingError.dataCorruptedError(
forKey: .id, in: container,
debugDescription: "empty session id is not resumable"
)
}
id = rawId
mtimeMs = try container.decode(Double.self, forKey: .mtimeMs)
cwd = (try? container.decode(String.self, forKey: .cwd)) ?? ""
project = (try? container.decode(String.self, forKey: .project)) ?? ""
preview = (try? container.decode(String.self, forKey: .preview)) ?? ""
}
}
extension Endpoints {
/// `GET /sessions` RO, no Origin (src/server.ts:479).
static func claudeSessions() -> APIRoute {
APIRoute(method: .get, path: "/sessions", originPolicy: .readOnly, body: nil)
}
}
extension APIClient {
/// `GET /sessions` the host's recent Claude Code sessions, newest first
/// (the server sorts by mtime and caps at 50). RO no Origin.
///
/// Malformed entries are dropped one by one; a non-array body throws
/// `.invalidResponseBody`. The server answers `[]` (never an error) when
/// `~/.claude/projects` is missing, so an empty list means "no history",
/// not "failed".
public func claudeSessions() async throws -> [HistorySession] {
let (data, response) = try await perform(Endpoints.claudeSessions())
guard response.statusCode == HTTPStatus.ok else {
throw APIClientError.unexpectedStatus(response.statusCode)
}
return try LossyList.decodeBody(HistorySession.self, from: data)
}
}

View File

@@ -187,6 +187,37 @@ public enum APIClientError: Error, Equatable, Sendable {
/// 500 from `GET /projects/detail` the server failed reading the repo
/// (src/server.ts:306-309, body `{error}`).
case projectDetailUnavailable
/// **401 from the access-token gate** (src/server.ts:459) the host has
/// `WEBTERM_TOKEN` set and this request carried no (or a wrong) `webterm_auth`
/// cookie. Distinct from a transport failure ON PURPOSE (ios-completion
/// §1.1): the UI must offer "enter the access token", not "retry".
case unauthorized
/// A configured access token violates the frozen shape (16512 chars of
/// `[A-Za-z0-9._~+/=-]`). Raised BEFORE any network I/O a shape-invalid
/// token can never match a server token (the server refuses to start with
/// one), and refusing it here is also what makes header injection
/// impossible.
case malformedToken
/// 500 from a read-only git side-channel (`/projects/log`, `/projects/pr`,
/// `/projects/worktree/state`) the host failed to read git state.
case gitDataUnavailable
/// 404 from `GET /projects/worktree/state` (src/server.ts:549-552) that
/// path is not a worktree of the repo (removed / never existed).
case worktreeNotFound
/// 503 from `POST /live-sessions/:id/queue` `QUEUE_ENABLED=0` on the host
/// (src/server.ts:608-611). A configuration state, not a failure to retry.
case queueDisabled
/// 409 from `POST /live-sessions/:id/queue` the queue is at
/// `queueMaxItems` (src/server.ts:637-641). Never silently dropped.
case queueFull
/// 400 from `POST /live-sessions/:id/queue` empty text or a malformed
/// session id (src/server.ts:620-627). Also raised client-side for empty
/// text before any network I/O.
case queueTextInvalid
/// 413 from `POST /live-sessions/:id/queue` text (plus the optional
/// trailing `\r`) exceeds the host's `queueItemMaxBytes`
/// (src/server.ts:632-635).
case queueTextTooLarge
/// Any other non-success status code.
case unexpectedStatus(Int)
@@ -213,6 +244,22 @@ public enum APIClientError: Error, Equatable, Sendable {
"项目不存在(路径可能已移动或删除)。"
case .projectDetailUnavailable:
"读取项目详情失败,请稍后再试。"
case .unauthorized:
"该主机启用了访问令牌,请填写正确的令牌后重试。"
case .malformedToken:
"访问令牌格式不合法:需 16512 个字符,且只能包含 A-Z a-z 0-9 . _ ~ + / = -。"
case .gitDataUnavailable:
"读取 git 状态失败,请稍后再试。"
case .worktreeNotFound:
"该 worktree 已不存在(可能已被删除或清理)。"
case .queueDisabled:
"该主机关闭了排队注入功能QUEUE_ENABLED=0"
case .queueFull:
"排队已满,请先等前面的任务发出去。"
case .queueTextInvalid:
"要排队的内容为空或会话标识不合法。"
case .queueTextTooLarge:
"内容过长,超出了主机允许的单条上限。"
case .unexpectedStatus(let status):
"服务器返回了意外状态码 \(status)"
}

View File

@@ -0,0 +1,155 @@
import Foundation
import WireProtocol
// B1 · `GET /projects/pr?path=` (src/server.ts:1058-1076) RO, NO Origin.
// Response = `src/types.ts:617-648` `PrStatus`.
//
// Out-of-band side-channel: the host spawns its own `gh` CLI, which makes a
// NETWORK call to GitHub with the host's credential. This route NEVER accepts or
// forwards a token, and `GH_ENABLED=0` disables it entirely. A valid git dir
// ALWAYS answers 200: every degrade (gh missing / unauthed / no PR / disabled)
// lives in `availability`, not in the HTTP status so the client renders one
// chip instead of branching on errors.
/// Why a `PrStatus` has (or lacks) PR data (src/types.ts:619-625).
/// An unknown/future wire value degrades to `.error`: a new server availability
/// must never make the chip crash or hide the row.
public enum PrAvailability: String, Sendable, Equatable, CaseIterable {
/// A PR exists for the current branch; the sibling fields are populated.
case ok
/// gh works but the branch has no PR (or no remote / default repo).
case noPr = "no-pr"
/// The `gh` binary is not on the host's PATH (ENOENT).
case notInstalled = "not-installed"
/// gh is present but not logged in (needs `gh auth login`).
case unauthenticated
/// `GH_ENABLED=0` the feature is off and gh is never spawned.
case disabled
/// gh spawned but failed for another reason (timeout, ). Also the
/// unknown/missing fallback.
case error
}
/// Rolled-up CI check counts from gh's `statusCheckRollup`
/// (src/types.ts:628-633).
public struct PrCheckSummary: Sendable, Equatable {
public let total: Int
public let passing: Int
public let failing: Int
public let pending: Int
public init(total: Int = 0, passing: Int = 0, failing: Int = 0, pending: Int = 0) {
self.total = total
self.passing = passing
self.failing = failing
self.pending = pending
}
}
extension PrCheckSummary: Decodable {
private enum CodingKeys: String, CodingKey {
case total, passing, failing, pending
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
total = LossyNumber.int(in: container, forKey: .total) ?? 0
passing = LossyNumber.int(in: container, forKey: .passing) ?? 0
failing = LossyNumber.int(in: container, forKey: .failing) ?? 0
pending = LossyNumber.int(in: container, forKey: .pending) ?? 0
}
}
/// `GET /projects/pr` result (src/types.ts:635-648). Everything except
/// `availability` is present only when `availability == .ok`.
///
/// `state` / `mergeable` stay RAW inert strings (the server already lower-cases
/// gh's `OPEN`/`MERGEABLE`): they are display text, and inventing an enum here
/// would just add a second place for a future gh value to break.
public struct PrStatus: Sendable, Equatable {
public let availability: PrAvailability
public let number: Int?
public let title: String?
public let url: String?
public let state: String?
public let isDraft: Bool?
public let mergeable: String?
public let headRefName: String?
public let baseRefName: String?
public let checks: PrCheckSummary?
public init(
availability: PrAvailability = .error,
number: Int? = nil,
title: String? = nil,
url: String? = nil,
state: String? = nil,
isDraft: Bool? = nil,
mergeable: String? = nil,
headRefName: String? = nil,
baseRefName: String? = nil,
checks: PrCheckSummary? = nil
) {
self.availability = availability
self.number = number
self.title = title
self.url = url
self.state = state
self.isDraft = isDraft
self.mergeable = mergeable
self.headRefName = headRefName
self.baseRefName = baseRefName
self.checks = checks
}
}
extension PrStatus: Decodable {
private enum CodingKeys: String, CodingKey {
case availability, number, title, url, state, isDraft, mergeable
case headRefName, baseRefName, checks
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let rawAvailability = try? container.decode(String.self, forKey: .availability)
availability = rawAvailability.flatMap(PrAvailability.init(rawValue:)) ?? .error
number = LossyNumber.int(in: container, forKey: .number)
title = try? container.decode(String.self, forKey: .title)
url = try? container.decode(String.self, forKey: .url)
state = try? container.decode(String.self, forKey: .state)
isDraft = try? container.decode(Bool.self, forKey: .isDraft)
mergeable = try? container.decode(String.self, forKey: .mergeable)
headRefName = try? container.decode(String.self, forKey: .headRefName)
baseRefName = try? container.decode(String.self, forKey: .baseRefName)
checks = try? container.decode(PrCheckSummary.self, forKey: .checks)
}
}
extension Endpoints {
/// `GET /projects/pr?path=` RO, no Origin. nil = `path` not encodable.
static func projectPr(path: String) -> APIRoute? {
pathQuery(path).map { query in
APIRoute(
method: .get, path: "/projects/pr", originPolicy: .readOnly,
body: nil, percentEncodedQuery: query
)
}
}
}
extension APIClient {
/// `GET /projects/pr?path=` the current branch's PR + CI rollup. RO no
/// Origin. 400/404/500 `.projectPathInvalid` / `.projectNotFound` /
/// `.gitDataUnavailable`; an empty path is rejected before any network I/O.
/// Note that a REACHABLE-but-degraded gh is a 200 with a non-`ok`
/// `availability`, NOT an error.
public func prStatus(path: String) async throws -> PrStatus {
try Self.requireNonEmptyPath(path)
guard let route = Endpoints.projectPr(path: path) else {
throw APIClientError.invalidRequest
}
let (data, response) = try await perform(route)
try Self.requireGitReadOK(response)
return try Self.decodeObject(PrStatus.self, from: data)
}
}

View File

@@ -23,10 +23,13 @@ public struct ProjectSessionRef: Sendable, Equatable {
public let clientCount: Int
public let createdAt: Int
public let exited: Bool
/// w6/G7: where the session runs lets the UI attribute it to a worktree
/// (src/types.ts:353). Optional additive field; nil on pre-w6 servers.
public let cwd: String?
public init(
id: UUID, title: String?, status: ClaudeStatus,
clientCount: Int, createdAt: Int, exited: Bool
clientCount: Int, createdAt: Int, exited: Bool, cwd: String? = nil
) {
self.id = id
self.title = title
@@ -34,12 +37,13 @@ public struct ProjectSessionRef: Sendable, Equatable {
self.clientCount = clientCount
self.createdAt = createdAt
self.exited = exited
self.cwd = cwd
}
}
extension ProjectSessionRef: Decodable {
private enum CodingKeys: String, CodingKey {
case id, title, status, clientCount, createdAt, exited
case id, title, status, clientCount, createdAt, exited, cwd
}
public init(from decoder: any Decoder) throws {
@@ -53,6 +57,7 @@ extension ProjectSessionRef: Decodable {
let rawStatus = try? container.decode(String.self, forKey: .status)
status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
title = try? container.decode(String.self, forKey: .title)
cwd = try? container.decode(String.self, forKey: .cwd)
}
}
@@ -69,12 +74,22 @@ public struct ProjectInfo: Sendable, Equatable {
/// Uncommitted changes; only present when the server runs the dirty check.
public let dirty: Bool?
/// Newest `~/.claude/projects` mtime for this cwd (ms) the sort key.
/// Derived from `fs.stat().mtimeMs`, so it arrives FRACTIONAL on a real host
/// and is decoded via `LossyNumber.int` (see there for why a plain
/// `decode(Int.self)` silently nils this field).
public let lastActiveMs: Int?
/// W3 sync chip commits on HEAD not on `@{u}` (src/types.ts:366).
public let ahead: Int?
/// W3 sync chip commits on `@{u}` not on HEAD (src/types.ts:367).
public let behind: Int?
/// HEAD commit time in ms (`git log -1 --format=%ct * 1000`, an integer).
public let lastCommitMs: Int?
public let sessions: [ProjectSessionRef]
public init(
name: String, path: String, isGit: Bool, branch: String?,
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef]
dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef],
ahead: Int? = nil, behind: Int? = nil, lastCommitMs: Int? = nil
) {
self.name = name
self.path = path
@@ -82,6 +97,9 @@ public struct ProjectInfo: Sendable, Equatable {
self.branch = branch
self.dirty = dirty
self.lastActiveMs = lastActiveMs
self.ahead = ahead
self.behind = behind
self.lastCommitMs = lastCommitMs
self.sessions = sessions
}
}
@@ -89,6 +107,7 @@ public struct ProjectInfo: Sendable, Equatable {
extension ProjectInfo: Decodable {
private enum CodingKeys: String, CodingKey {
case name, path, isGit, branch, dirty, lastActiveMs, sessions
case ahead, behind, lastCommitMs
}
public init(from decoder: any Decoder) throws {
@@ -98,7 +117,10 @@ extension ProjectInfo: Decodable {
isGit = try container.decode(Bool.self, forKey: .isGit)
branch = try? container.decode(String.self, forKey: .branch)
dirty = try? container.decode(Bool.self, forKey: .dirty)
lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs)
lastActiveMs = LossyNumber.int(in: container, forKey: .lastActiveMs)
ahead = LossyNumber.int(in: container, forKey: .ahead)
behind = LossyNumber.int(in: container, forKey: .behind)
lastCommitMs = LossyNumber.int(in: container, forKey: .lastCommitMs)
sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
}
@@ -106,10 +128,7 @@ extension ProjectInfo: Decodable {
/// `invalidResponseBody`; malformed elements dropped one by one (same
/// pattern as `LiveSessionInfo.decodeList`).
static func decodeList(from data: Data) throws -> [ProjectInfo] {
guard let entries = try? JSONDecoder().decode([LossyBox<ProjectInfo>].self, from: data) else {
throw APIClientError.invalidResponseBody
}
return entries.compactMap(\.value)
try LossyList.decodeBody(ProjectInfo.self, from: data)
}
}
@@ -164,6 +183,11 @@ public struct ProjectDetail: Sendable, Equatable {
public let isGit: Bool
public let branch: String?
public let dirty: Bool?
/// w6/G1: `git status --porcelain` line count, same gate as `dirty`
/// (src/types.ts:388). nil the host has the dirty check off, NOT "clean".
public let dirtyCount: Int?
/// w6/G1: upstream sync state; nil for a non-git dir (src/types.ts:389).
public let sync: SyncState?
public let worktrees: [WorktreeInfo]
public let sessions: [ProjectSessionRef]
public let hasClaudeMd: Bool
@@ -173,13 +197,16 @@ public struct ProjectDetail: Sendable, Equatable {
public init(
name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?,
worktrees: [WorktreeInfo], sessions: [ProjectSessionRef],
hasClaudeMd: Bool, claudeMd: String?
hasClaudeMd: Bool, claudeMd: String?,
dirtyCount: Int? = nil, sync: SyncState? = nil
) {
self.name = name
self.path = path
self.isGit = isGit
self.branch = branch
self.dirty = dirty
self.dirtyCount = dirtyCount
self.sync = sync
self.worktrees = worktrees
self.sessions = sessions
self.hasClaudeMd = hasClaudeMd
@@ -190,6 +217,7 @@ public struct ProjectDetail: Sendable, Equatable {
extension ProjectDetail: Decodable {
private enum CodingKeys: String, CodingKey {
case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd
case dirtyCount, sync
}
public init(from decoder: any Decoder) throws {
@@ -199,6 +227,8 @@ extension ProjectDetail: Decodable {
isGit = try container.decode(Bool.self, forKey: .isGit)
branch = try? container.decode(String.self, forKey: .branch)
dirty = try? container.decode(Bool.self, forKey: .dirty)
dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount)
sync = try? container.decode(SyncState.self, forKey: .sync)
hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false
claudeMd = try? container.decode(String.self, forKey: .claudeMd)
worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees)
@@ -206,31 +236,6 @@ extension ProjectDetail: Decodable {
}
}
// MARK: - Lossy decoding helpers (shared per-element tolerance)
/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
struct LossyBox<Wrapped: Decodable>: Decodable {
let value: Wrapped?
init(from decoder: any Decoder) {
value = try? Wrapped(from: decoder)
}
}
enum LossyList {
/// Decode `[Element]` at `key`, dropping malformed elements; a missing or
/// wrong-typed array degrades to `[]`.
static func decode<Element: Decodable, Key: CodingKey>(
_ type: Element.Type,
in container: KeyedDecodingContainer<Key>,
forKey key: Key
) -> [Element] {
let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
return boxes.compactMap(\.value)
}
}
// MARK: - Routes + client calls
extension Endpoints {
@@ -240,17 +245,17 @@ extension Endpoints {
}
/// `GET /projects/detail?path=` RO, no Origin (src/server.ts:293-310).
/// The ONE place `path` gets percent-encoded (strict unreserved-only set
/// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough).
/// nil = the path could not be encoded (surfaced as `.invalidRequest`).
/// `path` is encoded by the shared `pathQuery` choke point (strict
/// unreserved-only set see `unreservedCharacters` for why
/// `.urlQueryAllowed` is not enough). nil = the path could not be encoded
/// (surfaced as `.invalidRequest`).
static func projectDetail(path: String) -> APIRoute? {
guard let encoded = path.addingPercentEncoding(
withAllowedCharacters: unreservedCharacters
) else { return nil }
return APIRoute(
method: .get, path: "/projects/detail", originPolicy: .readOnly,
body: nil, percentEncodedQuery: "path=\(encoded)"
)
pathQuery(path).map { query in
APIRoute(
method: .get, path: "/projects/detail", originPolicy: .readOnly,
body: nil, percentEncodedQuery: query
)
}
}
}

View File

@@ -0,0 +1,64 @@
import Foundation
// Shared tolerant-decoding helpers for the UNTRUSTED server boundary (plan §4).
// ONE home for all of them: unknown fields ignored, malformed elements dropped
// one by one, wrong-typed optionals degraded to nil never a crash, never a
// whole-list failure because of a single bad entry.
/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
struct LossyBox<Wrapped: Decodable>: Decodable {
let value: Wrapped?
init(from decoder: any Decoder) {
value = try? Wrapped(from: decoder)
}
}
enum LossyList {
/// Decode `[Element]` at `key`, dropping malformed elements; a missing or
/// wrong-typed array degrades to `[]`.
static func decode<Element: Decodable, Key: CodingKey>(
_ type: Element.Type,
in container: KeyedDecodingContainer<Key>,
forKey key: Key
) -> [Element] {
let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
return boxes.compactMap(\.value)
}
/// Decode a whole top-level `[Element]` body, dropping malformed elements.
/// A non-array top level throws `.invalidResponseBody` that is the
/// "this port speaks HTTP but is not web-terminal" signal, not a degrade.
static func decodeBody<Element: Decodable>(
_ type: Element.Type, from data: Data
) throws -> [Element] {
guard let boxes = try? JSONDecoder().decode([LossyBox<Element>].self, from: data) else {
throw APIClientError.invalidResponseBody
}
return boxes.compactMap(\.value)
}
}
enum LossyNumber {
/// Decode an integer-valued field that the server may serialize as a
/// FRACTIONAL JSON number.
///
/// This is not paranoia: every `*Ms` field derived from `fs.stat().mtimeMs`
/// (`ProjectInfo.lastActiveMs` src/http/projects.ts:171,
/// `HistorySession.mtimeMs` src/http/history.ts:105,
/// `SyncState.lastFetchMs` src/http/projects.ts:170) carries sub-millisecond
/// precision on APFS: a real body reads `1785390645813.5327`. A plain
/// `decode(Int.self)` FAILS on that, so a bare `try?` would silently null the
/// field (or drop the whole entry) against every real server.
static func int<Key: CodingKey>(
in container: KeyedDecodingContainer<Key>, forKey key: Key
) -> Int? {
if let exact = try? container.decode(Int.self, forKey: key) { return exact }
guard let fractional = try? container.decode(Double.self, forKey: key),
fractional.isFinite,
fractional >= Double(Int.min), fractional <= Double(Int.max)
else { return nil }
return Int(fractional)
}
}

View File

@@ -0,0 +1,120 @@
import Foundation
import WireProtocol
// B1 · `GET /projects/worktree/state?path=` (src/server.ts:542-562) RO, NO
// Origin. Response = `src/types.ts:392-408` (`SyncState` / `WorktreeState`),
// fetched lazily per worktree row (w6/G7) because listing N worktrees eagerly
// would spend a git spawn per row on data nothing renders.
/// Upstream sync state for one repo or worktree (src/types.ts:392-398).
///
/// EVERY field degrades independently, and each absence is a NORMAL state: no
/// upstream, detached HEAD, empty repo and never-fetched all leave their field
/// nil. Two rules the UI must honour (they are the reason this type is all
/// optionals instead of zeros):
/// - `ahead` is always trustworthy (local refs only);
/// - `behind` is only as fresh as `lastFetchMs`, because `@{u}` is a locally
/// cached remote ref that only a fetch moves a stale `behind: 0` must NEVER
/// render as "in sync";
/// - `upstream == nil` means "nothing to compare against", which is NOT
/// "nothing to push".
public struct SyncState: Sendable, Equatable {
/// e.g. `origin/develop`; nil the branch tracks nothing.
public let upstream: String?
/// Commits on HEAD not on `@{u}`.
public let ahead: Int?
/// Commits on `@{u}` not on HEAD trust only with a fresh `lastFetchMs`.
public let behind: Int?
/// `FETCH_HEAD` mtime (ms); nil never fetched. Comes from
/// `fs.stat().mtimeMs`, so it is FRACTIONAL on a real host hence Double,
/// not Int (src/http/projects.ts readLastFetchMs).
public let lastFetchMs: Double?
/// HEAD is not on a branch no branch, no ahead/behind.
public let detached: Bool?
public init(
upstream: String? = nil, ahead: Int? = nil, behind: Int? = nil,
lastFetchMs: Double? = nil, detached: Bool? = nil
) {
self.upstream = upstream
self.ahead = ahead
self.behind = behind
self.lastFetchMs = lastFetchMs
self.detached = detached
}
}
extension SyncState: Decodable {
private enum CodingKeys: String, CodingKey {
case upstream, ahead, behind, lastFetchMs, detached
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
upstream = try? container.decode(String.self, forKey: .upstream)
ahead = LossyNumber.int(in: container, forKey: .ahead)
behind = LossyNumber.int(in: container, forKey: .behind)
lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs)
detached = try? container.decode(Bool.self, forKey: .detached)
}
}
/// The git state of ONE worktree (src/types.ts:402-408).
public struct WorktreeState: Sendable, Equatable {
public let path: String
public let branch: String?
public let sync: SyncState?
/// `git status --porcelain` line count; nil the host has the dirty check
/// disabled (`PROJECT_DIRTY_CHECK=0`), which is NOT "clean".
public let dirtyCount: Int?
public init(path: String, branch: String? = nil, sync: SyncState? = nil, dirtyCount: Int? = nil) {
self.path = path
self.branch = branch
self.sync = sync
self.dirtyCount = dirtyCount
}
}
extension WorktreeState: Decodable {
private enum CodingKeys: String, CodingKey {
case path, branch, sync, dirtyCount
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
path = try container.decode(String.self, forKey: .path)
branch = try? container.decode(String.self, forKey: .branch)
sync = try? container.decode(SyncState.self, forKey: .sync)
dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount)
}
}
extension Endpoints {
/// `GET /projects/worktree/state?path=` RO, no Origin.
static func worktreeState(path: String) -> APIRoute? {
pathQuery(path).map { query in
APIRoute(
method: .get, path: "/projects/worktree/state", originPolicy: .readOnly,
body: nil, percentEncodedQuery: query
)
}
}
}
extension APIClient {
/// `GET /projects/worktree/state?path=` branch + sync + dirty count for
/// ONE worktree row. RO no Origin. 400 `.projectPathInvalid`,
/// **404 `.worktreeNotFound`** (this route's 404 means "not a worktree of
/// this repo", not "no such project"), 500 `.gitDataUnavailable`.
/// An empty path is rejected before any network I/O.
public func worktreeState(path: String) async throws -> WorktreeState {
try Self.requireNonEmptyPath(path)
guard let route = Endpoints.worktreeState(path: path) else {
throw APIClientError.invalidRequest
}
let (data, response) = try await perform(route)
try Self.requireGitReadOK(response, notFound: .worktreeNotFound)
return try Self.decodeObject(WorktreeState.self, from: data)
}
}

View File

@@ -0,0 +1,314 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · 访`WEBTERM_TOKEN` ios-completion §1.1
///
/// `src/http/auth.ts` + `src/server.ts:393-460`
/// - `POST /auth`body `{"token":"<t>"}``Content-Type: application/json`
/// - **`Accept` `text/html`** 302 204/401
/// - 204 **** `Set-Cookie: webterm_auth=`
/// - 204 **** `Set-Cookie` ********""
/// - 401 429 10 //IP
///
/// **** `Cookie: webterm_auth=<t>`
/// `Set-Cookie` cookie jar`Cookie` `Origin` ****
/// **** Origin `server.ts` Origin cookie
///
/// ** URL query**`?token=` bootstrap
struct AccessTokenTests {
private static let base = "http://192.168.1.5:3000"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
/// 16512 `[A-Za-z0-9._~+/=-]` CLAUDE.md / config
private static let token = "s3cret-token_value.~+/="
private static let setCookieValue =
"webterm_auth=\(token); Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict"
private func makeEndpoint(_ base: String = AccessTokenTests.base) throws -> HostEndpoint {
let url = try #require(URL(string: base))
return try #require(HostEndpoint(baseURL: url))
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func makeClient(
token: String?, http: FakeHTTPTransport
) throws -> APIClient {
APIClient(endpoint: try makeEndpoint(), http: http, accessToken: token)
}
// MARK: - POST /auth
@Test("POST /auth:body 恰为 {\"token\":…}、Content-Type=JSON、Accept 不含 text/html、令牌绝不进 URL")
func authProbeRequestShapeIsFrozen() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 204,
headers: ["Set-Cookie": Self.setCookieValue]
)
// Act
_ = try await client.probeAccessToken(Self.token)
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == (try routeURL("/auth")))
#expect(request.url?.query == nil) // URL query
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
// /auth POST( cookie) Origin-iff-G Origin( Android )
#expect(request.value(forHTTPHeaderField: "Origin") == (try makeEndpoint()).originHeader)
let accept = try #require(request.value(forHTTPHeaderField: "Accept"))
#expect(!accept.contains("text/html")) // text/html 302
let body = try #require(request.httpBody)
let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(Set(object.keys) == Set(["token"]))
#expect(object["token"] as? String == Self.token)
}
@Test("每个请求都带 Accept: application/json —— 未认证时服务器才回 401 JSON 而不是 302 登录页")
func everyRequestAcceptsJSONSoTheGateAnswers401NotARedirect() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
)
// Act
_ = try await client.liveSessions()
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
// Assert
let requests = await http.recordedRequests
#expect(requests.count == 2)
for request in requests {
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
}
}
// MARK: - POST /auth
@Test("204 + Set-Cookie ⇒ .valid(令牌正确,可保存)")
func probe204WithSetCookieMeansTokenValid() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 204,
headers: ["Set-Cookie": Self.setCookieValue]
)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .valid)
}
@Test("204 但无 Set-Cookie ⇒ .authDisabled(服务器没开鉴权),绝不报成 .valid")
func probe204WithoutSetCookieMeansAuthDisabledNotAuthenticated() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 204)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .authDisabled)
#expect(result != .valid) // ""
}
@Test("401 ⇒ .invalidToken(不是 .unauthorized —— /auth 自己的 401 是路由语义)")
func probe401MeansInvalidTokenNotGateUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
method: "POST", url: try routeURL("/auth"), status: 401,
body: Data(#"{"error":"invalid token"}"#.utf8)
)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .invalidToken)
}
@Test("429 ⇒ .rateLimited(10 次/分/IP,src/server.ts:399-402)")
func probe429MeansRateLimited() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 429)
// Act
let result = try await client.probeAccessToken(Self.token)
// Assert
#expect(result == .rateLimited)
}
@Test("其他状态码(500) ⇒ unexpectedStatus,不猜测")
func probeUnexpectedStatusThrows() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await client.probeAccessToken(Self.token)
}
}
@Test("形状非法的令牌(过短/越界字符/过长)联网前就拒:malformedToken,零请求")
func malformedTokenIsRejectedBeforeAnyNetworkIO() async throws {
// Arrange 16512 ,
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
let malformed = [
"short", // < 16
String(repeating: "a", count: 513), // > 512
"has space in it x", //
"has\r\nCRLF-injection-x", // CRLF()
"中文令牌中文令牌中文令牌中文令牌", // ASCII
]
// Act + Assert
for candidate in malformed {
await #expect(throws: APIClientError.malformedToken) {
_ = try await client.probeAccessToken(candidate)
}
}
#expect(await http.recordedRequests.isEmpty)
}
// MARK: - Cookie Origin
@Test("Cookie iff 配置了令牌:RO 带 Cookie 不带 Origin;G 带 Cookie **并且**带 Origin")
func cookieIsOrthogonalToTheOriginRule() async throws {
// Arrange
let http = FakeHTTPTransport()
let endpoint = try makeEndpoint()
let client = APIClient(endpoint: endpoint, http: http, accessToken: Self.token)
let id = try #require(UUID(uuidString: Self.sessionIdString))
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204
)
// Act
_ = try await client.liveSessions()
try await client.killSession(id: id)
// Assert
let requests = await http.recordedRequests
let readOnly = try #require(requests.first)
let guarded = try #require(requests.last)
#expect(readOnly.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) // RO Origin
#expect(guarded.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
#expect(guarded.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) // Origin
}
@Test("未配置令牌 ⇒ 完全不带 Cookie 头(LAN 零配置行为不变)")
func noTokenConfiguredMeansNoCookieHeaderAtAll() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8))
// Act
_ = try await client.liveSessions()
// Assert
let request = try #require(await http.recordedRequests.first)
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
}
@Test("配置了形状非法的令牌 ⇒ 任何调用联网前抛 malformedToken(绝不静默发无认证请求)")
func malformedConfiguredTokenFailsFastOnEveryCall() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: "bad token", http: http)
// Act + Assert
await #expect(throws: APIClientError.malformedToken) {
_ = try await client.liveSessions()
}
#expect(await http.recordedRequests.isEmpty)
}
// MARK: - 401 .unauthorized
@Test("普通 RO 端点收 401 ⇒ 类型化 .unauthorized(不是 unexpectedStatus/网络错)")
func gate401OnReadOnlyEndpointSurfacesAsUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: nil, http: http)
await http.queueSuccess(
url: try routeURL("/live-sessions"), status: 401,
body: Data(#"{"error":"authentication required"}"#.utf8)
)
// Act + Assert
await #expect(throws: APIClientError.unauthorized) {
_ = try await client.liveSessions()
}
}
@Test("普通 G 端点收 401 ⇒ 同样是 .unauthorized,且话术非空(UI 引导补令牌)")
func gate401OnGuardedEndpointSurfacesAsUnauthorized() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
await http.queueSuccess(
method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 401
)
// Act + Assert
await #expect(throws: APIClientError.unauthorized) {
try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString)))
}
#expect(!APIClientError.unauthorized.message.isEmpty)
}
// MARK: -
@Test("APIClient 的 description/debugDescription 不含令牌明文(零日志泄漏)")
func clientDescriptionNeverLeaksTheToken() async throws {
// Arrange
let http = FakeHTTPTransport()
let client = try makeClient(token: Self.token, http: http)
// Act
let rendered = "\(client)" + String(reflecting: client)
// Assert
#expect(!rendered.contains(Self.token))
#expect(rendered.contains("redacted"))
}
@Test("hasAccessToken 只暴露有无,不暴露值")
func hasAccessTokenExposesPresenceOnly() async throws {
// Arrange + Act
let http = FakeHTTPTransport()
let withToken = try makeClient(token: Self.token, http: http)
let without = try makeClient(token: nil, http: http)
// Assert
#expect(withToken.hasAccessToken)
#expect(!without.hasAccessToken)
}
}

View File

@@ -0,0 +1,435 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · git ****ios-completion §1.2 = `src/`
/// - `GET /projects/log?path=[&n=]``src/server.ts:1030-1056` `GitLogResult`
/// `src/types.ts:757-772` w6/G4 `unpushed`/`upstream`
/// - `GET /projects/pr?path=``src/server.ts:1058-1076` `PrStatus`
/// `src/types.ts:617-648` git 200, body availability
/// - `GET /projects/worktree/state?path=``src/server.ts:542-562` `WorktreeState`
/// `src/types.ts:392-408``sync`
/// - `GET /sessions``src/server.ts:479-481` `[HistorySession]`
/// `src/http/history.ts:13-19``claude --resume`
///
/// `/projects/*` `path` 400 git 404
/// 500 RO ** Origin**
struct GitPanelReadTests {
private static let base = "http://192.168.1.5:3000"
private static let repoPath = "/Users/dev/web-terminal"
private static let encodedRepoPath = "%2FUsers%2Fdev%2Fweb-terminal"
private struct Fixture {
let http: FakeHTTPTransport
let client: APIClient
}
private func makeFixture() throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http))
}
private func routeURL(_ pathAndQuery: String) throws -> URL {
try #require(URL(string: Self.base + pathAndQuery))
}
// MARK: - Origin iff-GRO Origin
@Test("Origin iff-G(RO 侧):log/pr/worktree-state/sessions 四个端点均为 GET 且不带 Origin")
func readOnlyGitEndpointsNeverCarryOrigin() async throws {
// Arrange
let fixture = try makeFixture()
let query = "?path=\(Self.encodedRepoPath)"
await fixture.http.queueSuccess(
url: try routeURL("/projects/log\(query)"), body: Data(#"{"commits":[]}"#.utf8)
)
await fixture.http.queueSuccess(
url: try routeURL("/projects/pr\(query)"), body: Data(#"{"availability":"no-pr"}"#.utf8)
)
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state\(query)"),
body: Data(#"{"path":"\#(Self.repoPath)"}"#.utf8)
)
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data("[]".utf8))
// Act
_ = try await fixture.client.gitLog(path: Self.repoPath)
_ = try await fixture.client.prStatus(path: Self.repoPath)
_ = try await fixture.client.worktreeState(path: Self.repoPath)
_ = try await fixture.client.claudeSessions()
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 4)
for request in requests {
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
}
}
// MARK: - /projects/log
@Test("log 的 path 严格 percent-encode(单点);n 缺省则不带 n 参数")
func logPathIsStrictlyEncodedAndNilCountOmitsTheParam() async throws {
// Arrange
let fixture = try makeFixture()
let raw = "/Users/dev/my proj+α"
let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1"
let expected = try routeURL("/projects/log?path=\(encoded)")
await fixture.http.queueSuccess(url: expected, body: Data(#"{"commits":[]}"#.utf8))
// Act
_ = try await fixture.client.gitLog(path: raw, n: nil)
// Assert
#expect(await fixture.http.recordedRequests.first?.url == expected)
}
@Test("log 的 n 客户端夹到 [1,50](镜像 src/http/git-log.ts GIT_LOG_MAX,服务端仍会再夹一次)")
func logCountIsClampedClientSide() async throws {
// Arrange
let fixture = try makeFixture()
let body = Data(#"{"commits":[]}"#.utf8)
let cases: [(Int, Int)] = [(0, 1), (-7, 1), (10, 10), (999, 50)]
for (_, clamped) in cases {
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)&n=\(clamped)"),
body: body
)
}
// Act
for (requested, _) in cases {
_ = try await fixture.client.gitLog(path: Self.repoPath, n: requested)
}
// Assert FakeHTTPTransport URL ,
let queries = await fixture.http.recordedRequests.compactMap(\.url?.query)
#expect(queries == cases.map { "path=\(Self.encodedRepoPath)&n=\($0.1)" })
}
@Test("GitLogResult 全字段解码(hash/at/subject/unpushed + truncated + upstream,w6/G4)")
func gitLogDecodesFullSample() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"commits":[{"hash":"abc1234","at":1720000000000,"subject":"feat: x","unpushed":true},\
{"hash":"def5678","at":1719990000000,"subject":"fix: y"}],\
"truncated":true,"upstream":"origin/develop"}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let result = try await fixture.client.gitLog(path: Self.repoPath)
// Assert
#expect(result.commits.count == 2)
#expect(result.truncated)
#expect(result.upstream == "origin/develop")
let first = try #require(result.commits.first)
#expect(first.hash == "abc1234")
#expect(first.at == 1_720_000_000_000)
#expect(first.subject == "feat: x")
#expect(first.unpushed == true)
#expect(result.commits.last?.unpushed == nil) // nil( false)
}
@Test("log 容忍:畸形 commit 逐条丢弃、subject 缺省为空、truncated 缺省 false、未知字段忽略")
func gitLogToleratesDegradedShapes() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"commits":[{"hash":"ok1","at":1,"subject":"s"},42,{"at":2},{"hash":"ok2","at":3},\
{"hash":"bad","at":"nope"}],"futureField":{"x":1}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let result = try await fixture.client.gitLog(path: Self.repoPath)
// Assert
#expect(result.commits.map(\.hash) == ["ok1", "ok2"])
#expect(result.commits.last?.subject == "")
#expect(!result.truncated)
#expect(result.upstream == nil)
}
@Test("log 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒")
func gitLogMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/log?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(
url: url, status: status, body: Data(#"{"error":"nope"}"#.utf8)
)
}
// Act + Assert
let expected: [APIClientError] = [
.projectPathInvalid, .projectNotFound, .gitDataUnavailable,
]
for error in expected {
await #expect(throws: error) {
_ = try await fixture.client.gitLog(path: Self.repoPath)
}
#expect(!error.message.isEmpty)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitLog(path: "")
}
#expect(await fixture.http.recordedRequests.count == 3) // path
}
@Test("log 200 但 body 非对象 → invalidResponseBody")
func gitLogRejectsNonObjectBody() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"),
body: Data("[1,2]".utf8)
)
// Act + Assert
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.gitLog(path: Self.repoPath)
}
}
// MARK: - /projects/pr
@Test("PrStatus 全字段解码(availability=ok + checks 嵌套计数)")
func prStatusDecodesFullSample() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"availability":"ok","number":42,"title":"feat: x","url":"https://github.com/a/b/pull/42",\
"state":"open","isDraft":false,"mergeable":"mergeable","headRefName":"feat/x",\
"baseRefName":"develop","checks":{"total":5,"passing":4,"failing":0,"pending":1}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/pr?path=\(Self.encodedRepoPath)"), body: Data(body.utf8)
)
// Act
let pr = try await fixture.client.prStatus(path: Self.repoPath)
// Assert
#expect(pr.availability == .ok)
#expect(pr.number == 42)
#expect(pr.state == "open")
#expect(pr.isDraft == false)
#expect(pr.mergeable == "mergeable")
#expect(pr.headRefName == "feat/x")
#expect(pr.baseRefName == "develop")
#expect(pr.checks == PrCheckSummary(total: 5, passing: 4, failing: 0, pending: 1))
}
@Test("PrStatus :/ availability .error;; nil")
func prStatusDegradesUnknownAvailability() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
let bodies = [
#"{"availability":"quantum-ci"}"#, // 未来值 → .error
#"{}"#, // 缺失 → .error
#"{"availability":"not-installed"}"#,
#"{"availability":"unauthenticated"}"#,
#"{"availability":"disabled"}"#,
#"{"availability":"no-pr"}"#,
]
for body in bodies {
await fixture.http.queueSuccess(url: url, body: Data(body.utf8))
}
// Act
var seen: [PrAvailability] = []
for _ in bodies {
seen.append(try await fixture.client.prStatus(path: Self.repoPath).availability)
}
// Assert
#expect(seen == [.error, .error, .notInstalled, .unauthenticated, .disabled, .noPr])
// 非 ok 时兄弟字段一律缺省 → nil(绝不编造 PR 号)
await fixture.http.queueSuccess(url: url, body: Data(#"{"availability":"no-pr"}"#.utf8))
let degraded = try await fixture.client.prStatus(path: Self.repoPath)
#expect(degraded.number == nil)
#expect(degraded.checks == nil)
}
@Test("pr 400/404/500 projectPathInvalid/projectNotFound/gitDataUnavailable; path ")
func prStatusMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(url: url, status: status)
}
// Act + Assert
for error in [APIClientError.projectPathInvalid, .projectNotFound, .gitDataUnavailable] {
await #expect(throws: error) {
_ = try await fixture.client.prStatus(path: Self.repoPath)
}
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.prStatus(path: "")
}
}
// MARK: - /projects/worktree/state
@Test("WorktreeState ;sync.lastFetchMs fs.stat().mtimeMs ****")
func worktreeStateDecodesFullSampleIncludingFractionalMtime() async throws {
// Arrange — 1785390645813.5327 是真实 stat().mtimeMs 的形状(APFS 纳秒精度)
let fixture = try makeFixture()
let body = """
{"path":"\(Self.repoPath)","branch":"develop","dirtyCount":3,\
"sync":{"upstream":"origin/develop","ahead":2,"behind":1,\
"lastFetchMs":1785390645813.5327,"detached":false}}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
body: Data(body.utf8)
)
// Act
let state = try await fixture.client.worktreeState(path: Self.repoPath)
// Assert
#expect(state.path == Self.repoPath)
#expect(state.branch == "develop")
#expect(state.dirtyCount == 3)
let sync = try #require(state.sync)
#expect(sync.upstream == "origin/develop")
#expect(sync.ahead == 2)
#expect(sync.behind == 1)
#expect(sync.detached == false)
let lastFetchMs = try #require(sync.lastFetchMs)
#expect(abs(lastFetchMs - 1_785_390_645_813.5327) < 0.001)
}
@Test("WorktreeState : upstream/detached HEAD/ fetch ")
func worktreeStateDegradesEachFieldIndependently() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"path":"\(Self.repoPath)","sync":{"detached":true},"unknownField":1}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"),
body: Data(body.utf8)
)
// Act
let state = try await fixture.client.worktreeState(path: Self.repoPath)
// Assert
#expect(state.branch == nil)
#expect(state.dirtyCount == nil)
let sync = try #require(state.sync)
#expect(sync.detached == true)
#expect(sync.upstream == nil)
#expect(sync.ahead == nil)
#expect(sync.behind == nil)
#expect(sync.lastFetchMs == nil) // 从未 fetch —— 绝不编造时间戳
}
@Test("worktree/state 400/404/500 projectPathInvalid/worktreeNotFound/gitDataUnavailable; body invalidResponseBody")
func worktreeStateMapsErrorStatuses() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)")
for status in [400, 404, 500] {
await fixture.http.queueSuccess(url: url, status: status)
}
await fixture.http.queueSuccess(url: url, body: Data("7".utf8))
// Act + Assert
for error in [APIClientError.projectPathInvalid, .worktreeNotFound, .gitDataUnavailable] {
await #expect(throws: error) {
_ = try await fixture.client.worktreeState(path: Self.repoPath)
}
#expect(!error.message.isEmpty)
}
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.worktreeState(path: Self.repoPath)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.worktreeState(path: "")
}
}
// MARK: - GET /sessionsclaude --resume 历史)
@Test("HistorySession :mtimeMs fs.stat() ****()")
func claudeSessionsDecodeFractionalMtime() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
[{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cwd":"/Users/dev/web-terminal",\
"project":"web-terminal","mtimeMs":1785390645813.5327,"preview":"修一下 CJK locale"},\
{"id":"11111111-2222-4333-8444-555555555555","cwd":"","project":"unknown",\
"mtimeMs":1700000000000,"preview":""}]
"""
await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data(body.utf8))
// Act
let sessions = try await fixture.client.claudeSessions()
// Assert
#expect(sessions.count == 2)
let first = try #require(sessions.first)
#expect(first.id == "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b")
#expect(first.cwd == "/Users/dev/web-terminal")
#expect(first.project == "web-terminal")
#expect(abs(first.mtimeMs - 1_785_390_645_813.5327) < 0.001)
#expect(first.preview == " CJK locale")
#expect(sessions.last?.mtimeMs == 1_700_000_000_000) // 整数也要能解
}
@Test("/sessions : body invalidResponseBody")
func claudeSessionsToleratesDegradedShapes() async throws {
// Arrange
let fixture = try makeFixture()
let url = try routeURL("/sessions")
let degraded = """
[{"id":"keep-me","mtimeMs":1},"nope",{"cwd":"/x"},{"id":"","mtimeMs":2},\
{"id":"also-keep","mtimeMs":3,"extra":true}]
"""
await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8))
await fixture.http.queueSuccess(url: url, body: Data(#"{"error":"x"}"#.utf8))
// Act
let sessions = try await fixture.client.claudeSessions()
// Assert — id 是 `claude --resume <id>` 的实参,缺失/空 ⇒ 该条无用,丢弃
#expect(sessions.map(\.id) == ["keep-me", "also-keep"])
#expect(sessions.first?.cwd == "")
#expect(sessions.first?.project == "")
#expect(sessions.first?.preview == "")
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.claudeSessions()
}
}
@Test("/sessions 200 unexpectedStatus")
func claudeSessionsRejectsBadStatus() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(url: try routeURL("/sessions"), status: 500)
// Act + Assert
await #expect(throws: APIClientError.unexpectedStatus(500)) {
_ = try await fixture.client.claudeSessions()
}
}
}

View File

@@ -0,0 +1,459 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
import APIClient
/// B1 · **G** git + worktree + w2
/// ios-completion §1.2 = `src/`
///
/// - `POST /projects/git/stage``src/server.ts:1184-1218`body `{path,files,stage}`
/// - `POST /projects/git/commit``:1219-1255`body `{path,message}`
/// - `POST /projects/git/push``:1256-1289`body `{path}`
/// - `POST /projects/git/fetch``:1290-1319`body `{path}`
/// - `POST /projects/worktree``:1095-1123`body `{path,branch[,base]}`
/// - `DELETE /projects/worktree``:1124-1153`body `{path,worktreePath,force}`
/// - `POST /projects/worktree/prune``:1154-1183`body `{path}`
/// - `POST /live-sessions/:id/queue``:605-643`body `{text[,appendEnter]}`
///
/// ** `Origin`** body **** `error`
/// `src/http/git-ops.ts` / `worktrees.ts`SEC-M10
/// git stderr403 ****Origin ****
/// 403 message
struct GitWriteTests {
private static let base = "http://192.168.1.5:3000"
private static let repoPath = "/Users/dev/web-terminal"
private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b"
private struct Fixture {
let http: FakeHTTPTransport
let endpoint: HostEndpoint
let client: APIClient
}
private func makeFixture(accessToken: String? = nil) throws -> Fixture {
let baseURL = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let http = FakeHTTPTransport()
return Fixture(
http: http, endpoint: endpoint,
client: APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
)
}
private func routeURL(_ path: String) throws -> URL {
try #require(URL(string: Self.base + path))
}
private func bodyObject(_ request: URLRequest) throws -> [String: Any] {
let body = try #require(request.httpBody)
return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
}
// MARK: - Origin iff-GG Origin,)
@Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type")
func everyGitWriteRouteCarriesByteEqualOrigin() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
)
// Act
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true)
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x")
_ = try await fixture.client.gitPush(path: Self.repoPath)
_ = try await fixture.client.gitFetch(path: Self.repoPath)
_ = try await fixture.client.createWorktree(
path: Self.repoPath, branch: "wt-x", base: nil
)
_ = try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false
)
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
// Assert
let requests = await fixture.http.recordedRequests
#expect(requests.count == 7)
for request in requests {
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
}
}
@Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)")
func gitWriteCarriesCookieAlongsideOrigin() async throws {
// Arrange
let token = "s3cret-token_value.~+/="
let fixture = try makeFixture(accessToken: token)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8)
)
// Act
_ = try await fixture.client.gitPush(path: Self.repoPath)
// Assert
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)")
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
}
// MARK: - body
@Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}")
func gitWriteBodyShapesAreExact() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
)
// Act
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false)
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文")
_ = try await fixture.client.gitPush(path: Self.repoPath)
_ = try await fixture.client.gitFetch(path: Self.repoPath)
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
// Assert
let requests = await fixture.http.recordedRequests
let stage = try bodyObject(try #require(requests.first))
#expect(Set(stage.keys) == Set(["path", "files", "stage"]))
#expect(stage["path"] as? String == Self.repoPath)
#expect(stage["files"] as? [String] == ["a.txt", "b/c.md"])
#expect(stage["stage"] as? Bool == false)
let commit = try bodyObject(requests[1])
#expect(Set(commit.keys) == Set(["path", "message"]))
#expect(commit["message"] as? String == "fix: 修中文")
for index in 2...4 {
let single = try bodyObject(requests[index])
#expect(Set(single.keys) == Set(["path"]))
#expect(single["path"] as? String == Self.repoPath)
}
}
@Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}")
func worktreeBodyShapesAreExact() async throws {
// Arrange
let fixture = try makeFixture()
let ok = Data(#"{"ok":true}"#.utf8)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
// Act
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil)
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop")
_ = try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "/tmp/wt", force: true
)
// Assert
let requests = await fixture.http.recordedRequests
let withoutBase = try bodyObject(try #require(requests.first))
#expect(Set(withoutBase.keys) == Set(["path", "branch"]))
#expect(withoutBase["branch"] as? String == "wt-a")
let withBase = try bodyObject(requests[1])
#expect(Set(withBase.keys) == Set(["path", "branch", "base"]))
#expect(withBase["base"] as? String == "develop")
let remove = try bodyObject(requests[2])
#expect(Set(remove.keys) == Set(["path", "worktreePath", "force"]))
#expect(remove["worktreePath"] as? String == "/tmp/wt")
#expect(remove["force"] as? Bool == true)
#expect(requests[2].httpMethod == "DELETE") // DELETE **** JSON body
}
// MARK: - 200 payload body
@Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状")
func successPayloadsDecodePerRoute() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/stage"),
body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"),
body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"),
body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/fetch"),
body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree"),
body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "DELETE", url: try routeURL("/projects/worktree"),
body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"),
body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8)
)
// Act + Assert
#expect(
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
== .ok(StageResult(staged: true, count: 2))
)
#expect(
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
== .ok(CommitResult(commit: "abc1234"))
)
#expect(
try await fixture.client.gitPush(path: Self.repoPath)
== .ok(PushResult(branch: "develop", remote: "origin"))
)
let fetched = try await fixture.client.gitFetch(path: Self.repoPath)
guard case .ok(let fetchResult) = fetched else {
Issue.record("fetch 应为 .ok")
return
}
#expect(fetchResult.remote == "origin")
// lastFetchMs fs.stat().mtimeMs ,
#expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001)
#expect(
try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
== .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x"))
)
#expect(
try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "/tmp/wt", force: false
) == .ok(RemoveWorktreeResult(path: "/tmp/wt"))
)
#expect(
try await fixture.client.pruneWorktrees(path: Self.repoPath)
== .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"]))
)
}
@Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛")
func garbledSuccessPayloadDegradesToDefaults() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8)
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"),
body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8)
)
// Act + Assert
#expect(
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
== .ok(CommitResult(commit: ""))
)
#expect(
try await fixture.client.pruneWorktrees(path: Self.repoPath)
== .ok(PruneWorktreesResult(pruned: []))
)
}
// MARK: - 429 message
@Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试")
func rateLimitedIsItsOwnOutcome() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), status: 429,
body: Data(#"{"error":"Too many requests."}"#.utf8)
)
// Act
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
// Assert
#expect(outcome == .rateLimited)
}
@Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)")
func failuresCarryTheServerSafeMessageVerbatim() async throws {
// Arrange
let fixture = try makeFixture()
let cases: [(Int, String)] = [
(403, "Git operations are disabled."),
(409, "Nothing staged to commit."),
(400, "Set a git author identity (user.name / user.email) first."),
(500, "Git operation failed."),
]
for (status, message) in cases {
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/commit"), status: status,
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
)
}
// Act + Assert
for (status, message) in cases {
let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
#expect(outcome == .rejected(status: status, message: message))
}
}
@Test("失败 body 无法解析 → .rejected(status, nil),不编造原因")
func unparseableFailureBodyYieldsNilMessage() async throws {
// Arrange
let fixture = try makeFixture()
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree"), status: 500,
body: Data("<html>oops</html>".utf8)
)
// Act
let outcome = try await fixture.client.createWorktree(
path: Self.repoPath, branch: "x", base: nil
)
// Assert
#expect(outcome == .rejected(status: 500, message: nil))
}
@Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401")
func push401IsRouteClassifiedNotTheAccessTokenGate() async throws {
// Arrange src/http/git-ops.ts:108 git 401
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
let message = "Push authentication required on the host."
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/git/push"), status: 401,
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
)
// Act
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
// Assert gate .unauthorized,
#expect(outcome == .rejected(status: 401, message: message))
}
@Test("空 path 联网前拒(projectPathInvalid),七条写路由一致")
func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws {
// Arrange
let fixture = try makeFixture()
// Act + Assert
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitCommit(path: "", message: "m")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitPush(path: "")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.gitFetch(path: "")
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false)
}
await #expect(throws: APIClientError.projectPathInvalid) {
_ = try await fixture.client.pruneWorktrees(path: "")
}
#expect(await fixture.http.recordedRequests.isEmpty)
}
// MARK: - POST /live-sessions/:id/queuew2 pty
@Test("queue 路由:POST /live-sessions/<小写 UUID>/queue,带 Origin,body 恰为 {text,appendEnter}")
func queueRouteShapeIsExact() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
await fixture.http.queueSuccess(
method: "POST", url: url, body: Data(#"{"length":2}"#.utf8)
)
// Act
let depth = try await fixture.client.enqueueFollowup(
sessionId: id, text: "继续", appendEnter: true
)
// Assert
#expect(depth == 2)
let request = try #require(await fixture.http.recordedRequests.first)
#expect(request.httpMethod == "POST")
#expect(request.url == url) // :id
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
let body = try bodyObject(request)
#expect(Set(body.keys) == Set(["text", "appendEnter"]))
#expect(body["text"] as? String == "继续")
#expect(body["appendEnter"] as? Bool == true)
}
@Test("queue 错误映射:400/403/404/409/413/429/503 各自类型化,话术非空")
func queueMapsEveryServerStatusToATypedError() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue")
let cases: [(Int, APIClientError)] = [
(400, .queueTextInvalid),
(403, .forbidden),
(404, .sessionNotFound),
(409, .queueFull),
(413, .queueTextTooLarge),
(429, .rateLimited),
(503, .queueDisabled),
(500, .unexpectedStatus(500)),
]
for (status, _) in cases {
await fixture.http.queueSuccess(method: "POST", url: url, status: status)
}
// Act + Assert
for (_, expected) in cases {
await #expect(throws: expected) {
_ = try await fixture.client.enqueueFollowup(
sessionId: id, text: "x", appendEnter: false
)
}
#expect(!expected.message.isEmpty)
}
}
@Test("queue 空 text 联网前拒(镜像服务器 400 规则);200 但 body 无 length → invalidResponseBody")
func queueRejectsEmptyTextAndGarbledSuccessBody() async throws {
// Arrange
let fixture = try makeFixture()
let id = try #require(UUID(uuidString: Self.sessionIdString))
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/live-sessions/\(Self.sessionIdString)/queue"),
body: Data(#"{"ok":true}"#.utf8)
)
// Act + Assert
await #expect(throws: APIClientError.queueTextInvalid) {
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "", appendEnter: true)
}
await #expect(throws: APIClientError.invalidResponseBody) {
_ = try await fixture.client.enqueueFollowup(sessionId: id, text: "x", appendEnter: false)
}
}
}

View File

@@ -156,6 +156,67 @@ struct ProjectsTests {
#expect(projects.last?.sessions.isEmpty == true) // sessions []
}
@Test("回归:lastActiveMs 来自 fs.stat().mtimeMs —— **带小数**必须解出来(否则真机上排序键永远为 nil)")
func lastActiveMsDecodesFractionalStatMtime() async throws {
// Arrange 1785390645813.5327(APFS );
// decode(Int.self) , try? nil
let body = """
[{"name":"a","path":"/a","isGit":true,"lastActiveMs":1785390645813.5327,\
"lastCommitMs":1720000000000,"ahead":2,"behind":0,"sessions":[]}]
"""
// Act
let projects = try await fetchProjects(try makeFixture(), body: body)
// Assert
let project = try #require(projects.first)
#expect(project.lastActiveMs == 1_785_390_645_813)
#expect(project.lastCommitMs == 1_720_000_000_000)
#expect(project.ahead == 2)
#expect(project.behind == 0)
}
@Test("session ref 的 cwd(w6/G7)可选解码:有则解出,缺失 → nil")
func sessionRefDecodesOptionalCwd() async throws {
// Arrange
let body = """
[{"name":"a","path":"/a","isGit":true,"sessions":[\
{"id":"\(Self.sessionIdString)","status":"idle","clientCount":0,"createdAt":1,\
"exited":false,"cwd":"/a/.claude/worktrees/x"}]}]
"""
// Act
let projects = try await fetchProjects(try makeFixture(), body: body)
// Assert
#expect(projects.first?.sessions.first?.cwd == "/a/.claude/worktrees/x")
}
@Test("detail 的 dirtyCount / sync(w6/G1)可选解码,与 worktree/state 用同一 SyncState")
func projectDetailDecodesDirtyCountAndSync() async throws {
// Arrange
let fixture = try makeFixture()
let body = """
{"name":"a","path":"/a","isGit":true,"dirty":true,"dirtyCount":7,\
"sync":{"upstream":"origin/main","ahead":1,"behind":0,"lastFetchMs":1785390645813.5327},\
"worktrees":[],"sessions":[],"hasClaudeMd":false}
"""
await fixture.http.queueSuccess(
url: try routeURL("/projects/detail?path=%2Fa"), body: Data(body.utf8)
)
// Act
let detail = try await fixture.client.projectDetail(path: "/a")
// Assert
#expect(detail.dirtyCount == 7)
let sync = try #require(detail.sync)
#expect(sync.upstream == "origin/main")
#expect(sync.ahead == 1)
#expect(sync.behind == 0)
#expect(sync.lastFetchMs != nil)
}
@Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus")
func projectsRejectsNonArrayBodyAndBadStatus() async throws {
// Act + Assert

View File

@@ -0,0 +1,89 @@
import Foundation
/// Complete failure taxonomy for a per-host access token: the three validation
/// verdicts plus the one store-level miss. One enum so the UI has a single
/// thing to switch over when it saves a token (never a generic `Error`).
public enum AccessTokenError: Error, Equatable {
case tooShort(length: Int)
case tooLong(length: Int)
/// Deliberately payload-free. An error value gets logged, wrapped in an
/// alert string, and attached to crash reports carrying the rejected
/// token here would turn every one of those into a leak (§5.3). The
/// length cases carry only a length, which is not secret.
case invalidCharacters
/// `setAccessToken` for an id that is not in the store. Explicit error
/// rather than a silent no-op: a UI that thinks it saved a token but
/// didn't would send unauthenticated requests forever.
case unknownHost(UUID)
}
/// The host's shared access token (`WEBTERM_TOKEN`), validated at the boundary.
///
/// Charset and length are the FROZEN server contract (coordination doc §1.1),
/// byte-for-byte the same rule the server enforces at config load
/// `/^[A-Za-z0-9._~+/=-]{16,512}$/` (src/config.ts:178). Validating here means
/// a token that cannot possibly authenticate is rejected at the keyboard
/// instead of turning into a mystery 401 later, and a value of this type is
/// always safe to place verbatim in a `Cookie: webterm_auth=<t>` header (the
/// charset is exactly the cookie-safe one nothing to escape).
///
/// SECRET MATERIAL (§5.3). Three deliberate properties keep it out of logs:
/// - `description`/`debugDescription` are redacted, so string interpolation
/// (`"\(token)"`) the way a token usually reaches a log cannot leak it.
/// - `customMirror` hides the storage, so `dump()` and reflection-based crash
/// reporters see `<redacted>` too.
/// - it is intentionally NOT `Codable`: nothing can serialize a token by
/// accident. `Host` encodes it explicitly (one audited call site) into the
/// Keychain item, which is the only place a token is ever persisted.
public struct AccessToken: Sendable, Hashable, RawRepresentable,
CustomStringConvertible, CustomDebugStringConvertible,
CustomReflectable {
/// §1.1 length window (server: `{16,512}`).
public static let minLength = 16
public static let maxLength = 512
/// §1.1 charset `[A-Za-z0-9._~+/=-]`. A `Set` so validation is O(n) in the
/// token length with no regex engine in the hot path.
private static let allowedCharacters: Set<Character> = Set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-"
)
/// The token, verbatim as it must appear in the `Cookie` header.
public let rawValue: String
/// Validating init the ONLY way a token enters the app.
///
/// Leading/trailing whitespace is trimmed first: tokens arrive by paste,
/// and whitespace is never a legal token character, so trimming can never
/// change the meaning of a valid token. Interior whitespace is NOT
/// tolerated (that would be silent normalization of a secret).
public init(validating raw: String) throws {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let length = trimmed.count
guard length >= Self.minLength else { throw AccessTokenError.tooShort(length: length) }
guard length <= Self.maxLength else { throw AccessTokenError.tooLong(length: length) }
guard trimmed.allSatisfy(Self.allowedCharacters.contains) else {
throw AccessTokenError.invalidCharacters
}
self.rawValue = trimmed
}
/// Lenient seam over the same rules (`RawRepresentable`): invalid nil.
/// Used where a rejected value must degrade instead of failing a whole
/// operation decoding a tampered Keychain record (see `Host`).
///
/// Note: whitespace-padded input normalizes, so `rawValue` round-trips
/// only for already-trimmed strings.
public init?(rawValue: String) {
guard let token = try? AccessToken(validating: rawValue) else { return nil }
self = token
}
public var description: String { "AccessToken(redacted, \(rawValue.count) chars)" }
public var debugDescription: String { description }
public var customMirror: Mirror {
Mirror(self, children: ["rawValue": "<redacted>"], displayStyle: .struct)
}
}

View File

@@ -8,16 +8,90 @@ import WireProtocol
/// 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 struct Host: Sendable, Equatable, Codable, Identifiable,
CustomStringConvertible, CustomDebugStringConvertible {
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
/// This host's access token (`WEBTERM_TOKEN`), or nil when the host has
/// no auth configured which is the zero-config LAN default, so nil is
/// the normal state, not an error state.
///
/// Why it lives ON the host record instead of in its own Keychain item:
/// plan §5.3 assigns the host list to the Keychain precisely as the place
/// for "host authMaterial ". Piggy-backing on that one
/// item means the token inherits the audited protection policy
/// (`AfterFirstUnlockThisDeviceOnly`, never synchronizable see
/// `KeychainItemSpec`) and, just as importantly, that removing a host
/// removes its secret in the same write a separate item could be
/// orphaned in the Keychain forever.
public let accessToken: AccessToken?
public init(id: UUID, name: String, endpoint: HostEndpoint) {
/// `accessToken` defaults to nil so every existing three-argument call
/// site (pairing, tests, fixtures) keeps compiling and keeps meaning
/// "no token configured".
public init(id: UUID, name: String, endpoint: HostEndpoint, accessToken: AccessToken? = nil) {
self.id = id
self.name = name
self.endpoint = endpoint
self.accessToken = accessToken
}
/// Immutable update: returns a NEW host with the token set (or cleared with
/// nil). Never mutates the receiver the store persists the returned value.
public func withAccessToken(_ token: AccessToken?) -> Host {
Host(id: id, name: name, endpoint: endpoint, accessToken: token)
}
// MARK: - Codable (hand-written: migration + tamper behaviour is the point)
private enum CodingKeys: String, CodingKey {
case id, name, endpoint, accessToken
}
/// MIGRATION SAFETY: a host paired by an earlier build has no
/// `accessToken` key at all. `decodeIfPresent` maps both "key absent" and
/// "key is null" to nil, so those records keep loading unchanged.
///
/// TAMPER SAFETY: a stored value that no longer passes §1.1 validation
/// degrades to nil (the user re-enters a token) instead of throwing
/// throwing here would surface as `KeychainHostStoreError.corruptedData`
/// for the WHOLE list, i.e. every paired host lost over one bad field.
/// The `endpoint` stays strictly validated (a host without a dialable URL
/// is not a host), so this leniency is scoped to the one optional field.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(UUID.self, forKey: .id)
self.name = try container.decode(String.self, forKey: .name)
self.endpoint = try container.decode(HostEndpoint.self, forKey: .endpoint)
let stored = try container.decodeIfPresent(String.self, forKey: .accessToken)
self.accessToken = stored.flatMap(AccessToken.init(rawValue:))
}
/// Encodes the token as a plain string under `accessToken`, and omits the
/// key entirely when there is none (an absent key is exactly what the
/// decoder above treats as "no token", so old and new shapes stay
/// interchangeable in both directions).
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(endpoint, forKey: .endpoint)
try container.encodeIfPresent(accessToken?.rawValue, forKey: .accessToken)
}
// MARK: - Redacted description (§5.3)
/// Hand-written so the token can never ride along into a log line: the
/// synthesized reflection dump would print the `accessToken` child, and a
/// `Host` is the natural thing to interpolate when logging a connection.
/// Only the token's PRESENCE is reported.
public var description: String {
"Host(id: \(id), name: \(name), origin: \(endpoint.originHeader), "
+ "hasAccessToken: \(accessToken != nil))"
}
public var debugDescription: String { description }
}

View File

@@ -10,8 +10,44 @@ public protocol HostStore: Sendable {
/// 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".
/// collection, never throws for "not found". Removing a host also removes
/// its `accessToken` the token is part of the record (see `Host`), so no
/// orphaned secret can survive in storage.
func remove(id: UUID) async throws -> [Host]
/// This host's access token, or nil when none is configured / the host is
/// unknown. A read never throws for "not found" (mirrors `remove`).
func accessToken(host: UUID) async throws -> AccessToken?
/// Sets (or with nil, clears) the host's access token and returns the new
/// collection. Throws `AccessTokenError.unknownHost` rather than silently
/// dropping a token the UI believes it saved.
func setAccessToken(_ token: AccessToken?, host: UUID) async throws -> [Host]
}
/// Generic token read/write built from `loadAll`/`upsert`, so every conformer
/// including test doubles outside this package gets the behaviour for free
/// and cannot drift from it. `KeychainHostStore`/`InMemoryHostStore` override
/// both to do the read-modify-write INSIDE their actor (atomic against a
/// concurrent upsert/remove); this default's two suspension points cannot be.
extension HostStore {
public func accessToken(host id: UUID) async throws -> AccessToken? {
try await loadAll().first { $0.id == id }?.accessToken
}
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
let current = try await loadAll()
guard let target = current.first(where: { $0.id == id }) else {
throw AccessTokenError.unknownHost(id)
}
guard target.accessToken != token else { return current } // no-op: unchanged
return try await upsert(target.withAccessToken(token))
}
/// Clearing is writing nil named so the intent reads at the call site
/// (the token-settings UI removes a token; it does not "set nil").
public func clearAccessToken(host id: UUID) async throws -> [Host] {
try await setAccessToken(nil, host: id)
}
}
// Pure collection transforms shared by all HostStore implementations (DRY);
@@ -26,4 +62,12 @@ extension [Host] {
func removing(id: UUID) -> [Host] {
filter { $0.id != id }
}
/// Sets/clears one host's token. Returns nil when `id` is not in the
/// collection so callers can raise `AccessTokenError.unknownHost` a
/// sentinel-free way to distinguish "unchanged" from "unknown host".
func settingAccessToken(_ token: AccessToken?, host id: UUID) -> [Host]? {
guard contains(where: { $0.id == id }) else { return nil }
return map { $0.id == id ? $0.withAccessToken(token) : $0 }
}
}

View File

@@ -29,4 +29,18 @@ public actor InMemoryHostStore: HostStore {
hosts = updated
return updated
}
public func accessToken(host id: UUID) async throws -> AccessToken? {
hosts.first { $0.id == id }?.accessToken
}
/// Atomic override for the same reason as `KeychainHostStore`'s: the
/// read-modify-write stays inside the actor.
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
guard let updated = hosts.settingAccessToken(token, host: id) else {
throw AccessTokenError.unknownHost(id)
}
hosts = updated
return updated
}
}

View File

@@ -65,6 +65,27 @@ public actor KeychainHostStore: HostStore {
return updated
}
// MARK: - Access token (stored inside the same host record see Host)
public func accessToken(host id: UUID) async throws -> AccessToken? {
try readHosts().first { $0.id == id }?.accessToken
}
/// Atomic override of the `HostStore` default: the read-modify-write runs
/// inside the actor, so a concurrent `upsert`/`remove` cannot interleave
/// and lose the token (the default implementation awaits twice).
public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] {
let current = try readHosts()
guard let updated = current.settingAccessToken(token, host: id) else {
throw AccessTokenError.unknownHost(id)
}
guard updated != current else {
return current // explicit no-op: same token, nothing persisted
}
try persist(updated)
return updated
}
// MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec)
private func readHosts() throws -> [Host] {

View File

@@ -0,0 +1,210 @@
import Foundation
import Testing
import HostRegistry
// AccessToken: + ""
// : doc §1.1 `[A-Za-z0-9._~+/=-]` 16512
// ( src/config.ts:178 WEBTERM_TOKEN_RE );,
// / URL / (§5.3)
// MARK: - ()
@Test("合法令牌:覆盖全字符集 → 构造成功且 rawValue 逐字保真")
func validTokenPreservesRawValueVerbatim() throws {
// Arrange
let raw = Fixtures.validTokenString
// Act
let token = try AccessToken(validating: raw)
// Assert
#expect(token.rawValue == raw)
}
@Test("非法字符 → .invalidCharacters", arguments: [
"abcdefghijklmno p", //
"abcdefghijklmnop!", //
"abcdefghijklmnop%", // ( URL cookie-safe )
"abcdefghijklmnop,", // (cookie )
"abcdefghijklmnop;", // (cookie )
"abcdefghijklmnop令", // ASCII
])
func invalidCharactersAreRejectedWithTypedError(raw: String) {
// Act / Assert
#expect(throws: AccessTokenError.invalidCharacters) {
_ = try AccessToken(validating: raw)
}
}
@Test("非法字符错误不携带令牌内容(错误对象本身不得成为泄漏面)")
func invalidCharactersErrorCarriesNoTokenPayload() {
// Arrange
let secretish = "supersecret-tail!!!!!!"
// Act
var captured = ""
do {
_ = try AccessToken(validating: secretish)
Issue.record("期望抛错,实际构造成功")
} catch {
captured = String(describing: error) + String(reflecting: error)
}
// Assert
#expect(captured.isEmpty == false)
#expect(captured.contains("supersecret") == false)
}
// MARK: - ()
@Test("长度下限:15 → .tooShort(15)")
func tokenBelowMinimumLengthIsRejected() {
// Act / Assert
#expect(throws: AccessTokenError.tooShort(length: 15)) {
_ = try AccessToken(validating: Fixtures.tokenString(length: 15))
}
}
@Test("长度下限:恰好 16 → 通过")
func tokenAtMinimumLengthIsAccepted() throws {
// Act
let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.minLength))
// Assert
#expect(token.rawValue.count == 16)
}
@Test("长度上限:恰好 512 → 通过")
func tokenAtMaximumLengthIsAccepted() throws {
// Act
let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.maxLength))
// Assert
#expect(token.rawValue.count == 512)
}
@Test("长度上限:513 → .tooLong(513)")
func tokenAboveMaximumLengthIsRejected() {
// Act / Assert
#expect(throws: AccessTokenError.tooLong(length: 513)) {
_ = try AccessToken(validating: Fixtures.tokenString(length: 513))
}
}
@Test("空串 → .tooShort(0)(不是 invalidCharacters:先判长度更贴近用户话术)")
func emptyTokenIsRejectedAsTooShort() {
// Act / Assert
#expect(throws: AccessTokenError.tooShort(length: 0)) {
_ = try AccessToken(validating: "")
}
}
// MARK: - UX:
@Test("首尾空白/换行被裁剪后再校验:合法令牌不受影响(空白永非合法令牌字符)")
func surroundingWhitespaceIsTrimmedBeforeValidation() throws {
// Arrange
let pasted = " \n\t" + Fixtures.validTokenString + " \n"
// Act
let token = try AccessToken(validating: pasted)
// Assert
#expect(token.rawValue == Fixtures.validTokenString)
}
@Test("中间空白不被裁剪:仍是 .invalidCharacters(不做静默规范化)")
func interiorWhitespaceIsStillInvalid() {
// Act / Assert
#expect(throws: AccessTokenError.invalidCharacters) {
_ = try AccessToken(validating: "abcdefgh ijklmnop")
}
}
@Test("init?(rawValue:) 宽松入口:非法 → nil,不抛")
func lenientRawValueInitReturnsNilForInvalidToken() {
// Act / Assert
#expect(AccessToken(rawValue: "too-short") == nil)
#expect(AccessToken(rawValue: Fixtures.validTokenString)?.rawValue == Fixtures.validTokenString)
}
// MARK: - §5.3
@Test("§5.3 description/debugDescription 不含令牌,只给长度")
func stringRepresentationsRedactTheToken() {
// Arrange
let token = Fixtures.makeToken()
// Act
let description = token.description
let debugDescription = token.debugDescription
// Assert
#expect(description.contains(Fixtures.validTokenString) == false)
#expect(debugDescription.contains(Fixtures.validTokenString) == false)
#expect(description.contains("redacted"))
#expect(description.contains("\(Fixtures.validTokenString.count)"))
}
@Test("§5.3 String(describing:)/String(reflecting:) 也不泄漏(插值即日志的默认路径)")
func interpolationPathsRedactTheToken() {
// Arrange
let token = Fixtures.makeToken()
// Act
let interpolated = "token=\(token)"
// Assert
#expect(interpolated.contains(Fixtures.validTokenString) == false)
#expect(String(describing: token).contains(Fixtures.validTokenString) == false)
#expect(String(reflecting: token).contains(Fixtures.validTokenString) == false)
}
@Test("§5.3 Mirror/dump 反射不泄漏令牌(崩溃报告与 dump 是常见旁路)")
func reflectionDoesNotExposeTheToken() {
// Arrange
let token = Fixtures.makeToken()
// Act
let children = Mirror(reflecting: token).children.map { String(describing: $0.value) }
var dumped = ""
dump(token, to: &dumped)
// Assert
#expect(children.contains(where: { $0.contains(Fixtures.validTokenString) }) == false)
#expect(dumped.contains(Fixtures.validTokenString) == false)
}
@Test("§5.3 Host 的字符串表示不含令牌,只标注是否已配置")
func hostStringRepresentationRedactsTheToken() {
// Arrange
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
// Act
let described = String(describing: host)
let reflected = String(reflecting: host)
var dumped = ""
dump(host, to: &dumped)
// Assert
#expect(described.contains(Fixtures.validTokenString) == false)
#expect(reflected.contains(Fixtures.validTokenString) == false)
#expect(dumped.contains(Fixtures.validTokenString) == false)
#expect(described.contains("hasAccessToken: true"))
#expect(String(describing: Fixtures.makeHost()).contains("hasAccessToken: false"))
}
// MARK: -
@Test("Equatable/Hashable:同值相等且同 hash,异值不等")
func tokenValueSemanticsAreByRawValue() {
// Arrange
let a = Fixtures.makeToken()
let b = Fixtures.makeToken()
let other = Fixtures.makeToken(Fixtures.otherValidTokenString)
// Assert
#expect(a == b)
#expect(a.hashValue == b.hashValue)
#expect(a != other)
}

View File

@@ -0,0 +1,337 @@
import Foundation
import Security
import Testing
import HostRegistry
// 访 store (B2):read / write / clear,
// ""(Keychain )
// host Keychain item §5.3
// "Keychain:host ( authMaterial )", remove(id:)
/// keychain JSON ("")
///
/// `\/` `/`:JSONEncoder , `/`
/// `contains(token) == false` ""****
/// `[A-Za-z0-9._~+/=-]` `/` JSON ,
private func payloadText(_ attributes: [String: any Sendable]?) -> String {
guard let data = attributes?[kSecValueData as String] as? Data else { return "" }
return String(decoding: data, as: UTF8.self).replacingOccurrences(of: "\\/", with: "/")
}
/// conformer: §3.3 , override
/// (App HostStore )
private actor MinimalHostStore: HostStore {
private var hosts: [Host]
init(hosts: [Host]) { self.hosts = hosts }
func loadAll() async throws -> [Host] { hosts }
func upsert(_ host: Host) async throws -> [Host] {
hosts = hosts.contains(where: { $0.id == host.id })
? hosts.map { $0.id == host.id ? host : $0 }
: hosts + [host]
return hosts
}
func remove(id: UUID) async throws -> [Host] {
hosts = hosts.filter { $0.id != id }
return hosts
}
}
// MARK: - / / (Keychain )
@Test("setAccessToken 后 accessToken(host:) 读回同值,且 loadAll 的 Host 带上令牌")
func setAccessTokenPersistsAndReadsBack() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let host = Fixtures.makeHost()
_ = try await store.upsert(host)
let token = Fixtures.makeToken()
// Act
let updated = try await store.setAccessToken(token, host: host.id)
// Assert
#expect(updated.first?.accessToken == token)
#expect(try await store.accessToken(host: host.id) == token)
#expect(try await store.loadAll().first?.accessToken == token)
}
@Test("令牌跨 store 实例存活:同一 shim 上的新 store 读回令牌")
func accessTokenSurvivesAcrossStoreInstances() async throws {
// Arrange
let shim = FakeSecItemShim()
let host = Fixtures.makeHost()
let writer = KeychainHostStore(shim: shim)
_ = try await writer.upsert(host)
_ = try await writer.setAccessToken(Fixtures.makeToken(), host: host.id)
// Act
let reader = KeychainHostStore(shim: shim)
// Assert
#expect(try await reader.accessToken(host: host.id) == Fixtures.makeToken())
}
@Test("未配置令牌的主机:accessToken(host:) 为 nil(不是错误)")
func accessTokenIsNilWhenNeverSet() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let host = Fixtures.makeHost()
_ = try await store.upsert(host)
// Act / Assert
#expect(try await store.accessToken(host: host.id) == nil)
}
@Test("未知 host 的 accessToken 查询:nil(不 throw —— 只读路径宽松)")
func accessTokenForUnknownHostIsNil() async throws {
// Arrange
let store = KeychainHostStore(shim: FakeSecItemShim())
// Act / Assert
#expect(try await store.accessToken(host: UUID()) == nil)
}
@Test("setAccessToken(nil) 清除令牌,但主机本身保留")
func setAccessTokenNilClearsTokenAndKeepsHost() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let host = Fixtures.makeHost()
_ = try await store.upsert(host)
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
// Act
let updated = try await store.setAccessToken(nil, host: host.id)
// Assert
#expect(updated.map(\.id) == [host.id])
#expect(updated.first?.accessToken == nil)
#expect(try await store.accessToken(host: host.id) == nil)
#expect(payloadText(shim.recordedUpdates.last?.attributes).contains(Fixtures.validTokenString) == false)
}
@Test("clearAccessToken(host:) 等价于 setAccessToken(nil, host:)")
func clearAccessTokenRemovesTheToken() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let host = Fixtures.makeHost()
_ = try await store.upsert(host)
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
// Act
let updated = try await store.clearAccessToken(host: host.id)
// Assert
#expect(updated.first?.accessToken == nil)
#expect(try await store.accessToken(host: host.id) == nil)
}
@Test("替换令牌:旧值不再出现在写入载荷里")
func replacingTokenLeavesNoTraceOfTheOldValue() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let host = Fixtures.makeHost()
_ = try await store.upsert(host)
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
// Act
let updated = try await store.setAccessToken(
Fixtures.makeToken(Fixtures.otherValidTokenString), host: host.id
)
// Assert
#expect(updated.first?.accessToken?.rawValue == Fixtures.otherValidTokenString)
let payload = payloadText(shim.recordedUpdates.last?.attributes)
#expect(payload.contains(Fixtures.otherValidTokenString))
#expect(payload.contains(Fixtures.validTokenString) == false)
}
@Test("重复写同一令牌:显式 no-op,零额外持久化调用")
func settingTheSameTokenTwiceIsAnExplicitNoOp() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let host = Fixtures.makeHost()
_ = try await store.upsert(host)
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
let addsBefore = shim.recordedAdds.count
let updatesBefore = shim.recordedUpdates.count
// Act
let updated = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
// Assert
#expect(updated.first?.accessToken == Fixtures.makeToken())
#expect(shim.recordedAdds.count == addsBefore)
#expect(shim.recordedUpdates.count == updatesBefore)
}
@Test("给未知 host 写令牌:抛 .unknownHost,零持久化(绝不静默丢弃)")
func setAccessTokenForUnknownHostThrowsAndPersistsNothing() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
_ = try await store.upsert(Fixtures.makeHost())
let addsBefore = shim.recordedAdds.count
let unknownId = UUID()
// Act / Assert
await #expect(throws: AccessTokenError.unknownHost(unknownId)) {
_ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId)
}
#expect(shim.recordedAdds.count == addsBefore)
#expect(shim.recordedUpdates.isEmpty)
#expect(shim.recordedDeletes.isEmpty)
}
// MARK: - ()
@Test("remove 最后一个主机:整个 keychain item 被删,令牌随之消失")
func removingLastHostDeletesTheItemAndItsToken() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let host = Fixtures.makeHost()
_ = try await store.upsert(host)
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
// Act
let updated = try await store.remove(id: host.id)
// Assert
#expect(updated.isEmpty)
#expect(shim.recordedDeletes.count == 1)
#expect(try await store.accessToken(host: host.id) == nil)
#expect(try await KeychainHostStore(shim: shim).loadAll().isEmpty)
}
@Test("remove 其中一个主机:新载荷不含其令牌,另一主机的令牌完好")
func removingOneHostStripsOnlyItsTokenFromThePayload() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim)
let doomed = Fixtures.makeHost(name: "doomed")
let keeper = Fixtures.makeHost(name: "keeper", urlString: "https://mac.ts.net")
_ = try await store.upsert(doomed)
_ = try await store.upsert(keeper)
_ = try await store.setAccessToken(Fixtures.makeToken(), host: doomed.id)
_ = try await store.setAccessToken(
Fixtures.makeToken(Fixtures.otherValidTokenString), host: keeper.id
)
// Act
let updated = try await store.remove(id: doomed.id)
// Assert
#expect(updated.map(\.id) == [keeper.id])
let payload = payloadText(shim.recordedUpdates.last?.attributes)
#expect(payload.contains(Fixtures.validTokenString) == false)
#expect(payload.contains(Fixtures.otherValidTokenString))
#expect(try await store.accessToken(host: keeper.id)?.rawValue == Fixtures.otherValidTokenString)
}
// MARK: - §5.3
@Test("§5.3 带令牌写入时 add 属性字典仍是策略集:6 键、无 synchronizable、accessible 重申")
func storingATokenDoesNotWeakenTheKeychainPolicy() async throws {
// Arrange
let shim = FakeSecItemShim()
let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test")
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
// Act
_ = try await store.upsert(host)
// Assert
let attrs = try #require(shim.recordedAdds.first)
#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.count == 6)
}
// MARK: -
@Test("InMemoryHostStore:read/write/clear 与 Keychain 实现语义一致")
func inMemoryStoreHasTheSameAccessTokenSemantics() async throws {
// Arrange
let host = Fixtures.makeHost()
let store = InMemoryHostStore(hosts: [host])
let token = Fixtures.makeToken()
// Act
let afterSet = try await store.setAccessToken(token, host: host.id)
let read = try await store.accessToken(host: host.id)
let afterClear = try await store.clearAccessToken(host: host.id)
// Assert
#expect(afterSet.first?.accessToken == token)
#expect(read == token)
#expect(afterClear.first?.accessToken == nil)
#expect(try await store.accessToken(host: host.id) == nil)
}
@Test("InMemoryHostStore:未知 host 写令牌同样抛 .unknownHost")
func inMemoryStoreRejectsUnknownHostToo() async {
// Arrange
let store = InMemoryHostStore()
let unknownId = UUID()
// Act / Assert
await #expect(throws: AccessTokenError.unknownHost(unknownId)) {
_ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId)
}
}
@Test("InMemoryHostStore:remove 主机 → 令牌一并消失")
func inMemoryStoreRemovingHostDropsItsToken() async throws {
// Arrange
let host = Fixtures.makeHost()
let store = InMemoryHostStore(hosts: [host])
_ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id)
// Act
_ = try await store.remove(id: host.id)
// Assert
#expect(try await store.accessToken(host: host.id) == nil)
}
@Test("协议默认实现:只实现原三方法的 conformer 也能 read/write/clear")
func protocolDefaultImplementationsWorkForMinimalConformers() async throws {
// Arrange
let host = Fixtures.makeHost()
let store = MinimalHostStore(hosts: [host])
let token = Fixtures.makeToken()
// Act
let afterSet = try await store.setAccessToken(token, host: host.id)
let read = try await store.accessToken(host: host.id)
let afterClear = try await store.clearAccessToken(host: host.id)
// Assert
#expect(afterSet.first?.accessToken == token)
#expect(read == token)
#expect(afterClear.first?.accessToken == nil)
}
@Test("协议默认实现:未知 host 写令牌抛 .unknownHost")
func protocolDefaultImplementationRejectsUnknownHost() async {
// Arrange
let store = MinimalHostStore(hosts: [])
let unknownId = UUID()
// Act / Assert
await #expect(throws: AccessTokenError.unknownHost(unknownId)) {
_ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId)
}
}

View File

@@ -0,0 +1,192 @@
import Foundation
import Testing
import WireProtocol
import HostRegistry
// + (B2)
// **线** Keychain ( accessToken );
// = nil,(
// .corruptedData,)
/// `Host` : id/name/endpoint, Codable
/// "", JSON
private struct LegacyHostRecord: Encodable {
let id: UUID
let name: String
let endpoint: HostEndpoint
}
private func encodedLegacyPayload(_ host: Host) throws -> Data {
try JSONEncoder().encode([
LegacyHostRecord(id: host.id, name: host.name, endpoint: host.endpoint),
])
}
// MARK: - ()
@Test("迁移:旧形状记录(无 accessToken 字段)照常读出,令牌为 nil")
func legacyRecordWithoutTokenFieldStillLoads() async throws {
// Arrange
let legacy = Fixtures.makeHost(name: "paired-before-tokens")
let shim = FakeSecItemShim()
shim.forceCopyData(try encodedLegacyPayload(legacy))
let store = KeychainHostStore(shim: shim)
// Act
let loaded = try await store.loadAll()
// Assert
#expect(loaded.count == 1)
#expect(loaded.first?.id == legacy.id)
#expect(loaded.first?.name == "paired-before-tokens")
#expect(loaded.first?.endpoint == legacy.endpoint)
#expect(loaded.first?.accessToken == nil)
#expect(loaded == [legacy]) // == nil
}
@Test("迁移:逐字旧 JSON(键名钉死 id/name/endpoint.baseURL)解码成功且令牌为 nil")
func literalLegacyJSONDecodesWithNilToken() throws {
// Arrange ,()
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F",\
"name":"mac-studio","endpoint":{"baseURL":"http://192.168.1.5:3000"}}]
"""
// Act
let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
// Assert
#expect(hosts.count == 1)
#expect(hosts.first?.name == "mac-studio")
#expect(hosts.first?.endpoint.originHeader == "http://192.168.1.5:3000")
#expect(hosts.first?.accessToken == nil)
}
@Test("被篡改的非法令牌值:降级为 nil,不把整张主机表打成 .corruptedData")
func tamperedTokenValueDegradesToNilInsteadOfCorruptingTheList() async throws {
// Arrange ()
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\
"endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":"short!"}]
"""
let shim = FakeSecItemShim()
shim.forceCopyData(Data(json.utf8))
let store = KeychainHostStore(shim: shim)
// Act
let loaded = try await store.loadAll()
// Assert
#expect(loaded.count == 1)
#expect(loaded.first?.name == "mac-studio")
#expect(loaded.first?.accessToken == nil)
}
@Test("accessToken JSON null:, nil")
func explicitNullTokenDecodesAsNil() throws {
// Arrange
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\
"endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":null}]
"""
// Act
let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
// Assert
#expect(hosts.first?.accessToken == nil)
}
@Test("endpoint : http(s) baseURL ( endpoint)")
func endpointRemainsStrictlyValidatedOnDecode() {
// Arrange
let json = """
[{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"bad",\
"endpoint":{"baseURL":"ftp://192.168.1.5"}}]
"""
// Act / Assert
#expect(throws: (any Error).self) {
_ = try JSONDecoder().decode([Host].self, from: Data(json.utf8))
}
}
// MARK: - 新形状
@Test(": accessToken (/)")
func tokenIsPersistedAsAPlainStringUnderAccessTokenKey() throws {
// Arrange
let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken())
// Act
let data = try JSONEncoder().encode(host)
let object = try #require(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
// Assert
#expect(object["accessToken"] as? String == Fixtures.validTokenString)
}
@Test(": nil accessToken ()")
func nilTokenIsOmittedFromTheEncodedForm() throws {
// Arrange
let host = Fixtures.makeHost()
// Act
let data = try JSONEncoder().encode(host)
let object = try #require(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
// Assert
#expect(object.keys.contains("accessToken") == false)
#expect(object.keys.sorted() == ["endpoint", "id", "name"])
}
@Test("Codable : Host ")
func codableRoundTripPreservesTheToken() throws {
// Arrange
let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443")
.withAccessToken(Fixtures.makeToken())
// Act
let decoded = try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host))
// Assert
#expect(decoded == host)
#expect(decoded.accessToken == Fixtures.makeToken())
#expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443")
}
// MARK: -
@Test("withAccessToken 不可变:返回新值,原 Host 不变")
func withAccessTokenReturnsANewValueWithoutMutatingTheOriginal() {
// Arrange
let original = Fixtures.makeHost()
// Act
let withToken = original.withAccessToken(Fixtures.makeToken())
let cleared = withToken.withAccessToken(nil)
// Assert
#expect(original.accessToken == nil)
#expect(withToken.accessToken == Fixtures.makeToken())
#expect(cleared.accessToken == nil)
#expect(withToken.id == original.id)
#expect(withToken.name == original.name)
#expect(withToken.endpoint == original.endpoint)
#expect(cleared == original)
}
@Test("Equatable 含令牌:令牌不同的同 id 主机不相等(UI 才能感知令牌变化)")
func hostsDifferingOnlyByTokenAreNotEqual() {
// Arrange
let base = Fixtures.makeHost()
// Act / Assert
#expect(base.withAccessToken(Fixtures.makeToken()) != base)
#expect(base.withAccessToken(Fixtures.makeToken())
!= base.withAccessToken(Fixtures.makeToken(Fixtures.otherValidTokenString)))
}

View File

@@ -25,4 +25,28 @@ enum Fixtures {
static func makeSuiteName() -> String {
"HostRegistryTests." + UUID().uuidString
}
// MARK: - Access-token fixtures (coordination doc §1.1)
/// 22 chars exercising EVERY class of the frozen charset
/// `[A-Za-z0-9._~+/=-]` (upper/lower/digit/`.`/`_`/`~`/`+`/`/`/`=`/`-`),
/// comfortably above the 16-char minimum. Test-only value not a secret.
static let validTokenString = "A9z._~+/=-Abcdef012345"
/// A second, distinct valid token (for "replaced/other host" assertions).
static let otherValidTokenString = "Zy8-=/+~_.9876543210fedcba"
/// Builds a valid token; traps loudly if a fixture string is malformed
/// (a broken fixture must fail the suite, not silently skip assertions).
static func makeToken(_ raw: String = Fixtures.validTokenString) -> AccessToken {
guard let token = AccessToken(rawValue: raw) else {
fatalError("test fixture token invalid: length \(raw.count)")
}
return token
}
/// A repeated-character token of an exact length (length-boundary tests).
static func tokenString(length: Int) -> String {
String(repeating: "a", count: length)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import Testing
@testable import SessionCore
/// B3 · `AuthCookie` the ONE place SessionCore turns a configured access
/// token into an upgrade request header (`ios-completion` §1.1, FROZEN:
/// hand-written `Cookie`, never `Set-Cookie`/`HTTPCookieStorage`).
///
/// The policy mirrored here is the server's, verbatim:
/// - cookie name `webterm_auth` (src/http/auth.ts:30 `AUTH_COOKIE_NAME`)
/// - token charset/length `^[A-Za-z0-9._~+/=-]{16,512}$` (src/config.ts:178)
@Suite("AuthCookie")
struct AuthCookieTests {
/// A well-formed token exercising every allowed character class.
private static let validToken = "Ab9._~+/=-Ab9._~+/=-"
private static let minLengthToken = String(repeating: "a", count: 16)
private static let maxLengthToken = String(repeating: "a", count: 512)
@Test("cookie name matches the server's AUTH_COOKIE_NAME")
func cookieNameMatchesServer() {
#expect(AuthCookie.name == "webterm_auth")
}
@Test("a valid token becomes exactly `webterm_auth=<token>`")
func validTokenBecomesHeaderValue() {
// Arrange / Act
let header = AuthCookie.headerValue(token: Self.validToken)
// Assert
#expect(header == "webterm_auth=\(Self.validToken)")
}
@Test("no token configured (nil / empty) ⇒ no Cookie header at all")
func absentTokenYieldsNoHeader() {
#expect(AuthCookie.headerValue(token: nil) == nil)
#expect(AuthCookie.headerValue(token: "") == nil)
}
@Test("length policy: 16 and 512 accepted, 15 and 513 rejected (server charset rule)")
func lengthPolicyMatchesServer() {
#expect(AuthCookie.headerValue(token: Self.minLengthToken) != nil)
#expect(AuthCookie.headerValue(token: Self.maxLengthToken) != nil)
#expect(AuthCookie.headerValue(token: String(repeating: "a", count: 15)) == nil)
#expect(AuthCookie.headerValue(token: String(repeating: "a", count: 513)) == nil)
}
@Test(
"off-charset tokens are refused, never smuggled into the header (CRLF injection included)",
arguments: [
"aaaaaaaaaaaaaaaa\r\nX-Evil: 1", // header injection attempt
"aaaaaaaaaaaaaaaa;path=/", // cookie-attribute injection attempt
"aaaaaaaaaaaaaaaa aaaa", // space
"aaaaaaaaaaaaaaaa\"quoted\"",
"aaaaaaaaaaaaaaaa\u{00E9}", // non-ASCII
"aaaaaaaaaaaaaaaa\u{0000}",
]
)
func offCharsetTokensRefused(token: String) {
#expect(AuthCookie.headerValue(token: token) == nil)
}
}

View File

@@ -28,6 +28,22 @@ final class ScriptedWSServer: @unchecked Sendable {
let headers: [String: String]
}
/// A scripted NON-101 answer to the upgrade, byte-shaped like the real
/// server's reject path: it writes a bare status line and destroys the
/// socket no headers, no body (src/server.ts:1367-1379, both the Origin
/// gate and the access-token cookie gate).
struct UpgradeRejection: Sendable {
let statusCode: Int
let reasonPhrase: String
/// What BOTH server-side upgrade gates write (Origin, then cookie).
static let unauthorized = UpgradeRejection(statusCode: 401, reasonPhrase: "Unauthorized")
/// A non-401 rejection: must stay in the RETRYABLE class.
static let serverError = UpgradeRejection(
statusCode: 500, reasonPhrase: "Internal Server Error"
)
}
/// RFC 6455 wire constants (named no magic numbers).
private enum Wire {
static let finBit: UInt8 = 0x80
@@ -63,6 +79,7 @@ final class ScriptedWSServer: @unchecked Sendable {
private var connection: NWConnection?
private var rxBuffer = [UInt8]()
private var isHandshakeDone = false
private var upgradeRejection: UpgradeRejection?
private var startContinuation: CheckedContinuation<UInt16, any Error>?
private var stopContinuation: CheckedContinuation<Void, Never>?
@@ -131,6 +148,13 @@ final class ScriptedWSServer: @unchecked Sendable {
lock.withLock { connection }?.forceCancel()
}
/// Answer every subsequent upgrade with `rejection` instead of the 101.
/// Call before `connect` (the upgrade request is still recorded, so tests
/// can assert the headers a REJECTED handshake carried).
func rejectUpgrades(with rejection: UpgradeRejection) {
lock.withLock { upgradeRejection = rejection }
}
// MARK: - Connection handling
private func adopt(connection newConnection: NWConnection) {
@@ -184,22 +208,31 @@ final class ScriptedWSServer: @unchecked Sendable {
// MARK: - Handshake
private func tryCompleteHandshake(on connection: NWConnection) {
let requestBytes: [UInt8]? = lock.withLock {
let pending: (requestBytes: [UInt8], rejection: UpgradeRejection?)? = lock.withLock {
guard !isHandshakeDone, let end = Self.headerEndIndex(in: rxBuffer) else {
return nil
}
let request = Array(rxBuffer[..<end])
rxBuffer.removeFirst(end + Wire.headerTerminator.count)
isHandshakeDone = true
return request
return (request, upgradeRejection)
}
guard let requestBytes else { return }
let upgrade = Self.parseUpgrade(String(decoding: requestBytes, as: UTF8.self))
connection.send(
content: Self.handshakeResponse(for: upgrade),
completion: .contentProcessed { _ in }
)
guard let pending else { return }
let upgrade = Self.parseUpgrade(String(decoding: pending.requestBytes, as: UTF8.self))
// Record BEFORE answering, so a REJECTED upgrade is still observable.
upgradesContinuation.yield(upgrade)
guard let rejection = pending.rejection else {
connection.send(
content: Self.handshakeResponse(for: upgrade),
completion: .contentProcessed { _ in }
)
return
}
// Reject exactly like the server: status line, then destroy the socket.
connection.send(
content: Self.rejectionResponse(rejection),
completion: .contentProcessed { _ in connection.cancel() }
)
}
private static func headerEndIndex(in bytes: [UInt8]) -> Int? {
@@ -235,6 +268,10 @@ final class ScriptedWSServer: @unchecked Sendable {
return Data(response.utf8)
}
private static func rejectionResponse(_ rejection: UpgradeRejection) -> Data {
Data("HTTP/1.1 \(rejection.statusCode) \(rejection.reasonPhrase)\r\n\r\n".utf8)
}
// MARK: - Client frame parsing
private func drainClientFrames(on connection: NWConnection) {

View File

@@ -476,6 +476,70 @@ struct SessionEngineTests {
#expect(await harness.transport.connectAttempts.count == 1)
}
@Test("401 on the upgrade → connection(.failed(.unauthorized)) and NOT ONE reconnect attempt")
func unauthorizedIsTerminalAndNeverBacksOff() async throws {
// Arrange: the transport rejects the handshake with the typed 401 error
// (B3 · ios-completion §1.1 "WS upgrade 401 退").
let harness = try Harness()
await harness.transport.scriptConnectFailure(TermTransportError.unauthorized)
// Act
await harness.engine.open(sessionId: nil, cwd: nil)
// Assert: terminal failure instead of a backoff rung retrying a wrong
// token forever is useless AND a brute-force signal.
#expect(await harness.next() == .connection(.connecting))
#expect(await harness.next() == .connection(.failed(.unauthorized)))
#expect(await harness.next() == nil)
#expect(harness.clock.pendingSleeperCount == 0)
harness.clock.advance(by: .seconds(60))
#expect(await harness.transport.connectAttempts.count == 1)
// Assert: the engine is inert afterwards (same shape as .replayTooLarge).
await harness.engine.send(.input(data: "too late"))
#expect(await harness.engine.droppedOutboundFrameCount == 1)
}
@Test("ordinary failure still backs off; a 401 on the retry stops the ladder for good")
func ordinaryFailureBacksOffThen401EndsIt() async throws {
// Arrange: attempt #1 fails the ordinary (retryable) way, attempt #2 401s.
let harness = try Harness()
await harness.transport.scriptConnectFailure()
await harness.transport.scriptConnectFailure(TermTransportError.unauthorized)
// Act / Assert: rung 1 is unchanged by the new classification.
await harness.engine.open(sessionId: nil, cwd: nil)
#expect(await harness.next() == .connection(.connecting))
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .seconds(1))
// Assert: the 401 is terminal no rung 2, no third attempt, ever.
#expect(await harness.next() == .connection(.failed(.unauthorized)))
#expect(await harness.next() == nil)
#expect(harness.clock.pendingSleeperCount == 0)
harness.clock.advance(by: .seconds(60))
#expect(await harness.transport.connectAttempts.count == 2)
}
@Test("unauthorized surfacing mid-stream is classified terminally too (one classifier)")
func unauthorizedOnStreamIsAlsoTerminal() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act
await harness.transport.emitError(TermTransportError.unauthorized)
// Assert
#expect(await harness.next() == .connection(.failed(.unauthorized)))
#expect(await harness.next() == nil)
#expect(harness.clock.pendingSleeperCount == 0)
harness.clock.advance(by: .seconds(60))
#expect(await harness.transport.connectAttempts.count == 1)
}
// MARK: - Close
@Test("close() closes the transport, finishes the events stream, and leaks no tasks")

View File

@@ -30,6 +30,12 @@ struct URLSessionTermTransportTests {
static let normalCloseCode: UInt16 = 1000
/// Arbitrary non-text payload for the binary-frame drop test.
static let binaryPayload: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF]
/// A well-formed access token (server charset, src/config.ts:178).
static let accessToken = "Ab9._~+/=-Ab9._~+/=-"
/// A second well-formed token proves per-connect token resolution.
static let rotatedAccessToken = "Zz0-=/+~._Zz0-=/+~._"
/// Off-charset token: must NEVER reach the wire (header injection).
static let malformedAccessToken = "aaaaaaaaaaaaaaaa\r\nX-Evil: 1"
}
private struct TimedOut: Error {}
@@ -74,6 +80,22 @@ struct URLSessionTermTransportTests {
}
}
/// The first `count` upgrade requests, drained through ONE iterator
/// (`AsyncStream` is single-consumer buffered yields are never lost).
private static func firstUpgrades(
count: Int, from server: ScriptedWSServer
) async throws -> [ScriptedWSServer.Upgrade] {
try await withTimeout {
var collected: [ScriptedWSServer.Upgrade] = []
var iterator = server.upgrades.makeAsyncIterator()
while collected.count < count {
guard let upgrade = await iterator.next() else { throw TimedOut() }
collected.append(upgrade)
}
return collected
}
}
private static func collect(
_ count: Int, from frames: AsyncThrowingStream<String, any Error>
) async throws -> [String] {
@@ -278,6 +300,135 @@ struct URLSessionTermTransportTests {
}
}
// MARK: - Access token (B3 · ios-completion §1.1)
@Test("configured token ⇒ upgrade carries Cookie: webterm_auth=<t> AND Origin (additive, §1.1)")
func upgradeCarriesAuthCookieAlongsideOrigin() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
let connection = try await transport.connect(to: endpoint)
let upgrade = try await Self.firstUpgrade(server)
// The cookie NEVER replaces Origin the server checks Origin first,
// then the cookie (src/server.ts:1367-1379).
#expect(upgrade.headers["origin"] == endpoint.originHeader)
#expect(upgrade.headers["cookie"] == "\(AuthCookie.name)=\(TestTunables.accessToken)")
await connection.close()
}
@Test("no token configured ⇒ NO Cookie header at all (LAN zero-config unchanged)")
func upgradeOmitsCookieWhenNoTokenConfigured() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().connect(to: endpoint)
let upgrade = try await Self.firstUpgrade(server)
#expect(upgrade.headers["cookie"] == nil)
#expect(upgrade.headers["origin"] == endpoint.originHeader)
await connection.close()
}
@Test("off-charset token is dropped, never smuggled onto the wire; Origin still sent")
func malformedTokenIsNeverSentButOriginIs() async throws {
// A malformed stored token must not break a host that has auth DISABLED
// (it ignores the cookie), and must not become a header-injection vector.
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let transport = URLSessionTermTransport(
tokenProvider: { TestTunables.malformedAccessToken }
)
let connection = try await transport.connect(to: endpoint)
let upgrade = try await Self.firstUpgrade(server)
#expect(upgrade.headers["cookie"] == nil)
#expect(upgrade.headers["x-evil"] == nil)
#expect(upgrade.headers["origin"] == endpoint.originHeader)
await connection.close()
}
@Test("token provider is resolved PER CONNECT — a token added/rotated mid-run needs no relaunch")
func tokenProviderResolvedPerConnect() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let tokens = TokenSequence([TestTunables.accessToken, TestTunables.rotatedAccessToken])
let transport = URLSessionTermTransport(tokenProvider: { tokens.next() })
let first = try await transport.connect(to: endpoint)
await first.close()
let second = try await transport.connect(to: endpoint)
await second.close()
let upgrades = try await Self.firstUpgrades(count: 2, from: server)
#expect(upgrades.map { $0.headers["cookie"] } == [
"\(AuthCookie.name)=\(TestTunables.accessToken)",
"\(AuthCookie.name)=\(TestTunables.rotatedAccessToken)",
])
}
@Test("401 on the WS upgrade ⇒ typed TermTransportError.unauthorized (terminal, never retried)")
func unauthorizedUpgradeThrowsTypedUnauthorized() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
server.rejectUpgrades(with: .unauthorized)
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
var thrown: (any Error)?
do {
_ = try await Self.withTimeout { try await transport.connect(to: endpoint) }
} catch {
thrown = error
}
#expect(
(thrown as? TermTransportError) == .unauthorized,
"expected .unauthorized, got \(String(describing: thrown))"
)
}
@Test("a NON-401 upgrade rejection stays in the retryable class (not .unauthorized)")
func nonUnauthorizedUpgradeRejectionStaysRetryable() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
server.rejectUpgrades(with: .serverError)
var thrown: (any Error)?
do {
_ = try await Self.withTimeout {
try await URLSessionTermTransport().connect(to: endpoint)
}
} catch {
thrown = error
}
let failure = try #require(thrown, "a 500 upgrade must still fail the connect")
#expect(!(failure is TimedOut), "connect neither opened nor failed in time")
#expect((failure as? TermTransportError) != .unauthorized)
}
@Test("the token never appears in a thrown error (nothing loggable carries the secret)")
func thrownErrorNeverCarriesTheToken() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
server.rejectUpgrades(with: .unauthorized)
let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken })
var thrown: (any Error)?
do {
_ = try await Self.withTimeout { try await transport.connect(to: endpoint) }
} catch {
thrown = error
}
let failure = try #require(thrown)
#expect(!String(describing: failure).contains(TestTunables.accessToken))
#expect(!String(reflecting: failure).contains(TestTunables.accessToken))
#expect(!failure.localizedDescription.contains(TestTunables.accessToken))
}
/// Thread-safe invocation counter for the `@Sendable` identity provider
/// (URLSession may consult it off the test's isolation domain).
private final class CallCounter: @unchecked Sendable {
@@ -286,5 +437,24 @@ struct URLSessionTermTransportTests {
func increment() { lock.withLock { count += 1 } }
var value: Int { lock.withLock { count } }
}
/// Thread-safe token script: hands out one token per `connect` (the last
/// value repeats), proving the provider is re-consulted every time.
private final class TokenSequence: @unchecked Sendable {
private let lock = NSLock()
private var remaining: [String]
init(_ tokens: [String]) {
remaining = tokens
}
func next() -> String? {
lock.withLock {
guard let first = remaining.first else { return nil }
if remaining.count > 1 { remaining.removeFirst() }
return first
}
}
}
}
#endif