Merge branch 'dev' into canary
This commit is contained in:
@@ -22,23 +22,34 @@ enum ModelCatalog {
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"grok-build",
|
||||
"opencode",
|
||||
"hermes",
|
||||
"cursor-agent",
|
||||
]
|
||||
/// All effort levels, ordered lowest → highest. A given model supports a *prefix* of
|
||||
/// these (see `efforts(for:)`); the picker should only ever offer the supported ones.
|
||||
static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
/// Grok's reasoning levels. Grok Build exposes a single **"Auto"** reasoning mode (xAI picks
|
||||
/// the thinking depth itself), so the only API-level choice is `auto` — presented under the
|
||||
/// "Reasoning" noun (see `effortNoun`) and rendered "Auto" (`effortDisplayName`). The backend
|
||||
/// never passes a reasoning flag for Grok; the selection is cosmetic. (Orchestra is still
|
||||
/// offered below it in the menu — it's an orchestration mode, not a Grok reasoning level.)
|
||||
static let grokEfforts: [String] = ["auto"]
|
||||
/// The single **"Auto"** reasoning mode used by the ACP wrapper agents (Grok/OpenCode/Hermes/
|
||||
/// Cursor): the agent picks the thinking depth itself, so the only API-level choice is `auto` —
|
||||
/// presented under the "Reasoning" noun (see `effortNoun`) and rendered "Auto"
|
||||
/// (`effortDisplayName`). The backend never passes a reasoning flag; the selection is cosmetic.
|
||||
/// (Orchestra is still offered below it in the menu — an orchestration mode, not a reasoning
|
||||
/// level.)
|
||||
static let autoEfforts: [String] = ["auto"]
|
||||
|
||||
/// The effort levels `sku` actually supports. Grok exposes only its single "Auto" reasoning
|
||||
/// (`grokEfforts`); Codex models top out at "xhigh" (confirmed from `codex model/list`:
|
||||
/// gpt-5.x supports low/medium/high/xhigh); Claude models support the full range incl. "max".
|
||||
/// The backends whose agents expose only the single "Auto" reasoning (`autoEfforts`) — the ACP
|
||||
/// wrapper agents, which don't take a `reasoning_effort`-style flag.
|
||||
static let autoReasoningBackends: Set<BackendID> = [.grok, .opencode, .hermes, .cursorAgent]
|
||||
|
||||
/// The effort levels `sku` actually supports. The ACP wrapper agents expose only their single
|
||||
/// "Auto" reasoning (`autoEfforts`); Codex models top out at "xhigh" (confirmed from `codex
|
||||
/// model/list`: gpt-5.x supports low/medium/high/xhigh); Claude models support the full range
|
||||
/// incl. "max".
|
||||
static func efforts(for sku: String) -> [String] {
|
||||
if BackendID.forModel(sku) == .grok { return grokEfforts }
|
||||
if let backend = BackendID.forModel(sku), autoReasoningBackends.contains(backend) {
|
||||
return autoEfforts
|
||||
}
|
||||
guard let cap = effortCap(for: sku), let idx = efforts.firstIndex(of: cap) else {
|
||||
return efforts
|
||||
}
|
||||
@@ -64,7 +75,7 @@ enum ModelCatalog {
|
||||
/// expose a `reasoning_effort`-style thinking level), Claude "Effort".
|
||||
static func effortNoun(for sku: String) -> String {
|
||||
switch BackendID.forModel(sku) {
|
||||
case .codex, .grok: return "Reasoning"
|
||||
case .codex, .grok, .opencode, .hermes, .cursorAgent: return "Reasoning"
|
||||
default: return "Effort"
|
||||
}
|
||||
}
|
||||
@@ -100,6 +111,9 @@ enum ModelCatalog {
|
||||
case .claudeCode: return modelBackend == .claudeCode
|
||||
case .codex, .codexExec: return modelBackend == .codex
|
||||
case .grok: return modelBackend == .grok
|
||||
case .opencode: return modelBackend == .opencode
|
||||
case .hermes: return modelBackend == .hermes
|
||||
case .cursorAgent: return modelBackend == .cursorAgent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +182,9 @@ enum ModelCatalog {
|
||||
case "gpt-5.4": return "GPT-5.4"
|
||||
case "gpt-5.4-mini": return "GPT-5.4 Mini"
|
||||
case "grok-build": return "Grok Build"
|
||||
case "opencode": return "OpenCode"
|
||||
case "hermes": return "Hermes"
|
||||
case "cursor-agent": return "Cursor Agent"
|
||||
default: return prettify(sku)
|
||||
}
|
||||
}
|
||||
@@ -207,6 +224,9 @@ enum ModelCatalog {
|
||||
(.claudeCode, "claude-opus-4-8[1m]", "high"),
|
||||
(.codex, "gpt-5.5", "medium"),
|
||||
(.grok, "grok-build", "auto"),
|
||||
(.opencode, "opencode", "auto"),
|
||||
(.hermes, "hermes", "auto"),
|
||||
(.cursorAgent, "cursor-agent", "auto"),
|
||||
]
|
||||
|
||||
/// A comparable model on the *other* provider, for status-driven failover: each SKU pairs
|
||||
@@ -236,6 +256,11 @@ enum ModelCatalog {
|
||||
if sku.hasPrefix("claude-opus-4-8") { return 256_000 }
|
||||
if sku.hasPrefix("gpt-5") { return 350_000 } // codex gpt-5.x window (~353K observed)
|
||||
if sku.hasPrefix("grok") { return 256_000 } // grok-build window (inferred)
|
||||
// ACP wrapper agents are model-agnostic; use a conservative default window for the
|
||||
// composer's usage indicator until a live capture pins each one.
|
||||
if sku.hasPrefix("opencode") || sku.hasPrefix("hermes") || sku.hasPrefix("cursor") {
|
||||
return 200_000
|
||||
}
|
||||
return 200_000
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ struct NucleicApp: App {
|
||||
if SessionController.isBlankAPIKey(ProcessInfo.processInfo.environment["XAI_API_KEY"]) {
|
||||
unsetenv("XAI_API_KEY")
|
||||
}
|
||||
// Same for the other ACP wrapper agents that prefer an API-key env var over their CLI
|
||||
// login (OpenCode/Cursor); purge any blank inherited key so a login isn't shadowed.
|
||||
for key in ["OPENCODE_API_KEY", "CURSOR_API_KEY", "HERMES_API_KEY"] {
|
||||
if SessionController.isBlankAPIKey(ProcessInfo.processInfo.environment[key]) {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Show a Dock icon + foreground window when launched from the terminal.
|
||||
NSApplication.shared.setActivationPolicy(.regular)
|
||||
@@ -96,13 +103,18 @@ struct NucleicApp: App {
|
||||
macVMManager: macVMManager,
|
||||
conflictCoordinator: conflictCoordinator,
|
||||
approvalServerRegistry: approvalServerRegistry)
|
||||
case .grok:
|
||||
// Grok over ACP (`grok agent stdio`): standards JSON-RPC over stdio with a
|
||||
// native `session/request_permission` approval request, suspending on the same
|
||||
// ApprovalCoordinator as Claude/Codex (GROK_ADAPTER §3–4). The conflict
|
||||
// coordinator joins grok sessions to the lock system + autoship; on the vsock
|
||||
// control-plane path it execs in its control container with the interceptor wired.
|
||||
return GrokACPBackend(
|
||||
case .grok, .opencode, .hermes, .cursorAgent:
|
||||
// ACP agents (`grok agent stdio`, `opencode acp`, `hermes acp`,
|
||||
// `cursor-agent acp`): standards JSON-RPC over stdio with a native
|
||||
// `session/request_permission` approval request, suspending on the same
|
||||
// ApprovalCoordinator as Claude/Codex (GROK_ADAPTER §3–4, ADAPTERS §5). One
|
||||
// generic `ACPBackend` drives them all — only the per-agent profile differs.
|
||||
// The conflict coordinator joins these sessions to the lock system + autoship;
|
||||
// on the vsock control-plane path they exec in their control container with the
|
||||
// interceptor wired. A non-ACP backend here is unreachable (compiler-checked).
|
||||
let agent = ACPAgent.forBackend(session.backend) ?? .grok
|
||||
return ACPBackend(
|
||||
configuration: .init(agent: agent),
|
||||
conflictCoordinator: conflictCoordinator,
|
||||
containerManager: containerManager,
|
||||
approvalServerRegistry: approvalServerRegistry)
|
||||
|
||||
@@ -614,7 +614,17 @@ private struct MacVMSettingsTab: View {
|
||||
if let baseOSVersion {
|
||||
LabeledContent("Base image", value: "macOS \(baseOSVersion)")
|
||||
}
|
||||
if let baseProgress, !linuxBuilding {
|
||||
if building, baseProgress == nil, !linuxBuilding {
|
||||
// `building` flips true synchronously on click, but the engine's first progress
|
||||
// phase can be up to ~a minute out — it loads/validates the cached (~14 GB) restore
|
||||
// image, or stages the provisioning share and boots the guest, before publishing a
|
||||
// phase. Without this the panel just shows the greyed-out "…" menu and the action
|
||||
// feels dead; a spinner bridges the gap until the real phase checklist takes over.
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Starting base image build…").settingsCaption()
|
||||
}
|
||||
} else if let baseProgress, !linuxBuilding {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(seenBuildPhases, id: \.self) { phase in
|
||||
buildStageRow(
|
||||
@@ -848,8 +858,11 @@ private struct MacVMSettingsTab: View {
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(pkg.name).font(.callout)
|
||||
Text(pkg.available ? pkg.summary : (pkg.unavailableNote ?? pkg.summary))
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
let subtext = pkg.available ? pkg.summary : (pkg.unavailableNote ?? pkg.summary)
|
||||
if !subtext.isEmpty {
|
||||
Text(subtext)
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
@@ -2,13 +2,41 @@ import SwiftUI
|
||||
import AppKit
|
||||
import ObjectiveC
|
||||
|
||||
/// An `NSSplitView` that installs no divider cursor rects, so hovering the sidebar edge shows the
|
||||
/// normal arrow rather than the "move divider" resize cursor. The settings sidebar is a fixed width
|
||||
/// (see `SidebarColumnController`), so the resize affordance is misleading — the divider can't move.
|
||||
/// Applied to SwiftUI's live split view via `object_setClass` in `SidebarColumnConfigurator`.
|
||||
final class FixedDividerSplitView: NSSplitView {
|
||||
override func resetCursorRects() {
|
||||
// Intentionally empty: skip NSSplitView's default divider resize-cursor rects.
|
||||
/// Suppresses `NSSplitView`'s divider resize-cursor rects on flagged instances, so hovering the
|
||||
/// sidebar edge shows the normal arrow rather than the "move divider" cursor. Our sidebars are a
|
||||
/// fixed width (see `SidebarColumnController`), so the resize affordance is misleading — the
|
||||
/// divider can't move.
|
||||
///
|
||||
/// This must NOT be done by reclassing the live split view (`object_setClass` to a subclass with
|
||||
/// an empty `resetCursorRects`): AppKit's window sidebar tracking KVO-observes the split view
|
||||
/// (`NSThemeFrame` observes `splitView._peeking` via `_NSSplitViewPartitionAdapter`), and KVO
|
||||
/// works by isa-swizzling. Stomping the `NSKVONotifying_` class corrupts the observation
|
||||
/// bookkeeping, and the deferred `removeObserver:forKeyPath:` during `-[NSWindow dealloc]` then
|
||||
/// throws mid-dealloc and aborts the app — seconds after the window closed, when the autorelease
|
||||
/// pool finally drained. Instead, `-[NSSplitView resetCursorRects]` is replaced once, class-wide,
|
||||
/// with a version that no-ops only for instances carrying our associated-object flag. The object's
|
||||
/// class chain is never touched, so KVO is safe in either registration order.
|
||||
@MainActor
|
||||
private enum FixedDividerCursorSuppressor {
|
||||
private nonisolated(unsafe) static var flagKey: UInt8 = 0
|
||||
|
||||
private static let installSwizzle: Void = {
|
||||
let selector = #selector(NSView.resetCursorRects)
|
||||
guard let method = class_getInstanceMethod(NSSplitView.self, selector) else { return }
|
||||
typealias ResetCursorRects = @convention(c) (NSSplitView, Selector) -> Void
|
||||
let original = unsafeBitCast(method_getImplementation(method), to: ResetCursorRects.self)
|
||||
let replacement: @convention(block) (NSSplitView) -> Void = { splitView in
|
||||
guard objc_getAssociatedObject(splitView, &flagKey) == nil else { return }
|
||||
original(splitView, selector)
|
||||
}
|
||||
method_setImplementation(method, imp_implementationWithBlock(replacement))
|
||||
}()
|
||||
|
||||
static func suppressDividerCursor(of splitView: NSSplitView) {
|
||||
_ = installSwizzle
|
||||
guard objc_getAssociatedObject(splitView, &flagKey) == nil else { return }
|
||||
objc_setAssociatedObject(splitView, &flagKey, true, .OBJC_ASSOCIATION_RETAIN)
|
||||
splitView.window?.invalidateCursorRects(for: splitView)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +121,7 @@ struct SidebarColumnConfigurator: NSViewRepresentable {
|
||||
let splitController = splitView.delegate as? NSSplitViewController,
|
||||
let sidebar = splitController.splitViewItems.first
|
||||
else { return }
|
||||
// Swap in a subclass that draws no divider resize cursor. Idempotent: only reclass once,
|
||||
// then refresh so the (now absent) cursor rects take effect immediately.
|
||||
if !(splitView is FixedDividerSplitView) {
|
||||
object_setClass(splitView, FixedDividerSplitView.self)
|
||||
splitView.window?.invalidateCursorRects(for: splitView)
|
||||
}
|
||||
FixedDividerCursorSuppressor.suppressDividerCursor(of: splitView)
|
||||
controller.adopt(sidebar, in: view.window)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
/// Static profile for one **ACP** (Agent Client Protocol) coding agent — the invocation, auth,
|
||||
/// and container-seeding facts that differ per agent, factored out so a single generic
|
||||
/// [`ACPBackend`](ACPBackend.swift) can drive all of them. Every ACP agent speaks standards
|
||||
/// JSON-RPC 2.0 over stdio with native `session/request_permission` approvals, so above the
|
||||
/// process spawn they are identical (ADAPTERS §5); only these fields vary.
|
||||
///
|
||||
/// **Confidence:** Grok is validated against the shipped `grok agent stdio`; the three wrapper
|
||||
/// agents (OpenCode/Hermes/Cursor) are pinned from each vendor's ACP docs (the `<bin> acp`
|
||||
/// invocation is confirmed) but their auth env-var names / on-disk credential paths are
|
||||
/// best-effort and marked 🔴 — re-pin from a live capture, exactly as Grok was (GROK_ADAPTER §6).
|
||||
public struct ACPAgent: Sendable, Equatable {
|
||||
/// The backend identity this profile drives.
|
||||
public let backend: BackendID
|
||||
/// User-facing agent name (Settings, model catalog), e.g. "OpenCode".
|
||||
public let displayName: String
|
||||
/// CLI executable, resolved on PATH, e.g. "opencode".
|
||||
public let executable: String
|
||||
/// Arguments that put the CLI into ACP-server mode over stdio, e.g. `["acp"]`.
|
||||
public let launchArgs: [String]
|
||||
/// Model SKU the picker offers for this agent — the picker doubles as the backend selector
|
||||
/// (`BackendID.forModel`). A single SKU per wrapper agent (they're model-agnostic).
|
||||
public let sku: String
|
||||
/// `$HOME`-relative directories holding this agent's login/config, seeded into a per-session
|
||||
/// container home so a sandboxed run authenticates as the user (Codex/Grok analog,
|
||||
/// SessionController). Host runs inherit auth directly and never seed. 🔴 best-effort for the
|
||||
/// wrapper agents.
|
||||
public let containerHomeDirs: [String]
|
||||
/// Environment variables that, when present and non-blank, count as authenticated (checked
|
||||
/// before any on-disk credential). 🔴 names inferred for the wrapper agents.
|
||||
public let authEnvVars: [String]
|
||||
/// `$HOME`-relative credential file whose existence indicates a completed CLI login. 🔴 path
|
||||
/// inferred for the wrapper agents.
|
||||
public let credentialFile: String?
|
||||
|
||||
public init(
|
||||
backend: BackendID, displayName: String, executable: String, launchArgs: [String],
|
||||
sku: String, containerHomeDirs: [String], authEnvVars: [String], credentialFile: String?
|
||||
) {
|
||||
self.backend = backend
|
||||
self.displayName = displayName
|
||||
self.executable = executable
|
||||
self.launchArgs = launchArgs
|
||||
self.sku = sku
|
||||
self.containerHomeDirs = containerHomeDirs
|
||||
self.authEnvVars = authEnvVars
|
||||
self.credentialFile = credentialFile
|
||||
}
|
||||
|
||||
/// xAI Grok Build — `grok agent stdio` (validated; GROK_ADAPTER).
|
||||
public static let grok = ACPAgent(
|
||||
backend: .grok, displayName: "Grok Build", executable: "grok",
|
||||
launchArgs: ["agent", "stdio"], sku: "grok-build",
|
||||
containerHomeDirs: [".grok"],
|
||||
authEnvVars: ["XAI_API_KEY", "GROK_CODE_XAI_API_KEY"],
|
||||
credentialFile: ".grok/config.toml")
|
||||
|
||||
/// SST OpenCode — `opencode acp` (opencode.ai/docs/acp). Stores auth under
|
||||
/// `~/.local/share/opencode`; also honors provider keys (already forwarded).
|
||||
public static let opencode = ACPAgent(
|
||||
backend: .opencode, displayName: "OpenCode", executable: "opencode",
|
||||
launchArgs: ["acp"], sku: "opencode",
|
||||
containerHomeDirs: [".config/opencode", ".local/share/opencode"],
|
||||
authEnvVars: ["OPENCODE_API_KEY"],
|
||||
credentialFile: ".local/share/opencode/auth.json")
|
||||
|
||||
/// Nous Research Hermes Agent — `hermes acp` (hermes-agent.nousresearch.com/docs). Model-
|
||||
/// agnostic (drives Claude/Codex under the hood), so it also honors those provider keys.
|
||||
public static let hermes = ACPAgent(
|
||||
backend: .hermes, displayName: "Hermes", executable: "hermes",
|
||||
launchArgs: ["acp"], sku: "hermes",
|
||||
containerHomeDirs: [".config/hermes", ".hermes"],
|
||||
authEnvVars: ["HERMES_API_KEY", "NOUS_API_KEY"],
|
||||
credentialFile: ".config/hermes/auth.json")
|
||||
|
||||
/// Cursor CLI agent — `cursor-agent acp` (cursor.com/docs/cli/acp). Auth via `CURSOR_API_KEY`
|
||||
/// or `cursor-agent login`.
|
||||
public static let cursorAgent = ACPAgent(
|
||||
backend: .cursorAgent, displayName: "Cursor Agent", executable: "cursor-agent",
|
||||
launchArgs: ["acp"], sku: "cursor-agent",
|
||||
containerHomeDirs: [".cursor", ".config/cursor"],
|
||||
authEnvVars: ["CURSOR_API_KEY"],
|
||||
credentialFile: ".cursor/cli-config.json")
|
||||
|
||||
/// Every ACP agent profile, in display order (Grok first — the original — then the wrapper
|
||||
/// agents). The single source of truth `ACPBackend`, `ProviderAvailability`, and
|
||||
/// `SessionController` all consult so adding an agent is one entry here plus the enum case.
|
||||
public static let all: [ACPAgent] = [grok, opencode, hermes, cursorAgent]
|
||||
|
||||
/// The profile for a backend id, or nil if that backend isn't ACP-based.
|
||||
public static func forBackend(_ backend: BackendID) -> ACPAgent? {
|
||||
all.first { $0.backend == backend }
|
||||
}
|
||||
}
|
||||
@@ -1344,6 +1344,26 @@ public final class AppStore: ConflictArbiter {
|
||||
summaries.removeAll { gone.contains($0.id) }
|
||||
}
|
||||
|
||||
// Orchestra orphan sweep: a worker whose supervisor no longer exists — deleted before
|
||||
// deletion cascaded to workers, or through a path that bypassed the cascade — is
|
||||
// unreachable from every surface (`isSubagent` hides it from the sidebar, and the
|
||||
// Subagents panel that listed it died with the supervisor), yet it still costs a
|
||||
// reconstructed controller + transcript read here every launch, counts toward
|
||||
// active-chat rollups, and holds a worktree. Finish the interrupted cascade: delete it
|
||||
// (recursively taking any workers of its own). Scoped to sessions we just built a
|
||||
// controller for, so the delete reclaims the worktree and branch; a supervisor moved to
|
||||
// another Mac still has a tombstone row here, so its workers are correctly spared.
|
||||
let knownIDs = Set(allSessions.map(\.id))
|
||||
let orphanedWorkerIDs = allSessions
|
||||
.filter { session in
|
||||
guard let supervisorID = session.spawnedBySessionID else { return false }
|
||||
return !knownIDs.contains(supervisorID) && controllers[session.id] != nil
|
||||
}
|
||||
.map(\.id)
|
||||
for orphanID in orphanedWorkerIDs {
|
||||
await deleteSession(orphanID)
|
||||
}
|
||||
|
||||
// Clean up any sandbox containers orphaned by a previous run/crash.
|
||||
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
|
||||
// Same on-disk GC for macOS-VM clones (daemonless — no live guest survives the process).
|
||||
@@ -4973,30 +4993,75 @@ public final class AppStore: ConflictArbiter {
|
||||
|
||||
/// Live count of Orchestra workers currently occupying a concurrency slot, and the FIFO queue
|
||||
/// of `nucleic_subagent` calls parked waiting for one. Both are `@MainActor`-isolated, so their
|
||||
/// mutations never race. See `defaultOrchestraMaxConcurrentWorkers`.
|
||||
private var orchestraWorkersRunning = 0
|
||||
private var orchestraWorkerWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
/// mutations never race. See `defaultOrchestraMaxConcurrentWorkers`. Internal (not private)
|
||||
/// only so tests can observe the gate.
|
||||
private(set) var orchestraWorkersRunning = 0
|
||||
/// One parked `nucleic_subagent` spawn. Identified so a cancelled supervisor can pull ITS
|
||||
/// waiter back out of the queue — a queued spawn must die with its supervisor, not go on to
|
||||
/// create and run a worker whose result nobody will read.
|
||||
private struct OrchestraWorkerWaiter {
|
||||
let id: UUID
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
private var orchestraWorkerWaiters: [OrchestraWorkerWaiter] = []
|
||||
/// Test-only visibility into the parked-spawn queue.
|
||||
var orchestraWorkerWaiterCount: Int { orchestraWorkerWaiters.count }
|
||||
|
||||
/// Park until a worker slot is free, honoring the configured cap. `<= 0` means unlimited, so it
|
||||
/// returns immediately. Waiters wake in FIFO order as slots are released. Always pair with
|
||||
/// `releaseOrchestraWorkerSlot()` (via `defer`) so a thrown/early-returning spawn frees its slot.
|
||||
private func acquireOrchestraWorkerSlot() async {
|
||||
/// returns immediately. Waiters wake in FIFO order as slots are released. Returns `false` —
|
||||
/// WITHOUT acquiring — when the calling task is cancelled (its supervisor's run ended while the
|
||||
/// spawn was queued); the waiter is removed so it can't be woken into a dead spawn later. On
|
||||
/// `true`, always pair with `releaseOrchestraWorkerSlot()` (via `defer`) so a thrown/early-
|
||||
/// returning spawn frees its slot. Internal (not private) only for tests.
|
||||
func acquireOrchestraWorkerSlot() async -> Bool {
|
||||
let limit = defaultOrchestraMaxConcurrentWorkers
|
||||
if limit > 0 {
|
||||
while orchestraWorkersRunning >= limit {
|
||||
await withCheckedContinuation { orchestraWorkerWaiters.append($0) }
|
||||
if Task.isCancelled { return false }
|
||||
let id = UUID()
|
||||
await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
// Runs synchronously on the main actor before this task suspends, so even a
|
||||
// cancellation that fired before the append (the handler's hop below queues
|
||||
// behind this) finds the waiter and resumes it — never a stranded park.
|
||||
orchestraWorkerWaiters.append(
|
||||
OrchestraWorkerWaiter(id: id, continuation: continuation))
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in self?.cancelOrchestraWorkerWaiter(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if Task.isCancelled {
|
||||
// The wake-up that got us here may have been release() offering a freed slot; we
|
||||
// can't use it, so pass it along instead of stranding the remaining waiters.
|
||||
wakeNextOrchestraWorkerWaiter()
|
||||
return false
|
||||
}
|
||||
orchestraWorkersRunning += 1
|
||||
return true
|
||||
}
|
||||
|
||||
private func releaseOrchestraWorkerSlot() {
|
||||
orchestraWorkersRunning -= 1
|
||||
/// Remove + resume a cancelled spawn's waiter; the woken task sees its cancellation at the
|
||||
/// top of the acquire loop and bails out. A waiter already woken by `release()` (and so no
|
||||
/// longer queued) is left alone — the post-loop cancellation check declines the slot instead.
|
||||
private func cancelOrchestraWorkerWaiter(_ id: UUID) {
|
||||
guard let index = orchestraWorkerWaiters.firstIndex(where: { $0.id == id }) else { return }
|
||||
orchestraWorkerWaiters.remove(at: index).continuation.resume()
|
||||
}
|
||||
|
||||
private func wakeNextOrchestraWorkerWaiter() {
|
||||
if !orchestraWorkerWaiters.isEmpty {
|
||||
orchestraWorkerWaiters.removeFirst().resume()
|
||||
orchestraWorkerWaiters.removeFirst().continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal (not private) only for tests.
|
||||
func releaseOrchestraWorkerSlot() {
|
||||
orchestraWorkersRunning -= 1
|
||||
wakeNextOrchestraWorkerWaiter()
|
||||
}
|
||||
|
||||
/// Spawn one Orchestra worker as a first-class Nucleic session and wait for its turn to finish.
|
||||
/// The parent receives the worker's final assistant text, while the app retains the full child
|
||||
/// lifecycle: sidebar row, transcript, approvals, sandbox/container command observation, and model.
|
||||
@@ -5004,15 +5069,20 @@ public final class AppStore: ConflictArbiter {
|
||||
/// Concurrency: the supervisor may issue several `nucleic_subagent` calls in one turn; each runs
|
||||
/// here on its own task and blocks on `controller.join()`, so the workers execute in parallel,
|
||||
/// bounded by `defaultOrchestraMaxConcurrentWorkers` — excess calls park in `acquireOrchestraWorkerSlot`.
|
||||
private func spawnOrchestraSubagent(
|
||||
func spawnOrchestraSubagent(
|
||||
_ request: OrchestraSubagentRequest, parent: Session, project: Project
|
||||
) async -> OrchestraSubagentResult {
|
||||
let prompt = request.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !prompt.isEmpty else {
|
||||
return .denied(message: "Worker prompt is required.")
|
||||
}
|
||||
// Bound concurrent workers to the user's cap; parks here until a slot frees.
|
||||
await acquireOrchestraWorkerSlot()
|
||||
// Bound concurrent workers to the user's cap; parks here until a slot frees. A spawn whose
|
||||
// supervisor dies while queued (Stop/kill → its MCP handler task is cancelled) is refused
|
||||
// instead of acquiring: no worker session is ever created for a dead supervisor.
|
||||
guard await acquireOrchestraWorkerSlot() else {
|
||||
return .denied(
|
||||
message: "Worker spawn cancelled: the supervisor's run ended before a slot freed.")
|
||||
}
|
||||
defer { releaseOrchestraWorkerSlot() }
|
||||
let workerModel = defaultOrchestraWorkerModel ?? parent.model
|
||||
let title = Self.orchestraWorkerTitle(for: request.task)
|
||||
@@ -5027,9 +5097,29 @@ public final class AppStore: ConflictArbiter {
|
||||
sessionID: id, title: title, model: workerModel,
|
||||
message: "Worker session was created but its controller is unavailable.")
|
||||
}
|
||||
await controller.join()
|
||||
// If the supervisor's run ends while the worker is mid-turn, interrupt the worker:
|
||||
// its result has nowhere to go, so running on only burns tokens and a concurrency
|
||||
// slot. The worker session itself survives (interrupted, visible in the sidebar) for
|
||||
// the user to inspect or resume.
|
||||
await withTaskCancellationHandler {
|
||||
await controller.join()
|
||||
} onCancel: {
|
||||
Task { await controller.interrupt() }
|
||||
}
|
||||
if Task.isCancelled {
|
||||
return .failed(
|
||||
sessionID: id, title: title, model: workerModel,
|
||||
message: "The supervisor's run ended; the worker was interrupted.")
|
||||
}
|
||||
let snapshot = await controller.snapshot
|
||||
let events = await controller.transcriptSoFar()
|
||||
// The worker's product is the final reply handed back below — nothing ever resumes
|
||||
// this session (each `nucleic_subagent` call spawns a fresh one), so archive it now,
|
||||
// success or failure: a finished worker otherwise rests at `.awaitingInput` and
|
||||
// counts as an active chat forever, holding its sandbox container with it. Spared
|
||||
// only when it's the chat the user has open (peeking mid-run via the Subagents
|
||||
// panel, which lists archived workers all the same).
|
||||
if id != openSessionID { await setSessionArchived(id, true) }
|
||||
let lastError = events.reversed().compactMap { event -> AgentError? in
|
||||
if case .error(let error) = event.kind { return error }
|
||||
return nil
|
||||
@@ -5374,6 +5464,16 @@ public final class AppStore: ConflictArbiter {
|
||||
reportVMCleanupFailure(vmName)
|
||||
}
|
||||
await reapSharedControlContainerIfUnused()
|
||||
// Orchestra cascade: putting a supervisor away puts its (still-active) workers away
|
||||
// too — they're reachable only through its Subagents panel, so left active they'd
|
||||
// keep counting toward active-chat rollups and holding containers. Recursive for a
|
||||
// worker that itself supervised. Deliberately not mirrored on unarchive: workers
|
||||
// are one-shot and done; restoring the supervisor shouldn't resurrect their
|
||||
// containers (the Subagents panel lists archived workers regardless).
|
||||
let workerIDs = summaries.filter { $0.spawnedBySessionID == id && !$0.archived }.map(\.id)
|
||||
for workerID in workerIDs {
|
||||
await setSessionArchived(workerID, true)
|
||||
}
|
||||
} else {
|
||||
// Unarchiving: if the cleanup sweep reclaimed this chat's worktree while it was
|
||||
// archived, re-create it from the preserved branch so the chat is workable again.
|
||||
@@ -5435,6 +5535,14 @@ public final class AppStore: ConflictArbiter {
|
||||
lastError = "This session is being moved to another Mac — try again once that finishes."
|
||||
return
|
||||
}
|
||||
// Orchestra cascade: a supervisor's workers are reachable only through its Subagents
|
||||
// panel (the sidebar hides them — `isSubagent`), so they'd be stranded invisible if the
|
||||
// supervisor went away alone. Delete them with it, recursively covering a worker that
|
||||
// itself supervised. Ids are snapshotted first because each delete mutates `summaries`.
|
||||
let workerIDs = summaries.filter { $0.spawnedBySessionID == id }.map(\.id)
|
||||
for workerID in workerIDs {
|
||||
await deleteSession(workerID)
|
||||
}
|
||||
await cascadeChildren(of: id) // re-target nested children before this parent goes away
|
||||
if let controller = controllers[id] {
|
||||
do { try await controller.discard(force: true) }
|
||||
@@ -5842,8 +5950,7 @@ public final class AppStore: ConflictArbiter {
|
||||
// storm during an Anthropic outage, a failed run) and can beat the 30s status poll. Pull
|
||||
// that provider's status feed now so the incident indicator lights up promptly; the call
|
||||
// coalesces storms and no-ops when polling is off or the provider is disabled.
|
||||
if case .error = event.kind {
|
||||
let provider = StatusProvider.forBackend(snapshot.session.backend)
|
||||
if case .error = event.kind, let provider = StatusProvider.forBackend(snapshot.session.backend) {
|
||||
Task { [weak self] in await self?.refreshStatusFeedOnError(for: provider) }
|
||||
}
|
||||
upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
|
||||
|
||||
@@ -106,9 +106,12 @@ public struct ContainerSpec: Sendable, Equatable {
|
||||
/// interceptor endpoint. When set, ``ContainerEngine`` relays it into the guest over vsock (the
|
||||
/// framework's `UnixSocketConfiguration(.into)`), where it appears at ``controlSocketGuestPath``,
|
||||
/// so the agent's MCP bridge and the git/gh/command shims reach the host with **no IP listener**
|
||||
/// (hence no macOS incoming-connection / local-network prompts). `nil` → no relay (the legacy
|
||||
/// TCP-over-vmnet-gateway path). MUST be short: AF_UNIX `sun_path` caps at ~104 bytes, so it
|
||||
/// lives under a short dir (e.g. `/tmp`), never the long Application-Support session path.
|
||||
/// (hence no macOS incoming-connection / local-network prompts). The vsock control plane is
|
||||
/// MANDATORY for session containers — `SessionController` sets this on every session spec
|
||||
/// (shared and per-session), and backends refuse a containerized run without it. `nil` only for
|
||||
/// agent-created throwaway containers (`linux_container`), which run no control plane. MUST be
|
||||
/// short: AF_UNIX `sun_path` caps at ~104 bytes, so it lives under a short dir, never the long
|
||||
/// Application-Support session path.
|
||||
public let controlSocketHostPath: String?
|
||||
|
||||
/// Fixed in-guest path the relayed control socket appears at; the in-container control bridge
|
||||
|
||||
@@ -72,6 +72,10 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
/// if they step away. The CLI's default would abandon the suspended call and let the model
|
||||
/// proceed *without* an answer (observed as questions/approvals "timing out"), so this stays
|
||||
/// pinned to Node's max delay (~24.8 days, the largest `setTimeout` accepts before it wraps).
|
||||
/// The unbounded per-call wait does NOT strand server-side work when the run dies first:
|
||||
/// `teardownRun()`'s token unregister (and any connection close) cancels the approval
|
||||
/// server's in-flight handler tasks — most importantly parked/running `nucleic_subagent`
|
||||
/// spawns, which would otherwise hold Orchestra worker slots for a dead supervisor.
|
||||
///
|
||||
/// * `MCP_TIMEOUT` — the initial server *connection/handshake*, which involves no human. Pinning
|
||||
/// it as high as the tool timeout meant an unreachable approval server would wedge startup for
|
||||
@@ -274,6 +278,12 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
public func shutdown() async {
|
||||
terminating = true
|
||||
await approvals.cancelOutstanding(reason: "Session terminated")
|
||||
// Release this backend's OWN approval server — its loopback TCP listener (host runs) or
|
||||
// its per-session control socket (per-session containers), which would otherwise linger
|
||||
// on disk after the session ends. Never the registry's shared server (`runServer` on a
|
||||
// control-container run): other sessions in that container are still using it, and a
|
||||
// future run of this backend re-`start()`s idempotently either way.
|
||||
await approvalServer.stop()
|
||||
guard let handle else { return }
|
||||
handle.closeStdin()
|
||||
await handle.terminate()
|
||||
@@ -327,26 +337,23 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
}
|
||||
runServer = server
|
||||
|
||||
// 0b. Sandbox: if this run is containerized, bring the container up first so we know the
|
||||
// host-gateway address the child must use to reach us.
|
||||
// 0b. Sandbox: if this run is containerized, bring the container up first (its init
|
||||
// also launches the in-guest control bridge the child reaches us through).
|
||||
let sandbox = (run.container != nil) ? containerManager : nil
|
||||
var mcpHost = "127.0.0.1"
|
||||
if let sandbox, let cspec = run.container {
|
||||
let (name, gateway) = try await sandbox.ensureRunning(cspec)
|
||||
let (name, _) = try await sandbox.ensureRunning(cspec)
|
||||
activeContainerName = name
|
||||
mcpHost = gateway
|
||||
}
|
||||
|
||||
// 1. Approval bridge: per-session bearer token → handler. For a containerized
|
||||
// child we bind on all interfaces (gated by the bearer token) so it can reach
|
||||
// us over the VM gateway; otherwise loopback as before.
|
||||
// 1. Approval bridge: per-session bearer token → handler.
|
||||
let token = UUID().uuidString
|
||||
serverToken = token
|
||||
// Transport: with a relayed control socket, serve ONLY on the unix socket — no IP
|
||||
// listener at all, so macOS raises no incoming-connection / local-network prompts. The
|
||||
// agent + interceptor shims reach us via the in-guest loopback bridge (which forwards to
|
||||
// the relayed socket), so the control endpoint is `127.0.0.1:<bridge port>` inside the
|
||||
// VM. Otherwise the legacy path: bind the gateway (containerized) or loopback (host).
|
||||
// Transport: a containerized child ALWAYS rides the vsock control plane — we serve
|
||||
// ONLY on the relayed unix socket, no IP listener at all, so macOS raises no
|
||||
// incoming-connection / local-network prompts. The agent + interceptor shims reach us
|
||||
// via the in-guest loopback bridge (which forwards to the relayed socket), so the
|
||||
// control endpoint is `127.0.0.1:<bridge port>` inside the VM. A host (non-container)
|
||||
// run keeps the loopback TCP listener; the gateway-TCP path for containers is retired.
|
||||
let port: UInt16
|
||||
let controlHost: String
|
||||
if let controlSock = run.container?.controlSocketHostPath {
|
||||
@@ -362,9 +369,15 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
"approval control socket \(controlSock) is not listening "
|
||||
+ "(control endpoint unreachable)")
|
||||
}
|
||||
} else if run.container != nil {
|
||||
// Every containerized spec carries a control socket (SessionController sets it
|
||||
// unconditionally). A spec without one would silently fall back to a TCP endpoint
|
||||
// the guest can't reach — enforce the invariant loudly instead.
|
||||
throw BackendError.spawnFailed(
|
||||
"containerized run has no control socket — the vsock control plane is mandatory")
|
||||
} else {
|
||||
port = try await server.start(host: sandbox != nil ? "0.0.0.0" : "127.0.0.1")
|
||||
controlHost = mcpHost
|
||||
port = try await server.start(host: "127.0.0.1")
|
||||
controlHost = "127.0.0.1"
|
||||
// A bound listener always reports a non-zero ephemeral port; a 0 here means the
|
||||
// approval server didn't actually bind, so the agent could never reach us and would
|
||||
// silently stall on its first gated tool. Fail the run loudly instead.
|
||||
@@ -628,11 +641,11 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
.merging(cspec.env) { _, new in new }
|
||||
.merging(run.extraEnv) { _, new in new }
|
||||
// Tell the in-container `git` shim where to report (the host approval server,
|
||||
// reached over the VM gateway) and under which per-session bearer token. The
|
||||
// env is inherited by every git subprocess the agent spawns.
|
||||
// reached over the in-guest control bridge) and under which per-session bearer
|
||||
// token. The env is inherited by every git subprocess the agent spawns.
|
||||
if cspec.installGitInterceptor {
|
||||
// Point the in-container git/gh/command interceptor at the host control endpoint
|
||||
// (gateway+TCP, or the loopback control bridge on the vsock path). Shared helper
|
||||
// (the loopback control bridge, forwarding to the relayed socket). Shared helper
|
||||
// so every containerized backend wires the interceptor identically.
|
||||
env.merge(
|
||||
CommandInterceptor.hookEnv(
|
||||
|
||||
@@ -555,6 +555,11 @@ public actor MCPApprovalServer {
|
||||
/// 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>?
|
||||
/// The interface the live TCP listener was bound to (`"127.0.0.1"` / `"0.0.0.0"`), nil when no
|
||||
/// TCP listener is bound. Lets ``start(host:)`` detect a run that needs a *wider* bind than the
|
||||
/// cached one (a loopback bind can't serve a containerized child over the VM gateway) and rebind
|
||||
/// instead of returning a port the child can never reach. Internal (not private) for tests.
|
||||
private(set) var boundTCPHost: String?
|
||||
private var handlers: [String: Handler] = [:]
|
||||
private var conflictHandlers: [String: ConflictHandler] = [:]
|
||||
private var hostExecHandlers: [String: HostExecHandler] = [:]
|
||||
@@ -573,6 +578,15 @@ public actor MCPApprovalServer {
|
||||
private var commandReportHandlers: [String: CommandReportHandler] = [:]
|
||||
private var connectionTasks: [Int: Task<Void, Never>] = [:]
|
||||
private var nextConnectionID = 0
|
||||
/// In-flight `handle(_:)` tasks — one per request being served — indexed by id and by the
|
||||
/// request's bearer token. Handlers can suspend for a long time (an `approve` awaiting a human,
|
||||
/// a `nucleic_subagent` spawn awaiting a whole worker session), so when their session's run ends
|
||||
/// — the connection closes or the token unregisters — the task must be CANCELLED, not merely
|
||||
/// awaited: nobody can receive the response anymore, and a parked subagent spawn would otherwise
|
||||
/// go on to create and run a full worker for a dead supervisor. Entries remove themselves when
|
||||
/// the task finishes.
|
||||
private var handlerTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var handlerTaskIDsByToken: [String: Set<UUID>] = [:]
|
||||
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.
|
||||
@@ -593,7 +607,21 @@ public actor MCPApprovalServer {
|
||||
/// `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
|
||||
// Self-heal (the TCP mirror of `start(unixSocketPath:)`). The shared control-container
|
||||
// server is long-lived — on the default (non-vsock) transport it serves EVERY session in
|
||||
// the container over this one TCP bind — so it must not trust its first bind forever.
|
||||
// Keep the cached listener only while it is still `.ready` AND already serves the
|
||||
// requested interface (a wildcard bind serves loopback too; the reverse does not — a
|
||||
// loopback bind is unreachable from a containerized child over the VM gateway). A
|
||||
// listener that died post-`.ready` (network-stack transition, external cancel) would
|
||||
// otherwise keep publishing its stale port, which every new session baked into an
|
||||
// unreachable MCP config — surfacing as the CLI's opaque "Available MCP tools: none" and
|
||||
// poisoning ALL new sessions sharing the server until an app restart.
|
||||
if let existing = listener {
|
||||
let servesHost = boundTCPHost == host || boundTCPHost == "0.0.0.0"
|
||||
if existing.state == .ready, port != 0, servesHost { return port }
|
||||
invalidateTCPListener(existing) // dead or too-narrow bind — rebind below
|
||||
}
|
||||
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
|
||||
@@ -601,6 +629,20 @@ public actor MCPApprovalServer {
|
||||
return try await task.value
|
||||
}
|
||||
|
||||
/// Drop the cached TCP bind because its listener is no longer usable — it died after `.ready`
|
||||
/// (`.failed` on a network-stack transition, an out-from-under cancel) or it's bound to a
|
||||
/// narrower interface than the run needs. Clears `listener`/`port`/`boundTCPHost` so the next
|
||||
/// ``start(host:)`` rebinds fresh instead of returning the stale port. Identity-checked so a
|
||||
/// listener that was already replaced (``stop()``, an earlier self-heal) can't clobber its
|
||||
/// successor's live bind. Established connections are independent of the listener and unaffected.
|
||||
private func invalidateTCPListener(_ dead: NWListener) {
|
||||
guard listener === dead else { return }
|
||||
listener = nil
|
||||
port = 0
|
||||
boundTCPHost = nil
|
||||
dead.cancel()
|
||||
}
|
||||
|
||||
/// 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 /
|
||||
@@ -769,22 +811,48 @@ public actor MCPApprovalServer {
|
||||
}
|
||||
|
||||
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
|
||||
// The handler stays installed for the listener's whole life: first it settles the bind
|
||||
// (resuming the continuation exactly once), then it keeps watching so a post-`.ready`
|
||||
// death — `.failed` on a network-stack transition, an out-from-under `.cancelled` —
|
||||
// invalidates the cached bind and the next `start(host:)` rebinds, instead of the dead
|
||||
// listener's stale port poisoning every new session that shares this server.
|
||||
do {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
listener.stateUpdateHandler = { [weak self, weak listener] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
if resumeOnce.claim() { cont.resume() }
|
||||
case .waiting(let error):
|
||||
// Only a bind-time failure; post-`.ready` waiting may recover — leave it.
|
||||
if resumeOnce.claim() { cont.resume(throwing: error) }
|
||||
case .failed(let error):
|
||||
if resumeOnce.claim() {
|
||||
cont.resume(throwing: error)
|
||||
} else if let self, let listener {
|
||||
Task { await self.invalidateTCPListener(listener) }
|
||||
}
|
||||
case .cancelled:
|
||||
if resumeOnce.claim() {
|
||||
cont.resume(throwing: CancellationError())
|
||||
} else if let self, let listener {
|
||||
Task { await self.invalidateTCPListener(listener) }
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
listener.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
listener.start(queue: .global(qos: .userInitiated))
|
||||
} catch {
|
||||
// A `.waiting` bind failure leaves the listener alive and able to become ready later —
|
||||
// cancel it so an unpublished listener can't linger holding a socket nobody knows about.
|
||||
listener.cancel()
|
||||
throw error
|
||||
}
|
||||
// Ready and bound: publish atomically (no suspension between these and the return).
|
||||
self.listener = listener
|
||||
self.port = listener.port?.rawValue ?? 0
|
||||
self.boundTCPHost = host
|
||||
return self.port
|
||||
}
|
||||
|
||||
@@ -793,6 +861,7 @@ public actor MCPApprovalServer {
|
||||
tcpStartTask = nil
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
boundTCPHost = nil
|
||||
unixAcceptSource?.cancel() // its cancel handler closes the listening fd
|
||||
unixAcceptSource = nil
|
||||
unixListenFD = nil
|
||||
@@ -803,6 +872,9 @@ public actor MCPApprovalServer {
|
||||
}
|
||||
for task in connectionTasks.values { task.cancel() }
|
||||
connectionTasks.removeAll()
|
||||
for task in handlerTasks.values { task.cancel() }
|
||||
handlerTasks.removeAll()
|
||||
handlerTaskIDsByToken.removeAll()
|
||||
handlers.removeAll()
|
||||
conflictHandlers.removeAll()
|
||||
hostExecHandlers.removeAll()
|
||||
@@ -815,6 +887,15 @@ public actor MCPApprovalServer {
|
||||
commandReportHandlers.removeAll()
|
||||
}
|
||||
|
||||
/// TEST-ONLY seam: cancel the live TCP listener *out from under* the cached bind — simulating
|
||||
/// the post-`.ready` death (a network-stack `.failed`, an external cancel) that the state
|
||||
/// watcher and ``start(host:)``'s re-validation exist to heal. Does NOT clear the cached
|
||||
/// `listener`/`port`, so tests can observe the watcher doing that itself. Internal for
|
||||
/// `@testable` access; never called from production code.
|
||||
func debugCancelTCPListenerOutFromUnder() {
|
||||
listener?.cancel()
|
||||
}
|
||||
|
||||
public func register(token: String, handler: @escaping Handler) {
|
||||
handlers[token] = handler
|
||||
}
|
||||
@@ -947,6 +1028,11 @@ public actor MCPApprovalServer {
|
||||
}
|
||||
|
||||
public func unregister(token: String) {
|
||||
// The token's run is over: unwind its suspended calls first (a parked `nucleic_subagent`
|
||||
// spawn, a dangling `approve`) so nothing keeps working on behalf of the dead run. The
|
||||
// connection may well stay open — on a shared control-container server it carries other
|
||||
// sessions' traffic — so token unregistration, not connection close, is this signal.
|
||||
cancelHandlerTasks(token: token)
|
||||
handlers.removeValue(forKey: token)
|
||||
conflictHandlers.removeValue(forKey: token)
|
||||
hostExecHandlers.removeValue(forKey: token)
|
||||
@@ -1009,21 +1095,65 @@ public actor MCPApprovalServer {
|
||||
// request order (HTTP/1.1 has no response IDs), so each handler awaits the previous one's
|
||||
// send before writing its own; `previous` retains the in-flight chain for draining on close.
|
||||
var previous: Task<Void, Never>? = nil
|
||||
// Ids of every handler task spawned on this connection. Finished tasks removed themselves
|
||||
// from `handlerTasks`, so cancelling by id at teardown only reaches the still-running ones.
|
||||
var spawned: [UUID] = []
|
||||
while !Task.isCancelled {
|
||||
guard let request = await nextRequest(on: conn, buffer: &buffer) else { break }
|
||||
let close = request.headers["connection"]?.lowercased() == "close"
|
||||
let prior = previous
|
||||
previous = Task {
|
||||
let taskID = UUID()
|
||||
let token = Self.bearerToken(of: request)
|
||||
let task = Task {
|
||||
let response = await self.handle(request)
|
||||
await prior?.value
|
||||
try? await self.sendResponse(response, on: conn)
|
||||
await self.handlerTaskFinished(taskID, token: token)
|
||||
}
|
||||
previous = task
|
||||
spawned.append(taskID)
|
||||
// No suspension since the Task was created (this actor still holds `serve`), so the
|
||||
// task can't have reached `handlerTaskFinished` yet — registration never races removal.
|
||||
registerHandlerTask(task, id: taskID, token: token)
|
||||
if close { break }
|
||||
}
|
||||
// Let outstanding handlers finish and flush (in order) before `defer` closes the socket.
|
||||
// The connection is dead (client hung up, `Connection: close`, or the server is stopping):
|
||||
// no response can be delivered anymore, so CANCEL the still-running handlers instead of
|
||||
// letting them keep working for a peer that's gone — this is what unwinds an in-flight
|
||||
// `nucleic_subagent` spawn when its supervisor is killed. Cancellation is cooperative;
|
||||
// handlers that finish anyway just flush into the closed socket harmlessly.
|
||||
for id in spawned { handlerTasks[id]?.cancel() }
|
||||
// Let outstanding handlers unwind and flush (in order) before `defer` closes the socket.
|
||||
await previous?.value
|
||||
}
|
||||
|
||||
/// The request's bearer token (empty when absent/malformed) — the key handler tasks are
|
||||
/// indexed under so `unregister(token:)` can cancel a dead session's in-flight calls.
|
||||
private static func bearerToken(of request: HTTPRequest) -> String {
|
||||
let authorization = request.headers["authorization"] ?? ""
|
||||
return authorization.hasPrefix("Bearer ") ? String(authorization.dropFirst(7)) : ""
|
||||
}
|
||||
|
||||
private func registerHandlerTask(_ task: Task<Void, Never>, id: UUID, token: String) {
|
||||
handlerTasks[id] = task
|
||||
if !token.isEmpty { handlerTaskIDsByToken[token, default: []].insert(id) }
|
||||
}
|
||||
|
||||
private func handlerTaskFinished(_ id: UUID, token: String) {
|
||||
handlerTasks.removeValue(forKey: id)
|
||||
if !token.isEmpty, var ids = handlerTaskIDsByToken[token] {
|
||||
ids.remove(id)
|
||||
handlerTaskIDsByToken[token] = ids.isEmpty ? nil : ids
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel every in-flight handler task authenticated by `token` — the session's run has ended
|
||||
/// (teardown/shutdown unregisters its token), so its suspended calls must unwind now rather
|
||||
/// than survive their supervisor. Entries clean themselves up as the cancelled tasks finish.
|
||||
private func cancelHandlerTasks(token: String) {
|
||||
for id in handlerTaskIDsByToken[token] ?? [] { handlerTasks[id]?.cancel() }
|
||||
}
|
||||
|
||||
private struct HTTPRequest {
|
||||
let method: String
|
||||
let path: String
|
||||
@@ -2070,7 +2200,14 @@ 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 }
|
||||
init(fd: Int32) {
|
||||
self.fd = fd
|
||||
// A client that dies mid-call (a killed supervisor's CLI) closes its end while a response
|
||||
// may still be in flight; without this, that write raises SIGPIPE and kills the WHOLE app
|
||||
// rather than surfacing as a plain EPIPE from `write` (handled in `send`).
|
||||
var one: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout<Int32>.size))
|
||||
}
|
||||
|
||||
func receive(maxLength: Int) async throws -> Data? {
|
||||
let fd = self.fd
|
||||
|
||||
@@ -122,6 +122,21 @@ extension ContainerEngine {
|
||||
_ = try? await runToCompletion(container, ["sh", "-c", script])
|
||||
}
|
||||
|
||||
/// Preflight for the mandatory vsock control plane: the guest must be able to run the
|
||||
/// in-container control bridge — `node` on PATH plus the bridge script the init already tried
|
||||
/// to launch at start. Both ship in sandbox images ≥ `v4`; a custom image lacking either would
|
||||
/// leave the agent with no route to the host control endpoint, surfacing only as the CLI's
|
||||
/// opaque "Available MCP tools: none". Run once per fresh clone; throws an actionable error.
|
||||
func verifyControlBridge(_ spec: ContainerSpec, in container: LinuxContainer) async throws {
|
||||
let probe = "command -v node >/dev/null 2>&1 && test -f \(Self.controlBridgeGuestPath)"
|
||||
let exitCode = (try? await runToCompletion(container, ["sh", "-c", probe])) ?? -1
|
||||
guard exitCode != 0 else { return }
|
||||
throw ContainerError.startFailed(
|
||||
"image \(spec.image) can't run the mandatory vsock control plane: it must provide "
|
||||
+ "`node` and \(Self.controlBridgeGuestPath). Base custom images on "
|
||||
+ "\(ProjectSandbox.defaultImage) (v4 or later).")
|
||||
}
|
||||
|
||||
// MARK: - Disk GC (the daemonless replacement for orphan reaping)
|
||||
|
||||
/// On launch / after teardown: delete per-container rootfs clones whose name isn't in
|
||||
|
||||
@@ -600,6 +600,14 @@ public actor ContainerEngine {
|
||||
|
||||
if freshlyCloned {
|
||||
await seed(spec, in: container)
|
||||
// The vsock control plane is mandatory for agent containers, and its in-guest half —
|
||||
// `node` + the control bridge — rides in the image (sandbox image ≥ v4). A custom
|
||||
// image missing either would otherwise surface only as the agent CLI's opaque
|
||||
// "Available MCP tools: none" once the agent can't reach the host; probe once per
|
||||
// fresh clone and fail the start loudly and actionably instead.
|
||||
if spec.controlSocketHostPath != nil {
|
||||
try await verifyControlBridge(spec, in: container)
|
||||
}
|
||||
}
|
||||
return (spec.name, gateway)
|
||||
}
|
||||
|
||||
@@ -106,13 +106,17 @@ public actor ContainerManager {
|
||||
public static let claudeControlContainerName = "nucleic-control-claude" + channelSuffix
|
||||
public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix
|
||||
public static let xaiControlContainerName = "nucleic-control-xai" + channelSuffix
|
||||
public static let opencodeControlContainerName = "nucleic-control-opencode" + channelSuffix
|
||||
public static let hermesControlContainerName = "nucleic-control-hermes" + channelSuffix
|
||||
public static let cursorControlContainerName = "nucleic-control-cursor" + channelSuffix
|
||||
|
||||
/// Every shared control container name that can exist. Lifecycle sweeps (reconcile, teardown,
|
||||
/// status, recreate) iterate this so they stay correct no matter how the split setting is
|
||||
/// currently configured — e.g. turning splitting back off still reaps the per-family containers.
|
||||
public static let allSharedControlContainerNames =
|
||||
[sharedControlContainerName, claudeControlContainerName,
|
||||
codexControlContainerName, xaiControlContainerName]
|
||||
codexControlContainerName, xaiControlContainerName,
|
||||
opencodeControlContainerName, hermesControlContainerName, cursorControlContainerName]
|
||||
|
||||
/// The shared control container a session should use, given its backend and whether
|
||||
/// backend-splitting is on. With splitting off, every backend shares `nucleic-control`; with it
|
||||
@@ -126,6 +130,9 @@ public actor ContainerManager {
|
||||
case .claudeCode: return claudeControlContainerName
|
||||
case .codex, .codexExec: return codexControlContainerName
|
||||
case .grok: return xaiControlContainerName
|
||||
case .opencode: return opencodeControlContainerName
|
||||
case .hermes: return hermesControlContainerName
|
||||
case .cursorAgent: return cursorControlContainerName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +142,8 @@ public actor ContainerManager {
|
||||
/// per-agent-family containers. The primary `sharedControlContainerName` is excluded — when
|
||||
/// splitting is off it's the lone control sandbox, so there's no sibling for an agent to target.
|
||||
public nonisolated static let randomizedControlNames: Set<String> =
|
||||
[claudeControlContainerName, codexControlContainerName, xaiControlContainerName]
|
||||
[claudeControlContainerName, codexControlContainerName, xaiControlContainerName,
|
||||
opencodeControlContainerName, hermesControlContainerName, cursorControlContainerName]
|
||||
|
||||
/// Friendly type label for a shared control container's *logical* name (Control panel heading).
|
||||
public nonisolated static func controlTypeLabel(forLogical logical: String) -> String {
|
||||
@@ -143,6 +151,9 @@ public actor ContainerManager {
|
||||
case claudeControlContainerName: return "Claude"
|
||||
case codexControlContainerName: return "Codex"
|
||||
case xaiControlContainerName: return "xAI"
|
||||
case opencodeControlContainerName: return "OpenCode"
|
||||
case hermesControlContainerName: return "Hermes"
|
||||
case cursorControlContainerName: return "Cursor"
|
||||
default: return "Shared"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ public enum GitHubAuthMode: String, Sendable, Codable, CaseIterable, Identifiabl
|
||||
public enum GitHubCredentialSettings {
|
||||
/// Selected `GitHubAuthMode` (raw value). Absent/invalid → `.none`.
|
||||
public static let authModeKey = "nucleic.github.authMode"
|
||||
/// Whether to SSH-sign commits with the configured SSH key. Only meaningful in `.ssh` mode.
|
||||
/// Whether to SSH-sign commits with the configured SSH key. Only meaningful in `.ssh` mode — a
|
||||
/// `.managed` key is Nucleic's own identity and always signs (see `GitHubCredentialConfig.signsCommits`).
|
||||
public static let signCommitsKey = "nucleic.github.signCommitsSSH"
|
||||
/// Force the `gh` CLI to use SSH for git operations (`gh config set git_protocol ssh`). Only
|
||||
/// meaningful in `.ssh` mode (it needs the injected key). Off by default.
|
||||
@@ -305,6 +306,20 @@ public struct GitHubCredentialConfig: Sendable, Equatable {
|
||||
case .ssh, .managed: return !(sshPrivateKey ?? "").isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Nucleic SSH-signs the commits it creates with the configured key. A Nucleic-managed
|
||||
/// key is Nucleic's *own* git identity — the one it authenticates and signs as — so once one is
|
||||
/// set it always signs, regardless of the "Sign commits with this SSH key" toggle (which the user
|
||||
/// controls only for a *user-supplied* `.ssh` key, where signing is their choice). Token/none
|
||||
/// never sign. Used by every commit-creating host path and the sandbox provisioner, so a managed
|
||||
/// key signs uniformly — auto-commit, promote, integrate, on host and in container alike.
|
||||
public var signsCommits: Bool {
|
||||
switch mode {
|
||||
case .managed: return !(sshPrivateKey ?? "").isEmpty
|
||||
case .ssh: return signCommits && !(sshPrivateKey ?? "").isEmpty
|
||||
case .none, .token: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the configured GitHub secret into a sandbox's bind-mounted home and returns the
|
||||
@@ -432,7 +447,7 @@ public enum GitHubCredentialProvisioner {
|
||||
// Some OpenSSH builds still gate askpass on a non-empty DISPLAY; harmless to set.
|
||||
env["DISPLAY"] = ":0"
|
||||
}
|
||||
if config.signCommits {
|
||||
if config.signsCommits {
|
||||
// SSH commit signing: ssh-keygen signs with the private key at `user.signingkey`.
|
||||
// Pin the signer to OpenSSH's ssh-keygen at its standard path. In the container this
|
||||
// just makes the default explicit (the image's `openssh-client` installs it there); the
|
||||
@@ -514,8 +529,9 @@ public enum GitHubCredentialProvisioner {
|
||||
return (env, cleanup)
|
||||
}
|
||||
|
||||
/// Host-side **commit signing**. When the user enabled "Sign commits with this SSH key" and an
|
||||
/// SSH/managed key is configured, write that key into a private 0700 temp dir and return the
|
||||
/// Host-side **commit signing**. When signing is in effect for the configured key — always for a
|
||||
/// Nucleic-managed key (Nucleic's own git identity), or for a user `.ssh` key when they enabled
|
||||
/// "Sign commits with this SSH key" — write that key into a private 0700 temp dir and return the
|
||||
/// `git -c …` flags that make a host-side `git commit`/`git merge`/`git rebase` SSH-sign with it
|
||||
/// (`gpg.format=ssh`, `user.signingkey=<that key>`, `commit.gpgsign=true`, `tag.gpgsign=true`),
|
||||
/// plus any env needed to unlock a passphrase-protected `.ssh` key non-interactively. Returns
|
||||
@@ -534,8 +550,7 @@ public enum GitHubCredentialProvisioner {
|
||||
public static func commitSigning(
|
||||
_ config: GitHubCredentialConfig = .current()
|
||||
) -> (flags: [String], env: [String: String], cleanup: @Sendable () -> Void) {
|
||||
guard config.signCommits,
|
||||
config.mode == .ssh || config.mode == .managed,
|
||||
guard config.signsCommits,
|
||||
let key = config.sshPrivateKey, !key.isEmpty
|
||||
else { return ([], [:], {}) }
|
||||
|
||||
|
||||
@@ -455,7 +455,16 @@ public actor GitWorktreeManager: WorktreeManaging {
|
||||
try await git.checked(["add", "-A"], in: worktree.path)
|
||||
let staged = try await git.run(["diff", "--cached", "--quiet"], in: worktree.path)
|
||||
if staged.status != 0 { // 1 ⇒ there are staged changes to commit
|
||||
try await git.checked(["commit", "-m", message], in: worktree.path)
|
||||
// Sign the auto-commit with the configured Managed Git key — always for a
|
||||
// Nucleic-managed key (Nucleic's own git identity), or a user `.ssh` key when they
|
||||
// opted in. Without this env the `git commit` falls back to the host's ambient
|
||||
// gitconfig, signing with the user's *personal* key (or not at all) rather than the
|
||||
// Managed Git key — the same gap `integrate`/`promote` close. Empty flags + env when
|
||||
// signing isn't in effect, so the commit runs exactly as before.
|
||||
let (signFlags, signEnv, signCleanup) = GitHubCredentialProvisioner.commitSigning()
|
||||
defer { signCleanup() }
|
||||
try await git.checked(
|
||||
signFlags + ["commit", "-m", message], in: worktree.path, env: signEnv)
|
||||
await onCommit?()
|
||||
}
|
||||
case .manual:
|
||||
|
||||
@@ -21,7 +21,10 @@ import Foundation
|
||||
/// 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 GrokACPBackend: AgentBackend {
|
||||
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(
|
||||
@@ -36,20 +39,25 @@ public actor GrokACPBackend: AgentBackend {
|
||||
followUpWhileRunning: false) // single-shot per turn; resume next turn
|
||||
|
||||
public struct Configuration: Sendable {
|
||||
/// Executable name (resolved via PATH) or absolute path. Tests point this at the
|
||||
/// fake-grok stub (OBSERVABILITY B.4).
|
||||
/// 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(
|
||||
executable: String = "grok",
|
||||
agent: ACPAgent = .grok,
|
||||
executable: String? = nil,
|
||||
clientName: String = "Nucleic",
|
||||
clientVersion: String = "0.1",
|
||||
now: @escaping @Sendable () -> Date = { Date() }
|
||||
) {
|
||||
self.executable = executable
|
||||
self.agent = agent
|
||||
self.executable = executable ?? agent.executable
|
||||
self.clientName = clientName
|
||||
self.clientVersion = clientVersion
|
||||
self.now = now
|
||||
@@ -192,7 +200,7 @@ public actor GrokACPBackend: AgentBackend {
|
||||
return
|
||||
}
|
||||
|
||||
let stderrTail = GrokStderrTail()
|
||||
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
|
||||
@@ -259,12 +267,12 @@ public actor GrokACPBackend: AgentBackend {
|
||||
}
|
||||
handle = try await containerManager.exec(
|
||||
name: name, workdir: cspec.workdir, env: env,
|
||||
argv: [configuration.executable, "agent", "stdio"],
|
||||
argv: [configuration.executable] + configuration.agent.launchArgs,
|
||||
uid: cspec.runAsUID, gid: cspec.runAsGID)
|
||||
} else {
|
||||
let spec = ProcessSpec(
|
||||
executable: configuration.executable,
|
||||
args: ["agent", "stdio"],
|
||||
args: configuration.agent.launchArgs,
|
||||
cwd: run.worktree,
|
||||
env: run.extraEnv.merging(hostManagedGitEnv) { _, new in new },
|
||||
stdinMode: .pipe)
|
||||
@@ -277,7 +285,8 @@ public actor GrokACPBackend: AgentBackend {
|
||||
let text = String(decoding: line, as: UTF8.self)
|
||||
stderrTail.append(text)
|
||||
if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
|
||||
FileHandle.standardError.write(Data(("grok-stderr: " + text + "\n").utf8))
|
||||
let tag = self.configuration.agent.executable
|
||||
FileHandle.standardError.write(Data(("\(tag)-stderr: " + text + "\n").utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,7 +379,7 @@ public actor GrokACPBackend: AgentBackend {
|
||||
AgentError(
|
||||
recoverable: false,
|
||||
message: BackendDiagnostics.abnormalExitMessage(
|
||||
tool: "grok", exitCode: exitCode,
|
||||
tool: configuration.agent.executable, exitCode: exitCode,
|
||||
stderrTail: stderrTail.joined(), containerized: false))),
|
||||
nativeType: nil)
|
||||
finishRun(outcome: .errored)
|
||||
@@ -730,14 +739,18 @@ public actor GrokACPBackend: AgentBackend {
|
||||
sessionID: sessionID ?? SessionID(rawValue: "unknown"),
|
||||
seq: provisionalSeq,
|
||||
at: configuration.now(),
|
||||
backend: .grok,
|
||||
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 GrokStderrTail: @unchecked Sendable {
|
||||
private final class ACPStderrTail: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var lines: [String] = []
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ extension MacVMPackage {
|
||||
public static let chrome = MacVMPackage(
|
||||
id: "chrome",
|
||||
name: "Google Chrome",
|
||||
summary: "Latest stable release, installed into /Applications.",
|
||||
summary: "",
|
||||
available: true,
|
||||
unavailableNote: nil,
|
||||
installScript: """
|
||||
|
||||
@@ -53,8 +53,8 @@ public struct ProjectSandbox: Sendable, Codable, Equatable {
|
||||
/// changes — the new tag has no cache yet, so the next session pulls it fresh and launch-time
|
||||
/// reconcile prunes the superseded cache. (`v2` added the cross-language build tools:
|
||||
/// build-essential/make, python3/pip. `v3` added the GitHub CLI `gh` plus `curl`. `v4` added the
|
||||
/// Codex + Grok CLIs and `control-bridge.js` — the latter is REQUIRED by the now-default vsock
|
||||
/// control plane, so `v4` must ship before `vsockControlPlaneEnabled` defaults on. `v5` added
|
||||
/// Codex + Grok CLIs and `control-bridge.js` — the latter is REQUIRED by the now-mandatory vsock
|
||||
/// control plane, so every image from `v4` on must keep shipping it. `v5` added
|
||||
/// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`.)
|
||||
/// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
|
||||
public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v5"
|
||||
@@ -473,20 +473,12 @@ public enum ContainerServiceSettings {
|
||||
defaults.bool(forKey: splitControlContainersByBackendKey)
|
||||
}
|
||||
|
||||
/// When on, a shared Nucleic Control container's control plane (the approval MCP server + the
|
||||
/// git/gh/command interceptor endpoint) runs over a **vsock-relayed unix socket** instead of
|
||||
/// TCP/HTTP on the VM gateway — so macOS raises no incoming-connection / local-network prompts.
|
||||
/// The agent and the interceptor shims reach the host through an in-container loopback bridge
|
||||
/// that forwards to the relayed socket (see `docs/VSOCK_CONTROL_PLANE.md`). **On by default** as
|
||||
/// of sandbox image `v4` (which ships `control-bridge.js`); set the key to `false` to fall back to
|
||||
/// the legacy gateway-TCP path. Because it requires the bridge in the image, the default must move
|
||||
/// in lockstep with `ProjectSandbox.defaultImage` being a bridge-bearing tag (≥ `v4`).
|
||||
public static let vsockControlPlaneEnabledKey = "nucleic.container.vsockControlPlane"
|
||||
|
||||
/// **Always on.** The vsock control plane is mandatory — there is no longer a user-facing toggle
|
||||
/// to disable it, and any previously stored preference is ignored. The legacy gateway-TCP fallback
|
||||
/// path has been retired now that every shipping sandbox image (≥ `v4`) carries `control-bridge.js`.
|
||||
public static var vsockControlPlaneEnabled: Bool { true }
|
||||
// The vsock control plane (the approval MCP server + interceptor endpoint over a
|
||||
// vsock-relayed unix socket, `docs/VSOCK_CONTROL_PLANE.md`) is MANDATORY — always on for
|
||||
// every containerized run, shared and per-session alike. The old
|
||||
// `nucleic.container.vsockControlPlane` toggle and the legacy gateway-TCP fallback are
|
||||
// retired: every shipping sandbox image (≥ `v4`) carries `control-bridge.js`, and a custom
|
||||
// image without it fails loudly at container start (the engine's fresh-clone preflight).
|
||||
|
||||
/// Whether the in-container **bash command tracer** is active. The tracer installs a `DEBUG`/
|
||||
/// `EXIT` trap (sourced via `BASH_ENV`) that records EVERY command the agent runs inside a Bash
|
||||
|
||||
@@ -71,13 +71,17 @@ public enum ProviderAvailability {
|
||||
// OPENAI_API_KEY, else the credentials `codex login` writes.
|
||||
envKeyPresent("OPENAI_API_KEY") || credentialFileExists(".codex/auth.json")
|
||||
}),
|
||||
Probe(backend: .grok, name: "Grok Build", executable: "grok",
|
||||
isAuthenticated: {
|
||||
// XAI_API_KEY (Grok Build prefers it over login), else the config
|
||||
// `grok auth login` persists under ~/.grok (GROK_ADAPTER §1).
|
||||
envKeyPresent("XAI_API_KEY") || credentialFileExists(".grok/config.toml")
|
||||
}),
|
||||
]
|
||||
// The ACP agents (Grok + the wrapper agents) share one probe shape driven by their profile:
|
||||
// any auth env var present, else the CLI's on-disk credential file. Listed after the
|
||||
// native providers, in `ACPAgent.all` order.
|
||||
+ ACPAgent.all.map { agent in
|
||||
Probe(backend: agent.backend, name: agent.displayName, executable: agent.executable,
|
||||
isAuthenticated: {
|
||||
agent.authEnvVars.contains(where: envKeyPresent)
|
||||
|| (agent.credentialFile.map(credentialFileExists) ?? false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Probe every provider concurrently, returning their statuses in display order.
|
||||
public static func probeAll(processHost: ProcessHost = ProcessHost()) async -> [ProviderStatus] {
|
||||
|
||||
@@ -558,8 +558,15 @@ public actor SessionController {
|
||||
let hostAuth = (NSHomeDirectory() as NSString).appendingPathComponent(".codex/auth.json")
|
||||
CodexAuthFile.copyIfNewer(from: hostAuth, to: containerAuth)
|
||||
}
|
||||
case .grok:
|
||||
Self.seedAgentHome(hostDir: ".grok", skipping: [], into: writableHost)
|
||||
case .grok, .opencode, .hermes, .cursorAgent:
|
||||
// ACP wrapper agents store auth as plain files under `$HOME` (like Codex/Grok). Seed
|
||||
// each of the agent's known home dirs so a sandboxed run authenticates as the user.
|
||||
// Host-only in v1, so this path is exercised only if containers are enabled for them.
|
||||
if let agent = ACPAgent.forBackend(session.backend) {
|
||||
for dir in agent.containerHomeDirs {
|
||||
Self.seedAgentHome(hostDir: dir, skipping: [], into: writableHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stagingPath = "/nucleic/host-claude"
|
||||
@@ -573,10 +580,14 @@ public actor SessionController {
|
||||
if let key = ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"], !Self.isBlankAPIKey(key) {
|
||||
env["ANTHROPIC_API_KEY"] = key
|
||||
}
|
||||
// The Codex/Grok headless-auth path is an env key too; forward each non-blank one (harmless
|
||||
// for a Claude container). Codex reads OPENAI_API_KEY (CODEX_API_KEY is `codex exec`-only);
|
||||
// Grok reads XAI_API_KEY / GROK_CODE_XAI_API_KEY.
|
||||
for key in ["OPENAI_API_KEY", "CODEX_API_KEY", "XAI_API_KEY", "GROK_CODE_XAI_API_KEY"] {
|
||||
// The Codex/Grok/ACP headless-auth path is an env key too; forward each non-blank one
|
||||
// (harmless for a Claude container). Codex reads OPENAI_API_KEY (CODEX_API_KEY is `codex
|
||||
// exec`-only); Grok reads XAI_API_KEY / GROK_CODE_XAI_API_KEY; the ACP wrapper agents read
|
||||
// their own keys (OpenCode/Cursor/Hermes) plus, being model-agnostic, provider keys above.
|
||||
for key in [
|
||||
"OPENAI_API_KEY", "CODEX_API_KEY", "XAI_API_KEY", "GROK_CODE_XAI_API_KEY",
|
||||
"OPENCODE_API_KEY", "CURSOR_API_KEY", "HERMES_API_KEY", "NOUS_API_KEY",
|
||||
] {
|
||||
if let value = ProcessInfo.processInfo.environment[key], !Self.isBlankAPIKey(value) {
|
||||
env[key] = value
|
||||
}
|
||||
@@ -677,13 +688,15 @@ public actor SessionController {
|
||||
) { _, new in new }
|
||||
}
|
||||
|
||||
// The shared control container can run its control plane over a vsock-relayed unix socket
|
||||
// (no IP listener → no macOS prompts) when enabled — it's the long-lived, token-multiplexed
|
||||
// box the per-container socket + in-guest bridge are designed for. Per-session containers
|
||||
// stay on the gateway-TCP path. `nil` → legacy TCP (the default).
|
||||
let controlSocketHostPath: String? =
|
||||
(shared && ContainerServiceSettings.vsockControlPlaneEnabled)
|
||||
? ApprovalServerRegistry.controlSocketPath(for: name) : nil
|
||||
// Control plane: EVERY containerized run rides the vsock-relayed unix socket (no IP
|
||||
// listener → no macOS local-network prompts). The vsock control plane is mandatory — the
|
||||
// legacy gateway-TCP path is retired, for per-session containers too. A shared control
|
||||
// container meets the long-lived registry server at its container-stable path; a
|
||||
// per-session container gets its own socket (same app-owned runtime dir, keyed by its
|
||||
// unique name), served by its backend's own server. Requires the in-guest bridge —
|
||||
// sandbox image ≥ v4; a custom image must carry `node` + the bridge script, enforced by
|
||||
// the engine's fresh-clone preflight so a bridge-less image fails loudly at start.
|
||||
let controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)
|
||||
|
||||
return ContainerSpec(
|
||||
name: name,
|
||||
|
||||
@@ -105,18 +105,23 @@ public enum StatusProvider: String, Sendable, CaseIterable, Identifiable {
|
||||
case .claudeCode: return .claude
|
||||
case .codex, .codexExec: return .openai
|
||||
case .grok: return .xai
|
||||
// The ACP wrapper agents (OpenCode/Hermes/Cursor) are model-agnostic and have no single
|
||||
// public status page, so they drive no status-based failover.
|
||||
case .opencode, .hermes, .cursorAgent: return nil
|
||||
case nil: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider that serves a backend family: Claude Code → `.claude`, Codex → `.openai`,
|
||||
/// Grok → `.xai`. Total (unlike `forModel`, which is nil for an unknown SKU), so an agent error
|
||||
/// can refresh exactly its provider's status feed even when the run carried no explicit model.
|
||||
public static func forBackend(_ backend: BackendID) -> StatusProvider {
|
||||
/// Grok → `.xai`. Nil for the ACP wrapper agents, which have no dedicated status feed (so an
|
||||
/// error can't refresh "their" provider). Lets an agent error refresh exactly its provider's
|
||||
/// status feed even when the run carried no explicit model.
|
||||
public static func forBackend(_ backend: BackendID) -> StatusProvider? {
|
||||
switch backend {
|
||||
case .claudeCode: return .claude
|
||||
case .codex, .codexExec: return .openai
|
||||
case .grok: return .xai
|
||||
case .opencode, .hermes, .cursorAgent: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +53,29 @@ public enum BackendID: String, Sendable, Codable {
|
||||
/// stdio, baked into the binary. A sibling of `codex`: interactive approvals arrive as native
|
||||
/// `session/request_permission` requests (GROK_ADAPTER §3–4). Host-only in v1.
|
||||
case grok
|
||||
/// SST's **OpenCode** over ACP (`opencode acp`). A model-agnostic coding agent that speaks
|
||||
/// standards JSON-RPC 2.0 over stdio — a sibling of `grok`, driven by the same generic
|
||||
/// `ACPBackend` (ADAPTERS §5). Host-only in v1.
|
||||
case opencode
|
||||
/// Nous Research's **Hermes Agent** over ACP (`hermes acp`). Another ACP sibling on the shared
|
||||
/// `ACPBackend`; model-agnostic (it drives Claude/Codex under the hood). Host-only in v1.
|
||||
case hermes
|
||||
/// **Cursor CLI agent** over ACP (`cursor-agent acp`). ACP sibling on the shared `ACPBackend`;
|
||||
/// authenticates with `CURSOR_API_KEY` / `cursor-agent login`. Host-only in v1.
|
||||
case cursorAgent
|
||||
|
||||
/// Infer the backend from a model SKU so the model picker doubles as the backend
|
||||
/// selector: Claude SKUs → `claudeCode`, OpenAI/Codex SKUs (`gpt-*`, `o3*`, `o4*`, or
|
||||
/// anything containing `codex`) → `codex`, xAI SKUs (`grok-*`) → `grok`. `nil` when
|
||||
/// unrecognized, so callers fall back to the project/app default.
|
||||
/// anything containing `codex`) → `codex`, xAI SKUs (`grok-*`) → `grok`, and each ACP wrapper
|
||||
/// agent by its own SKU (`opencode`/`hermes`/`cursor-agent`). `nil` when unrecognized, so
|
||||
/// callers fall back to the project/app default.
|
||||
public static func forModel(_ model: String?) -> BackendID? {
|
||||
guard let model = model?.lowercased() else { return nil }
|
||||
if model.hasPrefix("claude") { return .claudeCode }
|
||||
if model.hasPrefix("grok") { return .grok }
|
||||
if model.hasPrefix("opencode") { return .opencode }
|
||||
if model.hasPrefix("hermes") { return .hermes }
|
||||
if model.hasPrefix("cursor") { return .cursorAgent }
|
||||
if model.hasPrefix("gpt") || model.hasPrefix("o3") || model.hasPrefix("o4")
|
||||
|| model.contains("codex")
|
||||
{
|
||||
@@ -71,13 +85,14 @@ public enum BackendID: String, Sendable, Codable {
|
||||
}
|
||||
|
||||
/// True for the OpenAI/Codex backends (`codex`, `codexExec`) — the GPT-family agents,
|
||||
/// as opposed to `claudeCode`/`grok`. Used to bucket sessions when keeping Claude and GPT
|
||||
/// agents in separate sandboxes (the two can otherwise compete and kill each other's
|
||||
/// processes). Grok is not part of this family (it runs host-only in v1).
|
||||
/// as opposed to `claudeCode`/`grok`/the ACP wrapper agents. Used to bucket sessions when
|
||||
/// keeping Claude and GPT agents in separate sandboxes (the two can otherwise compete and kill
|
||||
/// each other's processes). The ACP wrappers are not part of this family (they run host-only
|
||||
/// in v1).
|
||||
public var isCodexFamily: Bool {
|
||||
switch self {
|
||||
case .codex, .codexExec: return true
|
||||
case .claudeCode, .grok: return false
|
||||
case .claudeCode, .grok, .opencode, .hermes, .cursorAgent: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,9 @@ extension LiveActivitySnapshot {
|
||||
case .claudeCode: return "claude"
|
||||
case .codex, .codexExec: return "codex"
|
||||
case .grok: return "grok"
|
||||
case .opencode: return "opencode"
|
||||
case .hermes: return "hermes"
|
||||
case .cursorAgent: return "cursor"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ public struct WireModelCatalog: Sendable, Codable, Equatable {
|
||||
case .claudeCode: return model.backend == .claudeCode
|
||||
case .codex, .codexExec: return model.backend == .codex
|
||||
case .grok: return model.backend == .grok
|
||||
case .opencode: return model.backend == .opencode
|
||||
case .hermes: return model.backend == .hermes
|
||||
case .cursorAgent: return model.backend == .cursorAgent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,6 +782,153 @@ struct AppStoreTests {
|
||||
#expect(FileManager.default.fileExists(atPath: wtPath)) // nothing reclaimed
|
||||
}
|
||||
|
||||
// MARK: - Orchestra worker lifecycle (cascade + orphan sweep)
|
||||
|
||||
@Test func deleteSupervisorCascadesToItsWorkers() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
let project = try #require(await store.addProject(
|
||||
name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
|
||||
let supervisor = try await store.createSession(in: project, title: "boss", prompt: "go")
|
||||
let worker = try await store.createSession(
|
||||
in: project, title: "minion", prompt: "go", spawnedBy: supervisor)
|
||||
await waitFor { store.summaries.first { $0.id == worker }?.status == .awaitingInput }
|
||||
#expect(store.subagentSummaries(for: supervisor).map(\.id) == [worker])
|
||||
let workerWorktree = (repo.root as NSString)
|
||||
.appendingPathComponent(".nucleic/worktrees/minion")
|
||||
#expect(FileManager.default.fileExists(atPath: workerWorktree))
|
||||
|
||||
await store.deleteSession(supervisor)
|
||||
|
||||
// The worker went with its supervisor: row, summary, and worktree all gone.
|
||||
#expect(!store.summaries.contains { $0.id == supervisor })
|
||||
#expect(!store.summaries.contains { $0.id == worker })
|
||||
#expect(!FileManager.default.fileExists(atPath: workerWorktree))
|
||||
}
|
||||
|
||||
@Test func archiveSupervisorArchivesItsWorkersButUnarchiveLeavesThemPutAway() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
let project = try #require(await store.addProject(
|
||||
name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
|
||||
let supervisor = try await store.createSession(in: project, title: "boss", prompt: "go")
|
||||
let worker = try await store.createSession(
|
||||
in: project, title: "minion", prompt: "go", spawnedBy: supervisor)
|
||||
await waitFor { store.summaries.first { $0.id == worker }?.status == .awaitingInput }
|
||||
|
||||
await store.setSessionArchived(supervisor, true)
|
||||
#expect(store.summaries.first { $0.id == worker }?.archived == true)
|
||||
// Still listed by the parent's Subagents panel (it doesn't filter archived).
|
||||
#expect(store.subagentSummaries(for: supervisor).map(\.id) == [worker])
|
||||
|
||||
// Unarchiving the supervisor deliberately leaves the finished worker put away.
|
||||
await store.setSessionArchived(supervisor, false)
|
||||
#expect(store.summaries.first { $0.id == supervisor }?.archived == false)
|
||||
#expect(store.summaries.first { $0.id == worker }?.archived == true)
|
||||
}
|
||||
|
||||
@Test func spawnOrchestraSubagentArchivesTheFinishedWorker() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
let project = try #require(await store.addProject(
|
||||
name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
|
||||
let supervisor = try await store.createSession(in: project, title: "boss", prompt: "go")
|
||||
await waitFor { store.summaries.first { $0.id == supervisor }?.status == .awaitingInput }
|
||||
let parent = try #require(await store.liveSnapshot(supervisor)).session
|
||||
|
||||
let result = await store.spawnOrchestraSubagent(
|
||||
OrchestraSubagentRequest(task: "scan", prompt: "check it"),
|
||||
parent: parent, project: project)
|
||||
|
||||
guard case .completed(let workerID, _, _, _) = result else {
|
||||
Issue.record("expected .completed, got \(result)")
|
||||
return
|
||||
}
|
||||
let workerSummary = try #require(store.summaries.first { $0.id == workerID })
|
||||
#expect(workerSummary.spawnedBySessionID == supervisor)
|
||||
// The supervisor consumed the worker's reply — the finished worker is put away
|
||||
// immediately, so it never lingers as an "active" chat.
|
||||
#expect(workerSummary.archived == true)
|
||||
}
|
||||
|
||||
@Test func loadSessionsSweepsWorkersOrphanedByAMissingSupervisor() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let dbPath = (repo.container as NSString).appendingPathComponent("nucleic.sqlite")
|
||||
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
|
||||
.appendingPathComponent("transcripts"))
|
||||
let now: @Sendable () -> Date = { Date(timeIntervalSince1970: 1_700_000_000) }
|
||||
let worktrees = GitWorktreeManager(now: now)
|
||||
let makeBackend: @Sendable (Session) -> any AgentBackend = { session in
|
||||
let wtPath = session.worktreePath ?? ""
|
||||
return ScriptedBackend { e, _ in
|
||||
e.emit(.sessionStarted(SessionStarted(
|
||||
backendSessionID: "be", model: "m", cwd: wtPath, toolNames: [])))
|
||||
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
|
||||
e.emit(.runFinished(RunFinished(outcome: .completed)))
|
||||
}
|
||||
}
|
||||
|
||||
// First launch: two supervisors, one worker each. Supervisor one's DB row is then
|
||||
// deleted OUT FROM UNDER its worker (straight through the metadata store, the way a
|
||||
// pre-cascade delete or a bypassing path left orphans behind).
|
||||
let orphaned: SessionID
|
||||
let kept: SessionID
|
||||
let liveSupervisor: SessionID
|
||||
do {
|
||||
let db = try GRDBMetadataStore(path: dbPath)
|
||||
let store = AppStore(
|
||||
database: db, worktrees: worktrees, transcriptsDir: transcriptsDir,
|
||||
now: now, backendFactory: makeBackend)
|
||||
let project = try #require(await store.addProject(
|
||||
name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
let deadSupervisor = try await store.createSession(
|
||||
in: project, title: "boss-one", prompt: "go")
|
||||
liveSupervisor = try await store.createSession(
|
||||
in: project, title: "boss-two", prompt: "go")
|
||||
orphaned = try await store.createSession(
|
||||
in: project, title: "orphan", prompt: "go", spawnedBy: deadSupervisor)
|
||||
kept = try await store.createSession(
|
||||
in: project, title: "kept", prompt: "go", spawnedBy: liveSupervisor)
|
||||
await waitFor {
|
||||
[deadSupervisor, liveSupervisor, orphaned, kept].allSatisfy { id in
|
||||
store.summaries.first { $0.id == id }?.status == .awaitingInput
|
||||
}
|
||||
}
|
||||
try await db.deleteSession(id: deadSupervisor)
|
||||
}
|
||||
|
||||
let orphanWorktree = (repo.root as NSString)
|
||||
.appendingPathComponent(".nucleic/worktrees/orphan")
|
||||
let keptWorktree = (repo.root as NSString)
|
||||
.appendingPathComponent(".nucleic/worktrees/kept")
|
||||
#expect(FileManager.default.fileExists(atPath: orphanWorktree))
|
||||
|
||||
// Second launch over the same DB: the sweep deletes the unreachable worker — row,
|
||||
// summary, and worktree — while the live supervisor's worker is untouched.
|
||||
let db2 = try GRDBMetadataStore(path: dbPath)
|
||||
let store2 = AppStore(
|
||||
database: db2, worktrees: worktrees, transcriptsDir: transcriptsDir,
|
||||
now: now, backendFactory: makeBackend)
|
||||
await store2.loadProjects()
|
||||
await store2.loadSessions()
|
||||
|
||||
#expect(!store2.summaries.contains { $0.id == orphaned })
|
||||
#expect(store2.summaries.contains { $0.id == kept })
|
||||
#expect(store2.summaries.contains { $0.id == liveSupervisor })
|
||||
let rows = try await db2.loadAllSessions()
|
||||
#expect(!rows.contains { $0.id == orphaned })
|
||||
#expect(rows.contains { $0.id == kept })
|
||||
#expect(!FileManager.default.fileExists(atPath: orphanWorktree))
|
||||
#expect(FileManager.default.fileExists(atPath: keptWorktree))
|
||||
}
|
||||
|
||||
/// Builds a store whose scripted agent emits one finished tool call per `(id, name,
|
||||
/// input)`, all in a row, so they coalesce into a single group.
|
||||
private func storeEmitting(
|
||||
|
||||
@@ -22,6 +22,33 @@ import Testing
|
||||
#expect(BackendID.forModel("GPT-5.5") == .codex) // case-insensitive
|
||||
}
|
||||
|
||||
@Test func grokModelsRouteToGrok() {
|
||||
#expect(BackendID.forModel("grok-build") == .grok)
|
||||
#expect(BackendID.forModel("GROK-BUILD") == .grok) // case-insensitive
|
||||
}
|
||||
|
||||
@Test func acpWrapperModelsRouteToTheirBackends() {
|
||||
#expect(BackendID.forModel("opencode") == .opencode)
|
||||
#expect(BackendID.forModel("hermes") == .hermes)
|
||||
#expect(BackendID.forModel("cursor-agent") == .cursorAgent)
|
||||
#expect(BackendID.forModel("Cursor-Agent") == .cursorAgent) // case-insensitive
|
||||
}
|
||||
|
||||
/// Every offered SKU must resolve to a backend, and each ACP wrapper SKU's `ACPAgent` profile
|
||||
/// must round-trip back to the same backend — the two sources of truth agree.
|
||||
@Test func everyACPAgentProfileRoundTrips() {
|
||||
for agent in ACPAgent.all {
|
||||
#expect(BackendID.forModel(agent.sku) == agent.backend)
|
||||
#expect(ACPAgent.forBackend(agent.backend)?.sku == agent.sku)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func acpWrapperAgentsAreNotCodexFamily() {
|
||||
for backend in [BackendID.grok, .opencode, .hermes, .cursorAgent] {
|
||||
#expect(backend.isCodexFamily == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func unknownModelIsUnclassified() {
|
||||
#expect(BackendID.forModel("mystery-model") == nil)
|
||||
#expect(BackendID.forModel(nil) == nil)
|
||||
|
||||
@@ -40,14 +40,16 @@ struct ContainerSandboxTests {
|
||||
|
||||
// MARK: - ContainerSpec control-socket relay plumbing (no VM required)
|
||||
|
||||
/// The optional control-socket relay defaults off, survives the `renamed` copy the engine
|
||||
/// boundary makes for shared control containers, and exposes the agreed fixed guest mount point
|
||||
/// the in-guest bridge/shims connect to.
|
||||
/// The optional control-socket relay defaults off at the STRUCT level (agent-created throwaway
|
||||
/// containers run no control plane; session specs always set it — the vsock control plane is
|
||||
/// mandatory), survives the `renamed` copy the engine boundary makes for shared control
|
||||
/// containers, and exposes the agreed fixed guest mount point the in-guest bridge/shims
|
||||
/// connect to.
|
||||
@Test func controlSocketRelayPlumbing() {
|
||||
let base = ContainerSpec(
|
||||
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
|
||||
idleTimeout: 60, claudeHomeStaging: "/s", claudeHomeWritable: "/h")
|
||||
#expect(base.controlSocketHostPath == nil) // default: no relay (legacy TCP path)
|
||||
#expect(base.controlSocketHostPath == nil) // struct default: no relay (no control plane)
|
||||
|
||||
let wired = ContainerSpec(
|
||||
name: "nucleic-control", image: "img", mounts: [], workdir: "/w", env: [:],
|
||||
@@ -82,12 +84,17 @@ struct ContainerSandboxTests {
|
||||
#expect(ContainerManager.claudeControlContainerName == "nucleic-control-claude\(s)")
|
||||
#expect(ContainerManager.codexControlContainerName == "nucleic-control-codex\(s)")
|
||||
#expect(ContainerManager.xaiControlContainerName == "nucleic-control-xai\(s)")
|
||||
#expect(ContainerManager.opencodeControlContainerName == "nucleic-control-opencode\(s)")
|
||||
#expect(ContainerManager.hermesControlContainerName == "nucleic-control-hermes\(s)")
|
||||
#expect(ContainerManager.cursorControlContainerName == "nucleic-control-cursor\(s)")
|
||||
// Every shared name is distinct (a duplicate would let one family's sweep clobber another's).
|
||||
#expect(Set(ContainerManager.allSharedControlContainerNames).count
|
||||
== ContainerManager.allSharedControlContainerNames.count)
|
||||
#expect(ContainerManager.allSharedControlContainerNames ==
|
||||
["nucleic-control\(s)", "nucleic-control-claude\(s)",
|
||||
"nucleic-control-codex\(s)", "nucleic-control-xai\(s)"])
|
||||
"nucleic-control-codex\(s)", "nucleic-control-xai\(s)",
|
||||
"nucleic-control-opencode\(s)", "nucleic-control-hermes\(s)",
|
||||
"nucleic-control-cursor\(s)"])
|
||||
|
||||
// A per-session name never equals a shared name.
|
||||
let perSession = ContainerManager.containerName(
|
||||
@@ -155,8 +162,11 @@ struct ContainerSandboxTests {
|
||||
#expect(r.contains(ContainerManager.claudeControlContainerName))
|
||||
#expect(r.contains(ContainerManager.codexControlContainerName))
|
||||
#expect(r.contains(ContainerManager.xaiControlContainerName))
|
||||
#expect(r.contains(ContainerManager.opencodeControlContainerName))
|
||||
#expect(r.contains(ContainerManager.hermesControlContainerName))
|
||||
#expect(r.contains(ContainerManager.cursorControlContainerName))
|
||||
#expect(!r.contains(ContainerManager.sharedControlContainerName))
|
||||
#expect(r.count == 3)
|
||||
#expect(r.count == 6)
|
||||
}
|
||||
|
||||
/// The physical name hides the agent family (so a glimpsed sibling can't be identified) and keeps
|
||||
|
||||
@@ -293,6 +293,22 @@ struct GitHubCredentialsTests {
|
||||
#expect(FileManager.default.fileExists(atPath: hostKey))
|
||||
}
|
||||
|
||||
@Test func managedModeSignsInSandboxRegardlessOfFlag() throws {
|
||||
// In the sandbox provisioner too, a managed key signs whether or not the "Sign commits" flag
|
||||
// is on — Nucleic Control's container git ops sign with the managed key uniformly.
|
||||
let home = tempHome()
|
||||
let env = GitHubCredentialProvisioner.provision(
|
||||
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: false),
|
||||
hostHomeDir: home, containerHomeDir: home)
|
||||
let count = try #require(env["GIT_CONFIG_COUNT"].flatMap { Int($0) })
|
||||
var cfg: [String: String] = [:]
|
||||
for i in 0..<count {
|
||||
cfg[try #require(env["GIT_CONFIG_KEY_\(i)"])] = try #require(env["GIT_CONFIG_VALUE_\(i)"])
|
||||
}
|
||||
#expect(cfg["gpg.format"] == "ssh")
|
||||
#expect(cfg["commit.gpgsign"] == "true")
|
||||
}
|
||||
|
||||
@Test func managedModeWithoutKeyIsInactive() {
|
||||
let home = tempHome()
|
||||
let cfg = GitHubCredentialConfig(mode: .managed, sshPrivateKey: nil)
|
||||
@@ -396,19 +412,33 @@ struct GitHubCredentialsTests {
|
||||
// MARK: - Host-side commit signing (promote / integration commits)
|
||||
|
||||
@Test func commitSigningOffReturnsNothing() {
|
||||
// signCommits off, or a non-SSH mode, ⇒ no signing flags so the commit runs as before.
|
||||
// A user `.ssh` key with signing off ⇒ no signing flags, so the commit runs as before.
|
||||
let (flags, env, cleanup) = GitHubCredentialProvisioner.commitSigning(
|
||||
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: false))
|
||||
GitHubCredentialConfig(mode: .ssh, sshPrivateKey: fakeKey, signCommits: false))
|
||||
#expect(flags.isEmpty)
|
||||
#expect(env.isEmpty)
|
||||
cleanup() // no-op; must not crash
|
||||
|
||||
// Token mode has no signing key, so even with the flag on it signs nothing.
|
||||
let (tokenFlags, _, tokenCleanup) = GitHubCredentialProvisioner.commitSigning(
|
||||
GitHubCredentialConfig(mode: .token, token: "t", signCommits: true))
|
||||
#expect(tokenFlags.isEmpty) // token mode has no signing key
|
||||
#expect(tokenFlags.isEmpty)
|
||||
tokenCleanup()
|
||||
}
|
||||
|
||||
@Test func commitSigningManagedKeySignsRegardlessOfFlag() throws {
|
||||
// A Nucleic-managed key is Nucleic's own git identity: it signs the commits Nucleic creates
|
||||
// whether or not the user turned on "Sign commits with this SSH key" — the flag governs only
|
||||
// a user-supplied `.ssh` key. So `signCommits: false` must still yield the SSH signing flags.
|
||||
let (flags, _, cleanup) = GitHubCredentialProvisioner.commitSigning(
|
||||
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: false))
|
||||
defer { cleanup() }
|
||||
#expect(flags.contains("gpg.format=ssh"))
|
||||
#expect(flags.contains("commit.gpgsign=true"))
|
||||
#expect(flags.contains("gpg.ssh.program=/usr/bin/ssh-keygen"))
|
||||
#expect(flags.contains { $0.hasPrefix("user.signingkey=") })
|
||||
}
|
||||
|
||||
@Test func commitSigningWritesKeyAndReturnsSSHSigningFlags() throws {
|
||||
let (flags, env, cleanup) = GitHubCredentialProvisioner.commitSigning(
|
||||
GitHubCredentialConfig(mode: .managed, sshPrivateKey: fakeKey, signCommits: true))
|
||||
|
||||
@@ -662,6 +662,63 @@ import Testing
|
||||
#expect(resp.body?["result"]?["serverInfo"]?["name"]?.stringValue == "nucleic-approval")
|
||||
}
|
||||
|
||||
@Test func tcpStartSelfHealsAfterListenerDeath() async throws {
|
||||
// The TCP mirror of the unix-socket self-heal: on the default (non-vsock) transport, the
|
||||
// shared control-container server serves every session over ONE long-lived TCP bind. If
|
||||
// that listener dies post-`.ready` (network-stack `.failed`, an out-from-under cancel),
|
||||
// the next session's `start(host:)` must REBIND rather than return the corpse's stale
|
||||
// port — which the session baked into an unreachable MCP config, the claude CLI failed
|
||||
// with "MCP tool mcp__nucleic__approve … not found. Available MCP tools: none", and every
|
||||
// subsequent conversation in the container inherited until an app restart.
|
||||
let server = MCPApprovalServer()
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
let port0 = try await server.start(host: "127.0.0.1")
|
||||
#expect(port0 != 0)
|
||||
|
||||
// Kill the listener out from under the cached bind, then wait for the state watcher to
|
||||
// invalidate the cache (listener/port cleared → the desync is detected, not trusted).
|
||||
await server.debugCancelTCPListenerOutFromUnder()
|
||||
var tries = 0
|
||||
while await server.port != 0 && tries < 200 {
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
tries += 1
|
||||
}
|
||||
#expect(await server.port == 0)
|
||||
|
||||
// Re-calling start (what the next session's run loop does) binds a fresh, reachable port…
|
||||
let port1 = try await server.start(host: "127.0.0.1")
|
||||
#expect(port1 != 0)
|
||||
|
||||
// …and actually serves MCP again on it.
|
||||
await server.register(token: "healtok") { _ in .deny(message: "n/a") }
|
||||
let resp = try await post(
|
||||
.object(["jsonrpc": .string("2.0"), "id": .number(1), "method": .string("initialize")]),
|
||||
port: port1, token: "healtok")
|
||||
#expect(resp.status == 200)
|
||||
#expect(resp.body?["result"]?["serverInfo"]?["name"]?.stringValue == "nucleic-approval")
|
||||
}
|
||||
|
||||
@Test func tcpStartRebindsWiderForWildcardAndKeepsWildcardForLoopback() async throws {
|
||||
// A cached loopback bind can't serve a containerized child (which reaches the host over
|
||||
// the VM gateway), so a `0.0.0.0` request must rebind wider; the reverse — a loopback
|
||||
// request landing on a live wildcard bind — keeps the existing bind (wildcard serves
|
||||
// loopback), so the shared server never churns ports on ordinary reuse.
|
||||
let server = MCPApprovalServer()
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
_ = try await server.start(host: "127.0.0.1")
|
||||
#expect(await server.boundTCPHost == "127.0.0.1")
|
||||
|
||||
let wildcardPort = try await server.start(host: "0.0.0.0")
|
||||
#expect(wildcardPort != 0)
|
||||
#expect(await server.boundTCPHost == "0.0.0.0")
|
||||
|
||||
let loopbackPort = try await server.start(host: "127.0.0.1")
|
||||
#expect(loopbackPort == wildcardPort) // wildcard already serves loopback — no rebind
|
||||
#expect(await server.boundTCPHost == "0.0.0.0")
|
||||
}
|
||||
|
||||
// MARK: - ApprovalServerRegistry (container-scoped shared server)
|
||||
|
||||
@Test func registrySharesOneServerPerContainer() async {
|
||||
@@ -687,6 +744,185 @@ import Testing
|
||||
#expect(path == ApprovalServerRegistry.controlSocketPath(for: "nucleic-control-claude"))
|
||||
}
|
||||
|
||||
// MARK: - Cancellation propagation into in-flight handler tasks
|
||||
|
||||
/// A latch the suspended-handler tests poll: `trip()` marks the event, `tripped(within:)`
|
||||
/// waits (bounded) for it.
|
||||
private actor HandlerProbe {
|
||||
private var isTripped = false
|
||||
func trip() { isTripped = true }
|
||||
func tripped(withinMs timeout: Int = 5000) async -> Bool {
|
||||
var elapsed = 0
|
||||
while !isTripped && elapsed < timeout {
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
elapsed += 10
|
||||
}
|
||||
return isTripped
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a `nucleic_subagent` handler that parks for far longer than any test timeout and
|
||||
/// reports (via the probes) when it starts and when task cancellation reaches it — the stand-in
|
||||
/// for a real spawn blocked on a whole worker session.
|
||||
private func registerParkingSubagentHandler(
|
||||
on server: MCPApprovalServer, token: String, started: HandlerProbe, cancelled: HandlerProbe
|
||||
) async {
|
||||
await server.registerOrchestraSubagent(token: token) { _ in
|
||||
await started.trip()
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
return .failed(
|
||||
sessionID: nil, title: nil, model: nil, message: "handler was never cancelled")
|
||||
} catch {
|
||||
await cancelled.trip()
|
||||
return .denied(message: "spawn cancelled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's teardown unregisters its bearer token; that must CANCEL the token's in-flight
|
||||
/// handler tasks — a `nucleic_subagent` spawn parked inside the server must not keep working
|
||||
/// for a supervisor whose run has ended. (The connection itself stays open: on a shared
|
||||
/// control-container server it carries other sessions' traffic.)
|
||||
@Test func unregisterCancelsThatTokensInFlightHandlerTask() async throws {
|
||||
let server = MCPApprovalServer()
|
||||
let port = try await server.start()
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
let started = HandlerProbe()
|
||||
let cancelled = HandlerProbe()
|
||||
await registerParkingSubagentHandler(
|
||||
on: server, token: "orch", started: started, cancelled: cancelled)
|
||||
|
||||
let call = Task {
|
||||
try await self.post(
|
||||
[
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": [
|
||||
"name": "nucleic_subagent",
|
||||
"arguments": ["task": "scan", "prompt": "check it"],
|
||||
],
|
||||
], port: port, token: "orch")
|
||||
}
|
||||
#expect(await started.tripped())
|
||||
|
||||
await server.unregister(token: "orch") // the supervisor's run ended (teardownRun)
|
||||
// Cancellation reaches the parked handler promptly — nowhere near its 60s park.
|
||||
#expect(await cancelled.tripped())
|
||||
// The unwound handler's reply still flushes to the still-open connection.
|
||||
let response = try await call.value
|
||||
#expect(response.body?["result"]?["content"]?[0]?["text"]?.stringValue
|
||||
== #"{"denied":true,"message":"spawn cancelled"}"#)
|
||||
}
|
||||
|
||||
/// Unregistering one token must not disturb ANOTHER token's in-flight call on the same shared
|
||||
/// server — per-session teardown, not collective punishment.
|
||||
@Test func unregisterLeavesOtherTokensHandlersRunning() async throws {
|
||||
let server = MCPApprovalServer()
|
||||
let port = try await server.start()
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
let victimStarted = HandlerProbe()
|
||||
let victimCancelled = HandlerProbe()
|
||||
await registerParkingSubagentHandler(
|
||||
on: server, token: "dying", started: victimStarted, cancelled: victimCancelled)
|
||||
let survivorStarted = HandlerProbe()
|
||||
let survivorCancelled = HandlerProbe()
|
||||
await registerParkingSubagentHandler(
|
||||
on: server, token: "living", started: survivorStarted, cancelled: survivorCancelled)
|
||||
|
||||
let body: JSONValue = [
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": [
|
||||
"name": "nucleic_subagent", "arguments": ["task": "t", "prompt": "p"],
|
||||
],
|
||||
]
|
||||
let dyingCall = Task { try await self.post(body, port: port, token: "dying") }
|
||||
let livingCall = Task { try await self.post(body, port: port, token: "living") }
|
||||
#expect(await victimStarted.tripped())
|
||||
#expect(await survivorStarted.tripped())
|
||||
|
||||
await server.unregister(token: "dying")
|
||||
#expect(await victimCancelled.tripped())
|
||||
_ = try await dyingCall.value
|
||||
// The other session's parked call is untouched by its neighbor's teardown.
|
||||
#expect(!(await survivorCancelled.tripped(withinMs: 250)))
|
||||
|
||||
// Cleanup: unwind the survivor so nothing outlives the test.
|
||||
await server.unregister(token: "living")
|
||||
_ = try await livingCall.value
|
||||
}
|
||||
|
||||
/// Connect a raw `AF_UNIX` client to the server's socket. Returns the connected fd (< 0 on
|
||||
/// failure); the caller owns closing it — which is the point: these tests hang up mid-call.
|
||||
private func unixConnect(socketPath: String) -> Int32 {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return -1 }
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let pathBytes = Array(socketPath.utf8)
|
||||
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { raw in
|
||||
raw.withMemoryRebound(to: CChar.self, capacity: capacity) { dst in
|
||||
for (i, b) in pathBytes.enumerated() { dst[i] = CChar(bitPattern: b) }
|
||||
dst[pathBytes.count] = 0
|
||||
}
|
||||
}
|
||||
let rc = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
guard rc == 0 else {
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
/// When the CLIENT vanishes mid-call — a killed supervisor's `claude` process taking its
|
||||
/// connection with it — the dropped connection must cancel the in-flight handler task: the
|
||||
/// response is undeliverable and a parked `nucleic_subagent` spawn must die with its caller.
|
||||
@Test func connectionCloseCancelsInFlightHandlerTask() async throws {
|
||||
let socket = "/tmp/nuc-cxl-\(UUID().uuidString.prefix(8)).sock"
|
||||
let server = MCPApprovalServer()
|
||||
_ = try await server.start(unixSocketPath: socket)
|
||||
defer { Task { await server.stop() } }
|
||||
|
||||
let started = HandlerProbe()
|
||||
let cancelled = HandlerProbe()
|
||||
await registerParkingSubagentHandler(
|
||||
on: server, token: "orch", started: started, cancelled: cancelled)
|
||||
|
||||
// Raw client: send the tools/call, but NEVER read the response — then hang up.
|
||||
let request: JSONValue = [
|
||||
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": [
|
||||
"name": "nucleic_subagent", "arguments": ["task": "scan", "prompt": "check it"],
|
||||
],
|
||||
]
|
||||
let bodyData = try request.encodedData()
|
||||
var head = "POST /mcp HTTP/1.1\r\nHost: nucleic\r\nContent-Type: application/json\r\n"
|
||||
head += "Authorization: Bearer orch\r\n"
|
||||
head += "Content-Length: \(bodyData.count)\r\n\r\n"
|
||||
let payload = Data(head.utf8) + bodyData
|
||||
|
||||
let fd = unixConnect(socketPath: socket)
|
||||
#expect(fd >= 0)
|
||||
payload.withUnsafeBytes { raw in
|
||||
guard let base = raw.baseAddress else { return }
|
||||
var off = 0
|
||||
while off < raw.count {
|
||||
let n = write(fd, base + off, raw.count - off)
|
||||
if n > 0 { off += n } else { break }
|
||||
}
|
||||
}
|
||||
#expect(await started.tripped()) // the call is suspended inside the handler
|
||||
|
||||
close(fd) // the supervisor died; its connection drops mid-call
|
||||
#expect(await cancelled.tripped()) // …and cancellation reached the parked handler
|
||||
}
|
||||
|
||||
/// Concurrent `start(host:)` calls on the SAME shared server (the default once sessions share a
|
||||
/// control-container server) must all observe the real bound port — never the half-initialized
|
||||
/// `0` that the pre-fix reentrancy race could return, which baked an unreachable `:0` endpoint
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import NucleicCore
|
||||
|
||||
/// A one-shot gate: `wait()` suspends until `open()` (and returns immediately once open).
|
||||
private actor SlotTestGate {
|
||||
private var opened = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
func wait() async {
|
||||
if opened { return }
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
func open() {
|
||||
opened = true
|
||||
for waiter in waiters { waiter.resume() }
|
||||
waiters.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// An `AgentBackend` whose run parks mid-turn until `interrupt()` (or `shutdown()`) arrives, then
|
||||
/// settles as interrupted — the shape of an Orchestra worker whose supervisor dies mid-`join()`:
|
||||
/// the spawn path must interrupt it (its result has nowhere to go) rather than let it run on.
|
||||
private final class InterruptParkingBackend: AgentBackend, @unchecked Sendable {
|
||||
static let id = BackendID.claudeCode
|
||||
|
||||
let capabilities = BackendCapabilities(
|
||||
interactiveApprovals: true,
|
||||
allowAlwaysScopes: [.session, .toolName],
|
||||
canModifyToolInput: true,
|
||||
partialMessageStreaming: false,
|
||||
emitsThinking: false,
|
||||
emitsFileChangeEvents: false,
|
||||
nativeResume: true,
|
||||
sandboxModes: [],
|
||||
followUpWhileRunning: false)
|
||||
|
||||
private let gate = SlotTestGate()
|
||||
/// Set once `interrupt()` was called — the assertion hook for "the worker was stopped".
|
||||
let interrupted = LockedBox(false)
|
||||
|
||||
func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> {
|
||||
stream(sessionID: run.sessionID, cwd: run.worktree)
|
||||
}
|
||||
|
||||
func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> {
|
||||
stream(sessionID: resume.sessionID, cwd: resume.worktree)
|
||||
}
|
||||
|
||||
private func stream(sessionID: SessionID, cwd: String) -> AsyncThrowingStream<AgentEvent, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
let gate = self.gate
|
||||
let task = Task {
|
||||
var seq: UInt64 = 0
|
||||
func emit(_ kind: AgentEvent.Kind) {
|
||||
seq += 1
|
||||
continuation.yield(AgentEvent(
|
||||
sessionID: sessionID, seq: seq, at: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
backend: Self.id, nativeType: nil, kind: kind))
|
||||
}
|
||||
emit(.sessionStarted(SessionStarted(
|
||||
backendSessionID: "be-parked", model: "m", cwd: cwd, toolNames: [])))
|
||||
await gate.wait() // mid-turn park; only interrupt()/shutdown() releases it
|
||||
emit(.runFinished(RunFinished(outcome: .interrupted)))
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ input: AgentInput) async throws {}
|
||||
func respond(to approvalID: ApprovalID, _ decision: Decision, by responder: String) async throws {}
|
||||
func interrupt() async {
|
||||
interrupted.set(true)
|
||||
await gate.open()
|
||||
}
|
||||
func shutdown() async { await gate.open() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
// Serialized + isolated settings for the same reasons as AppStoreTests: @MainActor timing
|
||||
// assertions, and controlled-project registration touching the container-service settings.
|
||||
@Suite("Orchestra worker slots — cancellation", .serialized, .isolatedContainerSettings)
|
||||
struct OrchestraWorkerSlotTests {
|
||||
private func makeStore(
|
||||
repo: GitTestRepo, backend: @escaping @Sendable (Session) -> any AgentBackend
|
||||
) -> AppStore {
|
||||
let database = try! GRDBMetadataStore(path: nil)
|
||||
let worktrees = GitWorktreeManager(now: { Date(timeIntervalSince1970: 1_700_000_000) })
|
||||
let transcriptsDir = URL(fileURLWithPath: (repo.container as NSString)
|
||||
.appendingPathComponent("transcripts"))
|
||||
return AppStore(
|
||||
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
|
||||
now: { Date(timeIntervalSince1970: 1_700_000_000) }, backendFactory: backend)
|
||||
}
|
||||
|
||||
private func waitFor(_ condition: @escaping () -> Bool, timeoutMs: Int = 3000) async {
|
||||
var elapsed = 0
|
||||
while !condition() && elapsed < timeoutMs {
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
elapsed += 10
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The slot gate itself
|
||||
|
||||
/// A queued spawn whose supervisor dies must leave the queue: cancelling the parked task
|
||||
/// removes its waiter, `acquire` reports no slot, and the gate's bookkeeping stays intact
|
||||
/// for later acquires.
|
||||
@Test func cancelledWaiterIsRemovedAndAcquireReturnsFalse() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
|
||||
store.defaultOrchestraMaxConcurrentWorkers = 1
|
||||
|
||||
#expect(await store.acquireOrchestraWorkerSlot()) // occupy the only slot
|
||||
let parked = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
|
||||
await waitFor { store.orchestraWorkerWaiterCount == 1 }
|
||||
#expect(store.orchestraWorkerWaiterCount == 1)
|
||||
|
||||
parked.cancel()
|
||||
#expect(await parked.value == false) // no slot handed to the dead caller
|
||||
#expect(store.orchestraWorkerWaiterCount == 0) // and its waiter is gone, not a ghost
|
||||
#expect(store.orchestraWorkersRunning == 1) // the running count never moved
|
||||
|
||||
// The gate still works: release the held slot, a fresh acquire succeeds immediately.
|
||||
store.releaseOrchestraWorkerSlot()
|
||||
#expect(await store.acquireOrchestraWorkerSlot())
|
||||
store.releaseOrchestraWorkerSlot()
|
||||
#expect(store.orchestraWorkersRunning == 0)
|
||||
}
|
||||
|
||||
/// A cancelled waiter must not strand the ones queued behind it: after B is cancelled,
|
||||
/// releasing A's slot wakes C, which acquires normally.
|
||||
@Test func cancelledWaiterDoesNotStrandLaterWaiters() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
|
||||
store.defaultOrchestraMaxConcurrentWorkers = 1
|
||||
|
||||
#expect(await store.acquireOrchestraWorkerSlot()) // A holds the slot
|
||||
let b = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
|
||||
await waitFor { store.orchestraWorkerWaiterCount == 1 }
|
||||
let c = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
|
||||
await waitFor { store.orchestraWorkerWaiterCount == 2 }
|
||||
|
||||
b.cancel()
|
||||
#expect(await b.value == false)
|
||||
#expect(store.orchestraWorkerWaiterCount == 1) // only C remains queued
|
||||
|
||||
store.releaseOrchestraWorkerSlot() // A ends → the freed slot must reach C, not vanish
|
||||
#expect(await c.value == true)
|
||||
#expect(store.orchestraWorkersRunning == 1)
|
||||
store.releaseOrchestraWorkerSlot()
|
||||
}
|
||||
|
||||
/// Cancellation is honored even when no parking would occur: a spawn task cancelled before
|
||||
/// (or while) acquiring never takes a slot — including under an unlimited (`<= 0`) cap.
|
||||
@Test func preCancelledAcquireNeverTakesASlot() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
|
||||
|
||||
for cap in [4, 0] {
|
||||
store.defaultOrchestraMaxConcurrentWorkers = cap
|
||||
let task = Task { @MainActor in await store.acquireOrchestraWorkerSlot() }
|
||||
task.cancel()
|
||||
#expect(await task.value == false)
|
||||
#expect(store.orchestraWorkersRunning == 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The spawn path
|
||||
|
||||
/// The end-to-end guarantee for a queued spawn: a `nucleic_subagent` call parked on the cap
|
||||
/// whose supervisor dies is denied and never creates a worker session — no tokens, no
|
||||
/// worktree, no orphaned run for a dead parent.
|
||||
@Test func queuedSpawnCancelledBeforeSlotIsDeniedAndCreatesNothing() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo) { _ in ScriptedBackend { _, _ in } }
|
||||
store.defaultOrchestraMaxConcurrentWorkers = 1
|
||||
|
||||
let project = try #require(
|
||||
await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
// An idle parent (empty prompt starts no run) to hang the spawn off.
|
||||
let parentID = try await store.createSession(in: project, title: "supervisor", prompt: "")
|
||||
let parent = try #require(await store.liveSnapshot(parentID)).session
|
||||
let sessionsBefore = store.summaries.count
|
||||
|
||||
#expect(await store.acquireOrchestraWorkerSlot()) // occupy the only slot
|
||||
let spawn = Task { @MainActor in
|
||||
await store.spawnOrchestraSubagent(
|
||||
OrchestraSubagentRequest(task: "scan", prompt: "check it"),
|
||||
parent: parent, project: project)
|
||||
}
|
||||
await waitFor { store.orchestraWorkerWaiterCount == 1 }
|
||||
#expect(store.orchestraWorkerWaiterCount == 1) // the spawn is parked on the cap
|
||||
|
||||
spawn.cancel() // the supervisor's run ended (Stop/kill → its handler task is cancelled)
|
||||
guard case .denied(let message) = await spawn.value else {
|
||||
Issue.record("expected .denied for a spawn cancelled while queued")
|
||||
return
|
||||
}
|
||||
#expect(message.contains("cancelled"))
|
||||
#expect(store.summaries.count == sessionsBefore) // no worker session was ever created
|
||||
#expect(store.orchestraWorkersRunning == 1) // only the manual hold; nothing leaked
|
||||
store.releaseOrchestraWorkerSlot()
|
||||
}
|
||||
|
||||
/// A spawn cancelled mid-`join()` interrupts its in-flight worker (the result has nowhere to
|
||||
/// go), reports `.failed`, and releases the concurrency slot.
|
||||
@Test func spawnCancelledMidJoinInterruptsWorkerAndFreesSlot() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let backends = LockedBox([InterruptParkingBackend]())
|
||||
let store = makeStore(repo: repo) { _ in
|
||||
let backend = InterruptParkingBackend()
|
||||
backends.set(backends.get() + [backend])
|
||||
return backend
|
||||
}
|
||||
|
||||
let project = try #require(
|
||||
await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main"))
|
||||
let parentID = try await store.createSession(in: project, title: "supervisor", prompt: "")
|
||||
let parent = try #require(await store.liveSnapshot(parentID)).session
|
||||
|
||||
let spawn = Task { @MainActor in
|
||||
await store.spawnOrchestraSubagent(
|
||||
OrchestraSubagentRequest(task: "long job", prompt: "work forever"),
|
||||
parent: parent, project: project)
|
||||
}
|
||||
// Wait until the worker session exists and is mid-turn (its backend is parked).
|
||||
await waitFor { store.orchestraWorkersRunning == 1 && store.summaries.count == 2 }
|
||||
#expect(store.orchestraWorkersRunning == 1)
|
||||
let worker = try #require(backends.get().last)
|
||||
#expect(!worker.interrupted.get())
|
||||
|
||||
spawn.cancel() // the supervisor's run ended while the worker was mid-turn
|
||||
guard case .failed(let sessionID, _, _, let message) = await spawn.value else {
|
||||
Issue.record("expected .failed for a spawn cancelled mid-join")
|
||||
return
|
||||
}
|
||||
#expect(sessionID != nil)
|
||||
#expect(message.contains("interrupted"))
|
||||
#expect(worker.interrupted.get()) // the worker was actively stopped, not abandoned
|
||||
#expect(store.orchestraWorkersRunning == 0) // the slot came back
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ private func makeController(
|
||||
worktreeManager: (any WorktreeManaging)? = nil,
|
||||
project: Project? = nil,
|
||||
worktree: Worktree? = nil,
|
||||
effort: String? = nil,
|
||||
conversational: Bool = false
|
||||
) throws -> (controller: SessionController, transcriptURL: URL, cleanup: @Sendable () -> Void) {
|
||||
let dir = (NSTemporaryDirectory() as NSString)
|
||||
@@ -24,7 +25,7 @@ private func makeController(
|
||||
id: sessionID, projectID: project?.id ?? .generate(), backend: .claudeCode,
|
||||
title: "test session", status: .idle,
|
||||
worktreePath: worktree?.path, branch: worktree?.branch, baseSHA: worktree?.baseSHA,
|
||||
transcriptPath: url.path, createdAt: fixedNow, updatedAt: fixedNow)
|
||||
effort: effort, transcriptPath: url.path, createdAt: fixedNow, updatedAt: fixedNow)
|
||||
let controller = SessionController(
|
||||
session: session, backend: backend, transcript: writer,
|
||||
worktreeManager: worktreeManager, project: project, worktree: worktree,
|
||||
@@ -106,9 +107,11 @@ struct SessionControllerTests {
|
||||
-> (effort: String?, prompt: String?, orchestraActive: Bool)
|
||||
{
|
||||
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
|
||||
let (controller, _, cleanup) = try makeController(backend: backend, project: project)
|
||||
// Orchestra is a start-of-chat-only mode, so the session is born with the sentinel
|
||||
// (as `AppStore.createSession` does) rather than switched into it via `setEffort`.
|
||||
let (controller, _, cleanup) = try makeController(
|
||||
backend: backend, project: project, effort: OrchestrationMode.effortSentinel)
|
||||
defer { cleanup() }
|
||||
await controller.setEffort(OrchestrationMode.effortSentinel)
|
||||
await controller.start(prompt: AgentInput(text: "hi"))
|
||||
await controller.join()
|
||||
return (backend.lastRun?.effort, backend.lastRun?.appendSystemPrompt,
|
||||
@@ -164,16 +167,19 @@ struct SessionControllerTests {
|
||||
#expect(offTrunk?.contains("uses nvrsion") != true)
|
||||
}
|
||||
|
||||
@Test func orchestraIsAOneWayLatchOnceActive() async throws {
|
||||
// On a Control project, Orchestra is active once selected — and can't be turned off.
|
||||
@Test func orchestraIsFixedAtCreation() async throws {
|
||||
// Orchestra is decided when the chat is born and can't be flipped in either direction
|
||||
// mid-chat: the supervisor model is chosen at creation, so a running chat can neither
|
||||
// adopt Orchestra late nor drop the standing fan-out consent.
|
||||
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
|
||||
let (controller, _, cleanup) = try makeController(backend: backend, project: controlledProject())
|
||||
|
||||
// Born with Orchestra on a Control project → every attempt to leave is ignored —
|
||||
// a named level, and clearing to default.
|
||||
let (controller, _, cleanup) = try makeController(
|
||||
backend: backend, project: controlledProject(),
|
||||
effort: OrchestrationMode.effortSentinel)
|
||||
defer { cleanup() }
|
||||
|
||||
await controller.setEffort(OrchestrationMode.effortSentinel)
|
||||
#expect(await controller.snapshot.session.effort == OrchestrationMode.effortSentinel)
|
||||
|
||||
// Every attempt to leave Orchestra is ignored — a named level, and clearing to default.
|
||||
for attempt in ["high", "max", "low"] {
|
||||
await controller.setEffort(attempt)
|
||||
#expect(await controller.snapshot.session.effort == OrchestrationMode.effortSentinel)
|
||||
@@ -184,12 +190,20 @@ struct SessionControllerTests {
|
||||
// Re-selecting Orchestra is fine (idempotent); the legacy token normalizes to the current one.
|
||||
await controller.setEffort(OrchestrationMode.legacyEffortSentinel)
|
||||
#expect(OrchestrationMode.isOrchestra(await controller.snapshot.session.effort))
|
||||
|
||||
// Born without Orchestra → can't adopt it mid-chat, even on a Control project; the
|
||||
// rejected request doesn't clobber the ordinary level either.
|
||||
let (lateCtl, _, cleanLate) = try makeController(
|
||||
backend: backend, project: controlledProject(), effort: "high")
|
||||
defer { cleanLate() }
|
||||
await lateCtl.setEffort(OrchestrationMode.effortSentinel)
|
||||
#expect(await lateCtl.snapshot.session.effort == "high")
|
||||
}
|
||||
|
||||
@Test func ordinaryEffortStaysFreelyChangeable() async throws {
|
||||
// The latch is Orchestra-only: ordinary levels switch in both directions as before, and a
|
||||
// stray Orchestra selection on a non-Control project (where it never took effect) isn't
|
||||
// latched either.
|
||||
// The creation-time freeze is Orchestra-only: ordinary levels switch in both directions
|
||||
// as before, and a session born with a stray Orchestra effort on a non-Control project
|
||||
// (where it never took effect — e.g. the project left control) isn't latched either.
|
||||
let backend = ScriptedBackend { e, _ in simpleTurn(e) }
|
||||
|
||||
let (onCtl, _, cleanCtl) = try makeController(backend: backend, project: controlledProject())
|
||||
@@ -198,9 +212,10 @@ struct SessionControllerTests {
|
||||
await onCtl.setEffort("low")
|
||||
#expect(await onCtl.snapshot.session.effort == "low")
|
||||
|
||||
let (offCtl, _, cleanOff) = try makeController(backend: backend, project: uncontrolledProject())
|
||||
let (offCtl, _, cleanOff) = try makeController(
|
||||
backend: backend, project: uncontrolledProject(),
|
||||
effort: OrchestrationMode.effortSentinel)
|
||||
defer { cleanOff() }
|
||||
await offCtl.setEffort(OrchestrationMode.effortSentinel)
|
||||
await offCtl.setEffort("high") // not active here, so not latched
|
||||
#expect(await offCtl.snapshot.session.effort == "high")
|
||||
}
|
||||
|
||||
@@ -592,6 +592,16 @@ struct WorktreeManagerTests {
|
||||
defer { repo.cleanup() }
|
||||
let manager = manager()
|
||||
let project = repo.project()
|
||||
// Probe: this asserts the *no-signing* path, so skip on a host that signs commits regardless
|
||||
// — a global `commit.gpgsign=true` with a working signer, or an ambient Managed Git key that
|
||||
// now always signs — where the premise doesn't hold. Mirrors the signing-tooling skips in
|
||||
// `integrateFiresOnSignedWhenCommitIsSigned`. On CI / an unconfigured host nothing signs, so
|
||||
// the probe commit is unsigned and the assertion below runs.
|
||||
try repo.write("probe.txt", "p\n")
|
||||
try await repo.run(["add", "-A"])
|
||||
try await repo.run(["commit", "-m", "probe"])
|
||||
guard !(await Self.hasSignatureHeader(repo, "HEAD")) else { return } // host signs — skip
|
||||
|
||||
let wt = try await manager.create(for: .generate(), in: project, base: "main", slug: "unsigned")
|
||||
try repo.write("u.txt", "u\n", in: wt.path)
|
||||
try await manager.finalize(wt, commit: .auto(message: "u"))
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# Nucleic — Linux in-guest semantic (AT-SPI) control agent (design)
|
||||
|
||||
> **Status: proposed.** Today a Linux guest's computer-use is 100% host-side pixels (virtio-gpu
|
||||
> capture + USB HID injection); the semantic `ax_*` ops short-circuit for Linux with a "use screenshot
|
||||
> + pixel actions" message (`Sources/NucleicCore/MacVM/MacVMEngine+Computer.swift`). This doc plans the
|
||||
> Linux counterpart to the macOS native AX agent ([MACOS_VM_NATIVE_AGENT.md](MACOS_VM_NATIVE_AGENT.md)):
|
||||
> a semantic, framebuffer-independent control path that reads the accessibility tree and acts on
|
||||
> controls **by identity**, using Linux's **AT-SPI2** accessibility framework.
|
||||
|
||||
The macOS agent (`guest/NucleicVMAgent`) drives the guest through Apple's Accessibility API
|
||||
(`AXUIElement`) — read every control's role/title/value/frame/actions, then `AXPress` / set `AXValue` /
|
||||
set focus by identity. That is dramatically more robust than guessing `(x, y)` off a screenshot, and it
|
||||
keeps working when a *capture* API returns nothing useful. This doc brings the same capability to Linux
|
||||
guests via **AT-SPI2**, the de-facto Linux accessibility standard, slotting into the vsock + NDJSON +
|
||||
protocol-v2 transport and the `ax_*` tool surface Nucleic already ships.
|
||||
|
||||
Companion to [LINUX_VM.md](LINUX_VM.md) (the Linux guest subsystem this augments) and
|
||||
[MACOS_VM_NATIVE_AGENT.md](MACOS_VM_NATIVE_AGENT.md) (the macOS agent this mirrors).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why — semantic control, minus the macOS tax
|
||||
|
||||
The macOS agent's three wins (semantic control, framebuffer independence, vsock transport — see
|
||||
`MACOS_VM_NATIVE_AGENT.md` §1) apply verbatim to Linux, and Linux is *easier* on two axes:
|
||||
|
||||
- **No permission model.** macOS needs three TCC grants (`kTCCServiceAccessibility`,
|
||||
`kTCCServicePostEvent`, `kTCCServiceScreenCapture`) pre-written into `TCC.db`, plus a signed `.app`
|
||||
bundle and an Aqua session — the single hardest part of the macOS agent. AT-SPI needs **none of it**:
|
||||
just the AT-SPI D-Bus bus running in the session and the toolkit a11y bridges enabled.
|
||||
- **Input is already solved host-side.** The Wayland/X11 community's biggest a11y pain — synthesizing
|
||||
input — does not bite us: clicks/keystrokes go in via **host-side USB HID**
|
||||
(`VZUSBKeyboardConfiguration` + `VZUSBScreenCoordinatePointingDeviceConfiguration`), not via the
|
||||
display server. So this agent's job is narrower than a general Linux automation tool: **read the tree
|
||||
and act by identity.** For actions it prefers AT-SPI in-process actions (coordinate-free); the pixel
|
||||
path (host-side) remains the fallback.
|
||||
|
||||
## 2. The macOS ↔ Linux mapping
|
||||
|
||||
AT-SPI2 sits between Windows UIA's rigid structures and macOS AX's flexibility: enum roles, but
|
||||
**untyped action strings** — the same shape as AX. The `ax_*` wire ops map cleanly:
|
||||
|
||||
| Wire op (unchanged) | macOS backing (`Ops+AX.swift`) | Linux backing (AT-SPI2) |
|
||||
|---|---|---|
|
||||
| `ax_dump` | `AXUIElementCopyAttributeValue` (role/title/value/frame/actions), recurse `kAXChildren` | `Atspi.Accessible` tree: `get_role_name`, `get_name`, `AtspiValue`, `AtspiComponent.get_extents`, `AtspiAction` action names; recurse `get_child_at_index` |
|
||||
| `ax_element_at` | `AXUIElementCopyElementAtPosition` | `AtspiComponent.get_accessible_at_point(ATSPI_COORD_TYPE_SCREEN)` |
|
||||
| `ax_press` (action) | `AXUIElementPerformAction("AXPress"/…)` | `AtspiAction.do_action(i)` (map action name → index) |
|
||||
| `ax_set_value` | `AXUIElementSetAttributeValue(kAXValue)` | `AtspiEditableText.set_text_contents` / `AtspiValue.set_current_value` |
|
||||
| `ax_focus` | set `kAXFocused` | `AtspiComponent.grab_focus` |
|
||||
| `ping` readiness | `AXIsProcessTrusted()` + `CGPreflight*` | AT-SPI bus reachable? (report `atspi: true/false`) |
|
||||
|
||||
The `node` JSON is **identical** to macOS (§4 of `MACOS_VM_NATIVE_AGENT.md`): `ref`, `role`, `subrole?`,
|
||||
`title`, `value`, `enabled`, `focused`, `frame{x,y,w,h}`, `actions[]`, `children[]`. The per-connection
|
||||
`ref` registry and dump→act→dump staleness contract (§6.2 there) carry over unchanged, so the host side
|
||||
(`MacVMEngine+ComputerAgent`, `MCPApprovalServer` schema) needs no protocol changes — only to stop
|
||||
short-circuiting Linux.
|
||||
|
||||
## 3. Display server — **X11, not Wayland** (decision)
|
||||
|
||||
The Linux base currently runs a **Wayland** session (`labwc` compositor via `greetd` auto-login,
|
||||
`provision-linux-guest.sh`). **We are switching the automation base to X11.**
|
||||
|
||||
Rationale: the AT-SPI *tree* is vended by the toolkit over D-Bus and is compositor-agnostic (tree reads
|
||||
and action-by-identity work under either), but **global screen coordinates are the weak spot on
|
||||
Wayland**. Wayland deliberately hides absolute window positions from clients, so
|
||||
`AtspiComponent.get_extents(SCREEN)` returns window-relative or unreliable coordinates — precisely the
|
||||
gap the GNOME "Newton" project is still closing in 2025-26. That coordinate is exactly what the
|
||||
pixel-fallback path needs (element → `(x, y)` for the host to click), and it's what lets `ax_element_at`
|
||||
hit-test correctly. Under **X11, AT-SPI coordinate reporting is fully mature** and global geometry is
|
||||
trivial. Since Nucleic builds the base image, choosing X11 for the automation guest sidesteps the entire
|
||||
Wayland-a11y-immaturity problem at the cost of a compositor swap we control.
|
||||
|
||||
The X11 session (see §6): `Xorg` (modesetting driver on virtio-gpu) + a minimal WM (`openbox`) + an
|
||||
auto-login greeter, with `at-spi2-core` + the GTK/Qt a11y bridges enabled. Host-side capture/HID are
|
||||
unchanged — virtio-gpu scanout and USB HID don't care whether X11 or Wayland is compositing.
|
||||
|
||||
## 4. In-guest agent shape — split, don't rewrite
|
||||
|
||||
`guest/nucleic-linux-agent/src/agent.c` is a dependency-free static C binary carrying `ping` + `exec`,
|
||||
started early by `systemd/nucleic-linux-agent.service`. Hand-rolling AT-SPI's D-Bus marshalling in
|
||||
zero-dependency C would be miserable, and we do **not** want to add heavy deps to the boot-critical
|
||||
exec path. So:
|
||||
|
||||
- **Keep `agent.c` as-is** for `ping`/`exec` — static, zero-dep, early boot. Extend only its `ping`
|
||||
reply to advertise AT-SPI availability (`"atspi": true|false`).
|
||||
- **Add a separate a11y helper** linked against the real bindings — `libatspi-2.0` + GLib (C) or a small
|
||||
`python3-pyatspi` process. It is `apt`-installed in the provision phase and runs **inside the graphical
|
||||
session** (it needs the session's D-Bus + `AT_SPI_BUS`). The C agent forwards `ax_*` op lines to the
|
||||
helper (a local unix socket or a spawned request/response), or the host reaches the helper on a second
|
||||
vsock port. Recommended first cut: **helper listens on its own vsock port**, `agent.c` unchanged except
|
||||
for the readiness bit; the host tries the a11y port for `ax_*` and the base port for `ping`/`exec`.
|
||||
|
||||
Language for the helper: **Python + `pyatspi`** for the spike and likely for v1 (fast to write, the
|
||||
canonical AT-SPI AT-side API); revisit a static C/`libatspi` or Rust `xa11y` rewrite only if startup
|
||||
cost or the extra runtime deps in the base become a problem.
|
||||
|
||||
## 5. Known limitations (be honest)
|
||||
|
||||
- **Non-GTK/Qt apps expose poor/empty AT-SPI trees** — same caveat the macOS doc calls out (§9) for
|
||||
Electron/Java/GL. Electron/Chromium need `--force-renderer-accessibility`; enable
|
||||
`QT_ACCESSIBILITY=1` / `GTK_MODULES` bridges by default. Terminals and custom-drawn UIs give thin
|
||||
trees → fall back to the (host-side) pixel path.
|
||||
- **AT-SPI is chatty.** Every property is an individual D-Bus round-trip; a full tree walk of a rich app
|
||||
can take seconds. Mirror the macOS cost controls: `maxDepth`, a node budget (macOS caps at 800), and a
|
||||
per-app timeout so a hung app can't wedge the walker. Batch where the bindings allow.
|
||||
- **Lazy/virtualized views** (list/table rows) may be absent from the tree until scrolled into view —
|
||||
same as AX.
|
||||
|
||||
## 6. Provisioning changes (X11 base)
|
||||
|
||||
`provision-linux-guest.sh` phase-2 first-boot install changes from the Wayland stack to X11 + AT-SPI:
|
||||
|
||||
- **Out:** `labwc`, `foot` (Wayland-only terminal), `xwayland`, `wlr-randr`, `seatd`, the
|
||||
`dbus-run-session -- labwc` greetd session.
|
||||
- **In:** `xserver-xorg` + `xserver-xorg-video-*`/modesetting, `openbox` (WM), `xterm`, `x11-xserver-utils`;
|
||||
`at-spi2-core` + `gir1.2-atspi-2.0` + `python3-pyatspi` + `python3-gi` (the a11y helper + bridges);
|
||||
AT-SPI env (`GTK_MODULES=gail:atk-bridge`, `QT_ACCESSIBILITY=1`,
|
||||
`QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1`, Chromium `--force-renderer-accessibility` default).
|
||||
- **greetd session:** auto-login into an X session (`startx`/`xinit` launching `openbox-session`) instead
|
||||
of `labwc`. Autostart `xterm` (and the a11y helper) so a fresh screenshot isn't an empty root window.
|
||||
|
||||
The two-phase base build, bootstrap initramfs, vsock agent (`ping`/`exec`), host-side capture/HID, and
|
||||
clone lifecycle are all unchanged — this is a session-package swap plus the a11y helper.
|
||||
|
||||
## 7. Host wiring
|
||||
|
||||
Minimal, because the transport + tool surface already exist and are OS-neutral:
|
||||
|
||||
- `MacVMEngine+Computer.swift` — remove/relax the Linux short-circuit (currently ~L45) so `ax_*` route
|
||||
to the agent for Linux guests once `ping` reports `atspi:true`.
|
||||
- `MacVMEngine+ComputerAgent.swift` — the `ax_dump`/`ax_element_at`/`ax_press`/`ax_set_value`/`ax_focus`
|
||||
handlers are already OS-neutral; point them at the Linux a11y helper's port when the guest is Linux.
|
||||
- `agent.c` `send_ping_reply` — add `"atspi"` to the readiness block so the host advertises `ax_*` only
|
||||
when the helper is present.
|
||||
- `MCPApprovalServer` — no schema change (the `ax_*` actions already exist).
|
||||
|
||||
## 8. Rollout plan (phased, each independently landable)
|
||||
|
||||
0. **Spike / de-risk** — ✅ **DONE** (`guest/nucleic-linux-agent/spike/`). Under a headless X (Xvfb) a
|
||||
GTK probe app launches and `pyatspi` dumps the tree into the macOS-identical `node` JSON, then
|
||||
exercises `do_action` (press) + `set_text_contents` (set value) + `grab_focus` (focus). Ran green on
|
||||
Debian 12 arm64 (same toolkit stack as the Ubuntu 24.04 guest): all three primitives passed and
|
||||
`get_extents(SCREEN)` resolved to real geometry — confirming the bindings, the `node` shape, and the
|
||||
X11 coordinate assumption. See `spike/README.md`.
|
||||
1. **X11 base** — swap `provision-linux-guest.sh` to the X11 + AT-SPI stack (§6); rebuild + smoke-test
|
||||
the base boots to an X session with the AT-SPI bus up.
|
||||
2. **a11y helper** — implement `ax_dump`/`ax_element_at`/`ax_press`/`ax_set_value`/`ax_focus` in the
|
||||
helper, emitting the macOS-identical `node` JSON; ref registry + staleness; cost controls (§5).
|
||||
3. **Host wiring** — §7: stop short-circuiting Linux, route `ax_*` to the helper, advertise on
|
||||
`ping.atspi`.
|
||||
4. **Validate + iterate** — real-guest reliability across GTK/Qt/Electron; document coverage gaps.
|
||||
|
||||
## 9. Open questions / verify-on-real-VM
|
||||
|
||||
- **`get_extents(SCREEN)` accuracy under our X11 session** — expected good on X11; confirm on the real
|
||||
base (this is the load-bearing assumption behind the Wayland→X11 switch).
|
||||
- **a11y helper transport** — second vsock port vs. `agent.c`-forwarded unix socket. Second port is the
|
||||
simplest first cut; revisit if port management is awkward.
|
||||
- **Helper language** — Python/`pyatspi` (v1) vs. C/`libatspi` or Rust `xa11y` (if deps/startup bite).
|
||||
- **Non-GTK/Qt coverage** — per-app a11y enable toggles; document the gaps like the macOS doc does.
|
||||
|
||||
## 10. Cross-references
|
||||
|
||||
- [LINUX_VM.md](LINUX_VM.md) — the Linux guest subsystem (two-phase base build, vsock agent, host-side
|
||||
computer-use) this augments.
|
||||
- [MACOS_VM_NATIVE_AGENT.md](MACOS_VM_NATIVE_AGENT.md) — the macOS AX agent this mirrors (wire protocol
|
||||
§3–4, AX details §6, framebuffer independence §9, TCC/packaging §11).
|
||||
- AT-SPI2: freedesktop.org `Accessibility/AT-SPI2`; `libatspi` reference (`AtspiAccessible`,
|
||||
`AtspiComponent`, `AtspiAction`, `AtspiValue`, `AtspiEditableText`); `pyatspi2` (GNOME).
|
||||
</content>
|
||||
</invoke>
|
||||
+30
-28
@@ -21,7 +21,7 @@ Egress (the agent's outbound internet — Anthropic API, `git`, `npm`, `gh`) sta
|
||||
| | Transport | Carries | Status |
|
||||
|---|---|---|---|
|
||||
| stdio | virtio-vsock | `stream-json` / NDJSON prompt | already vsock — untouched |
|
||||
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **default** as of sandbox image `v4`; legacy gateway-TCP kept behind the off switch |
|
||||
| control plane | unix socket relayed over vsock (via in-guest loopback bridge) | approvals (MCP), git/gh/command interceptor events | **mandatory** (2026-07-09) for every containerized run — shared AND per-session; the toggle and the legacy gateway-TCP path are retired |
|
||||
| egress | vmnet NAT (gateway) | agent's outbound internet — Anthropic API, `git`, `npm`, `gh` + DNS | stays on NAT by design (§7) — can't ride vsock |
|
||||
|
||||
## Current state of Channel 2 (before this work)
|
||||
@@ -133,21 +133,24 @@ init child, so it reaches the **root-owned** relayed socket, while the non-root
|
||||
interceptor shims only ever touch **loopback TCP** — no socket-permission juggling, and (crucially)
|
||||
**the git/gh/command shims need no change**: they keep POSTing HTTP, now to the loopback bridge.
|
||||
|
||||
Host side (`ClaudeCodeBackend`): when the run's container carries a `controlSocketHostPath`, the
|
||||
(shared) server is started on the **UDS only — no IP listener at all** — and the control endpoint
|
||||
handed to the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL`
|
||||
env). Otherwise the legacy gateway-TCP path is used unchanged.
|
||||
Host side (`ClaudeCodeBackend`): the run's container always carries a `controlSocketHostPath`, so the
|
||||
server is started on the **UDS only — no IP listener at all** — and the control endpoint handed to
|
||||
the agent + shims becomes `127.0.0.1:<bridge port>` (`mcpConfig` + the `NUCLEIC_*_HOOK_URL` env). A
|
||||
containerized spec without a control socket fails the run loudly (the invariant is enforced, not
|
||||
silently degraded).
|
||||
|
||||
### 4. Wiring + the gate — DONE; default ON as of image `v4`
|
||||
### 4. Wiring — DONE; MANDATORY as of 2026-07-09 (was: gated, then default-on with image `v4`)
|
||||
|
||||
One flag gates the whole vertical: `ContainerServiceSettings.vsockControlPlaneEnabled` (now **default
|
||||
on** — `true` when unset, an explicit stored `false` still wins; Settings → "Control plane over
|
||||
vsock"). `SessionController.containerSpec()` sets
|
||||
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` only for a shared
|
||||
control container when the flag is on; everything downstream keys off that one field (relay attach,
|
||||
init bridge, UDS-only server, loopback endpoint). Flag off → byte-for-byte the legacy path. The
|
||||
default was moved in lockstep with `ProjectSandbox.defaultImage` reaching a bridge-bearing tag (`v4`),
|
||||
since the path requires `control-bridge.js` in the image.
|
||||
The gate is gone: `ContainerServiceSettings.vsockControlPlaneEnabled` and its defaults key were
|
||||
retired (the Settings toggle had already been removed). `SessionController.containerSpec()` sets
|
||||
`controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)` for **every**
|
||||
containerized spec — shared control containers AND per-session sandbox containers; everything
|
||||
downstream keys off that one field (relay attach, init bridge, UDS-only server, loopback endpoint).
|
||||
A shared control container meets the long-lived registry server; a per-session container's socket is
|
||||
served by its backend's own per-backend server (stopped — and the socket unlinked — at backend
|
||||
shutdown). Because the path requires `control-bridge.js` + `node` in the image (sandbox image ≥
|
||||
`v4`), `ContainerEngine` probes for both once per fresh clone and fails the start with an actionable
|
||||
error when a custom image lacks them.
|
||||
|
||||
### 5. Auth — unchanged
|
||||
|
||||
@@ -155,13 +158,14 @@ The per-session bearer token is unchanged: it still rides the HTTP `Authorizatio
|
||||
loopback → bridge → UDS), validated host-side exactly as before, and still disambiguates sessions on
|
||||
the shared per-container socket.
|
||||
|
||||
### 6. Remove the IP listener — DONE on the flag path
|
||||
### 6. Remove the IP listener — DONE
|
||||
|
||||
On the flag-on (now default) path the server never binds TCP (`start(unixSocketPath:)` only), so there
|
||||
is no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
|
||||
incoming-connection / local-network prompts have nothing to fire on. The legacy TCP path is retained
|
||||
behind an explicit `vsockControlPlane = false` for fallback; it can be deleted once the vsock path has
|
||||
soaked on real hardware.
|
||||
For every containerized run the server never binds TCP (`start(unixSocketPath:)` only), so there is
|
||||
no control-plane `NWListener` and `mcpConfig` carries no gateway host/port — the macOS
|
||||
incoming-connection / local-network prompts have nothing to fire on. The legacy gateway-TCP fallback
|
||||
is deleted; `ClaudeCodeBackend` refuses a containerized run whose spec somehow lacks a control socket
|
||||
(fail-loud, never silently degrade to an endpoint the guest can't reach). TCP survives only as the
|
||||
host (non-container) runs' loopback listener.
|
||||
|
||||
### 6b. Grok & Codex control containers — PARTIAL (behind the flag)
|
||||
|
||||
@@ -171,7 +175,7 @@ The container infra is backend-agnostic (registry keyed by name, the `nucleic-co
|
||||
gap. Done this pass:
|
||||
|
||||
- **`GrokACPBackend` / `CodexAppServerBackend`** gained a container-exec path: when a run carries a
|
||||
vsock control socket (so: flag on + shared control container) and a `ContainerManager` is present,
|
||||
vsock control socket (now: every containerized run) and a `ContainerManager` is present,
|
||||
the agent execs **inside the shared control container** (stdio over vsock) instead of on the host —
|
||||
i.e. each agent family runs isolated in its own box ("agenticide" separation). Gated on that one
|
||||
signal, so default behavior is unchanged. Both backends' approvals are native over their own stdio
|
||||
@@ -225,7 +229,7 @@ re-trigger them). **Only if** a Local Network prompt persists, tunnel egress ove
|
||||
`HTTPS_PROXY` → vsock → host forward proxy) and drop the IP interface — larger effort (DNS, non-HTTP
|
||||
protocols); do not start unless needed.
|
||||
|
||||
## Now the default (image `v4`)
|
||||
## Now mandatory (2026-07-09; default since image `v4`)
|
||||
|
||||
Promoted to default once the bridge-bearing image existed:
|
||||
|
||||
@@ -235,12 +239,10 @@ Promoted to default once the bridge-bearing image existed:
|
||||
2. `ProjectSandbox.defaultImage` bumped to `:v4`; the launch-time rootfs prune
|
||||
(`reconcileDisk`/`pruneObsoleteRootfs`) drops the stale `v3` cache and pulls `v4` fresh — no manual
|
||||
re-pull needed.
|
||||
3. `vsockControlPlaneEnabled` defaults on, so **Settings → Container → "Control plane over vsock"** is
|
||||
on. Turn it off (or `defaults write … nucleic.container.vsockControlPlane -bool NO`) to fall back to
|
||||
the legacy gateway-TCP path — no rebuild needed.
|
||||
|
||||
The first Nucleic Control session on real hardware is the validation: run the checklist below. Once it
|
||||
has soaked, the legacy TCP path (§6) can be deleted.
|
||||
3. Then made **mandatory**: the Settings toggle, the `nucleic.container.vsockControlPlane` defaults
|
||||
key, and the legacy gateway-TCP fallback are all retired. Every containerized run — the shared
|
||||
control containers and per-session sandbox containers alike — rides the relayed socket; custom
|
||||
images must provide `node` + `control-bridge.js` (enforced by the engine's fresh-clone preflight).
|
||||
|
||||
### Manual verification checklist (first real-hardware run)
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Phase-0 AT-SPI spike
|
||||
|
||||
De-risks the Linux semantic-control agent ([docs/LINUX_VM_AX_AGENT.md](../../../docs/LINUX_VM_AX_AGENT.md))
|
||||
before any agent code is written. It proves, on Ubuntu/Debian arm64, that **AT-SPI2** can back the
|
||||
Linux `ax_*` ops:
|
||||
|
||||
1. **`ax_dump`** — read the accessibility tree and emit it as the *same* `node` JSON the macOS agent
|
||||
produces (`ref`/`role`/`title`/`value`/`enabled`/`focused`/`frame`/`actions`/`children`), so the
|
||||
host side needs no protocol change.
|
||||
2. **Act by identity** — `ax_press` (`AtspiAction.do_action`), `ax_set_value`
|
||||
(`AtspiEditableText.set_text_contents`), `ax_focus` (`AtspiComponent.grab_focus`).
|
||||
3. **SCREEN coordinates are real** — the load-bearing assumption behind running the automation base on
|
||||
**X11 rather than Wayland**.
|
||||
|
||||
## Files
|
||||
|
||||
- `gtk_probe_app.py` — a tiny deterministic GTK3 target (entry + button + label), named "Nucleic AX
|
||||
Probe" so it can be looked up by identity.
|
||||
- `ax_dump_spike.py` — connects via `pyatspi`, dumps the tree as `node` JSON, and (with `--act`)
|
||||
exercises the three primitives, asserting the entry text and the button-driven label change.
|
||||
- `run_spike.sh` — brings up a headless AT-SPI env (Xvfb + session bus + `at-spi-bus-launcher`),
|
||||
launches the probe, and runs the dump. CI-friendly; exits non-zero on failure.
|
||||
|
||||
## Run it
|
||||
|
||||
**On a normally-installed system** (the Nucleic Linux build container with the packages installed, or a
|
||||
provisioned guest):
|
||||
|
||||
```sh
|
||||
apt-get install -y --no-install-recommends \
|
||||
xvfb at-spi2-core libatk-adaptor python3-gi python3-pyatspi gir1.2-gtk-3.0 dbus-x11 xterm
|
||||
guest/nucleic-linux-agent/spike/run_spike.sh
|
||||
```
|
||||
|
||||
Expected tail:
|
||||
|
||||
```
|
||||
[spike] dumped 6 nodes
|
||||
[spike] SCREEN coordinates look real (non-zero origins present) — X11 assumption holds
|
||||
[spike] set_text -> 'hello from at-spi'
|
||||
[spike] pressed button (action 'click') -> status text 'clicked' (was 'unclicked')
|
||||
[spike] ACT RESULT: PASS
|
||||
=== spike exit: 0 ===
|
||||
```
|
||||
|
||||
On a **real provisioned guest** the X + AT-SPI session is already up, so you can skip `run_spike.sh` and
|
||||
point `ax_dump_spike.py --app <name> --act` straight at a live application.
|
||||
|
||||
## Result (2026-07 spike run)
|
||||
|
||||
Ran green on Debian 12 (bookworm) arm64 — same toolkit stack as the Ubuntu 24.04 guest. All three
|
||||
primitives passed and SCREEN extents resolved to real geometry (child widgets at their laid-out
|
||||
coordinates), confirming the AT-SPI approach and the X11 decision.
|
||||
|
||||
### Note: running without root (extraction sandbox)
|
||||
|
||||
The spike ran in a container where packages couldn't be `dpkg`-installed (no root). They were extracted
|
||||
with `dpkg-deb -x` into a prefix and pointed at via `LD_LIBRARY_PATH` / `GI_TYPELIB_PATH` /
|
||||
`PYTHONPATH` / `XDG_DATA_DIRS`. Several components hardcode absolute helper paths that a prefix doesn't
|
||||
satisfy, so small shim wrappers were needed at the hardcoded locations:
|
||||
|
||||
- `/usr/bin/xkbcomp` — Xvfb execs this by absolute path to compile the keymap.
|
||||
- `/usr/bin/dbus-daemon` — `at-spi-bus-launcher` execs this by absolute path to spawn the a11y bus.
|
||||
- `/usr/libexec/at-spi-bus-launcher`, `/usr/libexec/at-spi2-registryd` — D-Bus activation targets.
|
||||
- Symlinks: `/usr/share/defaults/at-spi2` and `/usr/share/dbus-1/accessibility-services` (a11y bus
|
||||
config + registryd service dir).
|
||||
|
||||
**None of this is needed on a real system** where the packages are installed normally — the hardcoded
|
||||
paths exist. It's documented only so the container run is reproducible. The gsettings schemas must be
|
||||
compiled once (`glib-compile-schemas <prefix>/usr/share/glib-2.0/schemas`) or the launcher aborts with
|
||||
"Cannot get the default GSettingsSchemaSource".
|
||||
</content>
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase-0 AT-SPI spike for the Nucleic Linux semantic-control agent.
|
||||
|
||||
Proves the AT-SPI2 approach that will back the Linux `ax_*` ops (docs/LINUX_VM_AX_AGENT.md):
|
||||
|
||||
1. Read the accessibility tree and emit it as the SAME `node` JSON the macOS agent produces
|
||||
(ref/role/subrole/title/value/enabled/focused/frame/actions/children) — so the host side needs
|
||||
no protocol change.
|
||||
2. Act on controls BY IDENTITY: do_action (press), set_text_contents (set value), grab_focus.
|
||||
3. Report both SCREEN and WINDOW extents, so we can confirm the load-bearing assumption behind the
|
||||
Wayland->X11 switch: that SCREEN coordinates are real (needed by the pixel fallback + hit-test).
|
||||
|
||||
Usage:
|
||||
ax_dump_spike.py --app "Nucleic AX Probe" [--act]
|
||||
|
||||
Exits non-zero on failure so run_spike.sh can gate CI on it.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pyatspi
|
||||
|
||||
# AT-SPI role names are lowercase-spaced ("push button"); the host is role-string-agnostic (macOS AX
|
||||
# uses "AXButton"), so we pass the AT-SPI role name through verbatim rather than inventing a mapping.
|
||||
|
||||
|
||||
def _extents(acc, coord):
|
||||
try:
|
||||
comp = acc.queryComponent()
|
||||
except NotImplementedError:
|
||||
return None
|
||||
try:
|
||||
e = comp.getExtents(coord)
|
||||
return {"x": e.x, "y": e.y, "w": e.width, "h": e.height}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _actions(acc):
|
||||
try:
|
||||
act = acc.queryAction()
|
||||
except NotImplementedError:
|
||||
return []
|
||||
return [act.getName(i) for i in range(act.nActions)]
|
||||
|
||||
|
||||
def _value(acc):
|
||||
# Prefer editable/text content, then a numeric Value, else the accessible's description-free value.
|
||||
for query, attr in (("queryText", "text"),):
|
||||
try:
|
||||
t = getattr(acc, query)()
|
||||
return t.getText(0, -1)
|
||||
except NotImplementedError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return acc.queryValue().currentValue
|
||||
except NotImplementedError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def node(acc, reg, depth, max_depth, budget):
|
||||
"""Serialize one accessible to the macOS-identical `node` shape, assigning a stable `ref`."""
|
||||
if budget[0] <= 0:
|
||||
return None
|
||||
budget[0] -= 1
|
||||
|
||||
ref = "e%d" % len(reg)
|
||||
reg[ref] = acc
|
||||
|
||||
try:
|
||||
states = acc.getState()
|
||||
enabled = states.contains(pyatspi.STATE_ENABLED) or states.contains(pyatspi.STATE_SENSITIVE)
|
||||
focused = states.contains(pyatspi.STATE_FOCUSED)
|
||||
except Exception:
|
||||
enabled, focused = True, False
|
||||
|
||||
n = {
|
||||
"ref": ref,
|
||||
"role": acc.getRoleName(),
|
||||
"title": acc.name or None,
|
||||
"value": _value(acc),
|
||||
"enabled": bool(enabled),
|
||||
"focused": bool(focused),
|
||||
"frame": _extents(acc, pyatspi.DESKTOP_COORDS),
|
||||
"frameWindow": _extents(acc, pyatspi.WINDOW_COORDS), # spike-only: compare to catch bad coords
|
||||
"actions": _actions(acc),
|
||||
"children": [],
|
||||
}
|
||||
|
||||
if depth < max_depth:
|
||||
try:
|
||||
count = acc.childCount
|
||||
except Exception:
|
||||
count = 0
|
||||
for i in range(count):
|
||||
try:
|
||||
child = acc.getChildAtIndex(i)
|
||||
except Exception:
|
||||
child = None
|
||||
if child is None:
|
||||
continue
|
||||
c = node(child, reg, depth + 1, max_depth, budget)
|
||||
if c is not None:
|
||||
n["children"].append(c)
|
||||
return n
|
||||
|
||||
|
||||
def find_app(name):
|
||||
desktop = pyatspi.Registry.getDesktop(0)
|
||||
for i in range(desktop.childCount):
|
||||
app = desktop.getChildAtIndex(i)
|
||||
if app is not None and (app.name or "") == name:
|
||||
return app
|
||||
# Fall back to substring match — app names sometimes carry a suffix.
|
||||
for i in range(desktop.childCount):
|
||||
app = desktop.getChildAtIndex(i)
|
||||
if app is not None and name in (app.name or ""):
|
||||
return app
|
||||
return None
|
||||
|
||||
|
||||
def find_by_title(root, title):
|
||||
if (root.name or "") == title:
|
||||
return root
|
||||
for i in range(root.childCount):
|
||||
c = root.getChildAtIndex(i)
|
||||
if c is None:
|
||||
continue
|
||||
hit = find_by_title(c, title)
|
||||
if hit is not None:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--app", required=True, help="target application accessible name")
|
||||
ap.add_argument("--max-depth", type=int, default=25)
|
||||
ap.add_argument("--budget", type=int, default=800) # mirror the macOS 800-node cap
|
||||
ap.add_argument("--act", action="store_true", help="also exercise do_action/set_text/grab_focus")
|
||||
args = ap.parse_args()
|
||||
|
||||
app = find_app(args.app)
|
||||
if app is None:
|
||||
desktop = pyatspi.Registry.getDesktop(0)
|
||||
names = [desktop.getChildAtIndex(i).name for i in range(desktop.childCount)]
|
||||
print("FAIL: app %r not on the AT-SPI bus. Visible apps: %r" % (args.app, names), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
reg = {}
|
||||
tree = node(app, reg, 0, args.max_depth, [args.budget])
|
||||
print(json.dumps(tree, indent=2, ensure_ascii=False))
|
||||
print("\n[spike] dumped %d nodes" % len(reg), file=sys.stderr)
|
||||
|
||||
# Validate the X11 decision: every on-screen control should have real (non-zero) SCREEN extents.
|
||||
bad = []
|
||||
|
||||
# A top-level window can legitimately sit at the origin (esp. headless X with no WM), so only flag
|
||||
# *leaf-ish* controls that claim (0,0) — those indicate the SCREEN coordinate space isn't resolving.
|
||||
_containers = ("application", "filler", "frame", "window", "panel", "scroll pane")
|
||||
|
||||
def check(n):
|
||||
f = n.get("frame")
|
||||
if f and f["w"] > 0 and f["h"] > 0 and f["x"] == 0 and f["y"] == 0 and n["role"] not in _containers:
|
||||
bad.append(n["ref"])
|
||||
for c in n["children"]:
|
||||
check(c)
|
||||
|
||||
check(tree)
|
||||
if bad:
|
||||
print("[spike] WARN: %d nodes report (0,0) SCREEN origin (coords suspect): %r" % (len(bad), bad[:8]), file=sys.stderr)
|
||||
else:
|
||||
print("[spike] SCREEN coordinates look real (non-zero origins present) — X11 assumption holds", file=sys.stderr)
|
||||
|
||||
if not args.act:
|
||||
return 0
|
||||
|
||||
# --- act by identity: the three primitives ax_press / ax_set_value / ax_focus map to ---
|
||||
ok = True
|
||||
|
||||
entry = find_by_title(app, "probe-entry")
|
||||
if entry is not None:
|
||||
try:
|
||||
entry.queryComponent().grabFocus() # ax_focus
|
||||
entry.queryEditableText().setTextContents("hello from at-spi") # ax_set_value
|
||||
got = entry.queryText().getText(0, -1)
|
||||
print("[spike] set_text -> %r" % got, file=sys.stderr)
|
||||
ok = ok and (got == "hello from at-spi")
|
||||
except Exception as e:
|
||||
print("[spike] FAIL entry actions: %r" % e, file=sys.stderr)
|
||||
ok = False
|
||||
else:
|
||||
print("[spike] FAIL: probe-entry not found", file=sys.stderr)
|
||||
ok = False
|
||||
|
||||
button = find_by_title(app, "probe-button")
|
||||
status = find_by_title(app, "probe-status")
|
||||
if button is not None and status is not None:
|
||||
try:
|
||||
before = _value(status)
|
||||
act = button.queryAction() # ax_press
|
||||
names = [act.getName(i) for i in range(act.nActions)]
|
||||
idx = names.index("click") if "click" in names else 0
|
||||
act.doAction(idx)
|
||||
new_status = _value(status) # the label's TEXT value is what the click mutates
|
||||
print("[spike] pressed button (action %r) -> status text %r (was %r)" % (names[idx], new_status, before), file=sys.stderr)
|
||||
ok = ok and (new_status == "clicked")
|
||||
except Exception as e:
|
||||
print("[spike] FAIL button action: %r" % e, file=sys.stderr)
|
||||
ok = False
|
||||
else:
|
||||
print("[spike] FAIL: probe-button/probe-status not found", file=sys.stderr)
|
||||
ok = False
|
||||
|
||||
print("[spike] ACT RESULT: %s" % ("PASS" if ok else "FAIL"), file=sys.stderr)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A tiny, deterministic GTK3 app used as the AT-SPI target for the Phase-0 spike.
|
||||
|
||||
It exposes exactly the controls the semantic-control agent must be able to observe and drive:
|
||||
a labelled push button, an editable text entry, and a label whose text the button mutates. Titles
|
||||
are fixed so the spike can look elements up by identity. See docs/LINUX_VM_AX_AGENT.md §8.
|
||||
"""
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib # noqa: E402
|
||||
|
||||
# The AT-SPI *application* node is named after the program; set it so the spike can look the app up by
|
||||
# identity ("Nucleic AX Probe") rather than the argv[0] basename.
|
||||
GLib.set_prgname("Nucleic AX Probe")
|
||||
GLib.set_application_name("Nucleic AX Probe")
|
||||
|
||||
|
||||
class ProbeWindow(Gtk.Window):
|
||||
def __init__(self):
|
||||
super().__init__(title="Nucleic AX Probe")
|
||||
self.set_default_size(320, 160)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
box.set_border_width(12)
|
||||
self.add(box)
|
||||
|
||||
self.entry = Gtk.Entry()
|
||||
self.entry.set_placeholder_text("type here")
|
||||
self.entry.get_accessible().set_name("probe-entry")
|
||||
box.pack_start(self.entry, False, False, 0)
|
||||
|
||||
self.button = Gtk.Button(label="Click Me")
|
||||
self.button.get_accessible().set_name("probe-button")
|
||||
self.button.connect("clicked", self.on_click)
|
||||
box.pack_start(self.button, False, False, 0)
|
||||
|
||||
self.label = Gtk.Label(label="unclicked")
|
||||
self.label.get_accessible().set_name("probe-status")
|
||||
box.pack_start(self.label, False, False, 0)
|
||||
|
||||
self.connect("destroy", Gtk.main_quit)
|
||||
|
||||
def on_click(self, _btn):
|
||||
self.label.set_text("clicked")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
win = ProbeWindow()
|
||||
win.show_all()
|
||||
Gtk.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
# Phase-0 AT-SPI spike driver (docs/LINUX_VM_AX_AGENT.md §8).
|
||||
#
|
||||
# Brings up a headless AT-SPI environment, launches the GTK probe app, and runs ax_dump_spike.py to
|
||||
# prove: (1) the accessibility tree reads into the macOS-identical `node` JSON, (2) act-by-identity
|
||||
# (do_action / set_text_contents / grab_focus) works, and (3) SCREEN coordinates are real (the reason
|
||||
# the automation base is X11, not Wayland).
|
||||
#
|
||||
# Runs in the Nucleic Linux build container / CI. On a real provisioned guest the X + AT-SPI session is
|
||||
# already up, so you can skip the Xvfb/dbus bootstrap and just run ax_dump_spike.py against a live app.
|
||||
#
|
||||
# Deps (apt): xvfb at-spi2-core libatk-adaptor python3-gi python3-pyatspi gir1.2-gtk-3.0 dbus-x11.
|
||||
set -e
|
||||
DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
# Re-exec under a private session D-Bus if we don't have one (AT-SPI needs a session bus).
|
||||
if [ -z "$DBUS_SESSION_BUS_ADDRESS" ]; then
|
||||
exec dbus-run-session -- "$0" "$@"
|
||||
fi
|
||||
|
||||
# Headless X if none was provided.
|
||||
STARTED_XVFB=
|
||||
if [ -z "$DISPLAY" ]; then
|
||||
Xvfb :99 -screen 0 1920x1200x24 >/tmp/spike-xvfb.log 2>&1 &
|
||||
XVFB_PID=$!
|
||||
STARTED_XVFB=1
|
||||
DISPLAY=:99
|
||||
export DISPLAY
|
||||
# Wait for the X socket.
|
||||
i=0
|
||||
while [ ! -e /tmp/.X11-unix/X99 ] && [ $i -lt 50 ]; do i=$((i + 1)); sleep 0.1; done
|
||||
fi
|
||||
|
||||
# Force the GTK->AT-SPI bridge on (belt-and-suspenders with libatk-adaptor's environment.d default).
|
||||
GTK_MODULES="atk-bridge"; export GTK_MODULES
|
||||
NO_AT_BRIDGE=0; export NO_AT_BRIDGE
|
||||
|
||||
# The AT-SPI a11y bus (usually D-Bus-activated; launch explicitly so registryd is reachable).
|
||||
BUS=$(command -v at-spi-bus-launcher 2>/dev/null || echo /usr/libexec/at-spi-bus-launcher)
|
||||
"$BUS" --launch-immediately >/tmp/spike-atspi.log 2>&1 &
|
||||
ATSPI_PID=$!
|
||||
sleep 1
|
||||
|
||||
cleanup() {
|
||||
[ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true
|
||||
[ -n "$ATSPI_PID" ] && kill "$ATSPI_PID" 2>/dev/null || true
|
||||
[ -n "$STARTED_XVFB" ] && kill "$XVFB_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Launch the probe app on the a11y bus.
|
||||
python3 "$DIR/gtk_probe_app.py" &
|
||||
APP_PID=$!
|
||||
|
||||
# Wait for it to register on the AT-SPI bus (up to ~10s).
|
||||
i=0
|
||||
while [ $i -lt 50 ]; do
|
||||
if python3 "$DIR/ax_dump_spike.py" --app "Nucleic AX Probe" >/dev/null 2>&1; then break; fi
|
||||
i=$((i + 1)); sleep 0.2
|
||||
done
|
||||
|
||||
echo "=== ax_dump (tree as macOS-identical node JSON) ==="
|
||||
python3 "$DIR/ax_dump_spike.py" --app "Nucleic AX Probe" --act
|
||||
RC=$?
|
||||
echo "=== spike exit: $RC ==="
|
||||
exit $RC
|
||||
@@ -399,6 +399,8 @@ final class LiveActivityManager {
|
||||
case .claudeCode: .claude
|
||||
case .codex, .codexExec: .codex
|
||||
case .grok: .grok
|
||||
// ACP wrapper agents share the generic "Agent" tag/tint in the Live Activity for now.
|
||||
case .opencode, .hermes, .cursorAgent: .other
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user