clawdforge/clients/swift/Sources/Clawdforge/Models.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

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
}
}