Merge ios-completion: device builds unblocked, access token on both clients, P2 wave

Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 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,31 @@ 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. This is also the
/// correct reading for the WORKTREE writes: `src/http/worktrees.ts` has no
/// 401 of its own (403 kill-switch, else 400/404/500).
case accessTokenGate
/// The route owns its 401: `POST /auth`'s wrong-token answer
/// (src/server.ts:418) and the four **git-ops** writes stage, commit,
/// push, fetch where the server CLASSIFIES a host-side git credential
/// failure as 401 (src/http/git-ops.ts:108 "Push authentication required on
/// the host."). Nothing else reaches that classifier.
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 +62,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 +114,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 +131,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 +157,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 +219,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,398 @@
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 write here shares the shape: JSON body + `Origin`. What
/// differs is only how a **401** must be read, so that is the parameter.
private static func guardedWriteRoute<Body: Encodable>(
_ method: HTTPMethod, _ path: String, _ body: Body,
unauthorizedPolicy: UnauthorizedPolicy
) throws -> APIRoute {
APIRoute(
method: method, path: path, originPolicy: .guarded,
body: try JSONEncoder().encode(body),
unauthorizedPolicy: unauthorizedPolicy
)
}
/// The four **git-ops** writes and ONLY these four. `src/http/git-ops.ts:108`
/// classifies a HOST-side git credential failure as 401 ("Push authentication
/// required on the host."), and that classifier is reached only from
/// `stageFiles`/`commit`/`push`/`fetch`. Their 401 must not be mistaken for
/// the access-token gate telling us to enter a token.
private static func gitOpsRoute<Body: Encodable>(
_ method: HTTPMethod, _ path: String, _ body: Body
) throws -> APIRoute {
try guardedWriteRoute(method, path, body, unauthorizedPolicy: .routeDefined)
}
/// The worktree trio. Guarded, but the handler (`src/http/worktrees.ts`,
/// routed at src/server.ts:1095-1183) emits **no 401 at all** 403 for the
/// kill-switch, else 400/404/500. So the only 401 these can ever see is the
/// access-token gate, and they take the DEFAULT reading: pinning them
/// `.routeDefined` made a gated host's 401 surface as a worktree failure
/// instead of the token flow.
private static func worktreeRoute<Body: Encodable>(
_ method: HTTPMethod, _ path: String, _ body: Body
) throws -> APIRoute {
try guardedWriteRoute(method, path, body, unauthorizedPolicy: .accessTokenGate)
}
static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute {
try gitOpsRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage))
}
static func gitCommit(path: String, message: String) throws -> APIRoute {
try gitOpsRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message))
}
static func gitPush(path: String) throws -> APIRoute {
try gitOpsRoute(.post, "/projects/git/push", RepoPathBody(path: path))
}
static func gitFetch(path: String) throws -> APIRoute {
try gitOpsRoute(.post, "/projects/git/fetch", RepoPathBody(path: path))
}
static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute {
try worktreeRoute(
.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 worktreeRoute(
.delete, "/projects/worktree",
RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force)
)
}
static func pruneWorktrees(path: String) throws -> APIRoute {
try worktreeRoute(.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

@@ -28,21 +28,33 @@ import WireProtocol
/// deadline (the transport's own timeouts still apply). The production
/// default is a UI-layer decision (T-iOS-12); a shared
/// `Tunables.pairingProbeTimeout` would be a T-iOS-3 contract addition.
/// - accessToken: candidate `WEBTERM_TOKEN` for a host that gates its HTTP
/// surface (C1 over B1, ios-completion §1.1). It is stamped as
/// `Cookie: webterm_auth=` on the probe's TWO HTTP legs by `APIClient`;
/// the WS leg's cookie comes from the transport the caller passes (the
/// pairing flow builds a probe-scoped transport carrying the same
/// candidate), because `TermTransport.connect` is a frozen contract with no
/// credential parameter. `nil` = probe unauthenticated (the LAN default).
func runPairingProbeCore(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport,
clock: any Clock<Duration>,
timeout: Duration?
timeout: Duration?,
accessToken: String? = nil
) async -> Result<HostEndpoint, PairingError> {
guard let timeout else {
return await performProbe(endpoint: endpoint, http: http, ws: ws)
return await performProbe(
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
)
}
return await withTaskGroup(
of: Result<HostEndpoint, PairingError>.self
) { group in
group.addTask {
await performProbe(endpoint: endpoint, http: http, ws: ws)
await performProbe(
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
)
}
group.addTask {
// Cancellation (probe won) also lands here; the value is discarded.
@@ -63,14 +75,16 @@ func runPairingProbeCore(
public func runPairingProbe(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport
ws: any TermTransport,
accessToken: String? = nil
) async -> Result<HostEndpoint, PairingError> {
await runPairingProbeCore(
endpoint: endpoint,
http: http,
ws: ws,
clock: ContinuousClock(),
timeout: Tunables.pairingProbeTimeout
timeout: Tunables.pairingProbeTimeout,
accessToken: accessToken
)
}
@@ -79,23 +93,31 @@ public func runPairingProbe(
private func performProbe(
endpoint: HostEndpoint,
http: any HTTPTransport,
ws: any TermTransport
ws: any TermTransport,
accessToken: String?
) async -> Result<HostEndpoint, PairingError> {
let api = APIClient(endpoint: endpoint, http: http)
let api = APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
// Reachability + shape. Any HTTP-level answer that isn't the
// /live-sessions array shape means "some other service" ?
do {
_ = try await api.liveSessions()
} catch APIClientError.unauthorized {
// C1 · 401 on the RO leg is NOT "some other service": this host gates
// its HTTP surface and our candidate token was absent or wrong. The
// status alone cannot say which of the two gates rejected us, so the
// copy offers both remedies (see `unauthorizedPairingHint`).
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
} catch is APIClientError {
return .failure(.httpOkButNotWebTerminal)
} catch {
return .failure(PairingError.classify(error, endpoint: endpoint))
}
// WS upgrade the server's ONLY upgrade-reject path is the Origin 401
// (src/server.ts:646-651), so after passed, an unrecognizable connect
// failure is classified as originRejected.
// WS upgrade the server rejects an upgrade with 401 from TWO gates:
// the Origin/CSWSH check and then the `webterm_auth` cookie
// (src/server.ts:1367-1379). After passed, an unrecognizable connect
// failure is one of those two, so the fallback names both.
let connection: TransportConnection
do {
connection = try await ws.connect(to: endpoint)
@@ -103,7 +125,7 @@ private func performProbe(
return .failure(PairingError.classify(
error, endpoint: endpoint,
unrecognizedFallback: .originRejected(
hint: PairingError.originRejectedHint(for: endpoint)
hint: unauthorizedPairingHint(for: endpoint)
)
))
}
@@ -152,9 +174,14 @@ private func killProbeSession(
} catch APIClientError.sessionNotFound {
// Already gone (exited between attach and kill) the goal state.
} catch APIClientError.forbidden {
// 403 is UNAMBIGUOUS: only the guarded-HTTP Origin check answers 403
// (src/server.ts:332-339) the token gate answers 401. So this one
// keeps the pure Origin copy.
return .failure(.originRejected(
hint: PairingError.originRejectedHint(for: endpoint)
))
} catch APIClientError.unauthorized {
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
} catch let apiError as APIClientError {
return .failure(.hostUnreachable(underlying: apiError.message))
} catch {
@@ -162,3 +189,27 @@ private func killProbeSession(
}
return .success(endpoint)
}
// MARK: - The ambiguous 401 (C1 · fixes the pre-token "Origin rejected" verdict)
/// Copy for a **401** met during pairing.
///
/// Before the access token existed, 401 had exactly one cause on this path, so
/// the probe reported `originRejected` with Origin-only copy. With
/// `WEBTERM_TOKEN` live there are TWO causes and one status code: the server
/// checks Origin first and the `webterm_auth` cookie second both write 401
/// (src/server.ts:1367-1379; the RO HTTP gate likewise, src/server.ts:459).
/// A client provably cannot tell them apart, so guessing one remedy sends half
/// the users chasing the wrong knob. Both are named, token first (it is the
/// one the user can fix from the phone).
///
/// The `ALLOWED_ORIGINS=` value is still derived from `endpoint.originHeader`
/// the single point (plan §5.1), never hand-assembled, default ports omitted.
/// The error case stays `originRejected` because `PairingError` is a frozen
/// contract (§3.4) and its payload is exactly "the hint the UI shows verbatim".
func unauthorizedPairingHint(for endpoint: HostEndpoint) -> String {
"主机以 401 拒绝了这次配对,而两种原因会得到同一个状态码:"
+ "① 该主机启用了访问令牌WEBTERM_TOKEN本次配对没带或带错了令牌——请输入访问令牌后重试"
+ "② 主机的来源白名单不含本 App 拨号的地址——请在主机上设置 "
+ "ALLOWED_ORIGINS=\(endpoint.originHeader)(与 App 连接的 URL 完全一致)后重启 web-terminal。"
}

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,526 @@
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))
}
/// F5 · 401 ****,""
///
/// 401:`src/http/git-ops.ts:108`( git ),
/// `stageFiles`/`commit`/`push`/`fetch` worktree
/// `src/http/worktrees.ts`, 401(403 / 400 / 404 / 500),
/// `src/server.ts:1095-1183` 访,worktree 401
/// gate git " worktree ",
@Test("worktree 三条的 401 是访问令牌 gate(不是 git 拒绝);git-ops 四条才是路由自身语义")
func worktreeRoutes401IsTheAccessTokenGateNotAGitRejection() async throws {
// Arrange
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
let gateBody = Data(#"{"error":"authentication required"}"#.utf8)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree"), status: 401, body: gateBody
)
await fixture.http.queueSuccess(
method: "DELETE", url: try routeURL("/projects/worktree"), status: 401, body: gateBody
)
await fixture.http.queueSuccess(
method: "POST", url: try routeURL("/projects/worktree/prune"), status: 401, body: gateBody
)
// Act + Assert
await #expect(throws: APIClientError.unauthorized) {
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
}
await #expect(throws: APIClientError.unauthorized) {
_ = try await fixture.client.removeWorktree(
path: Self.repoPath, worktreePath: "\(Self.repoPath)/wt", force: false
)
}
await #expect(throws: APIClientError.unauthorized) {
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
}
}
@Test("git-ops 四条(stage/commit/push/fetch)全部保留路由自身的 401")
func allFourGitOpsRoutesKeepTheirOwn401() async throws {
// Arrange
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
let message = "Push authentication required on the host."
let body = Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
for path in ["/projects/git/stage", "/projects/git/commit", "/projects/git/push", "/projects/git/fetch"] {
await fixture.http.queueSuccess(
method: "POST", url: try routeURL(path), status: 401, body: body
)
}
// Act + Assert UI
#expect(
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
== .rejected(status: 401, message: message)
)
#expect(
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
== .rejected(status: 401, message: message)
)
#expect(
try await fixture.client.gitPush(path: Self.repoPath)
== .rejected(status: 401, message: message)
)
#expect(
try await fixture.client.gitFetch(path: Self.repoPath)
== .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