Add per-project sandboxing via Apple's container
Opt-in, per-project execution sandbox: when enabled, a project's sessions run `claude` inside an isolated Linux VM (Apple `container`) with the worktree bind-mounted, instead of directly on the host. Off by default — existing behavior is unchanged. - Domain: ProjectSandbox (enabled/image/idleTimeout) on Project; ContainerSpec on RunSpec/ResumeSpec. - Persistence: migration v7 adds project.sandbox_config (JSON). - ContainerRuntime: thin `container` CLI wrapper (preflight, default-image build, run/exec/stop/delete/list, host-gateway discovery). - ContainerManager: app-level per-session lifecycle — ensureRunning, idle auto-stop, teardown, orphan reconcile. - ClaudeCodeBackend: wraps the claude invocation in `container exec` when a ContainerSpec is present; binds the approval MCP server on 0.0.0.0 and rewrites its URL to the VM gateway so the containerized child can reach it. - Repo root + worktree base mounted at identical paths (git links + cwd-hash resolve); host ~/.claude mounted read-only and seeded into a writable claude-home so credentials are never mutated but native resume still works. - UI: ProjectSettingsSheet (toggle/image/idle) + "Sandboxed" badge; AppStore gains updateProject. - Tests: 9 new (arg construction, mount formatting, name parsing/derivation, sandbox JSON round-trip, MCP host rewrite). 112 pass. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -21,15 +21,22 @@ struct NucleicApp: App {
|
||||
path: support.appendingPathComponent("nucleic.sqlite").path)
|
||||
let worktrees = GitWorktreeManager()
|
||||
let transcriptsDir = support.appendingPathComponent("sessions", isDirectory: true)
|
||||
// Shared sandbox orchestrator: opt-in per project, dormant unless a session's
|
||||
// project enables it. The same instance is handed to every backend so container
|
||||
// lifecycle (idle stop, teardown, reconcile) stays consistent.
|
||||
let containerManager = ContainerManager()
|
||||
let store = AppStore(
|
||||
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir
|
||||
database: database, worktrees: worktrees, transcriptsDir: transcriptsDir,
|
||||
containerManager: containerManager
|
||||
) { _ in
|
||||
// Each chat turn is a single-shot run (stdin closed), with follow-ups
|
||||
// resumed via --resume. This is the only reliable way to use the
|
||||
// approval server: Claude's streaming-input mode hangs alongside
|
||||
// --permission-prompt-tool. Approval server is authoritative
|
||||
// (--permission-mode default); child is hermetic (--strict-mcp-config).
|
||||
ClaudeCodeBackend(configuration: .init(closeStdinAfterPrompt: true))
|
||||
ClaudeCodeBackend(
|
||||
configuration: .init(closeStdinAfterPrompt: true),
|
||||
containerManager: containerManager)
|
||||
}
|
||||
store.intelligence = AppleIntelligenceProvider()
|
||||
store.defaultModel = ModelCatalog.storedDefaultModel
|
||||
|
||||
@@ -13,6 +13,7 @@ struct ProjectView: View {
|
||||
|
||||
/// Full session records (with cached summary blurbs), most-recent first.
|
||||
@State private var sessions: [Session] = []
|
||||
@State private var showingSettings = false
|
||||
|
||||
private var active: [Session] { sessions.filter { !$0.archived } }
|
||||
/// Chats blocked on the user — what to deal with first.
|
||||
@@ -42,16 +43,25 @@ struct ProjectView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.task(id: project.id) { sessions = await store.sessions(for: project.id) }
|
||||
.task { await store.loadTodos() }
|
||||
.sheet(isPresented: $showingSettings) { ProjectSettingsSheet(project: project) }
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var sandboxEnabled: Bool { project.sandbox?.enabled == true }
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(project.name)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
Spacer()
|
||||
Button {
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Button {
|
||||
Task { await store.newSession(in: project) }
|
||||
} label: {
|
||||
@@ -67,6 +77,11 @@ struct ProjectView: View {
|
||||
Text("·")
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
Text(project.defaultBranch.value)
|
||||
if sandboxEnabled {
|
||||
Text("·")
|
||||
Label("Sandboxed", systemImage: "shield.lefthalf.filled")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -49,3 +49,81 @@ struct AddProjectSheet: View {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-project settings — currently the execution sandbox. When enabled, the project's
|
||||
/// sessions run `claude` inside an isolated Apple `container` (a Linux VM) with the
|
||||
/// worktree bind-mounted, instead of directly on the host.
|
||||
struct ProjectSettingsSheet: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let project: Project
|
||||
|
||||
@State private var sandboxEnabled: Bool
|
||||
@State private var image: String
|
||||
@State private var idleMinutes: Int
|
||||
|
||||
init(project: Project) {
|
||||
self.project = project
|
||||
let sandbox = project.sandbox ?? ProjectSandbox()
|
||||
_sandboxEnabled = State(initialValue: sandbox.enabled)
|
||||
_image = State(initialValue: sandbox.image ?? "")
|
||||
_idleMinutes = State(initialValue: max(1, sandbox.idleTimeoutSeconds / 60))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("\(project.name) Settings").font(.title2).bold()
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle(isOn: $sandboxEnabled) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Run sessions in a sandbox container")
|
||||
Text("Executes Claude inside an isolated Linux VM (Apple `container`) "
|
||||
+ "with this repo's worktree mounted. Requires Apple Silicon + the "
|
||||
+ "`container` tool installed.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if sandboxEnabled {
|
||||
Divider()
|
||||
HStack {
|
||||
Text("Image")
|
||||
TextField(ProjectSandbox.defaultImage, text: $image)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Text("Leave blank to use the bundled default image (built on first use).")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Stepper(
|
||||
"Stop container after \(idleMinutes) min idle",
|
||||
value: $idleMinutes, in: 1...240)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Save") { Task { await save() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 480)
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
var updated = project
|
||||
let trimmed = image.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
updated.sandbox = ProjectSandbox(
|
||||
enabled: sandboxEnabled,
|
||||
image: trimmed.isEmpty ? nil : trimmed,
|
||||
idleTimeoutSeconds: idleMinutes * 60)
|
||||
await store.updateProject(updated)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ public final class AppStore {
|
||||
private let backendFactory: BackendFactory
|
||||
private let transcriptsDir: URL
|
||||
private let now: @Sendable () -> Date
|
||||
/// Shared sandbox orchestrator (nil in tests / non-sandbox builds). Same instance the
|
||||
/// backend factory hands to each `ClaudeCodeBackend`, so lifecycle stays consistent.
|
||||
private let containerManager: ContainerManager?
|
||||
|
||||
private var controllers: [SessionID: SessionController] = [:]
|
||||
private var projectsByID: [ProjectID: Project] = [:]
|
||||
@@ -153,12 +156,14 @@ public final class AppStore {
|
||||
database: any SessionMetadataStore,
|
||||
worktrees: any WorktreeManaging,
|
||||
transcriptsDir: URL,
|
||||
containerManager: ContainerManager? = nil,
|
||||
now: @escaping @Sendable () -> Date = { Date() },
|
||||
backendFactory: @escaping BackendFactory
|
||||
) {
|
||||
self.database = database
|
||||
self.worktrees = worktrees
|
||||
self.transcriptsDir = transcriptsDir
|
||||
self.containerManager = containerManager
|
||||
self.now = now
|
||||
self.backendFactory = backendFactory
|
||||
}
|
||||
@@ -189,6 +194,8 @@ public final class AppStore {
|
||||
upsertSummary(SessionSummary(session, pendingApprovalCount: 0))
|
||||
}
|
||||
}
|
||||
// Clean up any sandbox containers orphaned by a previous run/crash.
|
||||
await containerManager?.reconcile(activeSessions: Array(controllers.keys))
|
||||
}
|
||||
|
||||
private func reconstructController(for session: Session, in project: Project) -> SessionController? {
|
||||
@@ -229,6 +236,17 @@ public final class AppStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist edits to a project's configuration (e.g. sandbox settings) and refresh the
|
||||
/// in-memory list. Sandbox changes take effect on the next turn started in the project.
|
||||
public func updateProject(_ project: Project) async {
|
||||
do {
|
||||
try await database.saveProject(project)
|
||||
await loadProjects()
|
||||
} catch {
|
||||
lastError = "Update project failed: \(error)"
|
||||
}
|
||||
}
|
||||
|
||||
public func renameProject(_ id: ProjectID, to name: String) async {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, var project = projectsByID[id] else { return }
|
||||
@@ -254,6 +272,7 @@ public final class AppStore {
|
||||
observers[session.id] = nil
|
||||
controllers[session.id] = nil
|
||||
}
|
||||
await containerManager?.teardown(session.id)
|
||||
if let path = session.worktreePath, let branch = session.branch, let baseSHA = session.baseSHA {
|
||||
let worktree = Worktree(
|
||||
sessionID: session.id, path: path, branch: branch,
|
||||
@@ -725,6 +744,7 @@ public final class AppStore {
|
||||
do { try await controller.discard(force: true) }
|
||||
catch { lastError = "Delete failed: \(error)" }
|
||||
}
|
||||
await containerManager?.teardown(id)
|
||||
observers[id]?.cancel()
|
||||
observers[id] = nil
|
||||
controllers[id] = nil
|
||||
|
||||
@@ -86,6 +86,63 @@ public enum SandboxMode: String, Sendable, Codable {
|
||||
case fullAccess = "danger-full-access"
|
||||
}
|
||||
|
||||
/// Request to run a session's `claude` process inside an Apple `container` (an isolated
|
||||
/// Linux VM) instead of on the host. Built by `SessionController` from `Project.sandbox`
|
||||
/// and carried on `RunSpec`/`ResumeSpec`; consumed by `ClaudeCodeBackend` (which wraps the
|
||||
/// invocation in `container exec`) and `ContainerManager` (lifecycle). `nil` → host-spawned.
|
||||
public struct ContainerSpec: Sendable, Equatable {
|
||||
/// One bind mount: a host path made visible inside the container.
|
||||
public struct Mount: Sendable, Equatable {
|
||||
public let host: String
|
||||
public let container: String
|
||||
public let readOnly: Bool
|
||||
public init(host: String, container: String, readOnly: Bool) {
|
||||
self.host = host
|
||||
self.container = container
|
||||
self.readOnly = readOnly
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable container name for this session (e.g. `nucleic-<sessionID.short>`).
|
||||
public let name: String
|
||||
/// Image reference to run (already resolved to the bundled default if no override).
|
||||
public let image: String
|
||||
/// Bind mounts. Repo root + worktree base are mounted at their identical host paths so
|
||||
/// git worktree links and the cwd-hash resolve; `~/.claude` is mounted read-only at a
|
||||
/// staging path and seeded into a writable claude-home.
|
||||
public let mounts: [Mount]
|
||||
/// Working directory inside the container (the session's worktree path).
|
||||
public let workdir: String
|
||||
/// Environment passed into the `claude` process (e.g. ANTHROPIC_API_KEY).
|
||||
public let env: [String: String]
|
||||
/// Stop the container after this much inactivity.
|
||||
public let idleTimeout: TimeInterval
|
||||
/// Read-only staging path where the host `~/.claude` is mounted inside the container.
|
||||
public let claudeHomeStaging: String
|
||||
/// Writable path seeded from the staging copy and used as the container's `~/.claude`.
|
||||
public let claudeHomeWritable: String
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
image: String,
|
||||
mounts: [Mount],
|
||||
workdir: String,
|
||||
env: [String: String],
|
||||
idleTimeout: TimeInterval,
|
||||
claudeHomeStaging: String,
|
||||
claudeHomeWritable: String
|
||||
) {
|
||||
self.name = name
|
||||
self.image = image
|
||||
self.mounts = mounts
|
||||
self.workdir = workdir
|
||||
self.env = env
|
||||
self.idleTimeout = idleTimeout
|
||||
self.claudeHomeStaging = claudeHomeStaging
|
||||
self.claudeHomeWritable = claudeHomeWritable
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Run / resume / input specs (BACKEND_PROTOCOL §2.2)
|
||||
|
||||
public enum ApprovalPolicy: Sendable {
|
||||
@@ -119,6 +176,8 @@ public struct RunSpec: Sendable {
|
||||
public let autoApprove: Bool
|
||||
public let approvalPolicy: ApprovalPolicy
|
||||
public let sandbox: SandboxMode?
|
||||
/// When set, run `claude` inside this Apple `container` instead of on the host.
|
||||
public let container: ContainerSpec?
|
||||
public let mcpConfigPath: URL?
|
||||
public let appendSystemPrompt: String?
|
||||
public let extraEnv: [String: String]
|
||||
@@ -133,6 +192,7 @@ public struct RunSpec: Sendable {
|
||||
autoApprove: Bool = false,
|
||||
approvalPolicy: ApprovalPolicy = .interactive,
|
||||
sandbox: SandboxMode? = nil,
|
||||
container: ContainerSpec? = nil,
|
||||
mcpConfigPath: URL? = nil,
|
||||
appendSystemPrompt: String? = nil,
|
||||
extraEnv: [String: String] = [:],
|
||||
@@ -146,6 +206,7 @@ public struct RunSpec: Sendable {
|
||||
self.autoApprove = autoApprove
|
||||
self.approvalPolicy = approvalPolicy
|
||||
self.sandbox = sandbox
|
||||
self.container = container
|
||||
self.mcpConfigPath = mcpConfigPath
|
||||
self.appendSystemPrompt = appendSystemPrompt
|
||||
self.extraEnv = extraEnv
|
||||
@@ -163,11 +224,13 @@ public struct ResumeSpec: Sendable {
|
||||
public let effort: String?
|
||||
public let autoApprove: Bool
|
||||
public let fork: Bool
|
||||
/// When set, resume `claude` inside this Apple `container` instead of on the host.
|
||||
public let container: ContainerSpec?
|
||||
|
||||
public init(
|
||||
sessionID: SessionID, backendSessionID: String, worktree: WorktreePath,
|
||||
prompt: AgentInput? = nil, model: String? = nil, effort: String? = nil,
|
||||
autoApprove: Bool = false, fork: Bool = false
|
||||
autoApprove: Bool = false, fork: Bool = false, container: ContainerSpec? = nil
|
||||
) {
|
||||
self.sessionID = sessionID
|
||||
self.backendSessionID = backendSessionID
|
||||
@@ -177,6 +240,7 @@ public struct ResumeSpec: Sendable {
|
||||
self.effort = effort
|
||||
self.autoApprove = autoApprove
|
||||
self.fork = fork
|
||||
self.container = container
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,13 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
private let processHost: ProcessHost
|
||||
private let approvalServer: MCPApprovalServer
|
||||
public let approvals: ApprovalCoordinator
|
||||
/// Optional sandbox orchestrator. `nil` → always spawn on the host (default). When a
|
||||
/// `RunSpec.container` is present and this is set, the run executes inside the container.
|
||||
private let containerManager: ContainerManager?
|
||||
|
||||
private var handle: (any ProcessHandle)?
|
||||
/// Name of the container the current run executes in (for the `finished` callback).
|
||||
private var activeContainerName: String?
|
||||
private var continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation?
|
||||
private var sessionID: SessionID?
|
||||
private var serverToken: String?
|
||||
@@ -84,12 +89,14 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
configuration: Configuration = Configuration(),
|
||||
processHost: ProcessHost = ProcessHost(),
|
||||
approvalServer: MCPApprovalServer = MCPApprovalServer(),
|
||||
approvals: ApprovalCoordinator = ApprovalCoordinator()
|
||||
approvals: ApprovalCoordinator = ApprovalCoordinator(),
|
||||
containerManager: ContainerManager? = nil
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.processHost = processHost
|
||||
self.approvalServer = approvalServer
|
||||
self.approvals = approvals
|
||||
self.containerManager = containerManager
|
||||
}
|
||||
|
||||
// MARK: - AgentBackend
|
||||
@@ -107,7 +114,8 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
prompt: resume.prompt ?? AgentInput(parts: []),
|
||||
model: resume.model,
|
||||
effort: resume.effort,
|
||||
autoApprove: resume.autoApprove)
|
||||
autoApprove: resume.autoApprove,
|
||||
container: resume.container)
|
||||
return makeStream(run: run, resumeArgs: args)
|
||||
}
|
||||
|
||||
@@ -165,15 +173,27 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
self.interruptRequested = false
|
||||
|
||||
do {
|
||||
// 1. Approval bridge: per-session bearer token → handler.
|
||||
// 0. Sandbox: if this run is containerized, bring the per-session container up
|
||||
// first so we know the host-gateway address the child must use to reach us.
|
||||
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)
|
||||
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.
|
||||
let token = UUID().uuidString
|
||||
serverToken = token
|
||||
let port = try await approvalServer.start()
|
||||
let port = try await approvalServer.start(host: sandbox != nil ? "0.0.0.0" : "127.0.0.1")
|
||||
await approvalServer.register(token: token) { [weak self] call in
|
||||
guard let self else { return .deny(message: "Session terminated") }
|
||||
return await self.handleApprovalCall(call)
|
||||
}
|
||||
let mcpConfig = approvalServer.mcpConfigJSON(port: port, token: token)
|
||||
let mcpConfig = approvalServer.mcpConfigJSON(host: mcpHost, port: port, token: token)
|
||||
|
||||
// 2. Spawn.
|
||||
var args = [
|
||||
@@ -214,12 +234,21 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
args += resumeArgs
|
||||
args += run.extraArgs
|
||||
|
||||
let spec = ProcessSpec(
|
||||
executable: configuration.executable,
|
||||
args: args,
|
||||
cwd: run.worktree,
|
||||
env: run.extraEnv,
|
||||
stdinMode: .pipe)
|
||||
// Spawn on the host, or wrap the same `claude` invocation in `container exec`.
|
||||
let spec: ProcessSpec
|
||||
if let sandbox, let cspec = run.container, let name = activeContainerName {
|
||||
let env = cspec.env.merging(run.extraEnv) { _, new in new }
|
||||
spec = sandbox.execProcessSpec(
|
||||
name: name, workdir: cspec.workdir, env: env,
|
||||
argv: [configuration.executable] + args)
|
||||
} else {
|
||||
spec = ProcessSpec(
|
||||
executable: configuration.executable,
|
||||
args: args,
|
||||
cwd: run.worktree,
|
||||
env: run.extraEnv,
|
||||
stdinMode: .pipe)
|
||||
}
|
||||
let handle = try await processHost.launch(spec)
|
||||
self.handle = handle
|
||||
|
||||
@@ -290,6 +319,11 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
if let serverToken {
|
||||
await approvalServer.unregister(token: serverToken)
|
||||
}
|
||||
// Release the sandbox: the turn is done, so the container's idle timer can arm.
|
||||
if let name = activeContainerName {
|
||||
await containerManager?.finished(name: name)
|
||||
activeContainerName = nil
|
||||
}
|
||||
self.handle = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,15 @@ public actor MCPApprovalServer {
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Returns the bound ephemeral port.
|
||||
/// Returns the bound ephemeral port. `host` is the local interface to bind to;
|
||||
/// default loopback for host runs, `0.0.0.0` for sandbox runs so the containerized
|
||||
/// `claude` can reach the approval server over the VM gateway (bearer-token gated).
|
||||
@discardableResult
|
||||
public func start() async throws -> UInt16 {
|
||||
public func start(host: String = "127.0.0.1") async throws -> UInt16 {
|
||||
if let _ = listener { return port }
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: .any)
|
||||
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(
|
||||
host: NWEndpoint.Host(host), port: .any)
|
||||
let listener = try NWListener(using: parameters)
|
||||
self.listener = listener
|
||||
|
||||
@@ -100,13 +103,17 @@ public actor MCPApprovalServer {
|
||||
handlers.removeValue(forKey: token)
|
||||
}
|
||||
|
||||
/// The MCP server entry for `--mcp-config` (inline JSON).
|
||||
public nonisolated func mcpConfigJSON(port: UInt16, token: String) -> String {
|
||||
/// The MCP server entry for `--mcp-config` (inline JSON). `host` is the address the
|
||||
/// child reaches the server at — loopback for host runs, the VM gateway IP for a
|
||||
/// containerized child (which can't see the host's `127.0.0.1`).
|
||||
public nonisolated func mcpConfigJSON(
|
||||
host: String = "127.0.0.1", port: UInt16, token: String
|
||||
) -> String {
|
||||
JSONValue.object([
|
||||
"mcpServers": .object([
|
||||
"nucleic": .object([
|
||||
"type": .string("http"),
|
||||
"url": .string("http://127.0.0.1:\(port)/mcp"),
|
||||
"url": .string("http://\(host):\(port)/mcp"),
|
||||
"headers": .object(["Authorization": .string("Bearer \(token)")]),
|
||||
])
|
||||
])
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// App-level coordinator for per-session sandbox containers (injected like
|
||||
/// `GitWorktreeManager`). Owns the lifecycle the `ContainerRuntime` is too low-level to
|
||||
/// track: keeping one long-lived container per session, an idle timer that stops it after
|
||||
/// inactivity, teardown on session end, and orphan reconciliation on launch.
|
||||
///
|
||||
/// Activity is reference-counted: `ensureRunning` (called at the start of every turn)
|
||||
/// marks the container busy and disarms the idle timer; `finished` marks the turn done and
|
||||
/// re-arms it. The container is only stopped when no turn is in flight, so a single
|
||||
/// long-running turn is never killed mid-flight.
|
||||
public actor ContainerManager {
|
||||
private let runtime: ContainerRuntime
|
||||
|
||||
/// Live per-container bookkeeping, keyed by container name.
|
||||
private var active: [String: Int] = [:] // in-flight turn count
|
||||
private var idleTimeouts: [String: TimeInterval] = [:]
|
||||
private var idleTimers: [String: Task<Void, Never>] = [:]
|
||||
|
||||
public init(runtime: ContainerRuntime = ContainerRuntime()) {
|
||||
self.runtime = runtime
|
||||
}
|
||||
|
||||
/// Stable container name for a session. Lowercased because `UUID().uuidString` (hence
|
||||
/// `SessionID.short`) is uppercase, while container names are conventionally lowercase
|
||||
/// and our reconcile parser matches lowercase hex.
|
||||
public nonisolated static func containerName(for session: SessionID) -> String {
|
||||
"nucleic-\(session.short.lowercased())"
|
||||
}
|
||||
|
||||
/// Ensure the session's container is up and return its name plus the host-gateway IP
|
||||
/// (the container's view of the Mac, for routing the approval MCP server). Marks a turn
|
||||
/// as in-flight; pair with `finished(name:)`.
|
||||
public func ensureRunning(_ spec: ContainerSpec) async throws
|
||||
-> (name: String, hostGateway: String)
|
||||
{
|
||||
cancelIdleTimer(spec.name)
|
||||
active[spec.name, default: 0] += 1
|
||||
idleTimeouts[spec.name] = spec.idleTimeout
|
||||
do {
|
||||
try await runtime.ensureRunning(spec)
|
||||
let gateway = try await runtime.hostGateway(name: spec.name)
|
||||
return (spec.name, gateway)
|
||||
} catch {
|
||||
// Roll back the activity bump so a failed start doesn't pin the container busy.
|
||||
finishBookkeeping(spec.name)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `ProcessSpec` that runs `argv` inside the named container (forwards to the
|
||||
/// runtime; pure, no lifecycle effect).
|
||||
public nonisolated func execProcessSpec(
|
||||
name: String, workdir: String, env: [String: String], argv: [String]
|
||||
) -> ProcessSpec {
|
||||
runtime.execProcessSpec(name: name, workdir: workdir, env: env, argv: argv)
|
||||
}
|
||||
|
||||
/// Mark a turn finished. When the last in-flight turn ends, arm the idle timer.
|
||||
public func finished(name: String) {
|
||||
finishBookkeeping(name)
|
||||
}
|
||||
|
||||
private func finishBookkeeping(_ name: String) {
|
||||
let remaining = max(0, (active[name] ?? 0) - 1)
|
||||
active[name] = remaining
|
||||
if remaining == 0 { armIdleTimer(name) }
|
||||
}
|
||||
|
||||
/// Stop and remove the session's container (on shutdown/discard).
|
||||
public func teardown(_ session: SessionID) async {
|
||||
let name = Self.containerName(for: session)
|
||||
cancelIdleTimer(name)
|
||||
active[name] = nil
|
||||
idleTimeouts[name] = nil
|
||||
await runtime.remove(name: name)
|
||||
}
|
||||
|
||||
/// On app launch, remove `nucleic-*` containers that don't correspond to a live session.
|
||||
public func reconcile(activeSessions: [SessionID]) async {
|
||||
let keep = Set(activeSessions.map { Self.containerName(for: $0) })
|
||||
for name in await runtime.list() where !keep.contains(name) {
|
||||
await runtime.remove(name: name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Idle timer
|
||||
|
||||
private func armIdleTimer(_ name: String) {
|
||||
cancelIdleTimer(name)
|
||||
let timeout = idleTimeouts[name] ?? TimeInterval(ProjectSandbox.defaultIdleTimeoutSeconds)
|
||||
idleTimers[name] = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
await self?.stopIfIdle(name)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelIdleTimer(_ name: String) {
|
||||
idleTimers[name]?.cancel()
|
||||
idleTimers[name] = nil
|
||||
}
|
||||
|
||||
private func stopIfIdle(_ name: String) async {
|
||||
idleTimers[name] = nil
|
||||
guard (active[name] ?? 0) == 0 else { return } // a turn snuck in; leave it running
|
||||
await runtime.stop(name: name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import Foundation
|
||||
|
||||
/// Errors surfaced from the `container` CLI wrapper.
|
||||
public enum ContainerError: Error, Sendable, CustomStringConvertible {
|
||||
/// The `container` tool isn't installed / not on PATH.
|
||||
case notInstalled
|
||||
/// A `container` subcommand exited non-zero. Carries the joined stderr tail.
|
||||
case commandFailed(command: String, status: Int32, stderr: String)
|
||||
/// Couldn't determine the host gateway address from inside the container.
|
||||
case gatewayUnavailable
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .notInstalled:
|
||||
return "Apple's `container` tool isn't installed or isn't on PATH. "
|
||||
+ "Install it from https://github.com/apple/container and run `container system start`."
|
||||
case let .commandFailed(command, status, stderr):
|
||||
return "`container \(command)` failed (status \(status)): \(stderr)"
|
||||
case .gatewayUnavailable:
|
||||
return "Couldn't determine the host gateway address from inside the container."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin async wrapper over Apple's `container` CLI. Stateless except for a cached
|
||||
/// gateway-IP lookup per container name. Each method shells out via `ProcessHost`
|
||||
/// (so executable resolution and stdio handling match the rest of the app) and
|
||||
/// surfaces failures as `ContainerError`. Lifecycle orchestration (idle timers,
|
||||
/// per-session bookkeeping) lives one layer up in `ContainerManager`.
|
||||
public actor ContainerRuntime {
|
||||
/// The `container` executable name (resolved via PATH) or an absolute path.
|
||||
private let executable: String
|
||||
private let processHost: ProcessHost
|
||||
/// Cached host-gateway IP per running container (the container's view of the Mac).
|
||||
private var gatewayCache: [String: String] = [:]
|
||||
|
||||
public init(executable: String = "container", processHost: ProcessHost = ProcessHost()) {
|
||||
self.executable = executable
|
||||
self.processHost = processHost
|
||||
}
|
||||
|
||||
// MARK: - Preflight
|
||||
|
||||
/// True if the `container` tool can be invoked at all.
|
||||
public func isAvailable() async -> Bool {
|
||||
do {
|
||||
_ = try await run(["--version"])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the container subsystem is up. Idempotent; safe to call before every run.
|
||||
public func ensureSystemStarted() async throws {
|
||||
guard await isAvailable() else { throw ContainerError.notInstalled }
|
||||
// `system start` is a no-op if already running; tolerate a non-zero exit so we
|
||||
// don't wedge on "already started" diagnostics from some builds.
|
||||
_ = try? await run(["system", "start"])
|
||||
}
|
||||
|
||||
// MARK: - Images
|
||||
|
||||
/// True if an image with this reference already exists locally.
|
||||
public func imageExists(_ reference: String) async -> Bool {
|
||||
do {
|
||||
_ = try await run(["image", "inspect", reference])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the bundled default sandbox image (`ProjectSandbox.defaultImage`) from an
|
||||
/// embedded Dockerfile. No-op if it already exists.
|
||||
public func ensureDefaultImage() async throws {
|
||||
let reference = ProjectSandbox.defaultImage
|
||||
if await imageExists(reference) { return }
|
||||
let context = try writeDefaultBuildContext()
|
||||
defer { try? FileManager.default.removeItem(at: context) }
|
||||
_ = try await run(["build", "--tag", reference, context.path])
|
||||
}
|
||||
|
||||
/// Default image: a Node base with git + the Claude Code CLI preinstalled.
|
||||
private func writeDefaultBuildContext() throws -> URL {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("nucleic-sandbox-build-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let dockerfile = """
|
||||
FROM node:22-bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates iproute2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
WORKDIR /workspace
|
||||
"""
|
||||
try dockerfile.write(
|
||||
to: dir.appendingPathComponent("Dockerfile"), atomically: true, encoding: .utf8)
|
||||
return dir
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Ensure the named container is running. Strategy avoids parsing `inspect` output:
|
||||
/// 1. `exec … true` succeeds → already running.
|
||||
/// 2. else `start` succeeds → existed but stopped.
|
||||
/// 3. else `run -d …` to create it fresh, then seed the writable claude-home.
|
||||
public func ensureRunning(_ spec: ContainerSpec) async throws {
|
||||
try await ensureSystemStarted()
|
||||
if spec.image == ProjectSandbox.defaultImage {
|
||||
try await ensureDefaultImage()
|
||||
}
|
||||
|
||||
if (try? await run(["exec", spec.name, "true"])) != nil { return }
|
||||
if (try? await run(["start", spec.name])) != nil { return }
|
||||
|
||||
var args = ["run", "--detach", "--name", spec.name]
|
||||
for mount in spec.mounts { args += mountArgs(mount) }
|
||||
args += [spec.image, "sleep", "infinity"]
|
||||
_ = try await run(args)
|
||||
try await seedClaudeHome(spec)
|
||||
}
|
||||
|
||||
/// Copy the read-only host `~/.claude` (mounted at the staging path) into the writable
|
||||
/// claude-home so `claude` can persist native session/transcript files without ever
|
||||
/// mutating the user's real credentials. Best-effort; missing source is tolerated.
|
||||
private func seedClaudeHome(_ spec: ContainerSpec) async throws {
|
||||
let script = "mkdir -p \(shellQuote(spec.claudeHomeWritable)); "
|
||||
+ "cp -a \(shellQuote(spec.claudeHomeStaging))/. \(shellQuote(spec.claudeHomeWritable))/ "
|
||||
+ "2>/dev/null || true"
|
||||
_ = try? await run(["exec", spec.name, "sh", "-c", script])
|
||||
}
|
||||
|
||||
/// Build the `ProcessSpec` that runs `argv` inside the container via `container exec`.
|
||||
/// The caller launches it through its own `ProcessHost` so stdin/stdout streaming
|
||||
/// (NDJSON) behaves exactly as a host spawn.
|
||||
public nonisolated func execProcessSpec(
|
||||
name: String, workdir: String, env: [String: String], argv: [String]
|
||||
) -> ProcessSpec {
|
||||
var args = ["exec", "--interactive", "--workdir", workdir]
|
||||
for key in env.keys.sorted() {
|
||||
args += ["--env", "\(key)=\(env[key]!)"]
|
||||
}
|
||||
args.append(name)
|
||||
args += argv
|
||||
// cwd here is the *host* working dir for the `container` client itself; the
|
||||
// container's cwd is controlled by --workdir. Use the host's temp dir, always valid.
|
||||
return ProcessSpec(
|
||||
executable: executable,
|
||||
args: args,
|
||||
cwd: NSTemporaryDirectory(),
|
||||
env: [:],
|
||||
stdinMode: .pipe)
|
||||
}
|
||||
|
||||
/// The container's view of the host (the default-route gateway, e.g. 192.168.64.1),
|
||||
/// discovered by reading the routing table inside the container. Cached per name.
|
||||
public func hostGateway(name: String) async throws -> String {
|
||||
if let cached = gatewayCache[name] { return cached }
|
||||
let out = try await run(
|
||||
["exec", name, "sh", "-c", "ip route | awk '/default/ {print $3; exit}'"])
|
||||
let ip = out.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !ip.isEmpty else { throw ContainerError.gatewayUnavailable }
|
||||
gatewayCache[name] = ip
|
||||
return ip
|
||||
}
|
||||
|
||||
public func stop(name: String) async {
|
||||
gatewayCache[name] = nil
|
||||
_ = try? await run(["stop", name])
|
||||
}
|
||||
|
||||
public func remove(name: String) async {
|
||||
gatewayCache[name] = nil
|
||||
_ = try? await run(["stop", name])
|
||||
_ = try? await run(["delete", name])
|
||||
}
|
||||
|
||||
/// All container names known to the runtime (running or stopped). Best-effort; an
|
||||
/// empty list on error so reconcile never throws.
|
||||
public func list() async -> [String] {
|
||||
guard let out = try? await run(["list", "--all"]) else { return [] }
|
||||
return parseContainerNames(out.stdout)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// `--volume host:container[:ro]`. Centralized so the exact mount syntax is one edit
|
||||
/// away and unit-testable.
|
||||
nonisolated func mountArgs(_ mount: ContainerSpec.Mount) -> [String] {
|
||||
let suffix = mount.readOnly ? ":ro" : ""
|
||||
return ["--volume", "\(mount.host):\(mount.container)\(suffix)"]
|
||||
}
|
||||
|
||||
/// Pull our container names out of `container list --all` tabular output. Defensive
|
||||
/// against column ordering (scans every token) but strict about shape — only
|
||||
/// `nucleic-<8 hex>` (the session-short naming) so the `nucleic-sandbox` *image* tag in
|
||||
/// the IMAGE column is never mistaken for a container.
|
||||
nonisolated func parseContainerNames(_ output: String) -> [String] {
|
||||
var names: Set<String> = []
|
||||
for line in output.split(separator: "\n") {
|
||||
for token in line.split(whereSeparator: { $0 == " " || $0 == "\t" }) {
|
||||
if Self.isContainerName(String(token)) { names.insert(String(token)) }
|
||||
}
|
||||
}
|
||||
return Array(names)
|
||||
}
|
||||
|
||||
/// True for a `nucleic-<8 lowercase-hex>` container name.
|
||||
nonisolated static func isContainerName(_ token: String) -> Bool {
|
||||
let prefix = "nucleic-"
|
||||
guard token.hasPrefix(prefix) else { return false }
|
||||
let suffix = token.dropFirst(prefix.count)
|
||||
return suffix.count == 8 && suffix.allSatisfy { $0.isHexDigit && !$0.isUppercase }
|
||||
}
|
||||
|
||||
private func shellQuote(_ path: String) -> String {
|
||||
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
private struct CommandResult { let status: Int32; let stdout: String; let stderr: String }
|
||||
|
||||
/// Run a `container` subcommand to completion, capturing stdout/stderr. Throws
|
||||
/// `ContainerError` on spawn failure or non-zero exit.
|
||||
@discardableResult
|
||||
private func run(_ args: [String]) async throws -> CommandResult {
|
||||
let spec = ProcessSpec(
|
||||
executable: executable, args: args, cwd: NSTemporaryDirectory(),
|
||||
env: [:], stdinMode: .closed)
|
||||
let handle: any ProcessHandle
|
||||
do {
|
||||
handle = try await processHost.launch(spec)
|
||||
} catch {
|
||||
throw ContainerError.notInstalled
|
||||
}
|
||||
async let outData = collect(handle.stdoutLines)
|
||||
async let errData = collect(handle.stderrLines)
|
||||
let status = await handle.wait()
|
||||
let stdout = await outData
|
||||
let stderr = await errData
|
||||
guard status == 0 else {
|
||||
throw ContainerError.commandFailed(
|
||||
command: args.joined(separator: " "), status: status, stderr: stderr)
|
||||
}
|
||||
return CommandResult(status: status, stdout: stdout, stderr: stderr)
|
||||
}
|
||||
|
||||
private func collect(_ stream: AsyncThrowingStream<Data, Error>) async -> String {
|
||||
var lines: [String] = []
|
||||
do {
|
||||
for try await line in stream { lines.append(String(decoding: line, as: UTF8.self)) }
|
||||
} catch {
|
||||
// Partial output is fine for our diagnostics.
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,9 @@ public final class GRDBMetadataStore: SessionMetadataStore {
|
||||
migrator.registerMigration("v6-todo-summary") { db in
|
||||
try db.execute(sql: "ALTER TABLE todo ADD COLUMN summary TEXT;")
|
||||
}
|
||||
migrator.registerMigration("v7-sandbox") { db in
|
||||
try db.execute(sql: "ALTER TABLE project ADD COLUMN sandbox_config TEXT;")
|
||||
}
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -235,6 +238,7 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
|
||||
var worktree_base: String?
|
||||
var setup_script: String?
|
||||
var setup_policy: String
|
||||
var sandbox_config: String?
|
||||
var created_at: Date
|
||||
|
||||
init(_ p: Project) {
|
||||
@@ -246,6 +250,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
|
||||
worktree_base = p.worktreeBase
|
||||
setup_script = p.setupScript
|
||||
setup_policy = p.setupPolicy.rawValue
|
||||
sandbox_config = p.sandbox.flatMap { sandbox in
|
||||
(try? JSONEncoder().encode(sandbox)).map { String(decoding: $0, as: UTF8.self) }
|
||||
}
|
||||
created_at = p.createdAt
|
||||
}
|
||||
|
||||
@@ -259,6 +266,9 @@ private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
|
||||
worktreeBase: worktree_base,
|
||||
setupScript: setup_script,
|
||||
setupPolicy: SetupPolicy(rawValue: setup_policy) ?? .block,
|
||||
sandbox: sandbox_config.flatMap { json in
|
||||
try? JSONDecoder().decode(ProjectSandbox.self, from: Data(json.utf8))
|
||||
},
|
||||
createdAt: created_at)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,39 @@ public enum SetupPolicy: String, Sendable, Codable {
|
||||
case warn
|
||||
}
|
||||
|
||||
/// Per-project execution-sandbox settings. When enabled, the project's sessions run
|
||||
/// `claude` inside an isolated Linux VM via Apple's `container` tool instead of on the
|
||||
/// host, with the worktree bind-mounted (CONTAINER_SANDBOX). `nil` on a Project means
|
||||
/// "off" — the default, host-spawned behavior.
|
||||
public struct ProjectSandbox: Sendable, Codable, Equatable {
|
||||
public var enabled: Bool
|
||||
/// Image reference to run. `nil` → the bundled default image (built on first use).
|
||||
public var image: String?
|
||||
/// Stop the per-session container after this many seconds of inactivity; the next
|
||||
/// turn transparently restarts it.
|
||||
public var idleTimeoutSeconds: Int
|
||||
|
||||
/// The bundled default image tag, used when `image` is nil.
|
||||
public static let defaultImage = "nucleic-sandbox:latest"
|
||||
public static let defaultIdleTimeoutSeconds = 900
|
||||
|
||||
public init(
|
||||
enabled: Bool = false,
|
||||
image: String? = nil,
|
||||
idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds
|
||||
) {
|
||||
self.enabled = enabled
|
||||
self.image = image
|
||||
self.idleTimeoutSeconds = idleTimeoutSeconds
|
||||
}
|
||||
|
||||
/// The image actually used to run containers (resolved override or bundled default).
|
||||
public var resolvedImage: String {
|
||||
if let image, !image.trimmingCharacters(in: .whitespaces).isEmpty { return image }
|
||||
return ProjectSandbox.defaultImage
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered git repository plus per-project configuration.
|
||||
public struct Project: Identifiable, Sendable, Codable, Equatable {
|
||||
public let id: ProjectID
|
||||
@@ -47,6 +80,8 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
|
||||
public var worktreeBase: String?
|
||||
public var setupScript: String?
|
||||
public var setupPolicy: SetupPolicy
|
||||
/// Execution-sandbox settings. `nil` → sessions run on the host (default).
|
||||
public var sandbox: ProjectSandbox?
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
@@ -58,6 +93,7 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
|
||||
worktreeBase: String? = nil,
|
||||
setupScript: String? = nil,
|
||||
setupPolicy: SetupPolicy = .block,
|
||||
sandbox: ProjectSandbox? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
@@ -68,6 +104,7 @@ public struct Project: Identifiable, Sendable, Codable, Equatable {
|
||||
self.worktreeBase = worktreeBase
|
||||
self.setupScript = setupScript
|
||||
self.setupPolicy = setupPolicy
|
||||
self.sandbox = sandbox
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,51 @@ public actor SessionController {
|
||||
|
||||
// MARK: - Lifecycle intents
|
||||
|
||||
/// The session's worktree path (live worktree, else the persisted path).
|
||||
private var worktreePath: String { session.worktreePath ?? worktree?.path ?? "" }
|
||||
|
||||
/// Build the sandbox `ContainerSpec` for this session when its project opts in;
|
||||
/// `nil` → run `claude` on the host (default). Mounts the repo root and the worktree
|
||||
/// base at identical paths (so git-worktree links + the cwd-hash resolve), the host
|
||||
/// `~/.claude` read-only into a staging path, and a per-session writable claude-home.
|
||||
private func containerSpec() -> ContainerSpec? {
|
||||
guard let project, let sandbox = project.sandbox, sandbox.enabled else { return nil }
|
||||
|
||||
let stagingPath = "/nucleic/host-claude"
|
||||
let writablePath = "/root/.claude"
|
||||
let writableHost = ((session.transcriptPath as NSString).deletingLastPathComponent
|
||||
as NSString).appendingPathComponent("claude-home")
|
||||
try? FileManager.default.createDirectory(
|
||||
atPath: writableHost, withIntermediateDirectories: true)
|
||||
|
||||
var mounts: [ContainerSpec.Mount] = [
|
||||
.init(host: project.rootPath, container: project.rootPath, readOnly: false),
|
||||
.init(
|
||||
host: project.resolvedWorktreeBase, container: project.resolvedWorktreeBase,
|
||||
readOnly: false),
|
||||
.init(host: writableHost, container: writablePath, readOnly: false),
|
||||
]
|
||||
let hostClaude = (NSHomeDirectory() as NSString).appendingPathComponent(".claude")
|
||||
if FileManager.default.fileExists(atPath: hostClaude) {
|
||||
mounts.append(.init(host: hostClaude, container: stagingPath, readOnly: true))
|
||||
}
|
||||
|
||||
var env: [String: String] = ["HOME": "/root"]
|
||||
if let key = ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"] {
|
||||
env["ANTHROPIC_API_KEY"] = key
|
||||
}
|
||||
|
||||
return ContainerSpec(
|
||||
name: ContainerManager.containerName(for: session.id),
|
||||
image: sandbox.resolvedImage,
|
||||
mounts: mounts,
|
||||
workdir: worktreePath,
|
||||
env: env,
|
||||
idleTimeout: TimeInterval(sandbox.idleTimeoutSeconds),
|
||||
claudeHomeStaging: stagingPath,
|
||||
claudeHomeWritable: writablePath)
|
||||
}
|
||||
|
||||
/// Begin a fresh run. Idempotent: a second call while running is a no-op.
|
||||
public func start(prompt: AgentInput) {
|
||||
guard runTask == nil else { return }
|
||||
@@ -107,12 +152,13 @@ public actor SessionController {
|
||||
session.updatedAt = now()
|
||||
let run = RunSpec(
|
||||
sessionID: session.id,
|
||||
worktree: session.worktreePath ?? worktree?.path ?? "",
|
||||
worktree: worktreePath,
|
||||
prompt: prompt,
|
||||
model: session.model,
|
||||
effort: session.effort,
|
||||
autoApprove: session.auto,
|
||||
approvalPolicy: .interactive)
|
||||
approvalPolicy: .interactive,
|
||||
container: containerSpec())
|
||||
consume(backend.start(run), injectingUserText: prompt.plainText)
|
||||
}
|
||||
|
||||
@@ -124,7 +170,8 @@ public actor SessionController {
|
||||
let spec = ResumeSpec(
|
||||
sessionID: session.id,
|
||||
backendSessionID: backendSessionID,
|
||||
worktree: session.worktreePath ?? worktree?.path ?? "")
|
||||
worktree: worktreePath,
|
||||
container: containerSpec())
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
}
|
||||
|
||||
@@ -162,19 +209,21 @@ public actor SessionController {
|
||||
guard runTask == nil else { return } // a turn is already running
|
||||
session.status = .running
|
||||
session.updatedAt = now()
|
||||
let worktreePath = session.worktreePath ?? worktree?.path ?? ""
|
||||
let container = containerSpec()
|
||||
if let backendSessionID = session.backendSessionID {
|
||||
let spec = ResumeSpec(
|
||||
sessionID: session.id, backendSessionID: backendSessionID,
|
||||
worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort, autoApprove: session.auto)
|
||||
model: session.model, effort: session.effort, autoApprove: session.auto,
|
||||
container: container)
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
} else {
|
||||
// No turn has run yet → this message starts the session.
|
||||
let run = RunSpec(
|
||||
sessionID: session.id, worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort,
|
||||
autoApprove: session.auto, approvalPolicy: .interactive)
|
||||
autoApprove: session.auto, approvalPolicy: .interactive,
|
||||
container: container)
|
||||
consume(backend.start(run), injectingUserText: nil)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import NucleicCore
|
||||
|
||||
@Suite("Container sandbox")
|
||||
struct ContainerSandboxTests {
|
||||
|
||||
// MARK: - exec argument construction
|
||||
|
||||
@Test func execProcessSpecOrdersFlagsAndArgv() {
|
||||
let runtime = ContainerRuntime(executable: "container")
|
||||
let spec = runtime.execProcessSpec(
|
||||
name: "nucleic-abc12345",
|
||||
workdir: "/Users/me/repos/demo-wt",
|
||||
env: ["ANTHROPIC_API_KEY": "sk-test", "HOME": "/root"],
|
||||
argv: ["claude", "-p", "--verbose"])
|
||||
|
||||
#expect(spec.executable == "container")
|
||||
// exec, --interactive, --workdir <wd>, then env (keys sorted), name, argv.
|
||||
#expect(spec.args == [
|
||||
"exec", "--interactive", "--workdir", "/Users/me/repos/demo-wt",
|
||||
"--env", "ANTHROPIC_API_KEY=sk-test",
|
||||
"--env", "HOME=/root",
|
||||
"nucleic-abc12345",
|
||||
"claude", "-p", "--verbose",
|
||||
])
|
||||
#expect(spec.stdinMode == .pipe)
|
||||
}
|
||||
|
||||
// MARK: - mount formatting
|
||||
|
||||
@Test func mountArgsFormatReadWriteAndReadOnly() {
|
||||
let runtime = ContainerRuntime()
|
||||
#expect(runtime.mountArgs(.init(host: "/a", container: "/a", readOnly: false))
|
||||
== ["--volume", "/a:/a"])
|
||||
#expect(runtime.mountArgs(.init(host: "/home/.claude", container: "/staging", readOnly: true))
|
||||
== ["--volume", "/home/.claude:/staging:ro"])
|
||||
}
|
||||
|
||||
// MARK: - container-list parsing (orphan reconcile)
|
||||
|
||||
@Test func parseContainerNamesPicksNucleicEntries() {
|
||||
let runtime = ContainerRuntime()
|
||||
let output = """
|
||||
ID IMAGE STATE NAME
|
||||
abc nucleic-sandbox running nucleic-abc12345
|
||||
def someother:latest running unrelated-box
|
||||
ghi nucleic-sandbox stopped nucleic-def67890
|
||||
"""
|
||||
let names = Set(runtime.parseContainerNames(output))
|
||||
#expect(names == ["nucleic-abc12345", "nucleic-def67890"])
|
||||
}
|
||||
|
||||
// MARK: - stable naming
|
||||
|
||||
@Test func containerNameDerivesFromSessionShortID() {
|
||||
let id = SessionID(rawValue: "abcdef12-3456-7890-aaaa-bbbbbbbbbbbb")
|
||||
#expect(ContainerManager.containerName(for: id) == "nucleic-abcdef12")
|
||||
}
|
||||
|
||||
/// `UUID().uuidString` is uppercase; the container name must be lowercased so it both
|
||||
/// validates as a container name and matches the reconcile parser.
|
||||
@Test func containerNameLowercasesUppercaseUUID() {
|
||||
let id = SessionID(rawValue: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")
|
||||
let name = ContainerManager.containerName(for: id)
|
||||
#expect(name == "nucleic-e621e1f8")
|
||||
#expect(ContainerRuntime.isContainerName(name))
|
||||
}
|
||||
|
||||
// MARK: - sandbox config persistence
|
||||
|
||||
@Test func projectSandboxRoundTripsThroughStore() async throws {
|
||||
let store = try GRDBMetadataStore(path: nil)
|
||||
let project = Project(
|
||||
id: .generate(), name: "demo", rootPath: "/repos/demo", defaultBranch: "main",
|
||||
sandbox: ProjectSandbox(enabled: true, image: "custom:1", idleTimeoutSeconds: 600),
|
||||
createdAt: Date(timeIntervalSince1970: 1_700_000_000))
|
||||
try await store.saveProject(project)
|
||||
let loaded = try await store.loadProjects()
|
||||
#expect(loaded == [project])
|
||||
#expect(loaded.first?.sandbox?.enabled == true)
|
||||
#expect(loaded.first?.sandbox?.image == "custom:1")
|
||||
#expect(loaded.first?.sandbox?.idleTimeoutSeconds == 600)
|
||||
}
|
||||
|
||||
@Test func projectWithoutSandboxLoadsAsNil() async throws {
|
||||
let store = try GRDBMetadataStore(path: nil)
|
||||
let project = Project(name: "plain", rootPath: "/repos/plain", defaultBranch: "main")
|
||||
try await store.saveProject(project)
|
||||
let loaded = try await store.loadProjects()
|
||||
#expect(loaded.first?.sandbox == nil)
|
||||
}
|
||||
|
||||
@Test func resolvedImageFallsBackToBundledDefault() {
|
||||
#expect(ProjectSandbox(enabled: true, image: nil).resolvedImage == ProjectSandbox.defaultImage)
|
||||
#expect(ProjectSandbox(enabled: true, image: " ").resolvedImage == ProjectSandbox.defaultImage)
|
||||
#expect(ProjectSandbox(enabled: true, image: "x:2").resolvedImage == "x:2")
|
||||
}
|
||||
|
||||
// MARK: - approval MCP URL host rewrite
|
||||
|
||||
@Test func mcpConfigRewritesHostForContainerGateway() {
|
||||
let server = MCPApprovalServer()
|
||||
let host = server.mcpConfigJSON(port: 5050, token: "tok")
|
||||
#expect(host.contains("http://127.0.0.1:5050/mcp"))
|
||||
|
||||
let gateway = server.mcpConfigJSON(host: "192.168.64.1", port: 5050, token: "tok")
|
||||
#expect(gateway.contains("http://192.168.64.1:5050/mcp"))
|
||||
#expect(gateway.contains("Bearer tok"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user