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.
248 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|