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