clawdforge/clients/swift/Sources/Clawdforge/ForgeClient.swift
Kayos fabc782c09 clients/swift: fileprivate → internal on URLSession bridge helpers
Sessions.swift is a separate file from ForgeClient.swift but in the same
module — fileprivate blocked the cross-file call. internal (default) is
the correct visibility: same-module accessible, not part of public API.

Also dropped the unused @preconcurrency on Sessions.swift's Foundation
imports — that file doesn't reference Sendable-warning-emitting types
directly (URLSession etc), so the attribute was a no-op generating
remarks. Kept @preconcurrency on ForgeClient.swift where it actually
suppresses URL/URLSession/JSONEncoder/JSONDecoder Sendable warnings.

Caught by crafting-table queue (job 77012573) — first real dogfood of
the build farm. exit_1, log surfaced the inaccessibility error in 6s.
2026-04-29 13:44:57 -07:00

505 lines
20 KiB
Swift

// 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. As of Swift 5.9.2 the
// async `URLSession.data(for:)` / `URLSession.upload(for:fromFile:)` are
// **only** on Apple platforms (macOS 12+, iOS 15+, etc.) Linux's
// FoundationNetworking exposes the callback-style URLSession API only.
// The `forgeData(for:)` / `forgeUpload(for:fromFile:)` helpers below
// bridge the callback API with `withCheckedThrowingContinuation` on Linux
// and forward to the native async API on Apple.
//
// `@preconcurrency` on the Foundation imports suppresses Sendable warnings
// for URL / URLSession / JSONEncoder / JSONDecoder which are not declared
// Sendable on swift-corelibs-foundation; the SDK still preserves Sendable
// semantics by the way it uses them (one shared session, immutable
// captured fields).
@preconcurrency import Foundation
#if canImport(FoundationNetworking)
@preconcurrency import FoundationNetworking
#endif
// MARK: - URLSession Linux/Apple bridge -----------------------------------
extension URLSession {
/// Async `(Data, URLResponse)` for a request uses the native API on
/// Apple, bridges the callback API on Linux. Behaves identically.
internal func forgeData(for request: URLRequest) async throws -> (Data, URLResponse) {
#if canImport(FoundationNetworking)
return try await withCheckedThrowingContinuation { cont in
let task = self.dataTask(with: request) { data, response, error in
if let error { cont.resume(throwing: error); return }
guard let data, let response else {
cont.resume(throwing: URLError(.unknown)); return
}
cont.resume(returning: (data, response))
}
task.resume()
}
#else
return try await self.data(for: request)
#endif
}
/// Async upload from a file uses the native API on Apple, bridges
/// the callback API on Linux.
internal func forgeUpload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) {
#if canImport(FoundationNetworking)
return try await withCheckedThrowingContinuation { cont in
let task = self.uploadTask(with: request, fromFile: fileURL) { data, response, error in
if let error { cont.resume(throwing: error); return }
guard let data, let response else {
cont.resume(throwing: URLError(.unknown)); return
}
cont.resume(returning: (data, response))
}
task.resume()
}
#else
return try await self.upload(for: request, fromFile: fileURL)
#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 <token>` 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")!`. **Must be host-only**
/// (scheme + host [+ optional port]). A non-empty path, query, or
/// fragment is rejected with ``ForgeError/invalidArgument(_:)``
/// the SDK builds request URLs as `baseURL.absoluteString + "/path"`
/// so e.g. `http://x/api` would silently produce malformed URLs.
/// - 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.
/// - Throws: ``ForgeError/invalidArgument(_:)`` if `baseURL` carries a
/// path, query, or fragment.
public init(
baseURL: URL,
token: String,
session: URLSession = .shared
) throws {
// Trim a trailing slash so path joins are predictable.
var s = baseURL.absoluteString
while s.hasSuffix("/") { s.removeLast() }
let trimmed = URL(string: s) ?? baseURL
// Reject baseURLs that carry their own path/query/fragment. The SDK
// constructs request URLs as `baseURL.absoluteString + "/path"` and
// a non-empty trailing path on baseURL would silently break that.
if !trimmed.path.isEmpty || trimmed.query != nil || trimmed.fragment != nil {
throw ForgeError.invalidArgument(
"baseURL must be host-only (scheme + host [+ port]); got \(baseURL.absoluteString)"
)
}
self.baseURL = trimmed
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.forgeUpload(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.
///
/// `name` must match the server-side constraint `^[a-z0-9_-]{1,64}$`
/// (lowercase alphanumerics, hyphen, underscore). Anything else
/// including path-traversal sequences like `foo/../bar` is rejected
/// client-side with ``ForgeError/invalidArgument(_:)``. This is stricter
/// than `.urlPathAllowed`, which leaves `/`, `+`, `;`, `=`, `,`, `@`
/// unescaped.
///
/// 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")
}
guard Self.isValidTokenName(name) else {
throw ForgeError.invalidArgument(
"revokeToken: name must match [a-z0-9_-]{1,64}; got \"\(name)\""
)
}
let req = try makeRequest(method: "DELETE", path: "/admin/tokens/\(name)")
_ = try await sendVoid(req)
}
/// Server-side constraint: `^[a-z0-9_-]{1,64}$`. Cheap manual scan to
/// avoid pulling in `NSRegularExpression` for one tiny check.
static func isValidTokenName(_ name: String) -> Bool {
guard (1...64).contains(name.count) else { return false }
for ch in name.unicodeScalars {
switch ch {
case "a"..."z", "0"..."9", "-", "_":
continue
default:
return false
}
}
return true
}
// 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<Body: Encodable>(
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<T: Decodable>(_ req: URLRequest, decode: T.Type) async throws -> T {
try Task.checkCancellation()
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.forgeData(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.forgeData(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<T: Decodable>(_ 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.
///
/// All line terminators are explicit `\r\n` (CRLF) per RFC 7578. Swift's
/// `""""""` multi-line literals use bare LF for source newlines, which
/// would inject a stray `\n` between the headers `\r\n\r\n` separator
/// and the file content corrupting any binary upload (PNG, PDF, JPEG)
/// because the receiver would treat that `\n` as the first byte of the
/// part body. We use plain string concatenation here so every byte on
/// the wire is auditable.
///
/// The temp file is created with 0o600 perms the staged body holds
/// the user's plaintext bearer token via the `Authorization` header on
/// the live request, but we still don't want world-readable copies of
/// arbitrary user uploads sitting in `/tmp`.
private func writeMultipartBody(
to dest: URL,
boundary: String,
ttlSecs: Int,
fileURL: URL
) throws {
FileManager.default.createFile(
atPath: dest.path,
contents: nil,
attributes: [.posixPermissions: 0o600]
)
guard let out = FileHandle(forWritingAtPath: dest.path) else {
throw ForgeError.invalidArgument("cannot open temp upload at \(dest.path)")
}
defer { try? out.close() }
let CRLF = "\r\n"
// ttl_secs field explicit CRLFs everywhere.
let ttlPart =
"--" + boundary + CRLF
+ "Content-Disposition: form-data; name=\"ttl_secs\"" + CRLF
+ CRLF
+ String(ttlSecs) + CRLF
try out.write(contentsOf: Data(ttlPart.utf8))
// file field header explicit CRLFs everywhere. The blank line
// between headers and body is a single CRLF; together with the CRLF
// that ends `Content-Type:` it forms the required `\r\n\r\n`
// separator.
let filename = fileURL.lastPathComponent
let mime = mimeType(for: fileURL)
let fileHeader =
"--" + boundary + CRLF
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + escapeFilename(filename) + "\"" + CRLF
+ "Content-Type: " + mime + CRLF
+ CRLF
try out.write(contentsOf: Data(fileHeader.utf8))
// file body chunked. Cooperative cancellation inside the loop so
// a multi-GB upload aborts quickly when the parent Task is cancelled.
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 {
try Task.checkCancellation()
let chunk = input.readData(ofLength: chunkSize)
if chunk.isEmpty { break }
try out.write(contentsOf: chunk)
}
// closing boundary leading CRLF terminates the file part body,
// then the dash-boundary-dash sequence + final CRLF.
let trailer = CRLF + "--" + boundary + "--" + CRLF
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: " ")
}
}
// MARK: - Redacted reflection
//
// The default `String(reflecting:)` mirror walks every stored property and
// would surface the bearer token in plain text via `print(client)`,
// `String(describing: client)`, SwiftUI `Text("\(client)")`, and any logging
// framework that calls `String(reflecting:)`. Override to redact.
extension ForgeClient: CustomStringConvertible, CustomDebugStringConvertible {
public var description: String {
"ForgeClient(baseURL: \(baseURL), token: <redacted>)"
}
public var debugDescription: String { description }
}