Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilAgentTransport.swift
T

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