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.
355 lines
12 KiB
Swift
355 lines
12 KiB
Swift
// 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. Optional fields are declared as Swift
|
|
/// `Optional`s so `JSONEncoder` omits them from the wire payload when `nil`,
|
|
/// matching the 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?
|
|
|
|
public init(
|
|
ok: Bool,
|
|
error: String,
|
|
stderr: String? = nil,
|
|
durationMs: Int? = nil,
|
|
stopReason: String? = nil
|
|
) {
|
|
self.ok = ok
|
|
self.error = error
|
|
self.stderr = stderr
|
|
self.durationMs = durationMs
|
|
self.stopReason = stopReason
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// AppToken redacts the plaintext `token` from `print` / `String(reflecting:)`
|
|
// / SwiftUI string interpolation. When `token` is non-nil (the create-token
|
|
// response) the default mirror would dump the secret straight to stdout.
|
|
extension AppToken: CustomStringConvertible, CustomDebugStringConvertible {
|
|
public var description: String {
|
|
let redacted = (token == nil) ? "nil" : "<redacted>"
|
|
return "AppToken(name: \(name), token: \(redacted), "
|
|
+ "ipCidrs: \(ipCidrs ?? []), createdAt: \(createdAt.map(String.init) ?? "nil"), "
|
|
+ "lastUsed: \(lastUsed.map(String.init) ?? "nil"), "
|
|
+ "enabled: \(enabled.map(String.init) ?? "nil"))"
|
|
}
|
|
public var debugDescription: String { description }
|
|
}
|
|
|
|
/// 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)
|
|
/// JSON number, represented as `Double`. Note: Swift's `Double` is IEEE
|
|
/// 754 binary64 with 53 bits of integer precision, so JSON integers
|
|
/// larger than `2^53` (≈ 9.007e15) will lose precision on decode. If the
|
|
/// server-side `result` ever contains 64-bit identifiers (e.g. snowflake
|
|
/// IDs, nanosecond timestamps) embed them as JSON strings rather than
|
|
/// numbers — the lossless `.string` case will preserve them verbatim.
|
|
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
|
|
}
|
|
}
|