770 lines
39 KiB
Swift
770 lines
39 KiB
Swift
import Foundation
|
|
|
|
/// `AgentBackend` over xAI Grok's **ACP** mode — `grok agent stdio` (BACKEND_PROTOCOL §7).
|
|
/// ACP (the Agent Client Protocol) is standards JSON-RPC 2.0 over the process's stdio, baked
|
|
/// directly into the `grok` binary, so this backend is a near-twin of
|
|
/// [`CodexAppServerBackend`](../Codex/CodexAppServerBackend.swift): it speaks JSON-RPC via the
|
|
/// shared [`JSONRPCConnection`](../JSONRPCConnection.swift) (with the `"jsonrpc"` header on, as
|
|
/// ACP requires), normalizes `session/update` notifications into `AgentEvent`s
|
|
/// ([`GrokACPDecoder`](GrokACPDecoder.swift)), and answers interactive approvals — which the agent
|
|
/// sends as `session/request_permission` server→client *requests* — through the shared
|
|
/// `ApprovalCoordinator`, the same suspension seam Claude's MCP bridge and Codex's JSON-RPC use.
|
|
///
|
|
/// This **replaces** the previous `grok -p --output-format streaming-json` + generated-PreToolUse-
|
|
/// hook design (and its `/grok-hook` HTTP bridge): ACP gives native streaming and a native
|
|
/// permission request, so the inferred streaming-json vocab and the entire hook bridge are gone.
|
|
///
|
|
/// **Turn model (v1).** Per-turn process, like the Codex backends: `start` spawns the agent and
|
|
/// opens a session; each follow-up `resume` spawns a fresh process and `session/load`s the same
|
|
/// session by id (falling back to a fresh session if the agent doesn't advertise `loadSession`).
|
|
/// `followUpWhileRunning = false`. Runs on the host by default; when a run carries a vsock control
|
|
/// container (Nucleic Control, control plane enabled) it execs inside it — stdio over vsock, with the
|
|
/// git/gh/command interceptor wired to the shared per-container server. (`sandboxModes = []` refers to
|
|
/// Codex-style in-process sandbox levels, which ACP doesn't expose — distinct from the container.)
|
|
public actor ACPBackend: AgentBackend {
|
|
/// Nominal protocol id (never read externally — the live backend identity travels on each
|
|
/// emitted `AgentEvent.backend`, taken from `configuration.agent.backend`). Defaults to Grok,
|
|
/// the original ACP agent.
|
|
public static let id = BackendID.grok
|
|
|
|
public nonisolated let capabilities = BackendCapabilities(
|
|
interactiveApprovals: true, // session/request_permission bridge
|
|
allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern], // via our ApprovalCoordinator cache
|
|
canModifyToolInput: false, // ACP permission can't rewrite tool input
|
|
partialMessageStreaming: true, // agent_message_chunk / agent_thought_chunk
|
|
emitsThinking: true, // agent_thought_chunk
|
|
emitsFileChangeEvents: false, // synthesized from tool-call locations
|
|
nativeResume: true, // session/load
|
|
sandboxModes: [], // host-only in v1
|
|
followUpWhileRunning: false) // single-shot per turn; resume next turn
|
|
|
|
public struct Configuration: Sendable {
|
|
/// Which ACP agent this backend drives (invocation, auth, seeding). Defaults to Grok, the
|
|
/// original ACP agent, so existing Grok call sites/tests are unchanged.
|
|
public var agent: ACPAgent
|
|
/// Executable name (resolved via PATH) or absolute path. Defaults to `agent.executable`;
|
|
/// tests override it to point at a fake stub (OBSERVABILITY B.4).
|
|
public var executable: String
|
|
public var clientName: String
|
|
public var clientVersion: String
|
|
public var now: @Sendable () -> Date
|
|
|
|
public init(
|
|
agent: ACPAgent = .grok,
|
|
executable: String? = nil,
|
|
clientName: String = "Nucleic",
|
|
clientVersion: String = "0.1",
|
|
now: @escaping @Sendable () -> Date = { Date() }
|
|
) {
|
|
self.agent = agent
|
|
self.executable = executable ?? agent.executable
|
|
self.clientName = clientName
|
|
self.clientVersion = clientVersion
|
|
self.now = now
|
|
}
|
|
}
|
|
|
|
/// ACP protocol version we negotiate (`grok agent stdio` speaks v1).
|
|
static let protocolVersion = 1
|
|
|
|
private let configuration: Configuration
|
|
private let processHost: ProcessHost
|
|
public let approvals: ApprovalCoordinator
|
|
/// Optional conflict coordinator. When set, edit-class permission requests are arbitrated so
|
|
/// Grok sessions join the lock system + Nucleic Control autoship, exactly like Claude.
|
|
private let conflictCoordinator: ConflictCoordinator?
|
|
/// Optional sandbox orchestrator. When set and a run carries a vsock control container, the
|
|
/// agent execs inside it (stdio over vsock) instead of on the host — the same per-family control
|
|
/// container isolation Claude gets. `nil` → always host.
|
|
private let containerManager: ContainerManager?
|
|
/// Name of the container the current run execs in (for the teardown `finished` callback).
|
|
private var activeContainerName: String?
|
|
/// Vends the shared, token-multiplexed approval server for a control container — Grok uses it
|
|
/// only for the git/gh/command interceptor report routes (its approvals are native ACP, not
|
|
/// MCP). `nil` → no interceptor reporting (host runs, or tests).
|
|
private let approvalServerRegistry: ApprovalServerRegistry?
|
|
/// The interceptor server + token for the current containerized run (for teardown unregister).
|
|
private var interceptorServer: MCPApprovalServer?
|
|
private var interceptorToken: String?
|
|
|
|
private var handle: (any ProcessHandle)?
|
|
private var rpc: JSONRPCConnection?
|
|
private var decoder = GrokACPDecoder()
|
|
private var continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation?
|
|
private var sessionID: SessionID?
|
|
private var provisionalSeq: UInt64 = 0
|
|
private var interruptRequested = false
|
|
private var terminating = false
|
|
private var sawRunFinished = false
|
|
private var autoApprove = false
|
|
/// The open ACP session's id (kept across turns so `resume` reattaches the same session).
|
|
private var acpSessionID: String?
|
|
/// Permission handlers run as detached tasks (the transport dispatches inbound requests off the
|
|
/// read loop), so they can still be mid-flight when the `session/prompt` response settles the
|
|
/// turn — ACP's terminal is decoupled from the permission round-trip. We count them and drain to
|
|
/// zero before finishing, so every `approvalRequested` gets its `approvalResolved` into the
|
|
/// stream ahead of `runFinished` (and its reply written before teardown closes the connection).
|
|
private var inflightApprovals = 0
|
|
private var approvalsDrained: CheckedContinuation<Void, Never>?
|
|
public private(set) var backendSessionID: String?
|
|
|
|
public init(
|
|
configuration: Configuration = Configuration(),
|
|
processHost: ProcessHost = ProcessHost(),
|
|
approvals: ApprovalCoordinator = ApprovalCoordinator(),
|
|
conflictCoordinator: ConflictCoordinator? = nil,
|
|
containerManager: ContainerManager? = nil,
|
|
approvalServerRegistry: ApprovalServerRegistry? = nil
|
|
) {
|
|
self.configuration = configuration
|
|
self.processHost = processHost
|
|
self.approvals = approvals
|
|
self.conflictCoordinator = conflictCoordinator
|
|
self.approvalServerRegistry = approvalServerRegistry
|
|
self.containerManager = containerManager
|
|
}
|
|
|
|
// MARK: - AgentBackend
|
|
|
|
public nonisolated func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> {
|
|
makeStream(run: run, resumeSessionID: nil)
|
|
}
|
|
|
|
public nonisolated func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> {
|
|
let run = RunSpec(
|
|
sessionID: resume.sessionID,
|
|
worktree: resume.worktree,
|
|
prompt: resume.prompt ?? AgentInput(parts: []),
|
|
model: resume.model,
|
|
effort: resume.effort,
|
|
autoApprove: resume.autoApprove,
|
|
appendSystemPrompt: resume.appendSystemPrompt)
|
|
return makeStream(run: run, resumeSessionID: resume.backendSessionID)
|
|
}
|
|
|
|
public func send(_ input: AgentInput) async throws {
|
|
// ACP turns are single-shot per process in v1; conversational follow-ups arrive via
|
|
// resume() (which session/loads the same session), as for the Codex backends.
|
|
throw BackendError.unsupported("Grok ACP follow-ups resume a new turn; send() is unused in v1")
|
|
}
|
|
|
|
public func respond(to approvalID: ApprovalID, _ decision: Decision, by responder: String) async throws {
|
|
let outcome = await approvals.resolve(approvalID, decision, by: responder)
|
|
if outcome == .unknownID {
|
|
throw BackendError.protocolViolation("No outstanding approval \(approvalID)")
|
|
}
|
|
}
|
|
|
|
public func interrupt() async {
|
|
interruptRequested = true
|
|
await cancelCurrentSession()
|
|
}
|
|
|
|
public func shutdown() async {
|
|
terminating = true
|
|
await approvals.cancelOutstanding(reason: "Session terminated")
|
|
await teardownTurn()
|
|
}
|
|
|
|
// MARK: - Run loop
|
|
|
|
private nonisolated func makeStream(run: RunSpec, resumeSessionID: String?)
|
|
-> AsyncThrowingStream<AgentEvent, Error>
|
|
{
|
|
AsyncThrowingStream { continuation in
|
|
let task = Task {
|
|
await self.runLoop(run: run, resumeSessionID: resumeSessionID, continuation: continuation)
|
|
}
|
|
continuation.onTermination = { _ in task.cancel() }
|
|
}
|
|
}
|
|
|
|
private func runLoop(
|
|
run: RunSpec,
|
|
resumeSessionID: String?,
|
|
continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation
|
|
) async {
|
|
self.continuation = continuation
|
|
self.sessionID = run.sessionID
|
|
self.sawRunFinished = false
|
|
self.interruptRequested = false
|
|
self.autoApprove = run.autoApprove
|
|
self.decoder = GrokACPDecoder()
|
|
|
|
// A re-attach-only resume (no prompt) needs no process: the session id we'd reattach is
|
|
// already known and the UI history comes from our transcript. Settle and return.
|
|
if run.prompt.parts.isEmpty {
|
|
if let resumeSessionID { acpSessionID = resumeSessionID; backendSessionID = resumeSessionID }
|
|
finishRun(outcome: .completed)
|
|
continuation.finish()
|
|
return
|
|
}
|
|
|
|
let stderrTail = ACPStderrTail()
|
|
do {
|
|
// 1. Spawn `grok agent stdio` — inside the shared control container (stdio over vsock)
|
|
// when this run carries a vsock control socket, else on the host. The container path is
|
|
// gated on that signal (vsock control plane on + a shared control container), so default
|
|
// behavior is unchanged. (Requires `grok` + its auth in the sandbox image.)
|
|
// Host-run (unsandboxed) agent: pin its git (every remote) to the Managed Git key for the
|
|
// run's lifetime (shredded when this `do` block exits — after the agent process ends). A
|
|
// containerized run gets the key via `provision` (cspec.env). No-op when unset.
|
|
let isHostRun = !(containerManager != nil && run.container?.controlSocketHostPath != nil)
|
|
var hostManagedGitEnv: [String: String] = [:]
|
|
let hostManagedGitCleanup: @Sendable () -> Void
|
|
if isHostRun {
|
|
let (e, c) = GitHubCredentialProvisioner.provisionHost()
|
|
hostManagedGitEnv = e
|
|
hostManagedGitEnv.removeValue(forKey: "GH_CONFIG_DIR")
|
|
hostManagedGitCleanup = c
|
|
} else {
|
|
hostManagedGitCleanup = {}
|
|
}
|
|
defer { hostManagedGitCleanup() }
|
|
|
|
let handle: any ProcessHandle
|
|
if let containerManager, let cspec = run.container, let controlSock = cspec.controlSocketHostPath {
|
|
let (name, _) = try await containerManager.ensureRunning(cspec)
|
|
activeContainerName = name
|
|
var env = cspec.env.merging(run.extraEnv) { _, new in new }
|
|
// Git/gh/command interceptor: Grok's in-container shims POST observed ops to the
|
|
// shared per-container approval server (over the relayed socket, via the in-guest
|
|
// bridge), which forwards them to the conflict coordinator (locks + autoship) — the
|
|
// same observation Claude gets. Grok's approvals stay native ACP, so only the report
|
|
// routes are registered, not the MCP approve tool.
|
|
if cspec.installGitInterceptor, let conflictCoordinator,
|
|
let registry = approvalServerRegistry
|
|
{
|
|
let server = await registry.server(for: cspec.name)
|
|
try await server.start(unixSocketPath: controlSock)
|
|
let token = UUID().uuidString
|
|
interceptorServer = server
|
|
interceptorToken = token
|
|
await server.registerGitReport(token: token) { [weak self] call in
|
|
Task { await self?.handleGitReport(call) }
|
|
}
|
|
await server.registerGhReport(token: token) { [weak self] call in
|
|
Task { await self?.handleGhReport(call) }
|
|
}
|
|
await server.registerCommandReport(token: token) { [weak self] call in
|
|
Task { await self?.handleCommandReport(call) }
|
|
}
|
|
// `check_conflict` MCP tool: the proactive conflict probe Claude has, reached over
|
|
// the same `/mcp` endpoint (wired into the ACP session's `mcpServers` in
|
|
// `openSession`), so Grok can avoid colliding with another active session.
|
|
let sid = run.sessionID
|
|
await server.registerConflict(token: token) { call in
|
|
let outcome = await conflictCoordinator.arbitrate(
|
|
sessionID: sid, task: call.task, files: call.files)
|
|
return MCPApprovalServer.ConflictReply(resolution: outcome.resolution)
|
|
}
|
|
env.merge(
|
|
CommandInterceptor.hookEnv(
|
|
host: "127.0.0.1", port: ContainerSpec.controlBridgePort,
|
|
token: token, sessionID: run.sessionID,
|
|
commandTracing: ContainerServiceSettings.commandTracingEnabled)
|
|
) { _, new in new }
|
|
}
|
|
handle = try await containerManager.exec(
|
|
name: name, workdir: cspec.workdir, env: env,
|
|
argv: [configuration.executable] + configuration.agent.launchArgs,
|
|
uid: cspec.runAsUID, gid: cspec.runAsGID)
|
|
} else {
|
|
let spec = ProcessSpec(
|
|
executable: configuration.executable,
|
|
args: configuration.agent.launchArgs,
|
|
cwd: run.worktree,
|
|
env: run.extraEnv.merging(hostManagedGitEnv) { _, new in new },
|
|
stdinMode: .pipe)
|
|
handle = try await processHost.launch(spec)
|
|
}
|
|
self.handle = handle
|
|
|
|
let stderrTask = Task {
|
|
for try await line in handle.stderrLines {
|
|
let text = String(decoding: line, as: UTF8.self)
|
|
stderrTail.append(text)
|
|
if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
|
|
let tag = self.configuration.agent.executable
|
|
FileHandle.standardError.write(Data(("\(tag)-stderr: " + text + "\n").utf8))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. JSON-RPC client (ACP: header on) + inbound (permission) request handler.
|
|
let rpc = JSONRPCConnection(handle: handle, includeVersionHeader: true)
|
|
self.rpc = rpc
|
|
await rpc.start { [weak self] request in
|
|
await self?.handleInboundRequest(request)
|
|
}
|
|
|
|
// 3. Handshake: initialize → (authenticate if required) → session/new | session/load.
|
|
let initResult = try await rpc.request("initialize", params: .object([
|
|
"protocolVersion": .number(Double(Self.protocolVersion)),
|
|
"clientCapabilities": .object([
|
|
"fs": .object(["readTextFile": .bool(false), "writeTextFile": .bool(false)]),
|
|
"terminal": .bool(false),
|
|
]),
|
|
"clientInfo": .object([
|
|
"name": .string(configuration.clientName),
|
|
"version": .string(configuration.clientVersion),
|
|
]),
|
|
]))
|
|
let agentCaps = initResult["agentCapabilities"] ?? .object([:])
|
|
let supportsLoad = agentCaps["loadSession"]?.boolValue ?? false
|
|
// If the agent advertises auth methods, it isn't authenticated yet — pick the first
|
|
// (best-effort; grok with a prior login or XAI_API_KEY reports none).
|
|
if let methodID = initResult["authMethods"]?.arrayValue?.first?["id"]?.stringValue {
|
|
_ = try? await rpc.request("authenticate", params: .object(["methodId": .string(methodID)]))
|
|
}
|
|
|
|
let sessionID = try await openSession(
|
|
rpc: rpc, run: run, resumeSessionID: resumeSessionID, supportsLoad: supportsLoad)
|
|
acpSessionID = sessionID
|
|
backendSessionID = sessionID
|
|
emit(
|
|
.sessionStarted(
|
|
SessionStarted(
|
|
backendSessionID: sessionID,
|
|
model: run.model ?? "",
|
|
cwd: run.worktree,
|
|
toolNames: [],
|
|
nativeTranscriptPath: nil)),
|
|
nativeType: "session/new")
|
|
|
|
// 4. Drain session/update notifications concurrently with the prompt turn. The decoder
|
|
// stays actor-isolated (only `self` + `rpc`, both Sendable, cross into the Task).
|
|
let drainTask = Task { [rpc] in
|
|
for await note in rpc.notifications {
|
|
self.handleNotification(note)
|
|
}
|
|
}
|
|
|
|
// 5. Run the turn. ACP's terminal is the session/prompt *response* (a stopReason), not
|
|
// a notification — so await it, then end the notification stream and let the drain
|
|
// deliver every buffered update before we settle (no trailing chunk dropped).
|
|
let promptResult = try? await rpc.request(
|
|
"session/prompt",
|
|
params: .object([
|
|
"sessionId": .string(sessionID),
|
|
"prompt": Self.promptContent(for: run.prompt),
|
|
]))
|
|
await rpc.endNotifications()
|
|
await drainTask.value
|
|
|
|
// 6. Resolve + drain any still-outstanding approval BEFORE settling. ACP's terminal (the
|
|
// prompt response) can arrive with a permission request still suspended (e.g. an
|
|
// interrupt/cancel); resolving it here and waiting for its handler to finish keeps
|
|
// every approvalResolved (and its wire reply) ahead of runFinished + teardown.
|
|
await approvals.cancelOutstanding(reason: "Run ended")
|
|
await drainOutstandingApprovals()
|
|
|
|
// 7. Settle the turn.
|
|
if let promptResult {
|
|
let stopReason = promptResult["stopReason"]?.stringValue
|
|
emit(
|
|
.turnCompleted(TurnCompleted(stopReason: stopReason, usage: nil)),
|
|
nativeType: "session/prompt")
|
|
finishRun(outcome: Self.outcome(forStopReason: stopReason, interrupted: interruptRequested))
|
|
} else if interruptRequested {
|
|
finishRun(outcome: .interrupted)
|
|
} else if terminating {
|
|
finishRun(outcome: .completed)
|
|
} else {
|
|
// No response and not a deliberate stop → the agent exited mid-turn. Classify like
|
|
// the other backends (ADAPTERS §1.5).
|
|
let exitCode = await handle.wait()
|
|
emit(
|
|
.error(
|
|
AgentError(
|
|
recoverable: false,
|
|
message: BackendDiagnostics.abnormalExitMessage(
|
|
tool: configuration.agent.executable, exitCode: exitCode,
|
|
stderrTail: stderrTail.joined(), containerized: false))),
|
|
nativeType: nil)
|
|
finishRun(outcome: .errored)
|
|
}
|
|
|
|
stderrTask.cancel()
|
|
await teardownTurn()
|
|
continuation.finish()
|
|
} catch {
|
|
// A deliberate stop (interrupt/shutdown) can abort the handshake mid-flight — settle the
|
|
// stream cleanly so a conversational session lands on awaitingInput (resumable), not a
|
|
// terminal error. Anything else is a genuine setup failure; throw so SessionController
|
|
// synthesizes the terminal error + runFinished (matching the other backends).
|
|
if interruptRequested || terminating {
|
|
await approvals.cancelOutstanding(reason: "Run interrupted")
|
|
await drainOutstandingApprovals()
|
|
if !sawRunFinished {
|
|
finishRun(outcome: interruptRequested ? .interrupted : .completed)
|
|
}
|
|
await teardownTurn()
|
|
continuation.finish()
|
|
} else {
|
|
await approvals.cancelOutstanding(reason: "Run failed")
|
|
await drainOutstandingApprovals()
|
|
await teardownTurn()
|
|
continuation.finish(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Open the ACP session: `session/load` by id when resuming an agent that supports it, else a
|
|
/// fresh `session/new` (a resume against an agent without `loadSession` degrades to a new
|
|
/// session — its new id becomes the resumable handle). `mcpServers` carries Nucleic's own MCP
|
|
/// server (`check_conflict` + the capability tools) over the in-guest control bridge; grok's
|
|
/// file/command approvals still ride ACP's native permission request.
|
|
private func openSession(
|
|
rpc: JSONRPCConnection, run: RunSpec, resumeSessionID: String?, supportsLoad: Bool
|
|
) async throws -> String {
|
|
let mcpServers = nucleicMCPServers()
|
|
if let resumeSessionID, supportsLoad {
|
|
_ = try await rpc.request("session/load", params: .object([
|
|
"sessionId": .string(resumeSessionID),
|
|
"cwd": .string(run.worktree),
|
|
"mcpServers": mcpServers,
|
|
]))
|
|
return resumeSessionID
|
|
}
|
|
let result = try await rpc.request("session/new", params: .object([
|
|
"cwd": .string(run.worktree),
|
|
"mcpServers": mcpServers,
|
|
]))
|
|
return result["sessionId"]?.stringValue ?? resumeSessionID ?? ""
|
|
}
|
|
|
|
/// The ACP `mcpServers` array advertising Nucleic's own MCP server to grok — an HTTP transport
|
|
/// entry pointing at `/mcp` over the in-guest control bridge, authenticated by this run's bearer
|
|
/// token. Empty when there's no control server (host runs), so grok stays hermetic there. Shape
|
|
/// follows the ACP HTTP `McpServer` (`type:"http"`, `headers:[{name,value}]`).
|
|
private func nucleicMCPServers() -> JSONValue {
|
|
guard let token = interceptorToken else { return .array([]) }
|
|
let url = "http://127.0.0.1:\(ContainerSpec.controlBridgePort)/mcp"
|
|
return .array([
|
|
.object([
|
|
"type": .string("http"),
|
|
"name": .string("nucleic"),
|
|
"url": .string(url),
|
|
"headers": .array([
|
|
.object([
|
|
"name": .string("Authorization"),
|
|
"value": .string("Bearer \(token)"),
|
|
])
|
|
]),
|
|
])
|
|
])
|
|
}
|
|
|
|
/// Decode + emit one drained notification (actor-isolated so the decoder never crosses an
|
|
/// isolation boundary).
|
|
private func handleNotification(_ note: JSONRPCConnection.Notification) {
|
|
for decoded in decoder.decode(method: note.method, params: note.params) {
|
|
emit(decoded.kind, nativeType: decoded.nativeType)
|
|
}
|
|
}
|
|
|
|
private func finishRun(outcome: RunFinished.Outcome) {
|
|
guard !sawRunFinished else { return }
|
|
sawRunFinished = true
|
|
emit(.runFinished(RunFinished(outcome: outcome)), nativeType: nil)
|
|
}
|
|
|
|
/// Suspend until every in-flight permission handler has finished (emitted its `approvalResolved`
|
|
/// and written its reply). A no-op when none are outstanding. Resumed by the last handler's
|
|
/// completion in `handlePermissionRequest`'s `defer`.
|
|
private func drainOutstandingApprovals() async {
|
|
guard inflightApprovals > 0 else { return }
|
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
approvalsDrained = continuation
|
|
}
|
|
}
|
|
|
|
/// Tear down the just-finished turn's process + connection. `acpSessionID`/`backendSessionID`
|
|
/// persist so the next `resume` reattaches the same session.
|
|
private func teardownTurn() async {
|
|
if let rpc { await rpc.close() }
|
|
self.rpc = nil
|
|
if let handle {
|
|
handle.closeStdin()
|
|
await handle.terminate()
|
|
}
|
|
self.handle = nil
|
|
if let token = interceptorToken {
|
|
// Unregister only this run's token; the shared per-container server keeps serving the
|
|
// other sessions in the box.
|
|
await interceptorServer?.unregister(token: token)
|
|
interceptorToken = nil
|
|
interceptorServer = nil
|
|
}
|
|
if let name = activeContainerName {
|
|
await containerManager?.finished(name: name)
|
|
activeContainerName = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Interceptor reporting (Nucleic Control containers)
|
|
|
|
/// Forward a ground-truth git op reported by the in-container `git` shim to the conflict
|
|
/// coordinator (locks + autoship), the same seam Claude's interceptor uses. Best-effort.
|
|
private func handleGitReport(_ call: MCPApprovalServer.GitReportCall) async {
|
|
guard let sessionID, let conflictCoordinator else { return }
|
|
await conflictCoordinator.observeGitOp(
|
|
sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
|
|
}
|
|
|
|
/// Forward a ground-truth `gh` action (PR/release/API) reported by the in-container `gh` shim.
|
|
private func handleGhReport(_ call: MCPApprovalServer.GhReportCall) async {
|
|
guard let sessionID, let conflictCoordinator else { return }
|
|
await conflictCoordinator.observeGhOp(
|
|
sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
|
|
}
|
|
|
|
/// Forward a non-git command the interceptor reported (shim or bash tracer) for the command feed.
|
|
private func handleCommandReport(_ call: MCPApprovalServer.CommandReportCall) async {
|
|
guard let sessionID, let conflictCoordinator else { return }
|
|
await conflictCoordinator.observeCommand(sessionID: sessionID, call: call)
|
|
}
|
|
|
|
/// Cooperatively cancel the in-flight turn: ACP `session/cancel` (a notification); the agent
|
|
/// halts and answers the outstanding `session/prompt` with `stopReason:"cancelled"`.
|
|
private func cancelCurrentSession() async {
|
|
guard let rpc, let acpSessionID else {
|
|
handle?.interruptThenKill() // no live session → signal + escalate so Stop always lands
|
|
return
|
|
}
|
|
try? await rpc.notify("session/cancel", params: .object(["sessionId": .string(acpSessionID)]))
|
|
// Safety net: if the agent ignores the cooperative cancel, force-stop the process group after
|
|
// a grace so Stop still takes effect (the next resume reattaches the same ACP session).
|
|
handle?.killGroupAfter(graceNanos: 8 * 1_000_000_000)
|
|
}
|
|
|
|
// MARK: - Approval bridge (server→client requests)
|
|
|
|
private func handleInboundRequest(_ request: JSONRPCConnection.InboundRequest) async {
|
|
guard let rpc else { return }
|
|
switch request.method {
|
|
case "session/request_permission":
|
|
await handlePermissionRequest(request, rpc: rpc)
|
|
case "fs/read_text_file", "fs/write_text_file":
|
|
// We advertised clientCapabilities.fs = false, so the agent shouldn't call these; if it
|
|
// does, answer method-not-found rather than hang (grok uses its own file tools).
|
|
try? await rpc.replyError(
|
|
to: request.id, code: -32601, message: "Client does not provide \(request.method)")
|
|
default:
|
|
try? await rpc.replyError(
|
|
to: request.id, code: -32601, message: "Unsupported request \(request.method)")
|
|
}
|
|
}
|
|
|
|
/// Decide a `session/request_permission`. Mirrors the Claude/old-Grok approval path: conflict
|
|
/// pre-check for edits, the always-rule cache, the read-only fast path, the auto-approve
|
|
/// blanket, then surface to the human — all suspending on the shared `ApprovalCoordinator`.
|
|
/// The reply is an ACP outcome ([`GrokACPDecisionMapping`](GrokACPDecisionMapping.swift)).
|
|
private func handlePermissionRequest(
|
|
_ request: JSONRPCConnection.InboundRequest, rpc: JSONRPCConnection
|
|
) async {
|
|
inflightApprovals += 1
|
|
defer {
|
|
inflightApprovals -= 1
|
|
if inflightApprovals == 0, let continuation = approvalsDrained {
|
|
approvalsDrained = nil
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
let toolCall = request.params["toolCall"] ?? .object([:])
|
|
let options = request.params["options"]?.arrayValue ?? []
|
|
let toolCallID = toolCall["toolCallId"]?.stringValue
|
|
// ACP's `toolCall` is a `ToolCallUpdate` — every field but `toolCallId` is optional, since
|
|
// the full details were already sent in the earlier `tool_call` notification. Recover them
|
|
// by id from the decoder, preferring whatever the request did include (RUNTIME §5).
|
|
let prior = toolCallID.flatMap { decoder.announcedToolCall(id: $0) }
|
|
let kindStr = toolCall["kind"]?.stringValue ?? prior?.kind ?? "other"
|
|
let rawInput = toolCall["rawInput"] ?? prior?.rawInput ?? .object([:])
|
|
let requestLocations = GrokACPDecoder.locationPaths(toolCall["locations"])
|
|
let locations = requestLocations.isEmpty ? (prior?.locations ?? []) : requestLocations
|
|
let toolName = GrokACPDecoder.toolName(forKind: kindStr)
|
|
let input = GrokACPDecoder.normalizedInput(forKind: kindStr, rawInput: rawInput, locations: locations)
|
|
|
|
func reply(_ decision: Decision) async {
|
|
let outcome = GrokACPDecisionMapping.permissionOutcome(for: decision, options: options)
|
|
try? await rpc.reply(to: request.id, result: .object(["outcome": outcome]))
|
|
}
|
|
|
|
// Structured-edit enforcement (shared with Claude/Codex): deny raw shell that mutates a
|
|
// workspace file (`sed -i`, `echo > file`, `tee`, …) so it can't bypass the
|
|
// fileChange→trunk-commit + conflict path. Steers grok to its structured edit tool; the
|
|
// residue-capture backstop covers anything that doesn't surface a permission request.
|
|
if toolName == "Bash", let command = input["command"]?.stringValue,
|
|
let denial = WorktreeMutationGuard.denialReason(command: command)
|
|
{
|
|
await reply(.deny(reason: denial))
|
|
return
|
|
}
|
|
|
|
// Auto-allow Nucleic's own MCP tools (the `nucleic` server): they're already gated by
|
|
// Nucleic's own handlers/UI, so grok must not additionally prompt for them. No-op if grok
|
|
// doesn't surface a permission request for configured MCP-server tools (best-effort match on
|
|
// the tool identity; refine once validated against a live grok session).
|
|
if Self.concernsNucleicMCP(toolCall) {
|
|
await reply(.allow())
|
|
return
|
|
}
|
|
|
|
// Conflict pre-check (internal project management): arbitrate every edit-class call so each
|
|
// touched file acquires its own lock before the edit lands (LOCKING §4.5).
|
|
let editPaths = GrokACPDecoder.editPaths(forKind: kindStr, rawInput: rawInput, locations: locations)
|
|
if let conflictCoordinator, !editPaths.isEmpty {
|
|
let outcome = await conflictCoordinator.arbitrate(
|
|
sessionID: sessionID ?? SessionID(rawValue: "unknown"), task: "", files: editPaths,
|
|
toolUseID: toolCallID)
|
|
switch outcome.resolution {
|
|
case .deferred, .cancelled:
|
|
await reply(.deny(reason: nil)) // blocked: another active Nucleic agent owns these files
|
|
return
|
|
case .proceed, .noConflict:
|
|
// Lock granted (or nothing to lock). If update-on-grant pulled a parent change into a
|
|
// file this edit targets, deny once to re-ground: ACP can't carry the notice text to
|
|
// the model, but the lock is held, so grok's natural retry (re-reading the file)
|
|
// re-grants instantly against fresh content — matching Claude's re-ground behavior
|
|
// instead of letting a stale-context write through (LOCKING §4.5).
|
|
if outcome.refreshNotice != nil {
|
|
await reply(.deny(reason: nil))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always-rule short-circuit: auto-answer a cached decision without surfacing (RUNTIME §5).
|
|
if let cached = await approvals.cachedDecision(toolName: toolName, input: input) {
|
|
await reply(cached.decision)
|
|
return
|
|
}
|
|
|
|
var risk = RiskClassifier.classify(toolName: toolName, input: input)
|
|
// A native `delete` kind maps to the `Edit` tool name (a write) for the UI, but deleting a
|
|
// file is destructive — force the destructive gate so it always surfaces and is never
|
|
// swept up by the auto-approve blanket below.
|
|
if kindStr == "delete" { risk = .destructive }
|
|
|
|
// Read-only fast path: cheap reads never prompt (the Claude path pre-allows Read/Glob/Grep).
|
|
if risk == .readOnly {
|
|
await reply(.allow())
|
|
return
|
|
}
|
|
|
|
// Nucleic-managed auto-approve: approve everything except destructive actions — and except
|
|
// un-classifiable ones (`.unknown`), which surface so an unrecognized/under-specified tool
|
|
// call is never silently auto-approved. The edit conflict pre-check above already ran.
|
|
if autoApprove, risk != .destructive, risk != .unknown {
|
|
await reply(.allow())
|
|
return
|
|
}
|
|
|
|
let approval = ApprovalRequest(
|
|
id: .generate(),
|
|
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
|
|
toolCallID: toolCallID,
|
|
toolName: toolName,
|
|
input: input,
|
|
title: RiskClassifier.title(toolName: toolName, input: input),
|
|
risk: risk,
|
|
createdAt: configuration.now())
|
|
|
|
emit(.approvalRequested(approval), nativeType: "session/request_permission")
|
|
let resolved = await approvals.waitForDecision(approval)
|
|
emit(.approvalResolved(resolved), nativeType: nil)
|
|
|
|
if case .cancelRun = resolved.decision {
|
|
interruptRequested = true
|
|
await cancelCurrentSession()
|
|
}
|
|
await reply(resolved.decision)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Whether an ACP permission request's `toolCall` concerns Nucleic's own MCP server (named
|
|
/// `nucleic` in the `mcpServers` we pass to `session/new`). Those tools are already gated by
|
|
/// Nucleic's own handlers, so such a request is auto-allowed rather than double-prompted. Keyed
|
|
/// narrowly on the `nucleic` server / `mcp__nucleic__*` identity so it never bypasses a real
|
|
/// tool; unknown shapes → false (a harmless no-op).
|
|
static func concernsNucleicMCP(_ toolCall: JSONValue) -> Bool {
|
|
for key in ["serverName", "server", "mcpServer"] where toolCall[key]?.stringValue == "nucleic" {
|
|
return true
|
|
}
|
|
for key in ["title", "name", "toolName", "kind"] {
|
|
if let v = toolCall[key]?.stringValue,
|
|
v.hasPrefix("mcp__nucleic__") || v == "nucleic" || v.hasPrefix("nucleic.")
|
|
|| v.hasPrefix("nucleic:")
|
|
{
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// ACP `ContentBlock[]` for a turn. Context parts render as labeled text blocks.
|
|
static func promptContent(for input: AgentInput) -> JSONValue {
|
|
var blocks: [JSONValue] = []
|
|
for part in input.parts {
|
|
switch part {
|
|
case .text(let text):
|
|
blocks.append(.object(["type": .string("text"), "text": .string(text)]))
|
|
case .context(let label, let body):
|
|
blocks.append(.object(["type": .string("text"), "text": .string("[\(label)]\n\(body)")]))
|
|
}
|
|
}
|
|
return .array(blocks)
|
|
}
|
|
|
|
/// Map an ACP `stopReason` onto a run outcome. `end_turn` is a normal turn end (→ awaitingInput,
|
|
/// resumable); token/request limits → maxTurns; an explicit cancel → interrupted.
|
|
static func outcome(forStopReason stopReason: String?, interrupted: Bool) -> RunFinished.Outcome {
|
|
if interrupted { return .interrupted }
|
|
switch stopReason {
|
|
case "end_turn", "completed": return .completed
|
|
case "cancelled": return .interrupted
|
|
case "max_tokens", "max_turn_requests": return .maxTurns
|
|
case "refusal": return .completed // the model declined; the turn ended cleanly
|
|
default: return .completed
|
|
}
|
|
}
|
|
|
|
private func emit(_ kind: AgentEvent.Kind, nativeType: String?) {
|
|
provisionalSeq += 1
|
|
continuation?.yield(
|
|
AgentEvent(
|
|
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
|
|
seq: provisionalSeq,
|
|
at: configuration.now(),
|
|
backend: configuration.agent.backend,
|
|
nativeType: nativeType,
|
|
kind: kind))
|
|
}
|
|
}
|
|
|
|
/// Backward-compatible name for the generic ACP backend, kept so existing Grok call sites and
|
|
/// tests (`GrokACPBackend(...)`) keep compiling; new agents construct `ACPBackend` with a profile.
|
|
public typealias GrokACPBackend = ACPBackend
|
|
|
|
/// Ring of recent stderr lines, kept for the synthesized error message on an abnormal exit.
|
|
private final class ACPStderrTail: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var lines: [String] = []
|
|
|
|
func append(_ line: String) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
lines.append(line)
|
|
if lines.count > 50 { lines.removeFirst(lines.count - 50) }
|
|
}
|
|
|
|
func joined() -> String {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return lines.suffix(10).joined(separator: "\n")
|
|
}
|
|
}
|