Add local CLI access via Unix-socket server
New CLIServerService listens on a Unix domain socket (~/Library/Application Support/oAI/cli.sock) speaking a minimal HTTP/1.1 subset, for one-shot non-streaming shell access to a single fixed model — e.g. an `ai "prompt"` zsh function — without opening the app window and without going through the tool-calling loop. Configured in Settings > MCP > CLI Access (toggle, provider, model — deliberately independent of the chat UI's active model). JSON request/response envelope rather than raw text so new fields (model override, streaming, tool support) can be added later without a breaking wire-format change. Verified live end-to-end against a real OpenRouter request, error paths, and clean-shutdown socket cleanup. 10 new unit tests cover the HTTP framing/parsing logic.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// CLIServerServiceTests.swift
|
||||
// oAITests
|
||||
//
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Confab
|
||||
|
||||
@Suite("CLIServerService pure HTTP helpers")
|
||||
struct CLIServerServiceTests {
|
||||
|
||||
// MARK: - parseRequestBody
|
||||
|
||||
@Test("Returns the body once headers and full Content-Length body have arrived")
|
||||
func parsesCompleteRequest() {
|
||||
let body = "{\"prompt\":\"hi\"}"
|
||||
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||
}
|
||||
|
||||
@Test("Returns nil when the header block hasn't fully arrived yet")
|
||||
func returnsNilForIncompleteHeaders() {
|
||||
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5"
|
||||
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test("Returns nil when the body hasn't fully arrived yet")
|
||||
func returnsNilForIncompleteBody() {
|
||||
let raw = "POST / HTTP/1.1\r\nContent-Length: 20\r\n\r\n{\"prompt\":\"hi\"}"
|
||||
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
|
||||
}
|
||||
|
||||
@Test("Content-Length header name match is case-insensitive")
|
||||
func headerNameIsCaseInsensitive() {
|
||||
let body = "abc"
|
||||
let raw = "POST / HTTP/1.1\r\ncontent-length: 3\r\n\r\n\(body)"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||
}
|
||||
|
||||
@Test("Missing Content-Length is treated as a zero-length body")
|
||||
func missingContentLengthIsEmptyBody() {
|
||||
let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("Extra trailing bytes beyond Content-Length don't prevent extraction (pipelined data ignored)")
|
||||
func extraTrailingBytesStillExtractsBody() {
|
||||
let body = "abc"
|
||||
let raw = "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\n\(body)EXTRA"
|
||||
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
|
||||
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
|
||||
}
|
||||
|
||||
// MARK: - Response encoding
|
||||
|
||||
@Test("successResponse produces a 200 with the response field set")
|
||||
func successResponseShape() throws {
|
||||
let data = CLIServerService.successResponse("hello world")
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
#expect(text.hasPrefix("HTTP/1.1 200 OK\r\n"))
|
||||
#expect(text.contains("Content-Type: application/json"))
|
||||
|
||||
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
||||
let bodyJSON = Data(text[bodyStart...].utf8)
|
||||
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
||||
#expect(decoded.response == "hello world")
|
||||
#expect(decoded.error == nil)
|
||||
}
|
||||
|
||||
@Test("errorResponse defaults to 400 Bad Request")
|
||||
func errorResponseDefaultStatus() throws {
|
||||
let data = CLIServerService.errorResponse("bad input")
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
#expect(text.hasPrefix("HTTP/1.1 400 Bad Request\r\n"))
|
||||
|
||||
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
|
||||
let bodyJSON = Data(text[bodyStart...].utf8)
|
||||
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
|
||||
#expect(decoded.error == "bad input")
|
||||
#expect(decoded.response == nil)
|
||||
}
|
||||
|
||||
@Test("errorResponse supports a custom status code")
|
||||
func errorResponseCustomStatus() {
|
||||
let data = CLIServerService.errorResponse("provider failed", statusCode: 500)
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
#expect(text.hasPrefix("HTTP/1.1 500 Internal Server Error\r\n"))
|
||||
}
|
||||
|
||||
@Test("Content-Length in the response header matches the actual JSON body byte count")
|
||||
func responseContentLengthMatchesBody() {
|
||||
let data = CLIServerService.successResponse("hello")
|
||||
let text = String(data: data, encoding: .utf8)!
|
||||
let headerEnd = text.range(of: "\r\n\r\n")!.upperBound
|
||||
let bodyByteCount = Data(text[headerEnd...].utf8).count
|
||||
|
||||
let lengthLine = text.split(separator: "\r\n").first { $0.hasPrefix("Content-Length:") }!
|
||||
let declaredLength = Int(lengthLine.split(separator: " ")[1])!
|
||||
#expect(declaredLength == bodyByteCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user