From e4e8192d4db0c691079580cc3ccd917790145c89 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 22:48:18 -0700 Subject: [PATCH] clients/swift: initial Swift SDK for clawdforge --- clients/swift/Examples/Basic/main.swift | 56 +++ clients/swift/Package.swift | 48 +++ clients/swift/README.md | 214 ++++++++++ clients/swift/Sources/Clawdforge/Errors.swift | 66 +++ .../Sources/Clawdforge/ForgeClient.swift | 375 ++++++++++++++++++ clients/swift/Sources/Clawdforge/Models.swift | 321 +++++++++++++++ .../ClawdforgeTests/ForgeClientTests.swift | 373 +++++++++++++++++ 7 files changed, 1453 insertions(+) create mode 100644 clients/swift/Examples/Basic/main.swift create mode 100644 clients/swift/Package.swift create mode 100644 clients/swift/README.md create mode 100644 clients/swift/Sources/Clawdforge/Errors.swift create mode 100644 clients/swift/Sources/Clawdforge/ForgeClient.swift create mode 100644 clients/swift/Sources/Clawdforge/Models.swift create mode 100644 clients/swift/Tests/ClawdforgeTests/ForgeClientTests.swift diff --git a/clients/swift/Examples/Basic/main.swift b/clients/swift/Examples/Basic/main.swift new file mode 100644 index 0000000..9c993ab --- /dev/null +++ b/clients/swift/Examples/Basic/main.swift @@ -0,0 +1,56 @@ +// Basic example for the Clawdforge SDK. +// +// Run with: CLAWDFORGE_TOKEN=cf_... swift run ClawdforgeBasicExample +// +// This file is intentionally named `main.swift` so it can use top-level +// `await` rather than wrapping in `@main`. Swift treats a single-file +// executable target named `main.swift` as a script. + +import Foundation +import Clawdforge + +let baseURL = URL(string: ProcessInfo.processInfo.environment["CLAWDFORGE_URL"] ?? "http://192.168.0.5:8800")! +guard let token = ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"] else { + FileHandle.standardError.write(Data("error: set CLAWDFORGE_TOKEN\n".utf8)) + exit(1) +} + +let client = ForgeClient(baseURL: baseURL, token: token) + +do { + // 1. Health check + let h = try await client.healthz() + print("claude_present: \(h.claudePresent)") + print("claude_version: \(h.claudeVersion ?? "unknown")") + + // 2. Run a prompt + let res = try await client.run(RunRequest( + prompt: #"Reply with JSON: {"hello": "world"}"#, + model: "sonnet", + timeoutSecs: 60 + )) + print("duration: \(res.durationMs)ms") + if case .object(let dict) = res.result, + case .string(let hello) = dict["hello"] ?? .null { + print("hello = \(hello)") + } else { + print("result: \(res.result)") + } + + // 3. Optional file upload + reuse — uncomment to try. + // + // let recipe = URL(fileURLWithPath: "./recipe.png") + // let ft = try await client.uploadFile(at: recipe, ttlSecs: 3600) + // let extract = try await client.run(RunRequest( + // prompt: "extract recipe data as JSON", + // files: [ft.fileToken] + // )) + // print("extract: \(extract.result)") + +} catch let err as ForgeError { + FileHandle.standardError.write(Data("clawdforge error: \(err.localizedDescription)\n".utf8)) + exit(2) +} catch { + FileHandle.standardError.write(Data("unexpected error: \(error)\n".utf8)) + exit(2) +} diff --git a/clients/swift/Package.swift b/clients/swift/Package.swift new file mode 100644 index 0000000..8f81bc4 --- /dev/null +++ b/clients/swift/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version:5.9 +// +// Clawdforge Swift SDK +// MIT License — see clawdforge repository LICENSE. +// +// Pure-Foundation HTTP client for the LAN-only clawdforge service. +// Builds on macOS, iOS, watchOS, tvOS, and Linux (swift-corelibs-foundation). + +import PackageDescription + +let package = Package( + name: "Clawdforge", + platforms: [ + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), + ], + products: [ + .library( + name: "Clawdforge", + targets: ["Clawdforge"] + ), + .executable( + name: "ClawdforgeBasicExample", + targets: ["ClawdforgeBasicExample"] + ), + ], + dependencies: [ + // Intentionally none. Foundation only. + ], + targets: [ + .target( + name: "Clawdforge", + path: "Sources/Clawdforge" + ), + .executableTarget( + name: "ClawdforgeBasicExample", + dependencies: ["Clawdforge"], + path: "Examples/Basic" + ), + .testTarget( + name: "ClawdforgeTests", + dependencies: ["Clawdforge"], + path: "Tests/ClawdforgeTests" + ), + ] +) diff --git a/clients/swift/README.md b/clients/swift/README.md new file mode 100644 index 0000000..303f8de --- /dev/null +++ b/clients/swift/README.md @@ -0,0 +1,214 @@ +# Clawdforge — Swift SDK + +Swift client for [clawdforge], a LAN-only HTTP service that wraps `claude -p` +subprocess calls behind a bearer-token-gated REST API. + +- **Pure Foundation** — no `Alamofire`, no third-party HTTP libs. +- **Async/await throughout** — every I/O method is `async throws`. +- **Apple platforms + Linux** — same source builds on macOS/iOS/tvOS/watchOS + and on Linux via `swift-corelibs-foundation`. +- **MIT licensed.** + +## Requirements + +| Platform | Minimum | +|---|---| +| Swift toolchain | 5.9 | +| macOS | 13 | +| iOS | 16 | +| tvOS | 16 | +| watchOS | 9 | +| Linux | any distro with Swift 5.9+ | + +## Install + +Add to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git", from: "0.1.0"), +], +targets: [ + .target( + name: "MyApp", + dependencies: [ + .product(name: "Clawdforge", package: "clawdforge"), + ] + ), +] +``` + +In an Xcode project: **File → Add Package Dependencies…**, paste the URL, +select the `Clawdforge` library product. + +## Quickstart + +```swift +import Clawdforge + +let client = ForgeClient( + baseURL: URL(string: "http://192.168.0.5:8800")!, + token: ProcessInfo.processInfo.environment["CLAWDFORGE_TOKEN"]! +) + +// Health check (no token strictly required, but bearer is sent anyway). +let h = try await client.healthz() +print(h.claudeVersion ?? "unknown") + +// Run a prompt. +let res = try await client.run(RunRequest( + prompt: #"Reply with JSON: {"hello": "world"}"#, + model: "sonnet", + timeoutSecs: 60 +)) +print("duration: \(res.durationMs)ms") + +// Narrow the heterogeneous result. +if case .object(let dict) = res.result, + case .string(let hello) = dict["hello"] ?? .null { + print(hello) // "world" +} +``` + +## Public API + +### `ForgeClient` + +A `Sendable` struct. Build once, share everywhere — `URLSession` underneath +is thread-safe. + +```swift +public struct ForgeClient: Sendable { + public init(baseURL: URL, token: String, session: URLSession = .shared) + + public func healthz() async throws -> HealthStatus + public func run(_ request: RunRequest) async throws -> RunResult + public func uploadFile(at fileURL: URL, ttlSecs: Int = 3600) async throws -> FileToken + + public func createToken(_ request: CreateTokenRequest) async throws -> AppToken + public func listTokens() async throws -> [AppToken] + public func revokeToken(name: String) async throws +} +``` + +### File upload + reuse + +```swift +let png = URL(fileURLWithPath: "./recipe.png") +let ft = try await client.uploadFile(at: png, ttlSecs: 3600) + +let res = try await client.run(RunRequest( + prompt: "Extract recipe data as JSON.", + files: [ft.fileToken] +)) +``` + +The upload streams from disk via `URLSession.upload(for:fromFile:)` — no +load-into-`Data()` for the file payload, so multi-megabyte files are fine. + +### Admin endpoints + +Pass the `ADMIN_BOOTSTRAP_TOKEN` as the client's `token` to use these. + +```swift +let admin = ForgeClient(baseURL: url, token: adminToken) + +let new = try await admin.createToken( + CreateTokenRequest(name: "myapp", ipCidrs: ["10.0.0.0/8"]) +) +print("save this:", new.token!) // shown ONCE + +let all = try await admin.listTokens() +try await admin.revokeToken(name: "myapp") +``` + +### `JSONValue` + +`RunResult.result` is a `JSONValue`. `claude -p --output-format json` may +emit either parsed JSON or a plain string when the model didn't return +JSON; `JSONValue` represents both losslessly. + +```swift +public enum JSONValue: Codable, Sendable, Equatable { + case object([String: JSONValue]) + case array([JSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null +} +``` + +Convenience accessors: `.stringValue`, `.numberValue`, `.boolValue`, +`.arrayValue`, `.objectValue` — each returns `nil` if the case doesn't +match. + +## Errors + +All thrown errors are the single `ForgeError` enum: + +```swift +public enum ForgeError: Error { + case auth(statusCode: Int, body: String) // 401 / 403 + case api(statusCode: Int, body: String) // any other non-2xx + case transport(URLError) // DNS, TLS, EOF, cancellation + case decoding(DecodingError) // server/client schema skew + case invalidArgument(String) // SDK precondition +} +``` + +Conforms to `LocalizedError`, so `error.localizedDescription` and SwiftUI's +`.alert(error:)` modifier produce sensible messages out of the box. + +`/run` failures with HTTP 502 surface as `.api(statusCode: 502, body: ...)`. +Decode the body as `RunFailure` to inspect `error`/`stderr`/`durationMs`/`stopReason`. + +```swift +do { + _ = try await client.run(RunRequest(prompt: "...")) +} catch let ForgeError.api(502, body) { + if let data = body.data(using: .utf8), + let failure = try? JSONDecoder().decode(RunFailure.self, from: data) { + print("claude failed: \(failure.error)") + } +} +``` + +## Cancellation + +`Task` cancellation propagates into `URLSession`. The SDK also calls +`Task.checkCancellation()` before the network hop so a cancel before the +request is dispatched throws `CancellationError` immediately. + +```swift +let handle = Task { + try await client.run(RunRequest(prompt: "long prompt")) +} +// ... +handle.cancel() +``` + +## Linux + +Builds on Linux via `swift-corelibs-foundation`. Async `URLSession` methods +(`data(for:)`, `upload(for:fromFile:)`) are available there from Swift 5.9. + +```bash +swift build -c release +swift test +``` + +## Tests + +Unit tests use a `URLProtocol` stub — no network, no clawdforge instance +required. Run: + +```bash +swift test +``` + +## License + +MIT. + +[clawdforge]: http://192.168.0.5:3001/Sulkta-Coop/clawdforge diff --git a/clients/swift/Sources/Clawdforge/Errors.swift b/clients/swift/Sources/Clawdforge/Errors.swift new file mode 100644 index 0000000..96f83f9 --- /dev/null +++ b/clients/swift/Sources/Clawdforge/Errors.swift @@ -0,0 +1,66 @@ +// Errors.swift +// +// One enum to rule them all. Conforms to LocalizedError so error messages +// surface cleanly in `print()`, `localizedDescription`, and SwiftUI's +// `.alert(error:)` modifier. + +import Foundation + +/// Every error this SDK throws. +public enum ForgeError: Error, Sendable { + /// Server returned 401 or 403. Token is missing, wrong, or the caller + /// IP isn't on the global / per-app allowlist. + case auth(statusCode: Int, body: String) + + /// Any other non-2xx response. ``body`` is the raw response body + /// (truncated to a sane size). For `/run` 502 failures, decode + /// ``body`` as ``RunFailure`` to inspect `error`/`stderr`/`duration_ms`. + case api(statusCode: Int, body: String) + + /// Underlying transport problem: DNS, connect refused, TLS, EOF, + /// timeout from `URLSessionConfiguration.timeoutIntervalForRequest`, + /// or `Task` cancellation surfaced as `URLError(.cancelled)`. + case transport(URLError) + + /// JSON decoding of a successful response failed. Almost always + /// indicates a server/client version skew. + case decoding(DecodingError) + + /// A precondition guarded by the SDK was violated (e.g. empty prompt, + /// nonexistent file path on upload, invalid base URL). + case invalidArgument(String) +} + +extension ForgeError: LocalizedError { + public var errorDescription: String? { + switch self { + case .auth(let code, let body): + let snippet = Self.snippet(body) + return "clawdforge: authentication failed (HTTP \(code))\(snippet.isEmpty ? "" : ": \(snippet)")" + case .api(let code, let body): + let snippet = Self.snippet(body) + return "clawdforge: HTTP \(code)\(snippet.isEmpty ? "" : ": \(snippet)")" + case .transport(let urlError): + return "clawdforge: transport error: \(urlError.localizedDescription)" + case .decoding(let decErr): + return "clawdforge: response decoding failed: \(decErr)" + case .invalidArgument(let msg): + return "clawdforge: invalid argument: \(msg)" + } + } + + /// Trim/clean a body for inclusion in error messages. Tries to pull + /// `error` / `detail` / `message` out of a JSON envelope, otherwise + /// returns the first ~500 chars. + private static func snippet(_ body: String) -> String { + guard !body.isEmpty else { return "" } + if let data = body.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + for key in ["error", "detail", "message"] { + if let v = obj[key] as? String, !v.isEmpty { return v } + } + } + let trimmed = body.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.count > 500 ? String(trimmed.prefix(500)) + "..." : trimmed + } +} diff --git a/clients/swift/Sources/Clawdforge/ForgeClient.swift b/clients/swift/Sources/Clawdforge/ForgeClient.swift new file mode 100644 index 0000000..1033c8c --- /dev/null +++ b/clients/swift/Sources/Clawdforge/ForgeClient.swift @@ -0,0 +1,375 @@ +// ForgeClient.swift +// +// Foundation-only async/await client for clawdforge. +// +// Design choice: ForgeClient is a Sendable struct, not an actor. The client +// holds an immutable URL, an immutable bearer token, and a URLSession (which +// is itself thread-safe by Apple's contract). There is no mutable state to +// guard, so an actor would just add hop overhead for every call. +// +// All public methods are `async throws`. Cancellation propagates from the +// caller's Task into URLSession via the structured-concurrency bridge in +// `URLSession.data(for:)` / `URLSession.upload(for:fromFile:)`, so wrapping +// a call in `Task { ... }.cancel()` cleanly aborts the in-flight request. +// +// Linux: builds against swift-corelibs-foundation. The async URLSession +// methods used here (`data(for:)`, `upload(for:fromFile:)`) are available +// on Linux as of Swift 5.9. + +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Thread-safe client for the clawdforge REST API. +/// +/// Construct once per (baseURL, token) pair and share across the app. +/// Instances are cheap and `Sendable`. +public struct ForgeClient: Sendable { + + // MARK: Stored + + /// Base URL, e.g. `http://192.168.0.5:8800`. Trailing slashes are trimmed. + public let baseURL: URL + + /// Bearer token. Sent as `Authorization: Bearer ` on every request. + /// For admin endpoints, pass the `ADMIN_BOOTSTRAP_TOKEN` here. + public let token: String + + /// Underlying URLSession. Defaults to `.shared`. Inject a configured + /// session for custom timeouts, proxies, or test harnesses. + public let session: URLSession + + /// JSON encoder used for all request bodies. Pre-configured for the + /// snake_case wire format via per-field `CodingKeys` on each model. + private let encoder: JSONEncoder + + /// JSON decoder used for all response bodies. + private let decoder: JSONDecoder + + // MARK: Init + + /// Create a client. + /// + /// - Parameters: + /// - baseURL: Root URL of the clawdforge instance. e.g. + /// `URL(string: "http://192.168.0.5:8800")!`. + /// - token: Bearer token (`cf_…` for app endpoints, the admin + /// bootstrap token for `/admin/*`). + /// - session: Optional `URLSession`. Defaults to `.shared`. Provide + /// a custom one for timeouts or for tests using `URLProtocol` + /// stubs. + public init( + baseURL: URL, + token: String, + session: URLSession = .shared + ) { + // Trim a trailing slash so path joins are predictable. + var s = baseURL.absoluteString + while s.hasSuffix("/") { s.removeLast() } + self.baseURL = URL(string: s) ?? baseURL + self.token = token + self.session = session + + let enc = JSONEncoder() + enc.outputFormatting = [.withoutEscapingSlashes] + self.encoder = enc + + self.decoder = JSONDecoder() + } + + // MARK: Public API + + /// `GET /healthz`. Liveness + `claude --version` smoke check. + /// + /// Does not require a bearer token, but the caller IP must satisfy + /// the global allowlist on the server. + public func healthz() async throws -> HealthStatus { + let req = try makeRequest(method: "GET", path: "/healthz") + return try await send(req, decode: HealthStatus.self) + } + + /// `POST /run`. Run a prompt through `claude -p` on the server. + /// + /// On HTTP 200 returns ``RunResult``. On HTTP 502 the underlying + /// `claude` invocation failed and ``ForgeError/api(statusCode:body:)`` + /// is thrown with a JSON body matching ``RunFailure``. + public func run(_ request: RunRequest) async throws -> RunResult { + guard !request.prompt.isEmpty else { + throw ForgeError.invalidArgument("prompt must not be empty") + } + let req = try makeRequest( + method: "POST", + path: "/run", + jsonBody: request + ) + return try await send(req, decode: RunResult.self) + } + + /// `POST /files`. Stream a file from disk to the server, receive an + /// `ff_…` token suitable for ``RunRequest/files``. + /// + /// Uses `URLSession.upload(for:fromFile:)` so the file body is streamed, + /// not buffered in memory. + /// + /// - Parameters: + /// - fileURL: Local file URL. Must be a regular readable file. + /// - ttlSecs: Server clamps to `[60, 86400]`. Defaults to `3600`. + public func uploadFile(at fileURL: URL, ttlSecs: Int = 3600) async throws -> FileToken { + guard fileURL.isFileURL else { + throw ForgeError.invalidArgument("uploadFile: URL is not a file URL") + } + let path = fileURL.path + guard FileManager.default.fileExists(atPath: path) else { + throw ForgeError.invalidArgument("uploadFile: no file at \(path)") + } + + // Stage the multipart body to a temp file, then `upload(for:fromFile:)` + // streams it from disk — no Data() blob in memory. + let boundary = "clawdforge-\(UUID().uuidString)" + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdforge-upload-\(UUID().uuidString).bin") + try writeMultipartBody( + to: tempURL, + boundary: boundary, + ttlSecs: ttlSecs, + fileURL: fileURL + ) + defer { + try? FileManager.default.removeItem(at: tempURL) + } + + var req = try makeRequest(method: "POST", path: "/files") + req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + try Task.checkCancellation() + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.upload(for: req, fromFile: tempURL) + } catch let urlError as URLError { + throw ForgeError.transport(urlError) + } catch { + throw ForgeError.transport(URLError(.unknown)) + } + + return try decodeOrThrow(FileToken.self, data: data, response: response) + } + + /// `POST /admin/tokens`. Mint a new per-app token. The plaintext is + /// returned in ``AppToken/token`` and is **not retrievable later**. + /// + /// Requires the admin bootstrap token in ``token``. + public func createToken(_ request: CreateTokenRequest) async throws -> AppToken { + guard !request.name.isEmpty else { + throw ForgeError.invalidArgument("createToken: name must not be empty") + } + let req = try makeRequest( + method: "POST", + path: "/admin/tokens", + jsonBody: request + ) + return try await send(req, decode: AppToken.self) + } + + /// `GET /admin/tokens`. Returns the configured tokens (no plaintexts). + /// + /// Requires the admin bootstrap token. + public func listTokens() async throws -> [AppToken] { + let req = try makeRequest(method: "GET", path: "/admin/tokens") + let envelope = try await send(req, decode: TokenList.self) + return envelope.tokens + } + + /// `DELETE /admin/tokens/{name}`. Revoke a token by app name. + /// + /// Requires the admin bootstrap token. + public func revokeToken(name: String) async throws { + guard !name.isEmpty else { + throw ForgeError.invalidArgument("revokeToken: name must not be empty") + } + let escaped = name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? name + let req = try makeRequest(method: "DELETE", path: "/admin/tokens/\(escaped)") + _ = try await sendVoid(req) + } + + // MARK: Internals + + /// Build a `URLRequest` with auth + accept headers. If `jsonBody` is + /// non-nil it is encoded and `Content-Type: application/json` is set. + private func makeRequest( + method: String, + path: String, + jsonBody: Body + ) throws -> URLRequest { + var req = try makeRequest(method: method, path: path) + do { + req.httpBody = try encoder.encode(jsonBody) + } catch { + throw ForgeError.invalidArgument("failed to encode request body: \(error)") + } + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + return req + } + + private func makeRequest(method: String, path: String) throws -> URLRequest { + guard let url = URL(string: baseURL.absoluteString + path) else { + throw ForgeError.invalidArgument("could not form URL for path \(path)") + } + var req = URLRequest(url: url) + req.httpMethod = method + if !token.isEmpty { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + req.setValue("application/json", forHTTPHeaderField: "Accept") + return req + } + + /// Execute a request and decode JSON into `T`. Cancellation-safe. + private func send(_ req: URLRequest, decode: T.Type) async throws -> T { + try Task.checkCancellation() + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: req) + } catch let urlError as URLError { + throw ForgeError.transport(urlError) + } catch { + throw ForgeError.transport(URLError(.unknown)) + } + + return try decodeOrThrow(T.self, data: data, response: response) + } + + /// Like ``send(_:decode:)`` but ignores response body. Used for DELETE. + private func sendVoid(_ req: URLRequest) async throws -> Void { + try Task.checkCancellation() + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: req) + } catch let urlError as URLError { + throw ForgeError.transport(urlError) + } catch { + throw ForgeError.transport(URLError(.unknown)) + } + try ensure2xx(data: data, response: response) + } + + /// Validate a 2xx, then decode JSON into `T`. Maps non-2xx into + /// ``ForgeError`` variants and decoding failure into + /// ``ForgeError/decoding(_:)``. + private func decodeOrThrow(_ type: T.Type, data: Data, response: URLResponse) throws -> T { + try ensure2xx(data: data, response: response) + do { + return try decoder.decode(T.self, from: data) + } catch let decErr as DecodingError { + throw ForgeError.decoding(decErr) + } catch { + // Foundation should always raise DecodingError, but be defensive. + throw ForgeError.api(statusCode: -1, body: String(data: data, encoding: .utf8) ?? "") + } + } + + private func ensure2xx(data: Data, response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw ForgeError.api(statusCode: -1, body: "non-HTTP response") + } + let code = http.statusCode + guard !(200..<300).contains(code) else { return } + + // Cap error-body size at 8 MiB so a misbehaving server can't fill RAM. + let cap = 8 * 1024 * 1024 + let bodyData = data.count > cap ? data.prefix(cap) : data + let body = String(data: bodyData, encoding: .utf8) ?? "" + + if code == 401 || code == 403 { + throw ForgeError.auth(statusCode: code, body: body) + } + throw ForgeError.api(statusCode: code, body: body) + } + + /// Stream a multipart/form-data body into a temp file. Stays disk-bound: + /// reads the source file in 1 MiB chunks rather than slurping it. + private func writeMultipartBody( + to dest: URL, + boundary: String, + ttlSecs: Int, + fileURL: URL + ) throws { + FileManager.default.createFile(atPath: dest.path, contents: nil) + guard let out = FileHandle(forWritingAtPath: dest.path) else { + throw ForgeError.invalidArgument("cannot open temp upload at \(dest.path)") + } + defer { try? out.close() } + + // ttl_secs field + let ttlPart = """ + --\(boundary)\r + Content-Disposition: form-data; name="ttl_secs"\r + \r + \(ttlSecs)\r + + """ + try out.write(contentsOf: Data(ttlPart.utf8)) + + // file field header + let filename = fileURL.lastPathComponent + let mime = mimeType(for: fileURL) + let fileHeader = """ + --\(boundary)\r + Content-Disposition: form-data; name="file"; filename="\(escapeFilename(filename))"\r + Content-Type: \(mime)\r + \r + + """ + try out.write(contentsOf: Data(fileHeader.utf8)) + + // file body — chunked + guard let input = FileHandle(forReadingAtPath: fileURL.path) else { + throw ForgeError.invalidArgument("cannot open \(fileURL.path) for reading") + } + defer { try? input.close() } + + let chunkSize = 1 * 1024 * 1024 + while true { + let chunk = input.readData(ofLength: chunkSize) + if chunk.isEmpty { break } + try out.write(contentsOf: chunk) + } + + // closing boundary + let trailer = "\r\n--\(boundary)--\r\n" + try out.write(contentsOf: Data(trailer.utf8)) + } + + /// Best-effort MIME type from extension. Defaults to + /// `application/octet-stream`. Server treats files as opaque blobs so + /// the value rarely matters; we just want to avoid sending nothing. + private func mimeType(for url: URL) -> String { + switch url.pathExtension.lowercased() { + case "txt", "md", "log": return "text/plain" + case "json": return "application/json" + case "html", "htm": return "text/html" + case "csv": return "text/csv" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "pdf": return "application/pdf" + default: return "application/octet-stream" + } + } + + /// RFC 7578 says backslash and double-quote should be escaped in the + /// `filename=` parameter. Newlines we just strip — no legitimate file + /// has them on POSIX. + private func escapeFilename(_ name: String) -> String { + name + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + } +} diff --git a/clients/swift/Sources/Clawdforge/Models.swift b/clients/swift/Sources/Clawdforge/Models.swift new file mode 100644 index 0000000..12cecc2 --- /dev/null +++ b/clients/swift/Sources/Clawdforge/Models.swift @@ -0,0 +1,321 @@ +// Models.swift +// +// Codable wire models for the clawdforge HTTP API. +// +// Wire format note: clawdforge speaks snake_case end-to-end (Python / +// Pydantic conventions). Swift convention is camelCase, so each model +// declares explicit `CodingKeys` to bridge the two. We deliberately do +// NOT use `JSONDecoder.KeyDecodingStrategy.convertFromSnakeCase` — the +// per-field map is more legible and survives renames cleanly. + +import Foundation + +// MARK: - Health + +/// Response from `GET /healthz`. +public struct HealthStatus: Codable, Sendable, Equatable { + /// Always `true` if the server returned 200. + public let ok: Bool + /// Whether the `claude` binary was found on `PATH` inside the container. + public let claudePresent: Bool + /// First line of `claude --version`, or `nil` if the smoke check failed. + public let claudeVersion: String? + + public init(ok: Bool, claudePresent: Bool, claudeVersion: String?) { + self.ok = ok + self.claudePresent = claudePresent + self.claudeVersion = claudeVersion + } + + private enum CodingKeys: String, CodingKey { + case ok + case claudePresent = "claude_present" + case claudeVersion = "claude_version" + } +} + +// MARK: - Run + +/// Body for `POST /run`. +/// +/// Only ``prompt`` is required. Fields left `nil` are omitted from the +/// wire payload via custom encoding so the server sees the same shape +/// the Python/Go/Rust clients send. +public struct RunRequest: Codable, Sendable, Equatable { + /// The user prompt to feed to `claude -p`. + public let prompt: String + /// Override the server's default model (e.g. `"sonnet"`, `"opus"`). `nil` uses default. + public let model: String? + /// Optional system prompt prepended to the run. + public let system: String? + /// `ff_…` file tokens previously returned by ``ForgeClient/uploadFile(at:ttlSecs:)``. + public let files: [String]? + /// Per-request timeout. Server clamps to `[5, 600]`. `nil` uses default. + public let timeoutSecs: Int? + + public init( + prompt: String, + model: String? = nil, + system: String? = nil, + files: [String]? = nil, + timeoutSecs: Int? = nil + ) { + self.prompt = prompt + self.model = model + self.system = system + self.files = files + self.timeoutSecs = timeoutSecs + } + + private enum CodingKeys: String, CodingKey { + case prompt + case model + case system + case files + case timeoutSecs = "timeout_secs" + } +} + +/// Successful response from `POST /run` (HTTP 200). +public struct RunResult: Codable, Sendable, Equatable { + /// Always `true` on success. + public let ok: Bool + /// The inner `result` from `claude -p --output-format json`. May be a + /// JSON object/array/scalar OR a plain string if the model didn't + /// return JSON. See ``JSONValue`` for narrowing helpers. + public let result: JSONValue + /// Wall-clock duration of the underlying `claude` subprocess. + public let durationMs: Int + /// Why the model stopped (e.g. `"end_turn"`, `"max_tokens"`). + public let stopReason: String? + + public init(ok: Bool, result: JSONValue, durationMs: Int, stopReason: String?) { + self.ok = ok + self.result = result + self.durationMs = durationMs + self.stopReason = stopReason + } + + private enum CodingKeys: String, CodingKey { + case ok + case result + case durationMs = "duration_ms" + case stopReason = "stop_reason" + } +} + +/// Error-shape body returned by `POST /run` when `claude` failed (HTTP 502). +/// +/// Surfaced inside ``ForgeError/api(statusCode:body:)`` for inspection. +public struct RunFailure: Codable, Sendable, Equatable { + public let ok: Bool + public let error: String + public let stderr: String? + public let durationMs: Int? + public let stopReason: String? + + private enum CodingKeys: String, CodingKey { + case ok + case error + case stderr + case durationMs = "duration_ms" + case stopReason = "stop_reason" + } +} + +// MARK: - Files + +/// Response from `POST /files`. +public struct FileToken: Codable, Sendable, Equatable { + /// Opaque `ff_…` token to pass in ``RunRequest/files``. + public let fileToken: String + /// Server-acknowledged TTL (clamped to `[60, 86400]`). + public let ttlSecs: Int + /// Size of the uploaded file in bytes. + public let size: Int + + public init(fileToken: String, ttlSecs: Int, size: Int) { + self.fileToken = fileToken + self.ttlSecs = ttlSecs + self.size = size + } + + private enum CodingKeys: String, CodingKey { + case fileToken = "file_token" + case ttlSecs = "ttl_secs" + case size + } +} + +// MARK: - Admin tokens + +/// Body for `POST /admin/tokens`. +public struct CreateTokenRequest: Codable, Sendable, Equatable { + /// Lowercase alphanumerics, `-`, `_`. Must start with `[a-z0-9]`. Max 64 chars. + public let name: String + /// Optional per-app CIDR allowlist on top of the global allowlist. + public let ipCidrs: [String] + + public init(name: String, ipCidrs: [String] = []) { + self.name = name + self.ipCidrs = ipCidrs + } + + private enum CodingKeys: String, CodingKey { + case name + case ipCidrs = "ip_cidrs" + } +} + +/// One row from `GET /admin/tokens`. Also returned by `POST /admin/tokens` +/// with the plaintext ``token`` populated; on `list`, `token` is `nil`. +public struct AppToken: Codable, Sendable, Equatable { + public let name: String + /// Plaintext token. **Only populated on create** — the server hashes it + /// and never exposes plaintext on subsequent list calls. + public let token: String? + public let ipCidrs: [String]? + public let createdAt: Int? + public let lastUsed: Int? + public let enabled: Int? + + public init( + name: String, + token: String? = nil, + ipCidrs: [String]? = nil, + createdAt: Int? = nil, + lastUsed: Int? = nil, + enabled: Int? = nil + ) { + self.name = name + self.token = token + self.ipCidrs = ipCidrs + self.createdAt = createdAt + self.lastUsed = lastUsed + self.enabled = enabled + } + + private enum CodingKeys: String, CodingKey { + case name + case token + case ipCidrs = "ip_cidrs" + case createdAt = "created_at" + case lastUsed = "last_used" + case enabled + } + + /// The server returns `ip_cidrs` as a comma-joined string in list + /// responses but as an array on create. Decode handles both. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.name = try c.decode(String.self, forKey: .name) + self.token = try c.decodeIfPresent(String.self, forKey: .token) + self.createdAt = try c.decodeIfPresent(Int.self, forKey: .createdAt) + self.lastUsed = try c.decodeIfPresent(Int.self, forKey: .lastUsed) + self.enabled = try c.decodeIfPresent(Int.self, forKey: .enabled) + + if let arr = try? c.decodeIfPresent([String].self, forKey: .ipCidrs) { + self.ipCidrs = arr + } else if let s = try c.decodeIfPresent(String.self, forKey: .ipCidrs) { + let trimmed = s.split(separator: ",").map { String($0) }.filter { !$0.isEmpty } + self.ipCidrs = trimmed.isEmpty ? nil : trimmed + } else { + self.ipCidrs = nil + } + } +} + +/// Wire envelope for `GET /admin/tokens`. +struct TokenList: Codable { + let tokens: [AppToken] +} + +// MARK: - JSONValue + +/// Heterogeneous JSON value used for ``RunResult/result``. +/// +/// `claude -p --output-format json` returns either parsed JSON (object, +/// array, scalar) or a plain string when the model's reply wasn't valid +/// JSON. ``JSONValue`` represents both shapes losslessly. +/// +/// Narrowing example: +/// ```swift +/// if case .object(let dict) = res.result, +/// case .string(let hello) = dict["hello"] { +/// print(hello) +/// } +/// ``` +public enum JSONValue: Codable, Sendable, Equatable { + case object([String: JSONValue]) + case array([JSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { + self = .null + } else if let b = try? c.decode(Bool.self) { + self = .bool(b) + } else if let n = try? c.decode(Double.self) { + self = .number(n) + } else if let s = try? c.decode(String.self) { + self = .string(s) + } else if let arr = try? c.decode([JSONValue].self) { + self = .array(arr) + } else if let obj = try? c.decode([String: JSONValue].self) { + self = .object(obj) + } else { + throw DecodingError.dataCorruptedError( + in: c, + debugDescription: "JSONValue: unsupported JSON shape" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .null: try c.encodeNil() + case .bool(let b): try c.encode(b) + case .number(let n): try c.encode(n) + case .string(let s): try c.encode(s) + case .array(let a): try c.encode(a) + case .object(let o): try c.encode(o) + } + } + + // MARK: Convenience accessors + + /// Returns the string payload if this is `.string`, else `nil`. + public var stringValue: String? { + if case .string(let s) = self { return s } + return nil + } + + /// Returns the numeric payload if this is `.number`, else `nil`. + public var numberValue: Double? { + if case .number(let n) = self { return n } + return nil + } + + /// Returns the boolean payload if this is `.bool`, else `nil`. + public var boolValue: Bool? { + if case .bool(let b) = self { return b } + return nil + } + + /// Returns the array payload if this is `.array`, else `nil`. + public var arrayValue: [JSONValue]? { + if case .array(let a) = self { return a } + return nil + } + + /// Returns the object payload if this is `.object`, else `nil`. + public var objectValue: [String: JSONValue]? { + if case .object(let o) = self { return o } + return nil + } +} diff --git a/clients/swift/Tests/ClawdforgeTests/ForgeClientTests.swift b/clients/swift/Tests/ClawdforgeTests/ForgeClientTests.swift new file mode 100644 index 0000000..2e34ed5 --- /dev/null +++ b/clients/swift/Tests/ClawdforgeTests/ForgeClientTests.swift @@ -0,0 +1,373 @@ +// ForgeClientTests.swift +// +// XCTest suite for ForgeClient. All HTTP I/O is stubbed via URLProtocol so +// tests run hermetically (no network, no clawdforge required) on macOS, +// iOS simulator, and Linux via swift-corelibs-foundation. + +import XCTest +@testable import Clawdforge + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - URLProtocol stub + +/// Minimal `URLProtocol` subclass for canned responses + assertions on the +/// outgoing request. Set ``handler`` before each test; the handler is +/// called once per request and returns `(HTTPURLResponse, Data)`. +/// +/// Test-only — Swift 5.9 strict-concurrency does not flag these statics +/// when the package is built at language mode 5. +final class URLProtocolMock: URLProtocol, @unchecked Sendable { + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var lastRequest: URLRequest? + static var lastBody: Data? + + static func reset() { + handler = nil + lastRequest = nil + lastBody = nil + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + // Capture the body. URLProtocol receives it via either httpBody or + // httpBodyStream, depending on whether `upload(for:fromFile:)` or + // `data(for:)` was used. + Self.lastRequest = request + if let body = request.httpBody { + Self.lastBody = body + } else if let stream = request.httpBodyStream { + stream.open() + defer { stream.close() } + var collected = Data() + let bufSize = 64 * 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufSize) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let n = stream.read(buffer, maxLength: bufSize) + if n <= 0 { break } + collected.append(buffer, count: n) + } + Self.lastBody = collected + } else { + Self.lastBody = nil + } + + guard let h = Self.handler else { + client?.urlProtocol( + self, + didFailWithError: URLError(.badServerResponse) + ) + return + } + do { + let (resp, data) = try h(request) + client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +// MARK: - Helpers + +private func makeClient() -> ForgeClient { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [URLProtocolMock.self] + let session = URLSession(configuration: config) + return ForgeClient( + baseURL: URL(string: "http://forge.test")!, + token: "cf_test_abcdef", + session: session + ) +} + +private func makeResponse( + url: URL, + status: Int, + headers: [String: String] = ["Content-Type": "application/json"] +) -> HTTPURLResponse { + HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers)! +} + +// MARK: - Tests + +final class ForgeClientTests: XCTestCase { + + override func setUp() { + super.setUp() + URLProtocolMock.reset() + } + + override func tearDown() { + URLProtocolMock.reset() + super.tearDown() + } + + // 1. Healthz + func testHealthzDecodesAndOmitsAuthHeader() async throws { + URLProtocolMock.handler = { req in + XCTAssertEqual(req.url?.path, "/healthz") + XCTAssertEqual(req.httpMethod, "GET") + // Auth header is sent as-is — server's healthz simply ignores it. + // We just verify the request is well-formed. + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer cf_test_abcdef") + let body = #"{"ok": true, "claude_present": true, "claude_version": "1.2.3"}"# + return (makeResponse(url: req.url!, status: 200), Data(body.utf8)) + } + let h = try await makeClient().healthz() + XCTAssertTrue(h.ok) + XCTAssertTrue(h.claudePresent) + XCTAssertEqual(h.claudeVersion, "1.2.3") + } + + // 2. Run — JSON object result + func testRunObjectResult() async throws { + URLProtocolMock.handler = { req in + XCTAssertEqual(req.url?.path, "/run") + XCTAssertEqual(req.httpMethod, "POST") + XCTAssertEqual(req.value(forHTTPHeaderField: "Content-Type"), "application/json") + + // Verify the body uses snake_case keys. + let body = URLProtocolMock.lastBody ?? Data() + let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] + XCTAssertEqual(json["prompt"] as? String, "Reply with JSON") + XCTAssertEqual(json["timeout_secs"] as? Int, 60) + + let resp = #""" + { + "ok": true, + "result": {"hello": "world", "n": 42, "ok": true}, + "duration_ms": 1234, + "stop_reason": "end_turn" + } + """# + return (makeResponse(url: req.url!, status: 200), Data(resp.utf8)) + } + + let res = try await makeClient().run(RunRequest( + prompt: "Reply with JSON", + model: "sonnet", + timeoutSecs: 60 + )) + XCTAssertEqual(res.durationMs, 1234) + XCTAssertEqual(res.stopReason, "end_turn") + + guard case .object(let dict) = res.result else { + return XCTFail("expected object") + } + XCTAssertEqual(dict["hello"]?.stringValue, "world") + XCTAssertEqual(dict["n"]?.numberValue, 42) + XCTAssertEqual(dict["ok"]?.boolValue, true) + } + + // 3. Run — string fallback when claude returned non-JSON + func testRunStringResult() async throws { + URLProtocolMock.handler = { req in + let resp = #""" + {"ok": true, "result": "hello world", "duration_ms": 10, "stop_reason": "end_turn"} + """# + return (makeResponse(url: req.url!, status: 200), Data(resp.utf8)) + } + let res = try await makeClient().run(RunRequest(prompt: "say hi")) + XCTAssertEqual(res.result.stringValue, "hello world") + } + + // 4. Run — empty prompt rejected client-side + func testRunEmptyPromptRejected() async { + do { + _ = try await makeClient().run(RunRequest(prompt: "")) + XCTFail("expected ForgeError.invalidArgument") + } catch ForgeError.invalidArgument { + // expected + } catch { + XCTFail("wrong error: \(error)") + } + } + + // 5. Run — 502 surfaces as .api with parseable RunFailure body + func testRun502SurfacesAsAPIError() async throws { + URLProtocolMock.handler = { req in + let body = #""" + {"ok": false, "error": "claude exited 1", "stderr": "boom", "duration_ms": 9, "stop_reason": "error"} + """# + return (makeResponse(url: req.url!, status: 502), Data(body.utf8)) + } + do { + _ = try await makeClient().run(RunRequest(prompt: "x")) + XCTFail("expected throw") + } catch ForgeError.api(let code, let body) { + XCTAssertEqual(code, 502) + let data = Data(body.utf8) + let failure = try JSONDecoder().decode(RunFailure.self, from: data) + XCTAssertEqual(failure.error, "claude exited 1") + XCTAssertEqual(failure.stderr, "boom") + } catch { + XCTFail("wrong error: \(error)") + } + } + + // 6. Auth — 401 maps to ForgeError.auth + func testAuthErrorOn401() async { + URLProtocolMock.handler = { req in + let body = #"{"detail":"bad token"}"# + return (makeResponse(url: req.url!, status: 401), Data(body.utf8)) + } + do { + _ = try await makeClient().run(RunRequest(prompt: "x")) + XCTFail("expected throw") + } catch ForgeError.auth(let code, _) { + XCTAssertEqual(code, 401) + } catch { + XCTFail("wrong error: \(error)") + } + } + + // 7. Admin — create token + func testCreateTokenReturnsPlaintext() async throws { + URLProtocolMock.handler = { req in + XCTAssertEqual(req.url?.path, "/admin/tokens") + XCTAssertEqual(req.httpMethod, "POST") + + let body = URLProtocolMock.lastBody ?? Data() + let json = try JSONSerialization.jsonObject(with: body) as! [String: Any] + XCTAssertEqual(json["name"] as? String, "cauldron") + XCTAssertEqual(json["ip_cidrs"] as? [String], ["10.0.0.0/8"]) + + let resp = #""" + {"name": "cauldron", "token": "cf_NEW_xyz", "ip_cidrs": ["10.0.0.0/8"]} + """# + return (makeResponse(url: req.url!, status: 200), Data(resp.utf8)) + } + let tk = try await makeClient().createToken( + CreateTokenRequest(name: "cauldron", ipCidrs: ["10.0.0.0/8"]) + ) + XCTAssertEqual(tk.name, "cauldron") + XCTAssertEqual(tk.token, "cf_NEW_xyz") + XCTAssertEqual(tk.ipCidrs, ["10.0.0.0/8"]) + } + + // 8. Admin — list tokens (server returns ip_cidrs as comma-string) + func testListTokensHandlesStringCidrs() async throws { + URLProtocolMock.handler = { req in + XCTAssertEqual(req.url?.path, "/admin/tokens") + XCTAssertEqual(req.httpMethod, "GET") + let resp = #""" + { + "tokens": [ + {"name": "cauldron", "ip_cidrs": "10.0.0.0/8,172.24.0.0/16", "created_at": 1700000000, "last_used": null, "enabled": 1}, + {"name": "petalparse", "ip_cidrs": "", "created_at": 1700000001, "last_used": 1700000999, "enabled": 1} + ] + } + """# + return (makeResponse(url: req.url!, status: 200), Data(resp.utf8)) + } + let toks = try await makeClient().listTokens() + XCTAssertEqual(toks.count, 2) + XCTAssertEqual(toks[0].name, "cauldron") + XCTAssertEqual(toks[0].ipCidrs, ["10.0.0.0/8", "172.24.0.0/16"]) + XCTAssertNil(toks[1].ipCidrs) // empty string -> nil + } + + // 9. Admin — revoke token issues DELETE with URL-encoded name + func testRevokeTokenDelete() async throws { + URLProtocolMock.handler = { req in + XCTAssertEqual(req.httpMethod, "DELETE") + XCTAssertTrue(req.url!.path.hasPrefix("/admin/tokens/")) + return (makeResponse(url: req.url!, status: 200), Data(#"{"ok": true}"#.utf8)) + } + try await makeClient().revokeToken(name: "cauldron") + XCTAssertEqual(URLProtocolMock.lastRequest?.url?.path, "/admin/tokens/cauldron") + } + + // 10. Upload — multipart body wraps the file with ttl_secs field + func testUploadFileMultipart() async throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("clawdforge-test-\(UUID().uuidString).txt") + try Data("hello upload".utf8).write(to: tmp) + defer { try? FileManager.default.removeItem(at: tmp) } + + URLProtocolMock.handler = { req in + XCTAssertEqual(req.url?.path, "/files") + XCTAssertEqual(req.httpMethod, "POST") + let ctype = req.value(forHTTPHeaderField: "Content-Type") ?? "" + XCTAssertTrue(ctype.hasPrefix("multipart/form-data; boundary=")) + + let body = URLProtocolMock.lastBody ?? Data() + let bodyStr = String(data: body, encoding: .utf8) ?? "" + XCTAssertTrue(bodyStr.contains("name=\"ttl_secs\"")) + XCTAssertTrue(bodyStr.contains("7200")) + XCTAssertTrue(bodyStr.contains("name=\"file\"")) + XCTAssertTrue(bodyStr.contains("hello upload")) + + let resp = #""" + {"file_token": "ff_abc123", "ttl_secs": 7200, "size": 12} + """# + return (makeResponse(url: req.url!, status: 200), Data(resp.utf8)) + } + + let ft = try await makeClient().uploadFile(at: tmp, ttlSecs: 7200) + XCTAssertEqual(ft.fileToken, "ff_abc123") + XCTAssertEqual(ft.ttlSecs, 7200) + XCTAssertEqual(ft.size, 12) + } + + // 11. JSONValue — round-trips array/null/nested + func testJSONValueRoundTrip() throws { + let json = #""" + { + "result": [1, "two", null, {"k": true}, [false, 3.14]] + } + """# + struct W: Codable { let result: JSONValue } + let decoded = try JSONDecoder().decode(W.self, from: Data(json.utf8)) + guard case .array(let arr) = decoded.result else { + return XCTFail("expected array") + } + XCTAssertEqual(arr.count, 5) + XCTAssertEqual(arr[0].numberValue, 1) + XCTAssertEqual(arr[1].stringValue, "two") + if case .null = arr[2] {} else { XCTFail("expected null") } + XCTAssertEqual(arr[3].objectValue?["k"]?.boolValue, true) + XCTAssertEqual(arr[4].arrayValue?[1].numberValue, 3.14) + + // re-encode and ensure stable shape + let re = try JSONEncoder().encode(decoded) + let reDecoded = try JSONDecoder().decode(W.self, from: re) + XCTAssertEqual(reDecoded, decoded) + } + + // 12. Cancellation propagates from Task to URLSession + func testCancellationThrows() async { + URLProtocolMock.handler = { _ in + // Block "forever" so the cancel path is the only way out. We + // simulate by throwing a cancellation-like URLError if the test + // harness doesn't tear down quickly enough. + Thread.sleep(forTimeInterval: 0.5) + throw URLError(.timedOut) + } + let client = makeClient() + let task = Task { + try await client.run(RunRequest(prompt: "x")) + } + task.cancel() + do { + _ = try await task.value + XCTFail("expected throw") + } catch is CancellationError { + // hit the early Task.checkCancellation in send(_:decode:) + } catch ForgeError.transport { + // hit URLSession's cancellation pathway + } catch { + XCTFail("wrong error: \(error)") + } + } +}