clawdforge/clients/swift
Kayos 7e878e6f45 clients/swift: apply audit findings — multipart fix + token redaction (e4e8192 → HEAD)
P1 (release blocker):
- multipart now RFC 7578 compliant (was injecting bare LF before file
  content via Swift """...""" multi-line literals; corrupted binary
  uploads — PNG/PDF/JPEG). Body now built via explicit "\r\n"
  concatenation so every byte on the wire is auditable.

P2:
- CustomStringConvertible redacts token on ForgeClient + AppToken
  (default mirror was leaking plaintext via print / String(reflecting:)
  / SwiftUI string interpolation).
- revokeToken now pre-validates name against ^[a-z0-9_-]{1,64}$ and
  rejects path-traversal sequences with ForgeError.invalidArgument
  before percent-encoding (urlPathAllowed left /, +, ;, =, ,, @
  unescaped).
- baseURL with non-empty path/query/fragment rejected at construct.
  init is now `throws` — host-only URLs only, since the SDK builds
  request URLs by string concatenation.

P3:
- Fixed misleading "custom encoding" comment on RunRequest (it's just
  Optional + JSONEncoder default behavior).
- public init on RunFailure (was decode-only).
- Task.checkCancellation() inside the multipart chunk loop — multi-GB
  uploads now abort promptly when the parent Task is cancelled.
- 0o600 perms on the staged temp upload file (was inheriting umask,
  typically 0o644 — unwanted in multi-tenant /tmp).
- Documented JSONValue.number Double precision limit (loses precision
  for ints > 2^53).

Tests:
- testMultipartIsCRLFCompliant: writes a PNG-signature payload, scans
  the captured body for the `\r\n\n` bare-LF pattern AND verifies the
  bytes after `Content-Type: image/png\r\n\r\n` match the payload
  exactly.
- testForgeClientDescriptionRedactsToken
- testAppTokenDescriptionRedactsToken (covers both nil and non-nil
  token cases)
- testRevokeTokenRejectsTraversalName: foo/../bar, FOO, spaces, +, ;,
  =, @, 65-char names, empty
- testBaseURLWithPathRejected: /api, /v1, ?query, #fragment; host-only
  variants still accepted
- testRunFailurePublicInit
- testTempFilePerms: scans /tmp during the in-flight upload to verify
  the staged clawdforge-upload-* file is 0o600
- Existing tests updated for the now-throwing init.

README + Examples updated for the throwing init.

Audit: memory/clawdforge-audits/swift-e4e8192.md

Note: untested locally — Swift toolchain not present in this sandbox.
Needs `swift build -c release` + `swift test` verification on a Swift
5.9+ host (macOS or Linux) before tagging the next release.
2026-04-28 23:12:17 -07:00
..
Examples/Basic clients/swift: apply audit findings — multipart fix + token redaction (e4e8192 → HEAD) 2026-04-28 23:12:17 -07:00
Sources/Clawdforge clients/swift: apply audit findings — multipart fix + token redaction (e4e8192 → HEAD) 2026-04-28 23:12:17 -07:00
Tests/ClawdforgeTests clients/swift: apply audit findings — multipart fix + token redaction (e4e8192 → HEAD) 2026-04-28 23:12:17 -07:00
Package.swift clients/swift: initial Swift SDK for clawdforge 2026-04-28 22:48:27 -07:00
README.md clients/swift: apply audit findings — multipart fix + token redaction (e4e8192 → HEAD) 2026-04-28 23:12:17 -07:00

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:

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

import Clawdforge

// `baseURL` must be host-only — scheme + host (+ port). A path/query/fragment
// is rejected at construct with `ForgeError.invalidArgument`.
let client = try 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.

public struct ForgeClient: Sendable {
    public init(baseURL: URL, token: String, session: URLSession = .shared) throws

    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

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.

let admin = try 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.

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:

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.

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.

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.

swift build -c release
swift test

Tests

Unit tests use a URLProtocol stub — no network, no clawdforge instance required. Run:

swift test

License

MIT.