feat(ios): access-token support + git-panel endpoints across the package layer
APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal file
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal 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: 16–512 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,28 @@ enum OriginPolicy: Sendable, Equatable {
|
||||
case guarded
|
||||
}
|
||||
|
||||
/// How a 401 on this route must be READ (ios-completion §1.1). The access-token
|
||||
/// gate answers 401 for any unauthed request (src/server.ts:459 `authGate` step
|
||||
/// 5), so on almost every route 401 means "token missing/wrong" → typed
|
||||
/// `.unauthorized`. Two route families define their OWN 401 and must not be
|
||||
/// swallowed by that rule — declared here, at the route, so the exception is
|
||||
/// visible instead of hidden in a call site.
|
||||
enum UnauthorizedPolicy: Sendable, Equatable {
|
||||
/// Default — a 401 can only be the access-token gate.
|
||||
case accessTokenGate
|
||||
/// The route owns its 401: `POST /auth`'s wrong-token answer
|
||||
/// (src/server.ts:418) and the git-write family, where the server
|
||||
/// CLASSIFIES a host-side git credential failure as 401
|
||||
/// (src/http/git-ops.ts:108 "Push authentication required on the host.").
|
||||
case routeDefined
|
||||
}
|
||||
|
||||
/// Header/content-type names used by the builder (no magic strings inline).
|
||||
enum HeaderName {
|
||||
static let origin = "Origin"
|
||||
static let contentType = "Content-Type"
|
||||
static let accept = "Accept"
|
||||
static let cookie = "Cookie"
|
||||
}
|
||||
|
||||
enum ContentTypeValue {
|
||||
@@ -41,28 +59,43 @@ struct APIRoute: Sendable, Equatable {
|
||||
/// nil for no query. Percent-encoding happens ONCE, in the route builder
|
||||
/// (T-iOS-38: `/projects/detail?path=`) — never at call sites.
|
||||
let percentEncodedQuery: String?
|
||||
/// See `UnauthorizedPolicy`. Defaults to the gate reading.
|
||||
let unauthorizedPolicy: UnauthorizedPolicy
|
||||
|
||||
init(
|
||||
method: HTTPMethod,
|
||||
path: String,
|
||||
originPolicy: OriginPolicy,
|
||||
body: Data?,
|
||||
percentEncodedQuery: String? = nil
|
||||
percentEncodedQuery: String? = nil,
|
||||
unauthorizedPolicy: UnauthorizedPolicy = .accessTokenGate
|
||||
) {
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.originPolicy = originPolicy
|
||||
self.body = body
|
||||
self.percentEncodedQuery = percentEncodedQuery
|
||||
self.unauthorizedPolicy = unauthorizedPolicy
|
||||
}
|
||||
|
||||
/// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port:
|
||||
/// the path is REPLACED, the query is REPLACED by `percentEncodedQuery`
|
||||
/// (dropped when nil), fragment/credentials are dropped — the same
|
||||
/// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping
|
||||
/// happens HERE and only here (single point; hand-stamping elsewhere is a
|
||||
/// review CRITICAL, plan §5.1).
|
||||
func urlRequest(for endpoint: HostEndpoint) -> URLRequest? {
|
||||
/// derivation philosophy as `HostEndpoint.wsURL`.
|
||||
///
|
||||
/// **Every header this client sends is stamped HERE and only here** (single
|
||||
/// point; hand-stamping elsewhere is a review CRITICAL, plan §5.1):
|
||||
/// - `Origin` **iff** `.guarded` — the security split;
|
||||
/// - `Cookie: webterm_auth=<t>` iff a token is configured — ORTHOGONAL to
|
||||
/// the Origin rule (ios-completion §1.1: the token never REPLACES Origin,
|
||||
/// both travel together, on RO and G alike);
|
||||
/// - `Accept: application/json` always — an `Accept` containing `text/html`
|
||||
/// makes the server treat the request as a browser navigation and answer
|
||||
/// 302→/login instead of 401/204 (src/server.ts:366-369,459).
|
||||
///
|
||||
/// `accessToken` MUST already be shape-validated (`AccessTokenRule`); the
|
||||
/// charset check is what makes a header-injection value impossible here.
|
||||
func urlRequest(for endpoint: HostEndpoint, accessToken: String? = nil) -> URLRequest? {
|
||||
guard var components = URLComponents(
|
||||
url: endpoint.baseURL, resolvingAgainstBaseURL: true
|
||||
) else { return nil }
|
||||
@@ -78,9 +111,15 @@ struct APIRoute: Sendable, Equatable {
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.accept)
|
||||
if originPolicy == .guarded {
|
||||
request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin)
|
||||
}
|
||||
if let accessToken {
|
||||
request.setValue(
|
||||
AuthCookie.headerValue(for: accessToken), forHTTPHeaderField: HeaderName.cookie
|
||||
)
|
||||
}
|
||||
if let body {
|
||||
request.httpBody = body
|
||||
request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType)
|
||||
@@ -89,17 +128,24 @@ struct APIRoute: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route
|
||||
/// table (verified against src/server.ts):
|
||||
/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314)
|
||||
/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609)
|
||||
/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293)
|
||||
/// · `GET /prefs` (:273)
|
||||
/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503)
|
||||
/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20
|
||||
/// shape, mirrors `/push/subscribe` :461-498)
|
||||
/// P1 builders live beside their feature models: `ApnsToken.swift`,
|
||||
/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner).
|
||||
/// Builders for the frozen endpoints (plan §3.4 · T-iOS-38 P1 · B1 增量). Route
|
||||
/// table (verified against src/server.ts at the line numbers shown):
|
||||
/// - RO — `GET /live-sessions` (:485) · `GET /live-sessions/:id/preview` (:585)
|
||||
/// · `GET /live-sessions/:id/events` (:984) · `GET /config/ui` (:1320)
|
||||
/// · `GET /projects` (:507) · `GET /projects/detail?path=` (:564)
|
||||
/// · `GET /prefs` (:518) · `GET /projects/log?path=[&n=]` (:1030)
|
||||
/// · `GET /projects/pr?path=` (:1058)
|
||||
/// · `GET /projects/worktree/state?path=` (:542) · `GET /sessions` (:479)
|
||||
/// - G — `DELETE /live-sessions/:id` (:689) · `POST /hook/decision` (:959)
|
||||
/// · `PUT /prefs` (:523) · `POST|DELETE /push/apns-token` (:871/:892)
|
||||
/// · `POST /auth` (:393) · `POST /live-sessions/:id/queue` (:605)
|
||||
/// · `POST /projects/git/stage|commit|push|fetch` (:1184/:1219/:1256/:1290)
|
||||
/// · `POST /projects/worktree` (:1095) · `DELETE /projects/worktree` (:1124)
|
||||
/// · `POST /projects/worktree/prune` (:1154)
|
||||
/// Builders live beside their feature models: `ApnsToken.swift`,
|
||||
/// `Projects.swift`, `Prefs.swift`, `AccessToken.swift`, `GitLog.swift`,
|
||||
/// `PrStatus.swift`, `WorktreeState.swift`, `GitWrite.swift`, `History.swift`,
|
||||
/// `FollowupQueue.swift`.
|
||||
enum Endpoints {
|
||||
/// Strict RFC 3986 unreserved set — everything else gets percent-encoded.
|
||||
/// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is
|
||||
@@ -108,6 +154,21 @@ enum Endpoints {
|
||||
static let unreservedCharacters = CharacterSet(
|
||||
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
)
|
||||
|
||||
/// THE single percent-encoding choke point for every query value in this
|
||||
/// package (`?path=`, and anything added later). nil = not encodable →
|
||||
/// callers surface `.invalidRequest` instead of building a broken URL.
|
||||
static func percentEncode(_ value: String) -> String? {
|
||||
value.addingPercentEncoding(withAllowedCharacters: unreservedCharacters)
|
||||
}
|
||||
|
||||
/// `path=<strictly-encoded>` — the query shared by every `/projects/*` read
|
||||
/// route (`detail`, `log`, `pr`, `worktree/state`). One builder, so a new
|
||||
/// route cannot re-introduce the `+`-decodes-to-space class of bug.
|
||||
static func pathQuery(_ path: String) -> String? {
|
||||
percentEncode(path).map { "path=\($0)" }
|
||||
}
|
||||
|
||||
static func liveSessions() -> APIRoute {
|
||||
APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil)
|
||||
}
|
||||
@@ -155,7 +216,7 @@ enum Endpoints {
|
||||
/// Server session ids are lowercase `crypto.randomUUID()` strings and
|
||||
/// `:id` route params are matched as EXACT strings — always serialize
|
||||
/// lowercase (same rule as `MessageCodec`'s attach encoding).
|
||||
private static func pathId(_ id: UUID) -> String {
|
||||
static func pathId(_ id: UUID) -> String {
|
||||
id.uuidString.lowercased()
|
||||
}
|
||||
|
||||
|
||||
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal file
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal file
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
377
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
377
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
@@ -0,0 +1,377 @@
|
||||
import Foundation
|
||||
import WireProtocol
|
||||
|
||||
// B1 · the seven **G** (state-changing) git/worktree routes — the highest-risk
|
||||
// channel in the app. Every one of them: `Origin` (CSRF) → `gitOpsEnabled` /
|
||||
// `worktreeEnabled` kill-switch (403) → per-IP rate limit (429) →
|
||||
// `isValidGitDir` three-prong (404). Sources:
|
||||
// POST /projects/git/stage src/server.ts:1184-1218 {path,files,stage}
|
||||
// POST /projects/git/commit src/server.ts:1219-1255 {path,message}
|
||||
// POST /projects/git/push src/server.ts:1256-1289 {path}
|
||||
// POST /projects/git/fetch src/server.ts:1290-1319 {path}
|
||||
// POST /projects/worktree src/server.ts:1095-1123 {path,branch[,base]}
|
||||
// DELETE /projects/worktree src/server.ts:1124-1153 {path,worktreePath,force}
|
||||
// POST /projects/worktree/prune src/server.ts:1154-1183 {path}
|
||||
//
|
||||
// The remote/branch/refspec of push and fetch are ALWAYS derived server-side —
|
||||
// this client cannot point them at an arbitrary URL, and never force-pushes.
|
||||
// Failure bodies carry the server's already-classified, already-sanitized `error`
|
||||
// string only (never raw git stderr, SEC-M10): the client shows it verbatim.
|
||||
|
||||
/// Outcome of one guarded git write. Three shapes, because the server gives
|
||||
/// exactly three:
|
||||
/// - `.ok` — 200 with the route's payload;
|
||||
/// - `.rejected` — a 4xx/5xx carrying the server's SAFE `error` message, to be
|
||||
/// displayed INERTLY. **403 is overloaded** (the Origin guard AND the feature
|
||||
/// kill-switch both answer 403) and the client cannot tell them apart by
|
||||
/// status, so it surfaces the message instead of inventing a typed variant;
|
||||
/// - `.rateLimited` — 429. Do NOT auto-retry (that is what the limiter is for).
|
||||
public enum GitWriteOutcome<Payload: Sendable & Equatable>: Sendable, Equatable {
|
||||
case ok(Payload)
|
||||
case rejected(status: Int, message: String?)
|
||||
case rateLimited
|
||||
}
|
||||
|
||||
/// A guarded write's 200 payload. `degraded` is what a 200 with a missing or
|
||||
/// garbled body decodes to: the write ALREADY HAPPENED, so a bad body must not
|
||||
/// be reported as a failure — and the fallback must not be a crash path either.
|
||||
protocol GitWritePayload: Decodable, Sendable, Equatable {
|
||||
static var degraded: Self { get }
|
||||
}
|
||||
|
||||
// MARK: - Per-route 200 payloads
|
||||
|
||||
/// `POST /projects/git/stage` → `{ok,staged,count}`.
|
||||
public struct StageResult: Sendable, Equatable, Decodable {
|
||||
/// true = files were staged (`git add`); false = unstaged (`git restore --staged`).
|
||||
public let staged: Bool
|
||||
public let count: Int
|
||||
|
||||
public init(staged: Bool = false, count: Int = 0) {
|
||||
self.staged = staged
|
||||
self.count = count
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case staged, count }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
staged = (try? container.decode(Bool.self, forKey: .staged)) ?? false
|
||||
count = LossyNumber.int(in: container, forKey: .count) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/commit` → `{ok,commit}` (short sha; `""` is possible).
|
||||
public struct CommitResult: Sendable, Equatable, Decodable {
|
||||
public let commit: String
|
||||
|
||||
public init(commit: String = "") {
|
||||
self.commit = commit
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case commit }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
commit = (try? container.decode(String.self, forKey: .commit)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/push` → `{ok,branch,remote}` (both server-derived).
|
||||
public struct PushResult: Sendable, Equatable, Decodable {
|
||||
public let branch: String?
|
||||
public let remote: String?
|
||||
|
||||
public init(branch: String? = nil, remote: String? = nil) {
|
||||
self.branch = branch
|
||||
self.remote = remote
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case branch, remote }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
remote = try? container.decode(String.self, forKey: .remote)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/git/fetch` → `{ok,remote,lastFetchMs}`. `lastFetchMs` is the
|
||||
/// post-fetch `FETCH_HEAD` mtime — fractional (Double), same as `SyncState`.
|
||||
/// nil means the mtime could not be read: the UI must NOT then claim the
|
||||
/// `behind` count was freshly verified.
|
||||
public struct FetchResult: Sendable, Equatable, Decodable {
|
||||
public let remote: String?
|
||||
public let lastFetchMs: Double?
|
||||
|
||||
public init(remote: String? = nil, lastFetchMs: Double? = nil) {
|
||||
self.remote = remote
|
||||
self.lastFetchMs = lastFetchMs
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case remote, lastFetchMs }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
remote = try? container.decode(String.self, forKey: .remote)
|
||||
lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/worktree` → `{ok,path,branch}` (git's canonical values).
|
||||
public struct CreateWorktreeResult: Sendable, Equatable, Decodable {
|
||||
public let path: String?
|
||||
public let branch: String?
|
||||
|
||||
public init(path: String? = nil, branch: String? = nil) {
|
||||
self.path = path
|
||||
self.branch = branch
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case path, branch }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
path = try? container.decode(String.self, forKey: .path)
|
||||
branch = try? container.decode(String.self, forKey: .branch)
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /projects/worktree` → `{ok,path}` (the canonical path removed).
|
||||
public struct RemoveWorktreeResult: Sendable, Equatable, Decodable {
|
||||
public let path: String?
|
||||
|
||||
public init(path: String? = nil) {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case path }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
path = try? container.decode(String.self, forKey: .path)
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /projects/worktree/prune` → `{ok,pruned}`. An empty list means
|
||||
/// "nothing to prune" — the route is idempotent.
|
||||
public struct PruneWorktreesResult: Sendable, Equatable, Decodable {
|
||||
public let pruned: [String]
|
||||
|
||||
public init(pruned: [String] = []) {
|
||||
self.pruned = pruned
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case pruned }
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
pruned = (try? container.decode([String].self, forKey: .pruned)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
// All-defaults fallbacks, declared next to nothing else so they stay one line
|
||||
// each and cannot drift from the memberwise defaults above.
|
||||
extension StageResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension CommitResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension PushResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension FetchResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension CreateWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension RemoveWorktreeResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
extension PruneWorktreesResult: GitWritePayload { static var degraded: Self { .init() } }
|
||||
|
||||
/// Shape of a failure body: the worktree routes emit `{error}`, git-ops
|
||||
/// `{ok:false,error}`. Both carry `error` as a SAFE string.
|
||||
private struct GitErrorBody: Decodable {
|
||||
let error: String?
|
||||
}
|
||||
|
||||
// MARK: - Request bodies (frozen field-for-field against src/server.ts)
|
||||
|
||||
private struct StageBody: Encodable {
|
||||
let path: String
|
||||
let files: [String]
|
||||
let stage: Bool
|
||||
}
|
||||
|
||||
private struct CommitBody: Encodable {
|
||||
let path: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
/// `{path}` — push · fetch · worktree prune all take exactly this.
|
||||
private struct RepoPathBody: Encodable {
|
||||
let path: String
|
||||
}
|
||||
|
||||
private struct CreateWorktreeBody: Encodable {
|
||||
let path: String
|
||||
let branch: String
|
||||
/// Omitted from the JSON when nil — the server reads a missing `base` as
|
||||
/// "branch from HEAD", and an explicit `null`/`""` is NOT the same thing.
|
||||
let base: String?
|
||||
}
|
||||
|
||||
private struct RemoveWorktreeBody: Encodable {
|
||||
let path: String
|
||||
let worktreePath: String
|
||||
let force: Bool
|
||||
}
|
||||
|
||||
// MARK: - Routes
|
||||
|
||||
extension Endpoints {
|
||||
/// Every guarded git write shares this shape: JSON body + `Origin`, and
|
||||
/// `.routeDefined` 401 — `src/http/git-ops.ts:108` classifies a HOST-side
|
||||
/// git credential failure as 401 ("Push authentication required on the
|
||||
/// host."), which must not be mistaken for the access-token gate telling us
|
||||
/// to enter a token.
|
||||
private static func gitWriteRoute<Body: Encodable>(
|
||||
_ method: HTTPMethod, _ path: String, _ body: Body
|
||||
) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: method, path: path, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(body),
|
||||
unauthorizedPolicy: .routeDefined
|
||||
)
|
||||
}
|
||||
|
||||
static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage))
|
||||
}
|
||||
|
||||
static func gitCommit(path: String, message: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message))
|
||||
}
|
||||
|
||||
static func gitPush(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/push", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func gitFetch(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/git/fetch", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute {
|
||||
try gitWriteRoute(
|
||||
.post, "/projects/worktree",
|
||||
CreateWorktreeBody(path: path, branch: branch, base: base)
|
||||
)
|
||||
}
|
||||
|
||||
/// DELETE **with** a JSON body — the server reads `express.json` here
|
||||
/// (src/server.ts:1124), so this is the wire shape, unusual as it looks.
|
||||
static func removeWorktree(path: String, worktreePath: String, force: Bool) throws -> APIRoute {
|
||||
try gitWriteRoute(
|
||||
.delete, "/projects/worktree",
|
||||
RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force)
|
||||
)
|
||||
}
|
||||
|
||||
static func pruneWorktrees(path: String) throws -> APIRoute {
|
||||
try gitWriteRoute(.post, "/projects/worktree/prune", RepoPathBody(path: path))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Client calls
|
||||
|
||||
extension APIClient {
|
||||
/// Stage (`stage: true` → `git add`) or unstage (`false` →
|
||||
/// `git restore --staged`) specific files. The file list is capped and
|
||||
/// realpath-contained server-side; body limit 64 KB.
|
||||
public func gitStage(
|
||||
path: String, files: [String], stage: Bool
|
||||
) async throws -> GitWriteOutcome<StageResult> {
|
||||
try await performGitWrite(path: path, StageResult.self) {
|
||||
try Endpoints.gitStage(path: path, files: files, stage: stage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit the STAGED changes. `message` is length-capped server-side and
|
||||
/// passed as a single `-m <msg>` argv — never a shell string, never a
|
||||
/// pathspec. An empty message comes back as `.rejected(400, …)`.
|
||||
public func gitCommit(
|
||||
path: String, message: String
|
||||
) async throws -> GitWriteOutcome<CommitResult> {
|
||||
try await performGitWrite(path: path, CommitResult.self) {
|
||||
try Endpoints.gitCommit(path: path, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current branch to its existing upstream, or `-u <sole-remote>
|
||||
/// <branch>` when it has none. Never a force-push; the remote is derived
|
||||
/// server-side. Tighter rate limit than stage/commit (network-bound).
|
||||
public func gitPush(path: String) async throws -> GitWriteOutcome<PushResult> {
|
||||
try await performGitWrite(path: path, PushResult.self) {
|
||||
try Endpoints.gitPush(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh remote-tracking refs (`refs/remotes` only — never a pull) so the
|
||||
/// panel's `behind` stops being a stale guess.
|
||||
public func gitFetch(path: String) async throws -> GitWriteOutcome<FetchResult> {
|
||||
try await performGitWrite(path: path, FetchResult.self) {
|
||||
try Endpoints.gitFetch(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a git worktree (`base` nil ⇒ branch from HEAD). The only
|
||||
/// write-to-disk feature; gated by `WORKTREE_ENABLED` (403 when off).
|
||||
public func createWorktree(
|
||||
path: String, branch: String, base: String?
|
||||
) async throws -> GitWriteOutcome<CreateWorktreeResult> {
|
||||
try await performGitWrite(path: path, CreateWorktreeResult.self) {
|
||||
try Endpoints.createWorktree(path: path, branch: branch, base: base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a worktree. Destructive: a dirty worktree needs `force: true`
|
||||
/// (otherwise the server answers 409 with a safe message), and the main
|
||||
/// worktree can never be removed (400).
|
||||
public func removeWorktree(
|
||||
path: String, worktreePath: String, force: Bool
|
||||
) async throws -> GitWriteOutcome<RemoveWorktreeResult> {
|
||||
try await performGitWrite(path: path, RemoveWorktreeResult.self) {
|
||||
try Endpoints.removeWorktree(path: path, worktreePath: worktreePath, force: force)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prune stale worktree registrations (idempotent — an empty `pruned` list
|
||||
/// means there was nothing to reclaim).
|
||||
public func pruneWorktrees(path: String) async throws -> GitWriteOutcome<PruneWorktreesResult> {
|
||||
try await performGitWrite(path: path, PruneWorktreesResult.self) {
|
||||
try Endpoints.pruneWorktrees(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The ONE place a guarded git write is executed and its status mapped, so
|
||||
/// all seven routes cannot drift apart: empty path rejected before any
|
||||
/// network I/O → 200 decoded (a garbled payload degrades to defaults rather
|
||||
/// than throwing — the write already happened) → 429 `.rateLimited` →
|
||||
/// everything else `.rejected` with the server's safe message.
|
||||
private func performGitWrite<Payload: GitWritePayload>(
|
||||
path: String,
|
||||
_ payload: Payload.Type,
|
||||
route build: () throws -> APIRoute
|
||||
) async throws -> GitWriteOutcome<Payload> {
|
||||
try Self.requireNonEmptyPath(path)
|
||||
let (data, response) = try await perform(try build())
|
||||
switch response.statusCode {
|
||||
case HTTPStatus.ok:
|
||||
let decoded = try? JSONDecoder().decode(Payload.self, from: data)
|
||||
return .ok(decoded ?? Payload.degraded)
|
||||
case HTTPStatus.tooManyRequests:
|
||||
return .rateLimited
|
||||
default:
|
||||
return .rejected(
|
||||
status: response.statusCode, message: Self.decodeGitError(from: data)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's SAFE `error` string, or nil when the body is empty/
|
||||
/// unparseable — the client never invents a reason.
|
||||
private static func decodeGitError(from data: Data) -> String? {
|
||||
(try? JSONDecoder().decode(GitErrorBody.self, from: data))?.error
|
||||
}
|
||||
}
|
||||
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal file
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 (16–512 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:
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 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)。"
|
||||
}
|
||||
|
||||
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal file
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal file
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal file
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
/// 合法形状:16–512 个 `[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 — 服务器启动时就校验 16–512 与字符集,故形状非法者不可能是正确令牌
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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-G(RO 侧:四个端点一律不带 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 /sessions(claude --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()
|
||||
}
|
||||
}
|
||||
}
|
||||
459
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal file
459
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal file
@@ -0,0 +1,459 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import TestSupport
|
||||
import WireProtocol
|
||||
import APIClient
|
||||
|
||||
/// B1 · **G(变更)** 端点:git 写通道 + worktree 管理 + w2 注入队列
|
||||
/// (ios-completion §1.2;真源 = `src/`)。
|
||||
///
|
||||
/// - `POST /projects/git/stage`(`src/server.ts:1184-1218`)body `{path,files,stage}`
|
||||
/// - `POST /projects/git/commit`(`:1219-1255`)body `{path,message}`
|
||||
/// - `POST /projects/git/push`(`:1256-1289`)body `{path}`
|
||||
/// - `POST /projects/git/fetch`(`:1290-1319`)body `{path}`
|
||||
/// - `POST /projects/worktree`(`:1095-1123`)body `{path,branch[,base]}`
|
||||
/// - `DELETE /projects/worktree`(`:1124-1153`)body `{path,worktreePath,force}`
|
||||
/// - `POST /projects/worktree/prune`(`:1154-1183`)body `{path}`
|
||||
/// - `POST /live-sessions/:id/queue`(`:605-643`)body `{text[,appendEnter]}`
|
||||
///
|
||||
/// 全部 **必带 `Origin`**。失败 body 只带服务端**已分类、已脱敏**的 `error`
|
||||
/// 字符串(`src/http/git-ops.ts` / `worktrees.ts`,SEC-M10)—— 客户端逐字展示,
|
||||
/// 绝不自己拼 git stderr。403 是**重载**状态(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-G(G 侧:七条写路由全带 Origin,逐字符相等)
|
||||
|
||||
@Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type")
|
||||
func everyGitWriteRouteCarriesByteEqualOrigin() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let ok = Data(#"{"ok":true}"#.utf8)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true)
|
||||
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x")
|
||||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
_ = try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
_ = try await fixture.client.createWorktree(
|
||||
path: Self.repoPath, branch: "wt-x", base: nil
|
||||
)
|
||||
_ = try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false
|
||||
)
|
||||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
#expect(requests.count == 7)
|
||||
for request in requests {
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)")
|
||||
func gitWriteCarriesCookieAlongsideOrigin() async throws {
|
||||
// Arrange
|
||||
let token = "s3cret-token_value.~+/="
|
||||
let fixture = try makeFixture(accessToken: token)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
let request = try #require(await fixture.http.recordedRequests.first)
|
||||
#expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader)
|
||||
}
|
||||
|
||||
// MARK: - body 形状逐字段冻结
|
||||
|
||||
@Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}")
|
||||
func gitWriteBodyShapesAreExact() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let ok = Data(#"{"ok":true}"#.utf8)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok
|
||||
)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false)
|
||||
_ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文")
|
||||
_ = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
_ = try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
let stage = try bodyObject(try #require(requests.first))
|
||||
#expect(Set(stage.keys) == Set(["path", "files", "stage"]))
|
||||
#expect(stage["path"] as? String == Self.repoPath)
|
||||
#expect(stage["files"] as? [String] == ["a.txt", "b/c.md"])
|
||||
#expect(stage["stage"] as? Bool == false)
|
||||
let commit = try bodyObject(requests[1])
|
||||
#expect(Set(commit.keys) == Set(["path", "message"]))
|
||||
#expect(commit["message"] as? String == "fix: 修中文")
|
||||
for index in 2...4 {
|
||||
let single = try bodyObject(requests[index])
|
||||
#expect(Set(single.keys) == Set(["path"]))
|
||||
#expect(single["path"] as? String == Self.repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}")
|
||||
func worktreeBodyShapesAreExact() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let ok = Data(#"{"ok":true}"#.utf8)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok)
|
||||
await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok)
|
||||
|
||||
// Act
|
||||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil)
|
||||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop")
|
||||
_ = try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "/tmp/wt", force: true
|
||||
)
|
||||
|
||||
// Assert
|
||||
let requests = await fixture.http.recordedRequests
|
||||
let withoutBase = try bodyObject(try #require(requests.first))
|
||||
#expect(Set(withoutBase.keys) == Set(["path", "branch"]))
|
||||
#expect(withoutBase["branch"] as? String == "wt-a")
|
||||
let withBase = try bodyObject(requests[1])
|
||||
#expect(Set(withBase.keys) == Set(["path", "branch", "base"]))
|
||||
#expect(withBase["base"] as? String == "develop")
|
||||
let remove = try bodyObject(requests[2])
|
||||
#expect(Set(remove.keys) == Set(["path", "worktreePath", "force"]))
|
||||
#expect(remove["worktreePath"] as? String == "/tmp/wt")
|
||||
#expect(remove["force"] as? Bool == true)
|
||||
#expect(requests[2].httpMethod == "DELETE") // DELETE **带** JSON body
|
||||
}
|
||||
|
||||
// MARK: - 200 payload 解码(畸形 body 降级为默认值,绝不抛)
|
||||
|
||||
@Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状")
|
||||
func successPayloadsDecodePerRoute() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/stage"),
|
||||
body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/commit"),
|
||||
body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"),
|
||||
body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/fetch"),
|
||||
body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree"),
|
||||
body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/projects/worktree"),
|
||||
body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"),
|
||||
body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
#expect(
|
||||
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
|
||||
== .ok(StageResult(staged: true, count: 2))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
== .ok(CommitResult(commit: "abc1234"))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitPush(path: Self.repoPath)
|
||||
== .ok(PushResult(branch: "develop", remote: "origin"))
|
||||
)
|
||||
let fetched = try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
guard case .ok(let fetchResult) = fetched else {
|
||||
Issue.record("fetch 应为 .ok")
|
||||
return
|
||||
}
|
||||
#expect(fetchResult.remote == "origin")
|
||||
// lastFetchMs 来自 fs.stat().mtimeMs —— 带小数,必须解出来
|
||||
#expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001)
|
||||
#expect(
|
||||
try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
|
||||
== .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x"))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "/tmp/wt", force: false
|
||||
) == .ok(RemoveWorktreeResult(path: "/tmp/wt"))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
== .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"]))
|
||||
)
|
||||
}
|
||||
|
||||
@Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛")
|
||||
func garbledSuccessPayloadDegradesToDefaults() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"),
|
||||
body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
#expect(
|
||||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
== .ok(CommitResult(commit: ""))
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
== .ok(PruneWorktreesResult(pruned: []))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 失败映射(429 独立;其余原样带出服务端安全 message)
|
||||
|
||||
@Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试")
|
||||
func rateLimitedIsItsOwnOutcome() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"), status: 429,
|
||||
body: Data(#"{"error":"Too many requests."}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
|
||||
// Assert
|
||||
#expect(outcome == .rateLimited)
|
||||
}
|
||||
|
||||
@Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)")
|
||||
func failuresCarryTheServerSafeMessageVerbatim() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
let cases: [(Int, String)] = [
|
||||
(403, "Git operations are disabled."),
|
||||
(409, "Nothing staged to commit."),
|
||||
(400, "Set a git author identity (user.name / user.email) first."),
|
||||
(500, "Git operation failed."),
|
||||
]
|
||||
for (status, message) in cases {
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/commit"), status: status,
|
||||
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||||
)
|
||||
}
|
||||
|
||||
// Act + Assert
|
||||
for (status, message) in cases {
|
||||
let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
#expect(outcome == .rejected(status: status, message: message))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("失败 body 无法解析 → .rejected(status, nil),不编造原因")
|
||||
func unparseableFailureBodyYieldsNilMessage() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree"), status: 500,
|
||||
body: Data("<html>oops</html>".utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let outcome = try await fixture.client.createWorktree(
|
||||
path: Self.repoPath, branch: "x", base: nil
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(outcome == .rejected(status: 500, message: nil))
|
||||
}
|
||||
|
||||
@Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401")
|
||||
func push401IsRouteClassifiedNotTheAccessTokenGate() async throws {
|
||||
// Arrange — src/http/git-ops.ts:108 把主机侧 git 凭据失败分类成 401
|
||||
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
|
||||
let message = "Push authentication required on the host."
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/git/push"), status: 401,
|
||||
body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||||
)
|
||||
|
||||
// Act
|
||||
let outcome = try await fixture.client.gitPush(path: Self.repoPath)
|
||||
|
||||
// Assert — 若被 gate 规则吞掉就会抛 .unauthorized,那会误导用户去补令牌
|
||||
#expect(outcome == .rejected(status: 401, message: message))
|
||||
}
|
||||
|
||||
@Test("空 path 联网前拒(projectPathInvalid),七条写路由一致")
|
||||
func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture()
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true)
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitCommit(path: "", message: "m")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitPush(path: "")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.gitFetch(path: "")
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil)
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false)
|
||||
}
|
||||
await #expect(throws: APIClientError.projectPathInvalid) {
|
||||
_ = try await fixture.client.pruneWorktrees(path: "")
|
||||
}
|
||||
#expect(await fixture.http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - POST /live-sessions/:id/queue(w2 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 — 非数组
|
||||
|
||||
Reference in New Issue
Block a user