96 lines
5.0 KiB
Swift
96 lines
5.0 KiB
Swift
import Foundation
|
|
|
|
protocol AgentSocket: Sendable {
|
|
func receive() async throws -> Data
|
|
func send(_ text: String) async throws
|
|
func close() async
|
|
func closeCode() async -> Int
|
|
}
|
|
|
|
protocol AgentAPIClienting: Sendable {
|
|
func agentDescriptor() async throws -> AgentDescriptor
|
|
func agentMessages(after: String?, before: String?) async throws -> AgentPage
|
|
func agentReadState() async throws -> AgentReadState
|
|
func agentMarkRead(cursor: String) async throws -> AgentReadState
|
|
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState
|
|
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
|
|
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
|
|
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
|
|
func agentDownload(_ attachment: AgentAttachment) async throws -> Data
|
|
func agentSocket(after: String) async throws -> any AgentSocket
|
|
}
|
|
|
|
actor AgentURLSocket: AgentSocket {
|
|
private let task: URLSessionWebSocketTask
|
|
init(task: URLSessionWebSocketTask) { self.task = task }
|
|
func receive() async throws -> Data {
|
|
switch try await task.receive() {
|
|
case let .string(text): return Data(text.utf8)
|
|
case .data: throw AgentFailure(message: "Agent sent a non-JSON text frame.")
|
|
@unknown default: throw AgentFailure(message: "Unsupported Agent frame.")
|
|
}
|
|
}
|
|
func send(_ text: String) async throws { try await task.send(.string(text)) }
|
|
func close() { task.cancel(with: .goingAway, reason: nil) }
|
|
func closeCode() -> Int { task.closeCode.rawValue }
|
|
}
|
|
|
|
extension APIConfiguration {
|
|
func agentWebSocketURL() throws -> URL {
|
|
guard var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
|
|
["http", "https"].contains(parts.scheme), parts.host != nil,
|
|
parts.user == nil, parts.password == nil else { throw APIError.invalidBaseURL }
|
|
parts.scheme = parts.scheme == "https" ? "wss" : "ws"
|
|
parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
|
|
? "/v1/agent/ws" : "/" + parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/agent/ws"
|
|
parts.query = nil
|
|
parts.fragment = nil
|
|
guard let url = parts.url else { throw APIError.invalidBaseURL }
|
|
return url
|
|
}
|
|
func agentAuthFrame(after: String) throws -> String {
|
|
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let value: JSONValue = .object([
|
|
"type": .string("auth"),
|
|
"clientKind": .string("native"),
|
|
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
|
|
"after": .string(try AgentCursor.normalize(after))
|
|
])
|
|
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
|
|
}
|
|
}
|
|
|
|
// Text-only test/legacy clients fail closed for the new wire path.
|
|
extension AgentAPIClienting {
|
|
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
|
try await agentMarkRead(cursor: cursor)
|
|
}
|
|
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
|
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
|
|
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
|
|
}
|
|
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { throw AgentAttachmentIssue.unavailable }
|
|
func agentDownload(_ attachment: AgentAttachment) async throws -> Data { throw AgentAttachmentIssue.unavailable }
|
|
}
|
|
|
|
final class AgentAttachmentRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
|
|
// Reject every redirect; attachment credentials are valid only for the configured API.
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
|
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
|
|
extension APIConfiguration {
|
|
func agentAttachmentContentURL(id: String) throws -> URL {
|
|
guard !id.isEmpty, var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
|
|
["http", "https"].contains(parts.scheme), parts.host != nil, parts.user == nil, parts.password == nil,
|
|
let escaped = id.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")) else { throw APIError.invalidBaseURL }
|
|
let base = parts.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
parts.percentEncodedPath = (base.isEmpty ? "" : "/" + base) + "/v1/agent/attachments/" + escaped + "/content"
|
|
parts.query = nil; parts.fragment = nil
|
|
guard let url = parts.url else { throw APIError.invalidBaseURL }
|
|
return url
|
|
}
|
|
}
|