Nucleic-Session: 44060009-FEE4-4E88-AD0B-F92AF480125C Co-authored-by: Nucleic <[email protected]>
1519 lines
79 KiB
Swift
1519 lines
79 KiB
Swift
import Foundation
|
|
import Network
|
|
|
|
/// Localhost HTTP MCP server hosted inside the app (ADAPTERS §1.2). Claude Code
|
|
/// is pointed at it via `--mcp-config` + `--permission-prompt-tool`; it exposes a
|
|
/// single tool `approve` whose call SUSPENDS until a human resolves the approval.
|
|
///
|
|
/// One server can serve all sessions: requests are authenticated by a per-session
|
|
/// bearer token, which keys the registered handler.
|
|
public actor MCPApprovalServer {
|
|
public struct ApprovalCall: Sendable {
|
|
public let toolName: String
|
|
public let input: JSONValue
|
|
public let toolUseID: String?
|
|
}
|
|
|
|
public enum Reply: Sendable {
|
|
case allow(updatedInput: JSONValue)
|
|
case deny(message: String)
|
|
|
|
/// The confirmed wire shape: a JSON-stringified `{behavior,…}` object
|
|
/// placed inside an MCP text content block — `behavior`/`message`/
|
|
/// `updatedInput`, NOT `decision`/`reason` (ADAPTERS §1.2 ✅).
|
|
public func wireJSON() -> String {
|
|
switch self {
|
|
case .allow(let updatedInput):
|
|
return JSONValue.object([
|
|
"behavior": .string("allow"), "updatedInput": updatedInput,
|
|
]).canonicalString()
|
|
case .deny(let message):
|
|
return JSONValue.object([
|
|
"behavior": .string("deny"), "message": .string(message),
|
|
]).canonicalString()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `check_conflict` tool call: the agent's task and the files/areas it expects to
|
|
/// touch. The handler suspends (like `approve`) until Nucleic decides + the user
|
|
/// answers, then returns a `ConflictReply` the agent reads to know how to proceed.
|
|
public struct ConflictCall: Sendable {
|
|
public let task: String
|
|
public let files: [String]
|
|
}
|
|
|
|
public struct ConflictReply: Sendable {
|
|
public let resolution: ConflictResolution
|
|
|
|
public init(resolution: ConflictResolution) { self.resolution = resolution }
|
|
|
|
/// Text-block JSON the agent reads. `conflict` is true unless there was no
|
|
/// overlap; `action` + `message` tell it exactly what to do.
|
|
public func wireJSON() -> String {
|
|
switch resolution {
|
|
case .noConflict:
|
|
return JSONValue.object(["conflict": .bool(false)]).canonicalString()
|
|
case .deferred:
|
|
return conflictJSON(
|
|
"deferred",
|
|
"This task was added to the Nucleic to-do list because it conflicts with "
|
|
+ "another active agent. Stop now and do NOT work on it.")
|
|
case .cancelled:
|
|
return conflictJSON(
|
|
"cancelled",
|
|
"The user cancelled this task because it conflicts with another active "
|
|
+ "agent. Stop now and do NOT work on it.")
|
|
case .proceed:
|
|
return conflictJSON(
|
|
"proceed",
|
|
"This task conflicts with another active agent, but the user chose to "
|
|
+ "proceed. You may continue.")
|
|
}
|
|
}
|
|
|
|
private func conflictJSON(_ action: String, _ message: String) -> String {
|
|
JSONValue.object([
|
|
"conflict": .bool(true),
|
|
"action": .string(action),
|
|
"message": .string(message),
|
|
]).canonicalString()
|
|
}
|
|
}
|
|
|
|
/// A `host_exec` tool call: the agent asks to run a shell command on the *host* machine,
|
|
/// outside the sandbox container (e.g. to build/run a macOS binary the Linux container
|
|
/// can't). The handler suspends (like `approve`) until the user approves and the host
|
|
/// command finishes, then returns its output. Always gated; never auto-approved.
|
|
public struct HostExecCall: Sendable {
|
|
public let command: String
|
|
/// The agent's stated justification for why this cannot run in the container. Required by
|
|
/// the tool schema and enforced (non-empty, non-boilerplate) at the choke point; surfaced
|
|
/// on the approval card as an *unverified claim* beside the command-derived summary.
|
|
public let reason: String
|
|
public init(command: String, reason: String = "") {
|
|
self.command = command
|
|
self.reason = reason
|
|
}
|
|
}
|
|
|
|
public enum HostExecReply: Sendable {
|
|
case denied(message: String)
|
|
case ran(exitCode: Int32, stdout: String, stderr: String)
|
|
|
|
/// Text-block JSON the agent reads back from the tool result.
|
|
public func wireJSON() -> String {
|
|
switch self {
|
|
case .denied(let message):
|
|
return JSONValue.object([
|
|
"denied": .bool(true), "message": .string(message),
|
|
]).canonicalString()
|
|
case .ran(let exitCode, let stdout, let stderr):
|
|
return JSONValue.object([
|
|
"exit_code": .number(Double(exitCode)),
|
|
"stdout": .string(stdout),
|
|
"stderr": .string(stderr),
|
|
]).canonicalString()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `mac_vm_exec` tool call: the agent asks to run a shell command inside its session's isolated
|
|
/// **macOS guest VM** (Apple Virtualization framework) — for Mac-only work like `xcodebuild`, an
|
|
/// `xcrun simctl` simulator run, or `codesign`. Unlike `host_exec` (which escapes onto the one
|
|
/// shared host), each session gets its own VM with its own Mac toolchain, so parallel Mac builds
|
|
/// don't thrash the one shared host. The handler suspends (like `approve`) until the user
|
|
/// approves and the in-VM command finishes, then returns its output. Always gated; never
|
|
/// auto-approved.
|
|
public struct MacVMExecCall: Sendable {
|
|
public let command: String
|
|
/// The agent's stated justification for why this needs the macOS VM (rather than the Linux
|
|
/// container). Required by the schema and enforced at the choke point; surfaced on the approval
|
|
/// card as an *unverified claim*.
|
|
public let reason: String
|
|
public init(command: String, reason: String = "") {
|
|
self.command = command
|
|
self.reason = reason
|
|
}
|
|
}
|
|
|
|
public enum MacVMExecReply: Sendable {
|
|
case denied(message: String)
|
|
case ran(exitCode: Int32, stdout: String, stderr: String)
|
|
|
|
/// Text-block JSON the agent reads back from the tool result (same shape as `host_exec`).
|
|
public func wireJSON() -> String {
|
|
switch self {
|
|
case .denied(let message):
|
|
return JSONValue.object([
|
|
"denied": .bool(true), "message": .string(message),
|
|
]).canonicalString()
|
|
case .ran(let exitCode, let stdout, let stderr):
|
|
return JSONValue.object([
|
|
"exit_code": .number(Double(exitCode)),
|
|
"stdout": .string(stdout),
|
|
"stderr": .string(stderr),
|
|
]).canonicalString()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `mac_vm_computer` tool call: one **computer-use action** against the session's macOS VM —
|
|
/// the agent driving the guest's GUI like its own Mac (Anthropic computer-use shape). The handler
|
|
/// performs the action in the guest over SSH and returns a fresh screenshot so the agent can see
|
|
/// the result. Runs inside the isolated, disposable VM, so — unlike `host_exec`/`mac_vm_exec` — it
|
|
/// is NOT per-action approval-gated (a per-click prompt would make a navigation loop unusable);
|
|
/// exposure is the opt-in boundary.
|
|
public struct MacVMComputerCall: Sendable {
|
|
/// The action verb: `screenshot`, `left_click`, `right_click`, `double_click`, `mouse_move`,
|
|
/// `left_click_drag`, `type`, `key`, `scroll`, `cursor_position`, `launch_app`, `wait`; plus
|
|
/// the AX-semantic actions when the guest runs the native agent
|
|
/// (docs/MACOS_VM_NATIVE_AGENT.md): `ax_dump`, `ax_element_at`, `ax_press`, `ax_set_value`,
|
|
/// `ax_focus`.
|
|
public let action: String
|
|
/// Target point (screen pixels; the screenshot is 1:1 so the agent targets what it sees).
|
|
public let x: Int?
|
|
public let y: Int?
|
|
/// Payload for `type` (literal text), `key` (a chord like `cmd+s`, `return`, `tab`),
|
|
/// `launch_app` (an application name), or `ax_press` (an optional AX action name to perform
|
|
/// instead of the default `AXPress`, e.g. `AXShowMenu`).
|
|
public let text: String?
|
|
/// For the `ax_*` element actions: the element handle from the last `ax_dump`/`ax_element_at`.
|
|
public let ref: String?
|
|
/// For `ax_set_value`: the value to write into the element.
|
|
public let value: String?
|
|
/// For `scroll`: `up`/`down`/`left`/`right` and a click/step amount.
|
|
public let scrollDirection: String?
|
|
public let scrollAmount: Int?
|
|
/// For `wait`: milliseconds to pause before the screenshot (e.g. let a window open).
|
|
public let durationMs: Int?
|
|
|
|
public init(
|
|
action: String, x: Int? = nil, y: Int? = nil, text: String? = nil,
|
|
ref: String? = nil, value: String? = nil,
|
|
scrollDirection: String? = nil, scrollAmount: Int? = nil, durationMs: Int? = nil
|
|
) {
|
|
self.action = action
|
|
self.x = x
|
|
self.y = y
|
|
self.text = text
|
|
self.ref = ref
|
|
self.value = value
|
|
self.scrollDirection = scrollDirection
|
|
self.scrollAmount = scrollAmount
|
|
self.durationMs = durationMs
|
|
}
|
|
}
|
|
|
|
public enum MacVMComputerReply: Sendable {
|
|
case denied(message: String)
|
|
/// The action ran; `text` is a short summary (e.g. cursor position / screen size), and
|
|
/// `imageBase64` is the post-action screenshot as base64 JPEG (nil only if capture failed).
|
|
case ok(text: String, imageBase64: String?)
|
|
|
|
/// Denial JSON for the text-only failure path.
|
|
public func deniedJSON() -> String {
|
|
if case .denied(let message) = self {
|
|
return JSONValue.object([
|
|
"denied": .bool(true), "message": .string(message),
|
|
]).canonicalString()
|
|
}
|
|
return JSONValue.object([:]).canonicalString()
|
|
}
|
|
}
|
|
|
|
/// A `mac_vm_request_operator` tool call: the agent — while driving the session's macOS VM via
|
|
/// `mac_vm_computer` — hits a step only a human can clear (a CAPTCHA, a login / 2FA prompt, an
|
|
/// interactive OS dialog) and asks the *user* to take the wheel. Modeled on `host_exec`: the agent
|
|
/// must justify the request (`reason`) and say exactly what it needs done (`instructions`). The
|
|
/// handler suspends (like `host_exec`) until the user finishes operating the VM or declines, then
|
|
/// returns the outcome. Always gated; never auto-approved.
|
|
public struct MacVMOperatorCall: Sendable {
|
|
/// The agent's stated justification for why a human is required (rather than the agent driving
|
|
/// the VM itself). Required by the schema and enforced at the choke point; surfaced on the
|
|
/// request card as an *unverified claim*.
|
|
public let reason: String
|
|
/// What, concretely, the agent needs the user to do in the VM (e.g. "solve the CAPTCHA in the
|
|
/// Safari window, then click Continue"). Required; shown to the user in the walkthrough popup.
|
|
public let instructions: String
|
|
public init(reason: String = "", instructions: String = "") {
|
|
self.reason = reason
|
|
self.instructions = instructions
|
|
}
|
|
}
|
|
|
|
public enum MacVMOperatorReply: Sendable {
|
|
case denied(message: String)
|
|
/// The user finished (or gave up on) the hand-off. `completed` is whether they say they did
|
|
/// what was asked; `note` is an optional free-text message back to the agent.
|
|
case resolved(completed: Bool, note: String)
|
|
|
|
/// Text-block JSON the agent reads back from the tool result.
|
|
public func wireJSON() -> String {
|
|
switch self {
|
|
case .denied(let message):
|
|
return JSONValue.object([
|
|
"denied": .bool(true), "message": .string(message),
|
|
]).canonicalString()
|
|
case .resolved(let completed, let note):
|
|
return JSONValue.object([
|
|
"completed": .bool(completed), "note": .string(note),
|
|
]).canonicalString()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A ground-truth report from the in-container `git` interceptor shim: the real git the
|
|
/// agent ran (argv + cwd + exit code) in a Nucleic Control container. Delivered over a
|
|
/// dedicated `POST /git-event` route (not an MCP tool), so the shim needs no MCP handshake.
|
|
public struct GitReportCall: Sendable {
|
|
public let argv: [String]
|
|
public let cwd: String
|
|
public let exitCode: Int
|
|
public let subcommand: String?
|
|
public let sessionID: String?
|
|
public init(argv: [String], cwd: String, exitCode: Int, subcommand: String?, sessionID: String?) {
|
|
self.argv = argv
|
|
self.cwd = cwd
|
|
self.exitCode = exitCode
|
|
self.subcommand = subcommand
|
|
self.sessionID = sessionID
|
|
}
|
|
}
|
|
|
|
/// A ground-truth report from the in-container `gh` interceptor shim — the agentic GitHub
|
|
/// action the agent ran (argv + cwd + exit code). Same shape and `POST /gh-event` transport as
|
|
/// `GitReportCall`; the host classifies it via `GhCommandSummary`.
|
|
public struct GhReportCall: Sendable {
|
|
public let argv: [String]
|
|
public let cwd: String
|
|
public let exitCode: Int
|
|
public let subcommand: String?
|
|
public let sessionID: String?
|
|
public init(argv: [String], cwd: String, exitCode: Int, subcommand: String?, sessionID: String?) {
|
|
self.argv = argv
|
|
self.cwd = cwd
|
|
self.exitCode = exitCode
|
|
self.subcommand = subcommand
|
|
self.sessionID = sessionID
|
|
}
|
|
}
|
|
|
|
/// A ground-truth report of one ordinary (non-git) command the in-container interceptor
|
|
/// observed — from the output-capturing shim (`source == .shim`, carrying argv + a capped
|
|
/// stdout/stderr head) or the bash command tracer (`source == .tracer`, a raw command line +
|
|
/// metadata only). Delivered over `POST /command-event`. One HTTP request may carry many
|
|
/// (the tracer batches a whole Bash tool call), so the route fans out to one call each.
|
|
public struct CommandReportCall: Sendable {
|
|
public enum Source: String, Sendable { case shim, tracer }
|
|
/// The resolved command name (shim), or nil when only a raw command line is available
|
|
/// (tracer) — the consumer derives it by tokenizing `commandLine`.
|
|
public let command: String?
|
|
/// Full argv (shim), or empty when only `commandLine` is available (tracer).
|
|
public let argv: [String]
|
|
/// The raw command line (tracer), or nil (shim).
|
|
public let commandLine: String?
|
|
public let cwd: String
|
|
public let exitCode: Int
|
|
/// Wall-clock duration in milliseconds, when the reporter measured it.
|
|
public let durationMs: Int?
|
|
/// Capped head of stdout / stderr (shim only; empty for the tracer).
|
|
public let stdout: String
|
|
public let stderr: String
|
|
/// True when stdout/stderr exceeded the capture cap (shim).
|
|
public let truncated: Bool
|
|
public let source: Source
|
|
public let sessionID: String?
|
|
|
|
public init(
|
|
command: String?, argv: [String], commandLine: String?, cwd: String, exitCode: Int,
|
|
durationMs: Int?, stdout: String, stderr: String, truncated: Bool, source: Source,
|
|
sessionID: String?
|
|
) {
|
|
self.command = command
|
|
self.argv = argv
|
|
self.commandLine = commandLine
|
|
self.cwd = cwd
|
|
self.exitCode = exitCode
|
|
self.durationMs = durationMs
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.truncated = truncated
|
|
self.source = source
|
|
self.sessionID = sessionID
|
|
}
|
|
}
|
|
|
|
public typealias Handler = @Sendable (ApprovalCall) async -> Reply
|
|
public typealias ConflictHandler = @Sendable (ConflictCall) async -> ConflictReply
|
|
public typealias HostExecHandler = @Sendable (HostExecCall) async -> HostExecReply
|
|
public typealias MacVMExecHandler = @Sendable (MacVMExecCall) async -> MacVMExecReply
|
|
public typealias MacVMComputerHandler = @Sendable (MacVMComputerCall) async -> MacVMComputerReply
|
|
public typealias MacVMOperatorHandler = @Sendable (MacVMOperatorCall) async -> MacVMOperatorReply
|
|
/// Synchronous + non-gated: it just records the observed op and returns. No user approval,
|
|
/// so a git report never blocks the agent's git call.
|
|
public typealias GitReportHandler = @Sendable (GitReportCall) -> Void
|
|
/// As `GitReportHandler`, for the `gh` interceptor's `POST /gh-event`.
|
|
public typealias GhReportHandler = @Sendable (GhReportCall) -> Void
|
|
/// As ``GitReportHandler``, for the non-git command interceptor (`POST /command-event`).
|
|
public typealias CommandReportHandler = @Sendable (CommandReportCall) -> Void
|
|
|
|
/// The dedicated POST path the `git` interceptor shim reports to — bearer-token gated like
|
|
/// the MCP route, but a plain JSON POST rather than JSON-RPC.
|
|
public static let gitEventPath = "/git-event"
|
|
/// The dedicated POST path the `gh` interceptor shim reports to (sibling of `gitEventPath`).
|
|
public static let ghEventPath = "/gh-event"
|
|
/// The dedicated POST path the non-git command shims + bash tracer report to.
|
|
public static let commandEventPath = CommandInterceptor.eventPath
|
|
|
|
public static let toolName = "approve"
|
|
/// Fully-qualified name passed to `--permission-prompt-tool`.
|
|
public static let qualifiedToolName = "mcp__nucleic__approve"
|
|
public static let conflictToolName = "check_conflict"
|
|
/// Fully-qualified name — pre-allowed so the agent calls it without an approval round-trip.
|
|
public static let qualifiedConflictToolName = "mcp__nucleic__check_conflict"
|
|
public static let hostExecToolName = "host_exec"
|
|
/// Fully-qualified name — pre-allowed via `--allowedTools` so the call reaches our handler
|
|
/// directly (the handler is the sole gate); never routed through Claude's permission path,
|
|
/// so `auto` mode can't auto-approve it. Only advertised when a handler is registered.
|
|
public static let qualifiedHostExecToolName = "mcp__nucleic__host_exec"
|
|
public static let macVMExecToolName = "mac_vm_exec"
|
|
/// Fully-qualified name — pre-allowed like `host_exec` so the call reaches our handler directly
|
|
/// (the handler is the sole gate). Only advertised when a handler is registered.
|
|
public static let qualifiedMacVMExecToolName = "mcp__nucleic__mac_vm_exec"
|
|
public static let macVMComputerToolName = "mac_vm_computer"
|
|
/// Fully-qualified name — pre-allowed so the computer-use action reaches our handler directly
|
|
/// (no per-action prompt). Only advertised when a handler is registered.
|
|
public static let qualifiedMacVMComputerToolName = "mcp__nucleic__mac_vm_computer"
|
|
public static let macVMOperatorToolName = "mac_vm_request_operator"
|
|
/// Fully-qualified name — pre-allowed like `host_exec` so the operator-help request reaches our
|
|
/// handler directly (the handler is the sole gate). Only advertised when a handler is registered.
|
|
public static let qualifiedMacVMOperatorToolName = "mcp__nucleic__mac_vm_request_operator"
|
|
|
|
private var listener: NWListener?
|
|
/// In-flight TCP bind, so concurrent `start(host:)` callers (the default once sessions share one
|
|
/// control-container server via ``ApprovalServerRegistry``) join the SAME bind and all receive
|
|
/// the real bound port. Without this, a second caller entering while the first is mid-bind saw a
|
|
/// half-initialized listener and returned `port == 0`, which the agent's MCP config baked into an
|
|
/// unreachable `:0` endpoint — stalling that session's first gated tool. Cleared when the bind
|
|
/// settles (success or failure), so a failed bind can be retried.
|
|
private var tcpStartTask: Task<UInt16, Error>?
|
|
private var handlers: [String: Handler] = [:]
|
|
private var conflictHandlers: [String: ConflictHandler] = [:]
|
|
private var hostExecHandlers: [String: HostExecHandler] = [:]
|
|
private var macVMExecHandlers: [String: MacVMExecHandler] = [:]
|
|
private var macVMComputerHandlers: [String: MacVMComputerHandler] = [:]
|
|
private var macVMOperatorHandlers: [String: MacVMOperatorHandler] = [:]
|
|
private var gitReportHandlers: [String: GitReportHandler] = [:]
|
|
private var ghReportHandlers: [String: GhReportHandler] = [:]
|
|
private var commandReportHandlers: [String: CommandReportHandler] = [:]
|
|
private var connectionTasks: [Int: Task<Void, Never>] = [:]
|
|
private var nextConnectionID = 0
|
|
public private(set) var port: UInt16 = 0
|
|
/// The bound unix-socket path when started via ``start(unixSocketPath:)`` (nil for a TCP
|
|
/// bind). Recorded so ``stop()`` can unlink the socket file it created.
|
|
public private(set) var boundUnixSocketPath: String?
|
|
/// The listening AF_UNIX socket fd + its accept source, for the unix-socket transport.
|
|
private var unixListenFD: Int32?
|
|
private var unixAcceptSource: DispatchSourceRead?
|
|
/// `(dev, ino)` of the bound unix socket node, captured at bind time. Compared against the live
|
|
/// file on each ``start(unixSocketPath:)`` to detect the socket being unlinked (a `/tmp`-style
|
|
/// reaper, a container teardown) or replaced by a different inode — the desync that used to
|
|
/// permanently brick a shared control server. Nil when no unix socket is bound.
|
|
private var boundUnixSocketInode: (dev: dev_t, ino: ino_t)?
|
|
|
|
public init() {}
|
|
|
|
/// Returns the bound ephemeral port. `host` is the local interface to bind to;
|
|
/// default loopback for host runs, `0.0.0.0` for sandbox runs so the containerized
|
|
/// `claude` can reach the approval server over the VM gateway (bearer-token gated).
|
|
@discardableResult
|
|
public func start(host: String = "127.0.0.1") async throws -> UInt16 {
|
|
if listener != nil { return port } // already bound → real port is published
|
|
if let task = tcpStartTask { return try await task.value } // bind in flight → join it
|
|
let task = Task { [self] in try await bind(host: host) }
|
|
tcpStartTask = task
|
|
defer { tcpStartTask = nil } // settle the single-flight (success or throw); allow a retry
|
|
return try await task.value
|
|
}
|
|
|
|
/// Bind the server to a **unix domain socket** instead of a TCP port — the transport for a
|
|
/// sandboxed agent, whose connection is relayed in over vsock (the framework's
|
|
/// `UnixSocketConfiguration`). No IP listener means macOS raises no incoming-connection /
|
|
/// local-network prompts. The HTTP/JSON-RPC dispatch is byte-for-byte identical to the TCP
|
|
/// path; only the listening socket differs.
|
|
///
|
|
/// A real `AF_UNIX` listener (not `NWListener` with a `.unix` endpoint, which Network.framework
|
|
/// matches in-process and never materializes on disk) so the framework's host-side relay — an
|
|
/// ordinary socket client — can connect to it. Any stale socket file at `path` (e.g. left by a
|
|
/// crash) is removed first, since `bind` fails if the path already exists. Returns the bound path.
|
|
@discardableResult
|
|
public func start(unixSocketPath path: String) async throws -> String {
|
|
// Self-heal (the core fix). A control container's approval server is long-lived and shared by
|
|
// every session in the box, reached through this ONE socket; a single desync used to brick the
|
|
// whole container — every new session's `claude` failing MCP init ("Available MCP tools: none")
|
|
// and every running session stalling on its next gated call — until an app restart, because the
|
|
// cached server trusted its first bind forever. So RE-VALIDATE on every call: keep the existing
|
|
// listener ONLY when it's still a live listening socket AND the file at `path` is still the exact
|
|
// socket node we bound. If the file was unlinked (a `/tmp`-style reaper, a container teardown) or
|
|
// replaced by a different inode, or the fd died, drop the orphaned listener and rebind so this
|
|
// call converges on a working socket. No `await` runs before the rebind, so concurrent callers on
|
|
// this actor can't interleave a half-bound state.
|
|
if unixListenFD != nil {
|
|
if boundUnixSocketPath == path, unixListenerIsHealthy(path: path) {
|
|
return path
|
|
}
|
|
teardownUnixListener() // stale / desynced — rebind below
|
|
}
|
|
try performUnixBind(path: path)
|
|
return path
|
|
}
|
|
|
|
/// Bind + listen a fresh AF_UNIX socket at `path` and wire its accept source, publishing
|
|
/// `unixListenFD` / `unixAcceptSource` / `boundUnixSocketPath` / `boundUnixSocketInode`. The
|
|
/// single bind site for both the first `start(unixSocketPath:)` and every self-heal rebind, so
|
|
/// they can't drift. Synchronous (no suspension) so the actor never exposes a half-bound listener.
|
|
private func performUnixBind(path: String) throws {
|
|
let fm = FileManager.default
|
|
try? fm.createDirectory(
|
|
atPath: (path as NSString).deletingLastPathComponent,
|
|
withIntermediateDirectories: true)
|
|
try? fm.removeItem(atPath: path) // a bind fails if the path already exists
|
|
|
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
guard fd >= 0 else {
|
|
throw UnixSocketError("socket() failed: \(String(cString: strerror(errno)))")
|
|
}
|
|
_ = fcntl(fd, F_SETFD, FD_CLOEXEC)
|
|
|
|
var addr = sockaddr_un()
|
|
addr.sun_family = sa_family_t(AF_UNIX)
|
|
let pathBytes = Array(path.utf8)
|
|
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
|
guard pathBytes.count < capacity else {
|
|
close(fd)
|
|
throw UnixSocketError("socket path too long (\(pathBytes.count) ≥ \(capacity)): \(path)")
|
|
}
|
|
withUnsafeMutablePointer(to: &addr.sun_path) { raw in
|
|
raw.withMemoryRebound(to: CChar.self, capacity: capacity) { dst in
|
|
for (i, byte) in pathBytes.enumerated() { dst[i] = CChar(bitPattern: byte) }
|
|
dst[pathBytes.count] = 0
|
|
}
|
|
}
|
|
let bindRC = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
}
|
|
}
|
|
guard bindRC == 0 else {
|
|
let err = String(cString: strerror(errno))
|
|
close(fd)
|
|
throw UnixSocketError("bind(\(path)) failed: \(err)")
|
|
}
|
|
// Only the host-side relay connects here; lock the socket to the owner.
|
|
chmod(path, 0o600)
|
|
guard listen(fd, 16) == 0 else {
|
|
let err = String(cString: strerror(errno))
|
|
close(fd)
|
|
try? fm.removeItem(atPath: path)
|
|
throw UnixSocketError("listen(\(path)) failed: \(err)")
|
|
}
|
|
|
|
// Accept off a background source so the actor never blocks; each accepted fd drives the same
|
|
// HTTP/JSON-RPC serve loop as a TCP connection.
|
|
let source = DispatchSource.makeReadSource(
|
|
fileDescriptor: fd, queue: .global(qos: .userInitiated))
|
|
source.setEventHandler { [weak self] in
|
|
let client = accept(fd, nil, nil)
|
|
guard client >= 0 else { return }
|
|
_ = fcntl(client, F_SETFD, FD_CLOEXEC)
|
|
guard let self else {
|
|
close(client)
|
|
return
|
|
}
|
|
Task { await self.adopt(UnixSocketByteConn(fd: client)) }
|
|
}
|
|
source.setCancelHandler { close(fd) }
|
|
source.resume()
|
|
|
|
unixListenFD = fd
|
|
unixAcceptSource = source
|
|
boundUnixSocketPath = path
|
|
boundUnixSocketInode = Self.inode(ofPath: path)
|
|
}
|
|
|
|
/// Whether a unix-socket control endpoint is currently bound and listening at `path`. The
|
|
/// containerized spawn path checks this (mirroring the TCP path's `port != 0` guard) so a
|
|
/// desynced control socket fails the run loudly and retryably instead of surfacing only as the
|
|
/// CLI's opaque "Available MCP tools: none" — and poisoning every session sharing the container.
|
|
public func isUnixSocketListening(atPath path: String) -> Bool {
|
|
boundUnixSocketPath == path && unixListenerIsHealthy(path: path)
|
|
}
|
|
|
|
/// Whether the current unix listener is still usable for `path`: we hold the listening fd AND the
|
|
/// file at `path` is still the very socket node we bound (not unlinked, not replaced). The fd
|
|
/// itself is only ever closed by ``stop()`` / ``teardownUnixListener()``, both of which also clear
|
|
/// `unixListenFD`, so a set fd is always live — the failure modes that actually occur are the file
|
|
/// being reaped (stat fails) or replaced by a fresh inode (identity mismatch), which is exactly
|
|
/// what this checks. Cheap enough to run on every `start(unixSocketPath:)` so a shared server
|
|
/// converges on a live socket instead of trusting a first bind a reaper or teardown may have voided.
|
|
private func unixListenerIsHealthy(path: String) -> Bool {
|
|
guard unixListenFD != nil, let bound = boundUnixSocketInode else { return false }
|
|
guard let current = Self.inode(ofPath: path) else { return false } // unlinked (reaped/torn down)
|
|
return current == bound // false → the path now points at a different socket node
|
|
}
|
|
|
|
/// Tear down the current unix listener without binding a replacement — cancels the accept source
|
|
/// (its cancel handler closes the fd) and clears the bound-socket bookkeeping. Leaves any file at
|
|
/// the path for ``performUnixBind(path:)`` to remove, so a rebind starts clean.
|
|
private func teardownUnixListener() {
|
|
unixAcceptSource?.cancel()
|
|
unixAcceptSource = nil
|
|
unixListenFD = nil
|
|
boundUnixSocketInode = nil
|
|
}
|
|
|
|
/// `(st_dev, st_ino)` identity of the filesystem node at `path`, or nil if it doesn't exist.
|
|
/// Used to detect a socket file unlinked or replaced out from under a live listener.
|
|
private static func inode(ofPath path: String) -> (dev: dev_t, ino: ino_t)? {
|
|
var st = stat()
|
|
guard stat(path, &st) == 0 else { return nil }
|
|
return (st.st_dev, st.st_ino)
|
|
}
|
|
|
|
/// Bind a TCP listener and resume once it is `.ready` (or throw on failure). The connection-
|
|
/// serving pipeline (``adopt(_:)`` → ``serve(_:)``) is transport-agnostic, so this drives the
|
|
/// exact same HTTP/JSON-RPC dispatch as the unix-socket path. Returns the bound ephemeral port.
|
|
///
|
|
/// `self.listener` and `self.port` are published only AFTER `.ready` — never before the await —
|
|
/// so a concurrent caller can't observe a half-bound listener with a 0 port, and a `.failed`
|
|
/// bind leaves both unset (no stale listener pinning a permanent port 0). The connection handler
|
|
/// is wired before `start`; it captures `self` directly, so it works whether or not
|
|
/// `self.listener` is assigned yet.
|
|
private func bind(host: String) async throws -> UInt16 {
|
|
let parameters = NWParameters.tcp
|
|
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(
|
|
host: NWEndpoint.Host(host), port: .any)
|
|
let listener = try NWListener(using: parameters)
|
|
|
|
listener.newConnectionHandler = { [weak self] connection in
|
|
guard let self else {
|
|
connection.cancel()
|
|
return
|
|
}
|
|
Task { await self.adopt(NWByteConn(connection)) }
|
|
}
|
|
|
|
let resumeOnce = OnceBox()
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
listener.stateUpdateHandler = { state in
|
|
switch state {
|
|
case .ready:
|
|
if resumeOnce.claim() { cont.resume() }
|
|
case .failed(let error), .waiting(let error):
|
|
if resumeOnce.claim() { cont.resume(throwing: error) }
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
listener.start(queue: .global(qos: .userInitiated))
|
|
}
|
|
// Ready and bound: publish atomically (no suspension between these and the return).
|
|
self.listener = listener
|
|
self.port = listener.port?.rawValue ?? 0
|
|
return self.port
|
|
}
|
|
|
|
public func stop() {
|
|
tcpStartTask?.cancel()
|
|
tcpStartTask = nil
|
|
listener?.cancel()
|
|
listener = nil
|
|
unixAcceptSource?.cancel() // its cancel handler closes the listening fd
|
|
unixAcceptSource = nil
|
|
unixListenFD = nil
|
|
boundUnixSocketInode = nil
|
|
if let path = boundUnixSocketPath {
|
|
try? FileManager.default.removeItem(atPath: path)
|
|
boundUnixSocketPath = nil
|
|
}
|
|
for task in connectionTasks.values { task.cancel() }
|
|
connectionTasks.removeAll()
|
|
handlers.removeAll()
|
|
conflictHandlers.removeAll()
|
|
hostExecHandlers.removeAll()
|
|
macVMExecHandlers.removeAll()
|
|
macVMComputerHandlers.removeAll()
|
|
macVMOperatorHandlers.removeAll()
|
|
gitReportHandlers.removeAll()
|
|
ghReportHandlers.removeAll()
|
|
commandReportHandlers.removeAll()
|
|
}
|
|
|
|
public func register(token: String, handler: @escaping Handler) {
|
|
handlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session `check_conflict` handler under the same bearer token.
|
|
public func registerConflict(token: String, handler: @escaping ConflictHandler) {
|
|
conflictHandlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session `host_exec` handler. Registering it both advertises the tool
|
|
/// in `tools/list` for this token and routes calls to the host runner (HOST_EXEC).
|
|
public func registerHostExec(token: String, handler: @escaping HostExecHandler) {
|
|
hostExecHandlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session `mac_vm_exec` handler. Registering it both advertises the tool in
|
|
/// `tools/list` for this token and routes calls to the macOS-VM runner (see docs/MACOS_VM.md).
|
|
public func registerMacVMExec(token: String, handler: @escaping MacVMExecHandler) {
|
|
macVMExecHandlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session `mac_vm_computer` handler. Registering it both advertises the tool in
|
|
/// `tools/list` for this token and routes computer-use actions to the macOS-VM GUI driver.
|
|
public func registerMacVMComputer(token: String, handler: @escaping MacVMComputerHandler) {
|
|
macVMComputerHandlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session `mac_vm_request_operator` handler. Registering it both advertises the
|
|
/// tool in `tools/list` for this token and routes operator-help requests to the assist coordinator.
|
|
public func registerMacVMOperator(token: String, handler: @escaping MacVMOperatorHandler) {
|
|
macVMOperatorHandlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session `git` interceptor report handler under the same bearer token.
|
|
/// Called for the `POST /git-event` route the in-container shim uses.
|
|
public func registerGitReport(token: String, handler: @escaping GitReportHandler) {
|
|
gitReportHandlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session `gh` interceptor report handler under the same bearer token.
|
|
/// Called for the `POST /gh-event` route the in-container shim uses.
|
|
public func registerGhReport(token: String, handler: @escaping GhReportHandler) {
|
|
ghReportHandlers[token] = handler
|
|
}
|
|
|
|
/// Register the per-session non-git command report handler (`POST /command-event`).
|
|
public func registerCommandReport(token: String, handler: @escaping CommandReportHandler) {
|
|
commandReportHandlers[token] = handler
|
|
}
|
|
|
|
public func unregister(token: String) {
|
|
handlers.removeValue(forKey: token)
|
|
conflictHandlers.removeValue(forKey: token)
|
|
hostExecHandlers.removeValue(forKey: token)
|
|
macVMExecHandlers.removeValue(forKey: token)
|
|
macVMComputerHandlers.removeValue(forKey: token)
|
|
macVMOperatorHandlers.removeValue(forKey: token)
|
|
gitReportHandlers.removeValue(forKey: token)
|
|
ghReportHandlers.removeValue(forKey: token)
|
|
commandReportHandlers.removeValue(forKey: token)
|
|
}
|
|
|
|
/// The MCP server entry for `--mcp-config` (inline JSON). `host` is the address the
|
|
/// child reaches the server at — loopback for host runs, the VM gateway IP for a
|
|
/// containerized child (which can't see the host's `127.0.0.1`).
|
|
public nonisolated func mcpConfigJSON(
|
|
host: String = "127.0.0.1", port: UInt16, token: String
|
|
) -> String {
|
|
JSONValue.object([
|
|
"mcpServers": .object([
|
|
"nucleic": .object([
|
|
"type": .string("http"),
|
|
"url": .string("http://\(host):\(port)/mcp"),
|
|
"headers": .object(["Authorization": .string("Bearer \(token)")]),
|
|
])
|
|
])
|
|
]).canonicalString()
|
|
}
|
|
|
|
// MARK: - Connection handling
|
|
|
|
private func adopt(_ conn: any ByteConn) {
|
|
let id = nextConnectionID
|
|
nextConnectionID += 1
|
|
let task = Task {
|
|
await self.serve(conn)
|
|
self.forget(id)
|
|
}
|
|
connectionTasks[id] = task
|
|
}
|
|
|
|
private func forget(_ id: Int) {
|
|
connectionTasks.removeValue(forKey: id)
|
|
}
|
|
|
|
private func serve(_ conn: any ByteConn) async {
|
|
defer { conn.close() }
|
|
var buffer = Data()
|
|
while !Task.isCancelled {
|
|
guard let request = await nextRequest(on: conn, buffer: &buffer) else { return }
|
|
let response = await handle(request)
|
|
do {
|
|
try await sendResponse(response, on: conn)
|
|
} catch {
|
|
return
|
|
}
|
|
if request.headers["connection"]?.lowercased() == "close" { return }
|
|
}
|
|
}
|
|
|
|
private struct HTTPRequest {
|
|
let method: String
|
|
let path: String
|
|
let headers: [String: String] // lowercased keys
|
|
let body: Data
|
|
}
|
|
|
|
private struct HTTPResponse {
|
|
let status: Int
|
|
let statusText: String
|
|
let contentType: String?
|
|
let body: Data
|
|
}
|
|
|
|
/// Hard ceiling on a single request's bytes (headers + body). The only clients are our own
|
|
/// in-container shims/tracer over loopback/VM-gateway, but a compromised agent shares the
|
|
/// per-session token, so cap the buffer to bound memory: a request that grows past this drops
|
|
/// the connection rather than letting an attacker stream gigabytes into `Data`. Generous enough
|
|
/// for a full command-event batch (capped at the source too) plus the largest MCP payload.
|
|
static let maxRequestBytes = 4 * 1024 * 1024
|
|
|
|
private func nextRequest(on conn: any ByteConn, buffer: inout Data) async -> HTTPRequest? {
|
|
while true {
|
|
if let request = Self.parseRequest(from: &buffer) { return request }
|
|
// Bound memory: an over-long request (or a lying Content-Length) drops the connection.
|
|
if buffer.count > Self.maxRequestBytes { return nil }
|
|
do {
|
|
guard let chunk = try await conn.receive(maxLength: 1 << 16) else { return nil }
|
|
buffer.append(chunk)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func parseRequest(from buffer: inout Data) -> HTTPRequest? {
|
|
guard let headerEnd = buffer.range(of: Data("\r\n\r\n".utf8)) else { return nil }
|
|
let headerData = buffer.subdata(in: buffer.startIndex..<headerEnd.lowerBound)
|
|
guard let headerText = String(data: headerData, encoding: .utf8) else { return nil }
|
|
let lines = headerText.components(separatedBy: "\r\n")
|
|
guard let requestLine = lines.first else { return nil }
|
|
let parts = requestLine.split(separator: " ")
|
|
guard parts.count >= 2 else { return nil }
|
|
|
|
var headers: [String: String] = [:]
|
|
for line in lines.dropFirst() {
|
|
guard let colon = line.firstIndex(of: ":") else { continue }
|
|
let key = line[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
|
|
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
|
headers[key] = value
|
|
}
|
|
|
|
let contentLength = headers["content-length"].flatMap(Int.init) ?? 0
|
|
let bodyStart = headerEnd.upperBound
|
|
guard buffer.distance(from: bodyStart, to: buffer.endIndex) >= contentLength else {
|
|
return nil
|
|
}
|
|
let body = buffer.subdata(in: bodyStart..<buffer.index(bodyStart, offsetBy: contentLength))
|
|
buffer.removeSubrange(buffer.startIndex..<buffer.index(bodyStart, offsetBy: contentLength))
|
|
|
|
return HTTPRequest(
|
|
method: String(parts[0]), path: String(parts[1]), headers: headers, body: body)
|
|
}
|
|
|
|
private func sendResponse(_ response: HTTPResponse, on conn: any ByteConn) async throws {
|
|
var head = "HTTP/1.1 \(response.status) \(response.statusText)\r\n"
|
|
if let contentType = response.contentType {
|
|
head += "Content-Type: \(contentType)\r\n"
|
|
}
|
|
head += "Content-Length: \(response.body.count)\r\nConnection: keep-alive\r\n\r\n"
|
|
var payload = Data(head.utf8)
|
|
payload.append(response.body)
|
|
try await conn.send(payload)
|
|
}
|
|
|
|
// MARK: - MCP / JSON-RPC dispatch
|
|
|
|
private func handle(_ request: HTTPRequest) async -> HTTPResponse {
|
|
guard request.method == "POST" else {
|
|
// Streamable-HTTP clients may probe GET (server-push channel) — we
|
|
// don't offer one, 405 is the spec-sanctioned answer.
|
|
return HTTPResponse(
|
|
status: 405, statusText: "Method Not Allowed", contentType: nil, body: Data())
|
|
}
|
|
|
|
// The `git` / `gh` interceptor shims post here (not JSON-RPC). Route before MCP dispatch.
|
|
if request.path == Self.gitEventPath {
|
|
return handleGitEvent(request)
|
|
}
|
|
if request.path == Self.ghEventPath {
|
|
return handleGhEvent(request)
|
|
}
|
|
// The non-git command shims + bash tracer post here (not JSON-RPC).
|
|
if request.path == Self.commandEventPath {
|
|
return handleCommandEvent(request)
|
|
}
|
|
|
|
let authorization = request.headers["authorization"] ?? ""
|
|
let token = authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
|
|
guard let handler = handlers[token] else {
|
|
return HTTPResponse(
|
|
status: 401, statusText: "Unauthorized", contentType: "application/json",
|
|
body: Data(#"{"error":"invalid bearer token"}"#.utf8))
|
|
}
|
|
|
|
guard let message = try? JSONValue(parsing: request.body) else {
|
|
return rpcError(id: .null, code: -32700, message: "Parse error")
|
|
}
|
|
|
|
let id = message["id"] ?? .null
|
|
let method = message["method"]?.stringValue ?? ""
|
|
|
|
// Notifications (no id) are acknowledged with 202 and no body.
|
|
if message["id"] == nil {
|
|
return HTTPResponse(status: 202, statusText: "Accepted", contentType: nil, body: Data())
|
|
}
|
|
|
|
switch method {
|
|
case "initialize":
|
|
let requested = message["params"]?["protocolVersion"]?.stringValue ?? "2025-06-18"
|
|
return rpcResult(
|
|
id: id,
|
|
result: .object([
|
|
"protocolVersion": .string(requested),
|
|
"capabilities": .object(["tools": .object(["listChanged": .bool(false)])]),
|
|
"serverInfo": .object([
|
|
"name": .string("nucleic-approval"), "version": .string("0.1.0"),
|
|
]),
|
|
]))
|
|
|
|
case "ping":
|
|
return rpcResult(id: id, result: .object([:]))
|
|
|
|
case "tools/list":
|
|
var tools: [JSONValue] = [
|
|
.object([
|
|
"name": .string(Self.toolName),
|
|
"description": .string(
|
|
"Ask the Nucleic user to approve or deny a gated tool call."),
|
|
"inputSchema": .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"tool_name": .object(["type": .string("string")]),
|
|
"input": .object(["type": .string("object")]),
|
|
"tool_use_id": .object(["type": .string("string")]),
|
|
]),
|
|
"required": .array([.string("tool_name"), .string("input")]),
|
|
]),
|
|
]),
|
|
.object([
|
|
"name": .string(Self.conflictToolName),
|
|
"description": .string(
|
|
"Before starting a distinct task that edits files, check whether it "
|
|
+ "conflicts with another currently-active Nucleic agent. Pass a "
|
|
+ "one-line task description and the repo-relative files/areas you "
|
|
+ "expect to change. If the result says the task was deferred or "
|
|
+ "cancelled, STOP and do not edit; if it says proceed (or there is "
|
|
+ "no conflict), continue."),
|
|
"inputSchema": .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"task": .object(["type": .string("string")]),
|
|
"files": .object([
|
|
"type": .string("array"),
|
|
"items": .object(["type": .string("string")]),
|
|
]),
|
|
]),
|
|
"required": .array([.string("task")]),
|
|
]),
|
|
]),
|
|
]
|
|
// Only advertise host_exec when this session opted into host build/run.
|
|
if hostExecHandlers[token] != nil {
|
|
tools.append(
|
|
.object([
|
|
"name": .string(Self.hostExecToolName),
|
|
"description": .string(
|
|
"Run a command on the HOST machine (macOS), OUTSIDE this Linux sandbox "
|
|
+ "— a LAST-RESORT escape hatch, NOT a general-purpose tool. It exists "
|
|
+ "solely for work that genuinely cannot run inside the container: "
|
|
+ "compiling or running a macOS-only target (a Swift package, a "
|
|
+ "SwiftUI/AppKit app, an xcodebuild/codesign/xcrun step). Before "
|
|
+ "calling it you must have confirmed the task is impossible in the "
|
|
+ "container — which already builds and tests JavaScript, Python, and "
|
|
+ "C/C++ and handles all ordinary shell, file, and search work. Do NOT "
|
|
+ "use it for convenience, for anything that would run in the "
|
|
+ "container, to install software, to fetch from the network, or to "
|
|
+ "run git/gh (host git/gh is blocked and bypasses the shared-trunk "
|
|
+ "version control — run git inside the container). Pass a single shell "
|
|
+ "`command`; it runs in this session's worktree on the host (combine "
|
|
+ "build + run into one command, e.g. \"swift build && swift run "
|
|
+ "MyTool\"). Also pass `reason`: a specific justification for why "
|
|
+ "this cannot run in the container (a container-runnable command is "
|
|
+ "rejected, and an empty/vague reason is rejected). Returns "
|
|
+ "{exit_code, stdout, stderr}. Every call needs explicit user "
|
|
+ "approval and is NEVER auto-approved, so call it only when host "
|
|
+ "execution is genuinely unavoidable."),
|
|
"inputSchema": .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"command": .object(["type": .string("string")]),
|
|
"reason": .object([
|
|
"type": .string("string"),
|
|
"description": .string(
|
|
"Why this command cannot run inside the sandbox container. "
|
|
+ "If the command names an executable that also exists on "
|
|
+ "Linux, do not just restate the command — the host need "
|
|
+ "is not self-evident, so explain what specifically makes "
|
|
+ "THIS invocation require macOS and why the container "
|
|
+ "cannot do it (e.g. it links an Xcode-only framework, or "
|
|
+ "it is a codesign/notarization step). Required; empty or "
|
|
+ "vague reasons are rejected."),
|
|
]),
|
|
]),
|
|
"required": .array([.string("command"), .string("reason")]),
|
|
]),
|
|
]))
|
|
}
|
|
// Only advertise mac_vm_exec when this session opted into the macOS VM.
|
|
if macVMExecHandlers[token] != nil {
|
|
tools.append(
|
|
.object([
|
|
"name": .string(Self.macVMExecToolName),
|
|
"description": .string(
|
|
"Run a command inside this session's isolated macOS VM (Apple "
|
|
+ "Virtualization) — a real, disposable Mac separate from both this Linux "
|
|
+ "sandbox AND the shared host. Use it for macOS-only work that needs a "
|
|
+ "full Mac: `xcodebuild`, `xcrun simctl` (booting/driving iOS/other "
|
|
+ "simulators), running a built .app, `codesign`/notarization, or any "
|
|
+ "true end-to-end test of a Mac/iOS build. PREFER this over `host_exec` "
|
|
+ "for such work: each agent gets its OWN VM with its own Mac "
|
|
+ "toolchain, so parallel Mac builds don't thrash the ONE shared Mac the "
|
|
+ "way host_exec does (host_exec escapes onto that single shared host). Do "
|
|
+ "NOT use it for work the Linux container already does (JavaScript, "
|
|
+ "Python, C/C++, ordinary shell/file/git) — that belongs in the "
|
|
+ "container. The command runs over SSH in the VM; the session's repo is "
|
|
+ "shared into the VM under \"/Volumes/My Shared Files/\". Pass a single "
|
|
+ "shell `command` (combine build + run, e.g. \"xcodebuild -scheme App "
|
|
+ "test\"), plus `reason`: why this needs the macOS VM specifically. "
|
|
+ "Returns {exit_code, stdout, stderr}. Every call needs explicit user "
|
|
+ "approval and is NEVER auto-approved."),
|
|
"inputSchema": .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"command": .object(["type": .string("string")]),
|
|
"reason": .object([
|
|
"type": .string("string"),
|
|
"description": .string(
|
|
"Why this command needs the macOS VM (a Mac-only toolchain: "
|
|
+ "Xcode, the simulators, codesign) rather than the Linux "
|
|
+ "container. Required; empty or vague reasons are rejected."),
|
|
]),
|
|
]),
|
|
"required": .array([.string("command"), .string("reason")]),
|
|
]),
|
|
]))
|
|
}
|
|
// Only advertise mac_vm_computer when this session opted into VM computer-use.
|
|
if macVMComputerHandlers[token] != nil {
|
|
tools.append(
|
|
.object([
|
|
"name": .string(Self.macVMComputerToolName),
|
|
"description": .string(
|
|
"Drive the GUI of this session's isolated macOS VM like your own Mac — a "
|
|
+ "computer-use loop for a real, disposable Mac. Use it to SEE and "
|
|
+ "operate the desktop when a command isn't enough: visually debug a "
|
|
+ "macOS/iOS app you built (launch it, look at its window, click through "
|
|
+ "it), drive Xcode or the Simulator UI, or verify how something looks. "
|
|
+ "Each call performs ONE `action` and returns a fresh screenshot of the "
|
|
+ "VM screen, so work in a loop: screenshot → decide → act → screenshot. "
|
|
+ "The screenshot is the full screen at its native pixels, so target "
|
|
+ "`x`,`y` exactly as you see them. Pixel actions: `screenshot` (just "
|
|
+ "look); `left_click`/`right_click`/`double_click` at `x`,`y`; "
|
|
+ "`mouse_move` to `x`,`y`; `left_click_drag` to `x`,`y` (from the "
|
|
+ "current cursor); `type` the literal `text`; `key` a chord in `text` "
|
|
+ "(e.g. \"cmd+s\", \"return\", \"tab\", \"cmd+shift+4\"); `scroll` with "
|
|
+ "`scroll_direction`+`scroll_amount`; `launch_app` the app named in "
|
|
+ "`text` (e.g. \"Safari\", \"Xcode\"); `cursor_position`; `wait` "
|
|
+ "`duration_ms` (let a window open). SEMANTIC actions (preferred when "
|
|
+ "available — they act on controls by identity, not coordinates, and "
|
|
+ "keep working even when screenshots come back blank): `ax_dump` "
|
|
+ "returns the frontmost app's accessibility tree (every control's "
|
|
+ "role/title/value/frame/actions, each with a `ref`); `ax_press` a "
|
|
+ "`ref` (optionally a specific AX action name in `text`); "
|
|
+ "`ax_set_value` writes `value` into a `ref`'d field; `ax_focus` a "
|
|
+ "`ref`; `ax_element_at` resolves the element under `x`,`y`. Convention: "
|
|
+ "ax_dump → act on a ref → ax_dump again (refs go stale after the UI "
|
|
+ "changes). The ax_* actions need the VM's native agent; if the reply "
|
|
+ "says it's unavailable, use screenshots + pixel actions. If screenshots "
|
|
+ "come back blank or unavailable, rely on `ax_dump` — it is "
|
|
+ "framebuffer-independent. This runs in the VM sandbox, so actions are "
|
|
+ "NOT individually approval-gated. Prefer `mac_vm_exec` for headless "
|
|
+ "build/test commands; use this when you need to look at or operate the "
|
|
+ "screen."),
|
|
"inputSchema": .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"action": .object([
|
|
"type": .string("string"),
|
|
"description": .string(
|
|
"One of: screenshot, left_click, right_click, double_click, "
|
|
+ "mouse_move, left_click_drag, type, key, scroll, "
|
|
+ "cursor_position, launch_app, wait, ax_dump, "
|
|
+ "ax_element_at, ax_press, ax_set_value, ax_focus."),
|
|
]),
|
|
"x": .object(["type": .string("integer")]),
|
|
"y": .object(["type": .string("integer")]),
|
|
"text": .object([
|
|
"type": .string("string"),
|
|
"description": .string(
|
|
"Text to type, a key/chord for `key`, an app name for "
|
|
+ "`launch_app`, or an AX action name for `ax_press` "
|
|
+ "(default AXPress)."),
|
|
]),
|
|
"ref": .object([
|
|
"type": .string("string"),
|
|
"description": .string(
|
|
"Element handle from the last ax_dump/ax_element_at (for "
|
|
+ "ax_press/ax_set_value/ax_focus)."),
|
|
]),
|
|
"value": .object([
|
|
"type": .string("string"),
|
|
"description": .string("The value to write, for ax_set_value."),
|
|
]),
|
|
"scroll_direction": .object(["type": .string("string")]),
|
|
"scroll_amount": .object(["type": .string("integer")]),
|
|
"duration_ms": .object(["type": .string("integer")]),
|
|
]),
|
|
"required": .array([.string("action")]),
|
|
]),
|
|
]))
|
|
}
|
|
// Only advertise mac_vm_request_operator when this session opted into VM computer-use.
|
|
if macVMOperatorHandlers[token] != nil {
|
|
tools.append(
|
|
.object([
|
|
"name": .string(Self.macVMOperatorToolName),
|
|
"description": .string(
|
|
"Ask the Nucleic USER to take the wheel and operate this session's macOS VM "
|
|
+ "by hand — a LAST-RESORT hand-off for the rare step you cannot do "
|
|
+ "yourself with `mac_vm_computer`, because it genuinely requires a human: "
|
|
+ "solving a CAPTCHA, completing a login / two-factor prompt, accepting a "
|
|
+ "one-time interactive OS dialog. Do NOT use it for ordinary GUI work you "
|
|
+ "can drive yourself (clicking, typing, launching apps) — that is what "
|
|
+ "`mac_vm_computer` is for; use this ONLY when a real person is required. "
|
|
+ "When you call it, Nucleic shows the user a request card, and if they "
|
|
+ "accept, opens an interactive viewer of the VM they can click and type "
|
|
+ "into, with your `instructions` shown alongside. The call BLOCKS until "
|
|
+ "the user finishes or declines. Pass `instructions`: exactly what the "
|
|
+ "user should do in the VM, specific and self-contained (name the window "
|
|
+ "/ page and the concrete action). Pass `reason`: why a human is required "
|
|
+ "rather than you doing it. Returns {completed, note}: `completed` is "
|
|
+ "whether the user says they did it, `note` is any message they left — "
|
|
+ "read both, then continue (re-screenshot the VM to confirm state). Every "
|
|
+ "call needs explicit user consent and is NEVER auto-approved."),
|
|
"inputSchema": .object([
|
|
"type": .string("object"),
|
|
"properties": .object([
|
|
"instructions": .object([
|
|
"type": .string("string"),
|
|
"description": .string(
|
|
"What the user should do in the VM, concretely and "
|
|
+ "self-contained (e.g. \"solve the CAPTCHA in the Safari "
|
|
+ "window, then click Continue\"). Required."),
|
|
]),
|
|
"reason": .object([
|
|
"type": .string("string"),
|
|
"description": .string(
|
|
"Why this step needs a human rather than you driving the VM "
|
|
+ "yourself. Required; empty or vague reasons are rejected."),
|
|
]),
|
|
]),
|
|
"required": .array([.string("instructions"), .string("reason")]),
|
|
]),
|
|
]))
|
|
}
|
|
return rpcResult(id: id, result: .object(["tools": .array(tools)]))
|
|
|
|
case "tools/call":
|
|
let params = message["params"]
|
|
let toolName = params?["name"]?.stringValue
|
|
let arguments = params?["arguments"]
|
|
switch toolName {
|
|
case Self.toolName:
|
|
let call = ApprovalCall(
|
|
toolName: arguments?["tool_name"]?.stringValue ?? "unknown",
|
|
input: arguments?["input"] ?? .object([:]),
|
|
toolUseID: arguments?["tool_use_id"]?.stringValue)
|
|
// This await is the bridge: it suspends the HTTP response until the
|
|
// human answers (ApprovalCoordinator), then returns the mapped reply.
|
|
let reply = await handler(call)
|
|
return toolResult(id: id, text: reply.wireJSON())
|
|
|
|
case Self.conflictToolName:
|
|
let files = (arguments?["files"]?.arrayValue ?? []).compactMap { $0.stringValue }
|
|
let call = ConflictCall(
|
|
task: arguments?["task"]?.stringValue ?? "", files: files)
|
|
// No handler registered (shouldn't happen) → fail open: no conflict.
|
|
guard let conflictHandler = conflictHandlers[token] else {
|
|
return toolResult(id: id, text: ConflictReply(resolution: .noConflict).wireJSON())
|
|
}
|
|
let reply = await conflictHandler(call)
|
|
return toolResult(id: id, text: reply.wireJSON())
|
|
|
|
case Self.hostExecToolName:
|
|
let call = HostExecCall(
|
|
command: arguments?["command"]?.stringValue ?? "",
|
|
reason: arguments?["reason"]?.stringValue ?? "")
|
|
// No handler (tool shouldn't be visible) → refuse rather than run anything.
|
|
guard let hostExecHandler = hostExecHandlers[token] else {
|
|
return toolResult(
|
|
id: id,
|
|
text: HostExecReply.denied(message: "Host execution is not enabled.")
|
|
.wireJSON())
|
|
}
|
|
// Suspends until the user approves and the host command finishes (HOST_EXEC).
|
|
let reply = await hostExecHandler(call)
|
|
return toolResult(id: id, text: reply.wireJSON())
|
|
|
|
case Self.macVMExecToolName:
|
|
let call = MacVMExecCall(
|
|
command: arguments?["command"]?.stringValue ?? "",
|
|
reason: arguments?["reason"]?.stringValue ?? "")
|
|
guard let macVMExecHandler = macVMExecHandlers[token] else {
|
|
return toolResult(
|
|
id: id,
|
|
text: MacVMExecReply.denied(message: "The macOS VM is not enabled.")
|
|
.wireJSON())
|
|
}
|
|
// Suspends until the user approves and the in-VM command finishes.
|
|
let reply = await macVMExecHandler(call)
|
|
return toolResult(id: id, text: reply.wireJSON())
|
|
|
|
case Self.macVMComputerToolName:
|
|
let call = MacVMComputerCall(
|
|
action: arguments?["action"]?.stringValue ?? "screenshot",
|
|
x: arguments?["x"]?.intValue,
|
|
y: arguments?["y"]?.intValue,
|
|
text: arguments?["text"]?.stringValue,
|
|
ref: arguments?["ref"]?.stringValue,
|
|
value: arguments?["value"]?.stringValue,
|
|
scrollDirection: arguments?["scroll_direction"]?.stringValue,
|
|
scrollAmount: arguments?["scroll_amount"]?.intValue,
|
|
durationMs: arguments?["duration_ms"]?.intValue)
|
|
guard let macVMComputerHandler = macVMComputerHandlers[token] else {
|
|
return toolResult(
|
|
id: id,
|
|
text: MacVMComputerReply.denied(message: "VM computer-use is not enabled.")
|
|
.deniedJSON())
|
|
}
|
|
// Runs the action in the VM and returns the post-action screenshot as image content.
|
|
switch await macVMComputerHandler(call) {
|
|
case .denied(let message):
|
|
return toolResult(
|
|
id: id, text: MacVMComputerReply.denied(message: message).deniedJSON())
|
|
case .ok(let text, let image):
|
|
if let image {
|
|
return toolResult(id: id, text: text, imageBase64: image)
|
|
}
|
|
return toolResult(id: id, text: text)
|
|
}
|
|
|
|
default:
|
|
return rpcError(id: id, code: -32602, message: "Unknown tool")
|
|
}
|
|
|
|
default:
|
|
return rpcError(id: id, code: -32601, message: "Method not found: \(method)")
|
|
}
|
|
}
|
|
|
|
/// Handle a `POST /git-event` from the in-container `git` interceptor shim: bearer-token
|
|
/// gated like the MCP route, but a plain JSON body `{argv, cwd, exitCode, subcommand,
|
|
/// sessionId}`. Fire-and-forget — the registered handler returns immediately (it offloads
|
|
/// any real work), so a report never blocks the agent's git call. Always 202s a known
|
|
/// token (even on a malformed body) so the shim never retries or stalls; 401s an unknown one.
|
|
private func handleGitEvent(_ request: HTTPRequest) -> HTTPResponse {
|
|
let authorization = request.headers["authorization"] ?? ""
|
|
let token = authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
|
|
guard let handler = gitReportHandlers[token] else {
|
|
return HTTPResponse(
|
|
status: 401, statusText: "Unauthorized", contentType: "application/json",
|
|
body: Data(#"{"error":"invalid bearer token"}"#.utf8))
|
|
}
|
|
if let message = try? JSONValue(parsing: request.body) {
|
|
let argv = (message["argv"]?.arrayValue ?? []).compactMap { $0.stringValue }
|
|
handler(GitReportCall(
|
|
argv: argv,
|
|
cwd: message["cwd"]?.stringValue ?? "",
|
|
exitCode: message["exitCode"]?.intValue ?? 0,
|
|
subcommand: message["subcommand"]?.stringValue,
|
|
sessionID: message["sessionId"]?.stringValue))
|
|
}
|
|
return HTTPResponse(status: 202, statusText: "Accepted", contentType: nil, body: Data())
|
|
}
|
|
|
|
/// Handle a `POST /gh-event` from the in-container `gh` interceptor shim — the exact twin of
|
|
/// `handleGitEvent`: bearer-token gated, plain JSON `{argv, cwd, exitCode, subcommand,
|
|
/// sessionId}`, fire-and-forget (202 on any known-token body, 401 on an unknown token).
|
|
private func handleGhEvent(_ request: HTTPRequest) -> HTTPResponse {
|
|
let authorization = request.headers["authorization"] ?? ""
|
|
let token = authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
|
|
guard let handler = ghReportHandlers[token] else {
|
|
return HTTPResponse(
|
|
status: 401, statusText: "Unauthorized", contentType: "application/json",
|
|
body: Data(#"{"error":"invalid bearer token"}"#.utf8))
|
|
}
|
|
if let message = try? JSONValue(parsing: request.body) {
|
|
let argv = (message["argv"]?.arrayValue ?? []).compactMap { $0.stringValue }
|
|
handler(GhReportCall(
|
|
argv: argv,
|
|
cwd: message["cwd"]?.stringValue ?? "",
|
|
exitCode: message["exitCode"]?.intValue ?? 0,
|
|
subcommand: message["subcommand"]?.stringValue,
|
|
sessionID: message["sessionId"]?.stringValue))
|
|
}
|
|
return HTTPResponse(status: 202, statusText: "Accepted", contentType: nil, body: Data())
|
|
}
|
|
|
|
/// Handle a `POST /command-event` from the non-git command interceptor: bearer-token gated
|
|
/// like `/git-event`. Two body shapes, both fanned out to one `CommandReportCall` per command:
|
|
///
|
|
/// * shim — `{type:"command", command, argv, cwd, exitCode, durationMs, stdout, stderr,
|
|
/// truncated, sessionId}` — one rich, output-carrying report.
|
|
/// * tracer — `{type:"command-batch", sessionId, events:[{line, cwd, exitCode, durationMs}]}`
|
|
/// — a batch of metadata-only reports for a whole Bash tool call.
|
|
///
|
|
/// Fire-and-forget: handlers return immediately. Always 202s a known token (even on a malformed
|
|
/// body) so the shim/tracer never retry or stall; 401s an unknown one.
|
|
private func handleCommandEvent(_ request: HTTPRequest) -> HTTPResponse {
|
|
let authorization = request.headers["authorization"] ?? ""
|
|
let token = authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
|
|
guard let handler = commandReportHandlers[token] else {
|
|
return HTTPResponse(
|
|
status: 401, statusText: "Unauthorized", contentType: "application/json",
|
|
body: Data(#"{"error":"invalid bearer token"}"#.utf8))
|
|
}
|
|
if let message = try? JSONValue(parsing: request.body) {
|
|
for call in Self.parseCommandReports(message) { handler(call) }
|
|
}
|
|
return HTTPResponse(status: 202, statusText: "Accepted", contentType: nil, body: Data())
|
|
}
|
|
|
|
/// Decode a `/command-event` body into zero or more `CommandReportCall`s. A `command-batch`
|
|
/// (tracer) yields one per `events[]` entry; anything else is treated as a single shim report.
|
|
static func parseCommandReports(_ message: JSONValue) -> [CommandReportCall] {
|
|
let sessionID = message["sessionId"]?.stringValue
|
|
if message["type"]?.stringValue == "command-batch" {
|
|
return (message["events"]?.arrayValue ?? []).map { event in
|
|
CommandReportCall(
|
|
command: nil, argv: [], commandLine: event["line"]?.stringValue ?? "",
|
|
cwd: event["cwd"]?.stringValue ?? "",
|
|
exitCode: event["exitCode"]?.intValue ?? 0,
|
|
durationMs: event["durationMs"]?.intValue,
|
|
stdout: "", stderr: "", truncated: false, source: .tracer, sessionID: sessionID)
|
|
}
|
|
}
|
|
let argv = (message["argv"]?.arrayValue ?? []).compactMap { $0.stringValue }
|
|
return [CommandReportCall(
|
|
command: message["command"]?.stringValue, argv: argv,
|
|
commandLine: message["commandLine"]?.stringValue,
|
|
cwd: message["cwd"]?.stringValue ?? "",
|
|
exitCode: message["exitCode"]?.intValue ?? 0,
|
|
durationMs: message["durationMs"]?.intValue,
|
|
stdout: message["stdout"]?.stringValue ?? "",
|
|
stderr: message["stderr"]?.stringValue ?? "",
|
|
truncated: message["truncated"]?.boolValue ?? false,
|
|
source: .shim, sessionID: sessionID)]
|
|
}
|
|
|
|
/// A successful `tools/call` result wrapping `text` in a single MCP text content block.
|
|
private func toolResult(id: JSONValue, text: String) -> HTTPResponse {
|
|
rpcResult(
|
|
id: id,
|
|
result: .object([
|
|
"content": .array([
|
|
.object(["type": .string("text"), "text": .string(text)])
|
|
]),
|
|
"isError": .bool(false),
|
|
]))
|
|
}
|
|
|
|
/// As ``toolResult(id:text:)`` but also attaches an image content block, so a tool can return a
|
|
/// **screenshot the model actually sees** (standard MCP image content; the Claude CLI forwards an
|
|
/// image tool result into the model's context). Used by `mac_vm_computer` to close the see→act loop.
|
|
/// `mimeType` is the base64 payload's type (e.g. `image/jpeg`).
|
|
private func toolResult(
|
|
id: JSONValue, text: String, imageBase64: String, mimeType: String = "image/jpeg"
|
|
) -> HTTPResponse {
|
|
rpcResult(
|
|
id: id,
|
|
result: .object([
|
|
"content": .array([
|
|
.object(["type": .string("text"), "text": .string(text)]),
|
|
.object([
|
|
"type": .string("image"),
|
|
"data": .string(imageBase64),
|
|
"mimeType": .string(mimeType),
|
|
]),
|
|
]),
|
|
"isError": .bool(false),
|
|
]))
|
|
}
|
|
|
|
private func rpcResult(id: JSONValue, result: JSONValue) -> HTTPResponse {
|
|
let body = JSONValue.object(["jsonrpc": .string("2.0"), "id": id, "result": result])
|
|
return HTTPResponse(
|
|
status: 200, statusText: "OK", contentType: "application/json",
|
|
body: (try? body.encodedData()) ?? Data())
|
|
}
|
|
|
|
private func rpcError(id: JSONValue, code: Int, message: String) -> HTTPResponse {
|
|
let body = JSONValue.object([
|
|
"jsonrpc": .string("2.0"), "id": id,
|
|
"error": .object(["code": .number(Double(code)), "message": .string(message)]),
|
|
])
|
|
return HTTPResponse(
|
|
status: 200, statusText: "OK", contentType: "application/json",
|
|
body: (try? body.encodedData()) ?? Data())
|
|
}
|
|
}
|
|
|
|
// MARK: - Transport abstraction
|
|
|
|
/// A bidirectional byte stream the HTTP/JSON-RPC serve loop drives, so the same dispatch runs over
|
|
/// either a Network.framework TCP connection (host loopback) or a raw unix-domain socket (the
|
|
/// vsock-relayed control plane). `receive` returns nil at EOF; `close` is idempotent.
|
|
private protocol ByteConn: Sendable {
|
|
func receive(maxLength: Int) async throws -> Data?
|
|
func send(_ data: Data) async throws
|
|
func close()
|
|
}
|
|
|
|
/// ``ByteConn`` over a Network.framework connection (the TCP transport). Started on creation so the
|
|
/// serve loop can receive immediately.
|
|
private final class NWByteConn: ByteConn, @unchecked Sendable {
|
|
private let connection: NWConnection
|
|
init(_ connection: NWConnection) {
|
|
self.connection = connection
|
|
connection.start(queue: .global(qos: .userInitiated))
|
|
}
|
|
func receive(maxLength: Int) async throws -> Data? {
|
|
try await withCheckedThrowingContinuation { cont in
|
|
connection.receive(minimumIncompleteLength: 1, maximumLength: maxLength) {
|
|
data, _, isComplete, error in
|
|
if let error {
|
|
cont.resume(throwing: error)
|
|
} else if let data, !data.isEmpty {
|
|
cont.resume(returning: data)
|
|
} else if isComplete {
|
|
cont.resume(returning: nil)
|
|
} else {
|
|
cont.resume(returning: Data())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func send(_ data: Data) async throws {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
connection.send(
|
|
content: data,
|
|
completion: .contentProcessed { error in
|
|
if let error { cont.resume(throwing: error) } else { cont.resume() }
|
|
})
|
|
}
|
|
}
|
|
func close() { connection.cancel() }
|
|
}
|
|
|
|
/// ``ByteConn`` over an accepted `AF_UNIX` stream socket (the vsock-relayed transport). Reads/writes
|
|
/// are blocking POSIX calls offloaded to a background queue, so the actor never blocks and a turn
|
|
/// suspended on an approval holds no thread (no read is in flight between request and response). The
|
|
/// control plane's live connection count is small (one MCP connection plus short-lived interceptor
|
|
/// posts per container), so a thread parked on `read` per live connection is acceptable.
|
|
private final class UnixSocketByteConn: ByteConn, @unchecked Sendable {
|
|
private let fd: Int32
|
|
private let lock = NSLock()
|
|
private var closed = false
|
|
init(fd: Int32) { self.fd = fd }
|
|
|
|
func receive(maxLength: Int) async throws -> Data? {
|
|
let fd = self.fd
|
|
return try await withCheckedThrowingContinuation { cont in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
var buffer = [UInt8](repeating: 0, count: maxLength)
|
|
while true {
|
|
let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress, maxLength) }
|
|
if n > 0 {
|
|
cont.resume(returning: Data(buffer[0..<n]))
|
|
} else if n == 0 {
|
|
cont.resume(returning: nil)
|
|
} else if errno == EINTR {
|
|
continue
|
|
} else {
|
|
cont.resume(throwing: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func send(_ data: Data) async throws {
|
|
let fd = self.fd
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let result: Result<Void, Error> = data.withUnsafeBytes { raw in
|
|
guard let base = raw.baseAddress else { return .success(()) }
|
|
var offset = 0
|
|
while offset < raw.count {
|
|
let n = write(fd, base + offset, raw.count - offset)
|
|
if n > 0 {
|
|
offset += n
|
|
} else if n < 0 && errno == EINTR {
|
|
continue
|
|
} else {
|
|
return .failure(POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO))
|
|
}
|
|
}
|
|
return .success(())
|
|
}
|
|
cont.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func close() {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
guard !closed else { return }
|
|
closed = true
|
|
shutdown(fd, SHUT_RDWR)
|
|
_ = Darwin.close(fd)
|
|
}
|
|
}
|
|
|
|
/// Failure binding/listening the control-plane unix socket.
|
|
private struct UnixSocketError: Error, CustomStringConvertible {
|
|
let description: String
|
|
init(_ description: String) { self.description = description }
|
|
}
|
|
|
|
/// Resume-once guard for NWListener state callbacks (ready may be followed by
|
|
/// failed; the continuation must fire exactly once).
|
|
private final class OnceBox: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var claimed = false
|
|
|
|
func claim() -> Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if claimed { return false }
|
|
claimed = true
|
|
return true
|
|
}
|
|
}
|