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.
505 lines
20 KiB
Swift
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 }
|
|
}
|