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]>
90 lines
3.8 KiB
Swift
90 lines
3.8 KiB
Swift
import SwiftUI
|
|
import NucleicCore
|
|
|
|
/// The macOS cockpit (PLAN "macOS UI"). A thin SwiftUI shell over `AppStore` — it
|
|
/// holds no canonical state, just observes the store and sends intents (RUNTIME §3).
|
|
///
|
|
/// Runs as a SwiftPM executable; launch with
|
|
/// `swift run --build-system native NucleicApp`. The store persists under
|
|
/// `~/Library/Application Support/Nucleic`.
|
|
@main
|
|
struct NucleicApp: App {
|
|
@State private var store: AppStore
|
|
|
|
init() {
|
|
// Show a Dock icon + foreground window when launched from the terminal.
|
|
NSApplication.shared.setActivationPolicy(.regular)
|
|
|
|
let support = Self.supportDirectory()
|
|
do {
|
|
let database = try GRDBMetadataStore(
|
|
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,
|
|
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),
|
|
containerManager: containerManager)
|
|
}
|
|
store.intelligence = AppleIntelligenceProvider()
|
|
store.defaultModel = ModelCatalog.storedDefaultModel
|
|
store.defaultEffort = ModelCatalog.storedDefaultEffort
|
|
store.defaultAuto = ModelCatalog.storedDefaultAuto
|
|
_store = State(initialValue: store)
|
|
} catch {
|
|
fatalError("Could not open the Nucleic store at \(support.path): \(error)")
|
|
}
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
RootView()
|
|
.environment(store)
|
|
.frame(minWidth: 900, minHeight: 560)
|
|
.task {
|
|
await store.loadProjects()
|
|
await store.loadSessions()
|
|
await store.loadTodos()
|
|
}
|
|
}
|
|
.windowToolbarStyle(.unified)
|
|
.commands {
|
|
CommandGroup(replacing: .newItem) {
|
|
// Cmd-N goes to the home dashboard — the surface for starting a new
|
|
// chat (its chat bar picks the project, model, and effort).
|
|
Button("New Chat") {
|
|
store.goHome()
|
|
}
|
|
.keyboardShortcut("n", modifiers: .command)
|
|
// Cmd-T captures a quick idea into the home to-do inbox from anywhere.
|
|
Button("New To-Do") {
|
|
store.presentQuickTodo()
|
|
}
|
|
.keyboardShortcut("t", modifiers: .command)
|
|
}
|
|
}
|
|
Settings {
|
|
SettingsView().environment(store)
|
|
}
|
|
}
|
|
|
|
private static func supportDirectory() -> URL {
|
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
let dir = base.appendingPathComponent("Nucleic", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}
|
|
}
|