Files
oai-swift/oAI/Services/CLIServerService.swift
T
rune ef6265c1c5 Fix actor-isolation warning in CLIServerService's connection handler
sendAndClose is main-actor-isolated (implicit under -default-isolation
MainActor), but the Task in readRequest's Network.framework callback
runs nonisolated (DisableOutwardActorInference means it no longer
inherits the class's actor from lexical context). Calling sendAndClose
without hopping onto the actor is a hard error in Swift 6 mode.
2026-08-11 10:45:17 +02:00

248 lines
10 KiB
Swift

//
// CLIServerService.swift
// Confab
//
// Local Unix-socket server for one-shot shell/CLI access to a single fixed model
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import Network
import os
/// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a
/// single fixed model (Settings → MCP → CLI Access) — e.g. an `ai "prompt"` shell function.
/// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override.
/// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom
/// protocol, so it stays curl-friendly and trivially extensible — new fields can be added to the
/// JSON request/response bodies later without changing the transport.
final class CLIServerService {
static let shared = CLIServerService()
private init() {}
private let log = Logger(subsystem: Log.subsystem, category: "cli")
/// All listener/connection callbacks run on this single serial queue, so `activeConnections`
/// never needs a lock — Network.framework callbacks don't run on the main thread.
private let queue = DispatchQueue(label: "com.oai.Confab.cliserver")
private var listener: NWListener?
private var activeConnections: [ObjectIdentifier: NWConnection] = [:]
static let socketPath: String = {
(("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString)
.appendingPathComponent("cli.sock")
}()
// MARK: - Lifecycle
func start() {
guard SettingsService.shared.cliServerEnabled else {
log.info("CLI server disabled")
return
}
startListening()
}
func stop() {
listener?.cancel()
listener = nil
for (_, connection) in activeConnections { connection.cancel() }
activeConnections.removeAll()
try? FileManager.default.removeItem(atPath: Self.socketPath)
log.info("CLI server stopped")
}
/// Call after toggling cliServerEnabled or changing the provider/model, so the change takes
/// effect immediately instead of requiring an app relaunch.
func restart() {
stop()
start()
}
private func startListening() {
// Remove a stale socket file left behind by an unclean shutdown (bind() fails on an
// existing path even if nothing is listening on it anymore).
try? FileManager.default.removeItem(atPath: Self.socketPath)
let params = NWParameters()
params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options()
params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath)
params.allowLocalEndpointReuse = true
do {
let newListener = try NWListener(using: params)
newListener.newConnectionHandler = { [weak self] connection in
self?.handle(connection: connection)
}
newListener.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
self?.log.info("CLI server listening at \(Self.socketPath)")
case .failed(let error):
self?.log.error("CLI server listener failed: \(error.localizedDescription)")
default:
break
}
}
newListener.start(queue: queue)
listener = newListener
} catch {
log.error("Failed to start CLI server: \(error.localizedDescription)")
}
}
// MARK: - Connection handling
private func handle(connection: NWConnection) {
let id = ObjectIdentifier(connection)
activeConnections[id] = connection
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .failed, .cancelled:
self?.activeConnections.removeValue(forKey: id)
default:
break
}
}
connection.start(queue: queue)
readRequest(on: connection, buffer: Data())
}
private func readRequest(on connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else { return }
var buffer = buffer
if let data { buffer.append(data) }
if let body = Self.parseRequestBody(from: buffer) {
Task {
let responseData = await self.processRequest(body)
await self.sendAndClose(responseData, on: connection)
}
return
}
if isComplete || error != nil {
connection.cancel()
return
}
self.readRequest(on: connection, buffer: buffer)
}
}
private func sendAndClose(_ data: Data, on connection: NWConnection) {
let id = ObjectIdentifier(connection)
connection.send(content: data, completion: .contentProcessed { [weak self] _ in
connection.cancel()
self?.activeConnections.removeValue(forKey: id)
})
}
// MARK: - Request processing
private struct AskRequest: Decodable {
let prompt: String
}
private func processRequest(_ body: Data) async -> Data {
guard let req = try? JSONDecoder().decode(AskRequest.self, from: body),
!req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}")
}
let settings = SettingsService.shared
guard settings.cliServerEnabled else {
return Self.errorResponse("CLI server is disabled in Settings")
}
guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access")
}
guard !settings.cliServerModel.isEmpty else {
return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access")
}
do {
let request = ChatRequest(
messages: [Message(role: .user, content: req.prompt)],
model: settings.cliServerModel,
stream: false
)
let response = try await provider.chat(request: request)
return Self.successResponse(response.content)
} catch {
log.error("CLI request failed: \(error.localizedDescription)")
return Self.errorResponse(error.localizedDescription, statusCode: 500)
}
}
// MARK: - Pure helpers (unit tested — see CLIServerServiceTests.swift)
struct AskResponseBody: Codable {
let response: String?
let error: String?
}
/// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full
/// header block and (per Content-Length) body have arrived, or nil if more data is needed.
/// Not a general-purpose HTTP parser — this server only ever talks to `curl --unix-socket`,
/// so the method/path/most headers are read past rather than validated.
static func parseRequestBody(from buffer: Data) -> Data? {
let headerTerminator = Data("\r\n\r\n".utf8)
guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil }
let headerData = buffer.subdata(in: buffer.startIndex..<headerEndRange.lowerBound)
guard let headerString = String(data: headerData, encoding: .utf8) else { return nil }
var contentLength = 0
for line in headerString.split(separator: "\r\n") {
let parts = line.split(separator: ":", maxSplits: 1)
guard parts.count == 2,
parts[0].trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("Content-Length") == .orderedSame,
let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) else { continue }
contentLength = value
}
let bodyStart = headerEndRange.upperBound
let availableBodyLength = buffer.distance(from: bodyStart, to: buffer.endIndex)
guard availableBodyLength >= contentLength else { return nil }
let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength)
return buffer.subdata(in: bodyStart..<bodyEnd)
}
static func makeHTTPResponse(statusCode: Int, statusText: String, jsonBody: Data) -> Data {
let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n"
var data = Data(header.utf8)
data.append(jsonBody)
return data
}
static func successResponse(_ text: String) -> Data {
let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data()
return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body)
}
static func errorResponse(_ message: String, statusCode: Int = 400) -> Data {
let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data()
let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error"
return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body)
}
}