From 140f63a55d59374c894ed884c01cf54c6038809d Mon Sep 17 00:00:00 2001 From: Simion Agavriloaei Date: Fri, 26 Jun 2026 11:11:46 +0300 Subject: [PATCH 1/2] feat(sandbox): Docker sandbox mode (experimental, opt-in) Add a second, stronger sandboxing mode that runs the agent CLI inside a Docker container instead of macOS Seatbelt. The container is the isolation boundary: the agent can only touch the paths we bind-mount (worktree, its parent .git, composition members, and a persistent per-agent config dir). Backend (src-tauri): - docker.rs: build_spec / render_argv / render_preview (single source of truth for preview == spawn), check, content-addressed image_tag, build_command (streamed) / build_image, image_status with image-exists gating + stale detection (keep last-built), read/write_dockerfile, cleanup_workspace / cleanup_all. - Per-agent config-dir mapping: claude (CLAUDE_CONFIG_DIR), codex (CODEX_HOME), gemini / copilot / agy via direct dir mounts; grok deferred. Host dir is data_dir()/docker-agents/{agent}, shared across all Docker workspaces of that agent (login + sessions + MCP persistence). - Bundled default Dockerfile (all agents) as assets/Dockerfile.default. - Data model: docker_sandbox_enabled + docker_extra_args on Workspace, docker_sandbox_enabled master switch on Settings (serde-default migration). - Commands: docker_check, docker_image_status, docker_get/default/set_ dockerfile, docker_build_image (background thread, never blocks the UI), docker_preview_command, workspace_set_docker. - pty_spawn: Docker branch rewrites to `docker run` (refuses if no image built, never lazily builds), skips PID registration + login-env, forces Seatbelt off. Cleanup wired into archive, spawn pre-removal, and app quit. Frontend (src): - Settings: Docker sandbox section (master toggle, docker availability, CodeMirror Dockerfile editor, Build + Update-agents, image status with "rebuild to apply" stale warning, streamed build log). - Workspace sandbox dialog: Seatbelt | Docker cage selector (gated on image-exists), how-it-works explainer, annotated mount rows, extra-args, live command preview. Container glyph on the workspace row. Co-Authored-By: Claude Opus 4.8 (1M context) --- src-tauri/assets/Dockerfile.default | 59 ++ src-tauri/src/docker.rs | 591 ++++++++++++++++++ src-tauri/src/lib.rs | 248 +++++++- src-tauri/src/sandbox.rs | 4 +- .../dialogs/WorkspaceDockerPanel.tsx | 147 +++++ .../dialogs/WorkspaceSandboxDialog.tsx | 117 +++- src/components/settings/DockerSection.tsx | 307 +++++++++ src/components/settings/Settings.tsx | 6 +- src/components/sidebar/Sidebar.tsx | 16 +- src/lib/ipc.ts | 39 ++ src/lib/types.ts | 12 + src/store/app.ts | 2 +- 12 files changed, 1531 insertions(+), 17 deletions(-) create mode 100644 src-tauri/assets/Dockerfile.default create mode 100644 src-tauri/src/docker.rs create mode 100644 src/components/dialogs/WorkspaceDockerPanel.tsx create mode 100644 src/components/settings/DockerSection.tsx diff --git a/src-tauri/assets/Dockerfile.default b/src-tauri/assets/Dockerfile.default new file mode 100644 index 0000000..91ad738 --- /dev/null +++ b/src-tauri/assets/Dockerfile.default @@ -0,0 +1,59 @@ +# termic Docker sandbox — generic image for ALL agents. +# One image, every supported agent installed. The per-workspace agent is +# just the command run inside it. +# +# Edit the marked regions to customize. Rebuild from termic afterwards. +# +# Agents are intentionally UNPINNED (always latest). To upgrade them (and +# the LTS Node base), rebuild WITHOUT cache: docker build --no-cache --pull +# termic exposes this as a one-click "Update agents" / rebuild-no-cache. + +# ── Base: current Node LTS (always >=22, satisfies Copilot) + Debian ──── +# node:lts auto-tracks the latest LTS; --pull on rebuild fetches it fresh. +FROM node:lts-bookworm + +# ── System toolchain (git is essential; the rest are common agent deps) ─ +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + curl \ + ca-certificates \ + ripgrep \ + less \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# Native-binary installers (grok, agy) drop into ~/.local/bin. +ENV PATH="/root/.local/bin:${PATH}" + +# The workspace is bind-mounted from the host, so its files are owned by a +# different uid than the container user. Without this, git refuses every +# operation with "detected dubious ownership". Safe here: the only repos in +# this throwaway cage are the user's own mounted worktrees. +RUN git config --global --add safe.directory '*' + +# ── Agents (one per line, each preceded by its source / docs page) ────── +# Can't comment inside a `\`-continued RUN, so one install per line. +# https://www.npmjs.com/package/@anthropic-ai/claude-code +RUN npm install -g @anthropic-ai/claude-code +# https://www.npmjs.com/package/@openai/codex +RUN npm install -g @openai/codex +# https://www.npmjs.com/package/@google/gemini-cli +RUN npm install -g @google/gemini-cli +# https://www.npmjs.com/package/@github/copilot +RUN npm install -g @github/copilot +# grok (xAI Grok Build, native binary -> ~/.grok/bin) https://docs.x.ai/build/cli +RUN curl -fsSL https://x.ai/cli/install.sh | bash +# agy (Google Antigravity, native binary -> ~/.local/bin) https://antigravity.google/docs/cli-install +RUN curl -fsSL https://antigravity.google/cli/install.sh | bash + +# ── Add your MCP servers here (installed into the image) ──────────────── +# RUN npm install -g @some/mcp-server + +# ── Add CLI tools / system packages here ──────────────────────────────── +# RUN apt-get update && apt-get install -y && rm -rf /var/lib/apt/lists/* + +# ── Add baked-in skills here ──────────────────────────────────────────── +# COPY my-skills/ /root/.claude/skills/ + +WORKDIR /workspace +CMD ["bash"] diff --git a/src-tauri/src/docker.rs b/src-tauri/src/docker.rs new file mode 100644 index 0000000..8b1f193 --- /dev/null +++ b/src-tauri/src/docker.rs @@ -0,0 +1,591 @@ +// Docker sandbox mode (opt-in, experimental). Parallel to `sandbox.rs`, +// but the isolation boundary is a Docker container instead of macOS +// Seatbelt: the agent CLI runs inside `docker run` and can only touch the +// paths we bind-mount (the worktree, its parent `.git`, composition +// members, and a persistent per-agent config dir). Default-deny by +// construction. +// +// This module is PURE command construction + image/container lifecycle. +// No long-running daemon (consistent with the "no backend daemon" rule — +// we only shell out to the user's `docker`). `render_argv` is the single +// source of truth: the argv previewed in the UI and the argv actually +// spawned come from the same function, so they can never drift. +// +// Design: docs/plans/docker-sandbox/design.md + +use crate::sandbox::{canonicalize_or_keep, parent_git_dir_for_worktree}; +use crate::{data_dir, Workspace}; +use serde::{Deserialize, Serialize}; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; +use std::process::Command; + +/// Tag prefix for every image we build. Cleanup and listing filter on this. +const IMAGE_REPO: &str = "termic-sandbox"; +/// Label key stamped on every container we run, so cleanup can find them +/// robustly even if the `--name` was munged. +const LABEL_KEY: &str = "termic.workspace"; + +// ───────────────────────────── Mounts ────────────────────────────────── + +/// Why a mount exists — surfaced per-row in the dialog so the user can +/// always answer "what can this container see, and why?". `Implicit` +/// mounts are added by termic; `User` mounts come from extra-args / the +/// editable mount list. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum MountProvenance { + Implicit, + User, +} + +/// A single bind mount: host path -> container path, with rw/ro and the +/// human explanation shown in the mount list + command-preview comment. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Mount { + pub host: String, + pub container: String, + pub read_only: bool, + pub provenance: MountProvenance, + /// Plain-language reason shown in the dialog row and as the trailing + /// `# comment` in the command preview. + pub why: String, + /// Load-bearing implicit mounts (worktree, parent `.git`) are shown + /// but warn-on-remove rather than silently removable. + pub load_bearing: bool, +} + +impl Mount { + fn implicit(host: String, container: String, read_only: bool, why: &str, load_bearing: bool) -> Self { + Mount { + host, + container, + read_only, + provenance: MountProvenance::Implicit, + why: why.to_string(), + load_bearing, + } + } +} + +// ───────────────────────────── Spec ──────────────────────────────────── + +/// Everything needed to render one `docker run` invocation for a workspace +/// agent spawn. Produced by `build_spec`; rendered to argv by `render_argv`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DockerSpec { + /// `termic-{workspaceId}` (stable, human-facing). + pub container_name: String, + /// `termic.workspace={workspaceId}` — what cleanup filters on. + pub label: String, + /// `termic-sandbox:{dockerfileHash}`. + pub image: String, + /// host -> container bind mounts (rw/ro), with provenance + why. + pub mounts: Vec, + /// Working dir inside the container — MUST equal the host cwd (same + /// absolute path) so the worktree `.git` pointer + session cwd-key line up. + pub workdir: String, + /// Env injected via `-e` (TERM and config-dir relocation only — NEVER + /// secrets; credentials arrive via the config-dir mount). + pub env: Vec<(String, String)>, + /// User-appended `docker run` args, inserted at a defined point. + pub extra_args: Vec, +} + +// ─────────────────────── Per-agent config dir ────────────────────────── + +/// How a given agent's persistent config dir is wired into the container. +/// `env_relocation` uses the agent's own relocation env var (cleanest — +/// folds even HOME-root dotfiles into the one mounted dir); the others are +/// direct dir mounts. NEVER mount the whole container HOME — it shadows +/// agent binaries baked into HOME at build time (grok in ~/.grok/bin, agy +/// in ~/.local/bin). See findings.md. +struct AgentConfig { + /// Container path the config dir is mounted at. + container_dir: &'static str, + /// `Some((VAR, value))` when the agent supports a config-dir relocation + /// env var (claude `CLAUDE_CONFIG_DIR`, codex `CODEX_HOME`). + relocation_env: Option<(&'static str, &'static str)>, + /// Extra container dirs to also mount from the same host config dir + /// (e.g. agy needs `.antigravity` alongside `.gemini`). + extra_dirs: &'static [&'static str], +} + +/// Map an agent id to its config-dir wiring. Returns `None` for agents we +/// don't yet support in Docker mode (grok is the Phase-1 outlier: binary + +/// skills + config all live under ~/.grok, no clean relocation env). +fn agent_config(agent_id: &str) -> Option { + Some(match agent_id { + "claude" => AgentConfig { + container_dir: "/root/.claude", + relocation_env: Some(("CLAUDE_CONFIG_DIR", "/root/.claude")), + extra_dirs: &[], + }, + "codex" => AgentConfig { + container_dir: "/root/.codex", + relocation_env: Some(("CODEX_HOME", "/root/.codex")), + extra_dirs: &[], + }, + "gemini" => AgentConfig { + container_dir: "/root/.gemini", + relocation_env: None, + extra_dirs: &[], + }, + "copilot" => AgentConfig { + container_dir: "/root/.copilot", + relocation_env: None, + extra_dirs: &[], + }, + // agy shares the `.gemini` config shape + its own `.antigravity`. + // Its binary lives in ~/.local/bin — do NOT mount ~/.local. + "agy" | "antigravity" => AgentConfig { + container_dir: "/root/.gemini", + relocation_env: None, + extra_dirs: &["/root/.antigravity"], + }, + // grok deferred from Phase 1 (see design.md "outlier"). + _ => return None, + }) +} + +/// Host directory that persists an agent's login + sessions + MCP config +/// ACROSS every Docker workspace of that agent. The sameness of this path +/// IS the cross-workspace sharing. termic-owned, never the host's real +/// `~/.claude` (full isolation from the OS agent). +pub fn agent_config_host_dir(agent_id: &str) -> PathBuf { + // `data_dir()` already respects the dev/prod (`termic_dev`/`termic`) + // split, so dev and release don't share login state. + let base = data_dir() + .map(|d| d.join("docker-agents")) + .unwrap_or_else(|_| PathBuf::from("/tmp/termic-docker-agents")); + base.join(agent_id) +} + +// ──────────────────────────── build_spec ─────────────────────────────── + +/// Build the full `DockerSpec` for a workspace agent spawn. `cmd`/`args` +/// are the agent argv (unchanged from the Seatbelt path); `cwd` is the +/// host working dir (mounted + `-w` at the identical absolute path). +pub fn build_spec( + ws: &Workspace, + agent_id: &str, + image: &str, + cwd: &str, + extra_args: Vec, +) -> DockerSpec { + let mut mounts: Vec = Vec::new(); + + // 1. The worktree itself, at the SAME absolute path inside the + // container (required for the worktree `.git` pointer + session + // cwd-key to resolve). + let ws_path = canonicalize_or_keep(&ws.path); + mounts.push(Mount::implicit( + ws_path.clone(), + ws_path.clone(), + false, + "your code (the workspace)", + true, + )); + + // 2. Parent `.git` for a worktree (pointer file holds an absolute + // path into /.git/worktrees/). Same-path mount or git + // breaks. Reuses the exact Seatbelt logic. + if let Some(parent_git) = parent_git_dir_for_worktree(&ws.path) { + mounts.push(Mount::implicit( + parent_git.clone(), + parent_git, + false, + "git metadata, required for worktrees to work", + true, + )); + } + + // 3. Composition members (linked repos in a multi-repo workspace), + // each at its identical absolute path. + for m in &ws.composition { + if m.path.is_empty() { + continue; + } + let p = canonicalize_or_keep(&m.path); + if p == ws_path { + continue; // host member == workspace wrapper, already mounted + } + mounts.push(Mount::implicit( + p.clone(), + p, + false, + "linked repo in this workspace", + true, + )); + if let Some(parent_git) = parent_git_dir_for_worktree(&m.path) { + mounts.push(Mount::implicit( + parent_git.clone(), + parent_git, + false, + "git metadata for a linked repo", + true, + )); + } + } + + // 4. The persistent per-agent config dir (login + sessions + MCP + + // customizations), shared across all Docker workspaces of this + // agent. rw. Plus relocation env if the agent supports it. + let mut env: Vec<(String, String)> = vec![ + ("TERM".to_string(), "xterm-256color".to_string()), + ("COLORTERM".to_string(), "truecolor".to_string()), + ]; + if let Some(cfg) = agent_config(agent_id) { + let host_cfg = agent_config_host_dir(agent_id).to_string_lossy().into_owned(); + mounts.push(Mount::implicit( + host_cfg.clone(), + cfg.container_dir.to_string(), + false, + "your Docker agent: login, MCP servers, settings, history (shared across all your Docker workspaces)", + false, + )); + for extra in cfg.extra_dirs { + // Extra dirs share the same host config dir subtree by name. + let sub = PathBuf::from(&host_cfg) + .join(extra.trim_start_matches("/root/.")) + .to_string_lossy() + .into_owned(); + mounts.push(Mount::implicit( + sub, + extra.to_string(), + false, + "additional config dir for this agent", + false, + )); + } + if let Some((var, val)) = cfg.relocation_env { + env.push((var.to_string(), val.to_string())); + } + } + + DockerSpec { + container_name: format!("termic-{}", ws.id), + label: format!("{LABEL_KEY}={}", ws.id), + image: image.to_string(), + mounts, + workdir: canonicalize_or_keep(cwd), + env, + extra_args, + } +} + +// ──────────────────────────── render_argv ────────────────────────────── + +/// Render the spec to the exact argv we spawn. THE single source of truth: +/// the UI preview is just this output pretty-printed (see `render_preview`). +/// Spawned argv == previewed argv, always. +pub fn render_argv(spec: &DockerSpec, cmd: &str, args: &[String]) -> Vec { + let mut argv: Vec = vec![ + "run".into(), + "--rm".into(), + "-i".into(), + "-t".into(), + "--name".into(), + spec.container_name.clone(), + "--label".into(), + spec.label.clone(), + ]; + for m in &spec.mounts { + argv.push("-v".into()); + let suffix = if m.read_only { ":ro" } else { "" }; + argv.push(format!("{}:{}{}", m.host, m.container, suffix)); + } + argv.push("-w".into()); + argv.push(spec.workdir.clone()); + for (k, v) in &spec.env { + argv.push("-e".into()); + argv.push(format!("{k}={v}")); + } + argv.extend(spec.extra_args.iter().cloned()); + argv.push(spec.image.clone()); + argv.push(cmd.to_string()); + argv.extend(args.iter().cloned()); + argv +} + +/// Pretty-print the argv for the dialog's command-preview pane: multi-line, +/// one flag/mount per line with `\` continuations, copy-paste-runnable, and +/// a trailing `# why` comment on each mount line. Display sugar only — the +/// spawned argv comes from `render_argv`, unchanged. +pub fn render_preview(spec: &DockerSpec, cmd: &str, args: &[String]) -> String { + let mut lines: Vec = vec!["docker run --rm -it \\".into()]; + lines.push(format!(" --name {} \\", spec.container_name)); + lines.push(format!(" --label {} \\", spec.label)); + for m in &spec.mounts { + let suffix = if m.read_only { ":ro" } else { "" }; + lines.push(format!( + " -v {}:{}{} \\{}", + m.host, + m.container, + suffix, + format!(" # {}", m.why), + )); + } + lines.push(format!(" -w {} \\", spec.workdir)); + for (k, v) in &spec.env { + lines.push(format!(" -e {k}={v} \\")); + } + for ea in &spec.extra_args { + lines.push(format!(" {ea} \\")); + } + lines.push(format!(" {} \\", spec.image)); + let agent_line = if args.is_empty() { + cmd.to_string() + } else { + format!("{cmd} {}", args.join(" ")) + }; + lines.push(format!(" {agent_line}")); + lines.join("\n") +} + +// ─────────────────────── Dockerfile storage ──────────────────────────── + +/// Directory holding the editable Dockerfile + build metadata. +fn docker_dir() -> PathBuf { + data_dir() + .map(|d| d.join("docker")) + .unwrap_or_else(|_| PathBuf::from("/tmp/termic-docker")) +} + +/// Path to the user-editable Dockerfile (one generic file, all agents). +pub fn dockerfile_path() -> PathBuf { + docker_dir().join("Dockerfile") +} + +/// The shipped default Dockerfile (validated: builds + runs all agents). +/// Ship this as reset-to-default; the commented regions are the user's +/// customization surface. +pub const DEFAULT_DOCKERFILE: &str = include_str!("../assets/Dockerfile.default"); + +/// Read the current Dockerfile, falling back to (and persisting) the +/// shipped default on first run / missing file. +pub fn read_dockerfile() -> String { + let path = dockerfile_path(); + match std::fs::read_to_string(&path) { + Ok(s) if !s.trim().is_empty() => s, + _ => { + let _ = write_dockerfile(DEFAULT_DOCKERFILE); + DEFAULT_DOCKERFILE.to_string() + } + } +} + +/// Persist an edited Dockerfile. +pub fn write_dockerfile(contents: &str) -> Result<(), String> { + let dir = docker_dir(); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + std::fs::write(dir.join("Dockerfile"), contents).map_err(|e| e.to_string()) +} + +// ──────────────────────────── Image build ────────────────────────────── + +/// Build the image from the given Dockerfile. Blocking + IO-heavy — the +/// command layer MUST run this on a background thread (never on the +/// synchronous Tauri command path, which would freeze the webview). The +/// spawn path NEVER calls this; build is an explicit Settings action. +/// +/// `no_cache` => `--no-cache --pull` ("Update agents": refresh the LTS base +/// and re-fetch the unpinned agents). Returns the built tag on success, or +/// the combined build log on failure. +pub fn build_image(dockerfile: &str, no_cache: bool) -> Result { + let (mut cmd, tag) = build_command(dockerfile, no_cache)?; + let output = cmd.output().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "docker binary not found".to_string() + } else { + e.to_string() + } + })?; + if output.status.success() { + Ok(tag) + } else { + let mut log = String::from_utf8_lossy(&output.stdout).into_owned(); + log.push_str(&String::from_utf8_lossy(&output.stderr)); + Err(log) + } +} + +/// Construct the `docker build` Command + the tag it will produce, writing +/// the Dockerfile to disk first. The caller drives execution (the command +/// layer streams its output line-by-line off a background thread; never on +/// the synchronous Tauri path). `no_cache` => `--no-cache --pull`. +pub fn build_command(dockerfile: &str, no_cache: bool) -> Result<(Command, String), String> { + let tag = image_tag(dockerfile); + let dir = docker_dir(); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let df_path = dir.join("Dockerfile"); + std::fs::write(&df_path, dockerfile).map_err(|e| e.to_string())?; + + let mut cmd = Command::new("docker"); + // --progress=plain so the streamed log is line-based (not a TTY redraw). + cmd.args(["build", "--progress=plain", "-t", &tag, "-f"]); + cmd.arg(&df_path); + if no_cache { + cmd.args(["--no-cache", "--pull"]); + } + // Build context is the docker dir (lets users `COPY` baked skills etc. + // from a path they control next to the Dockerfile). + cmd.arg(&dir); + Ok((cmd, tag)) +} + +// ─────────────────────── Image tag + availability ────────────────────── + +/// Content-addressed image tag: `termic-sandbox:{hash}`. Editing the +/// Dockerfile changes the hash, so a stale build no longer matches — +/// surfaced as a "rebuild to apply" warning in Settings. DefaultHasher is +/// fixed-seed (stable across runs); a non-crypto hash is sufficient for +/// cache-keying (we only need "did the Dockerfile change?"). +pub fn image_tag(dockerfile: &str) -> String { + let mut h = DefaultHasher::new(); + dockerfile.hash(&mut h); + format!("{IMAGE_REPO}:{:016x}", h.finish()) +} + +/// Result of `docker_check`: is the binary present, is the daemon up? +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct DockerStatus { + /// `docker` binary resolvable on PATH. + pub binary: bool, + /// `docker info` succeeds (daemon reachable). + pub daemon: bool, + /// `docker --version` string, when available. + pub version: Option, +} + +/// Probe for the `docker` binary + a running daemon. Cheap; no build. +pub fn check() -> DockerStatus { + let version = Command::new("docker") + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + let binary = version.is_some(); + let daemon = binary + && Command::new("docker") + .arg("info") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + DockerStatus { binary, daemon, version } +} + +/// Does an image with this tag already exist locally? (Drives dropdown +/// availability + the "not built / rebuild" Settings state.) +pub fn image_exists(tag: &str) -> bool { + Command::new("docker") + .args(["image", "inspect", tag]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// File recording the tag of the last successful build. Lets us keep the +/// last-built image available in the dropdown even after the Dockerfile is +/// edited (the edit only takes effect on the next build). +fn last_built_file() -> PathBuf { + docker_dir().join("last_built_tag") +} + +/// Record a successfully built tag. +pub fn record_built_tag(tag: &str) { + let _ = std::fs::create_dir_all(docker_dir()); + let _ = std::fs::write(last_built_file(), tag); +} + +/// The tag of the last successful build, if any. +pub fn last_built_tag() -> Option { + std::fs::read_to_string(last_built_file()) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Image state for the Settings Docker section + dropdown gating. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct DockerImageStatus { + /// Content tag of the CURRENT (possibly-edited) Dockerfile. + pub current_tag: String, + /// Is the current Dockerfile's image built? + pub current_built: bool, + /// Tag of the last successful build (may differ from current_tag). + pub last_built_tag: Option, + /// Does the last-built image still exist locally? + pub last_built_exists: bool, + /// Dockerfile edited since the last successful build (built image is + /// stale). Drives the "rebuild to apply" warning in Settings. + pub stale: bool, + /// Is the current Dockerfile byte-identical to the shipped default? + pub is_default: bool, + /// Whether Docker mode should be offered in the workspace dropdown at + /// all (a usable built image exists). + pub available: bool, +} + +/// Compute the current image status from the on-disk Dockerfile + docker. +pub fn image_status() -> DockerImageStatus { + let dockerfile = read_dockerfile(); + let current_tag = image_tag(&dockerfile); + let current_built = image_exists(¤t_tag); + let last = last_built_tag(); + let last_built_exists = last.as_deref().map(image_exists).unwrap_or(false); + let stale = match &last { + Some(t) => last_built_exists && *t != current_tag, + None => false, + }; + DockerImageStatus { + current_tag, + current_built, + is_default: dockerfile == DEFAULT_DOCKERFILE, + // Dropdown availability: any usable built image (current OR the + // last-built one we keep around after an edit). + available: current_built || last_built_exists, + last_built_tag: last, + last_built_exists, + stale, + } +} + +/// The tag a spawn should actually run: prefer the current Dockerfile's +/// image; fall back to the last-built image (kept available after an edit). +/// `None` => nothing usable is built; the spawn must refuse. +pub fn spawn_image_tag() -> Option { + let dockerfile = read_dockerfile(); + let current = image_tag(&dockerfile); + if image_exists(¤t) { + return Some(current); + } + last_built_tag().filter(|t| image_exists(t)) +} + +// ──────────────────────────── Cleanup ────────────────────────────────── + +/// `docker rm -f` every container labeled for this workspace. Non-fatal. +pub fn cleanup_workspace(ws_id: &str) { + rm_by_filter(&format!("label={LABEL_KEY}={ws_id}")); +} + +/// `docker rm -f` every termic-labeled container (app quit). Non-fatal. +pub fn cleanup_all() { + rm_by_filter(&format!("label={LABEL_KEY}")); +} + +fn rm_by_filter(filter: &str) { + let ids = Command::new("docker") + .args(["ps", "-aq", "--filter", filter]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default(); + for id in ids.lines().filter(|l| !l.trim().is_empty()) { + let _ = Command::new("docker").args(["rm", "-f", id]).output(); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0eec13..293b8fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ use tauri::{AppHandle, Emitter, State}; use uuid::Uuid; mod sandbox; +mod docker; mod proxy; mod repo_config; mod shell_env; @@ -326,6 +327,20 @@ pub struct Workspace { /// semantics. Shell tabs are excluded (no session to resume). #[serde(default)] pub right_split_tabs: Vec, + /// Docker sandbox mode for this workspace. Mutually exclusive with the + /// Seatbelt sandbox: when true, the agent PTY runs inside `docker run` + /// (the container is the isolation boundary) instead of `sandbox-exec`, + /// and the Seatbelt path is forced off. Only meaningful when the global + /// `Settings::docker_sandbox_enabled` master switch is on AND the image + /// is built. See docs/plans/docker-sandbox/design.md. + #[serde(default)] + pub docker_sandbox_enabled: bool, + /// User-appended `docker run` args for this workspace (e.g. `--memory + /// 4g`, `-e FOO=bar`, an extra `-v`). Inserted at a fixed point in the + /// rendered argv. Phase 1 offers extra-args only (no full override); the + /// computed mounts/workdir/config-dir stay termic-owned. + #[serde(default)] + pub docker_extra_args: Vec, } /// One durable agent tab. `session_id` is termic's own per-tab session @@ -898,12 +913,44 @@ fn pty_spawn( }) .map_err(|e| e.to_string())?; + // ── Docker sandbox branch ────────────────────────────────────── + // If the workspace is in Docker mode (and the global master switch is + // on), the container is the isolation boundary: rewrite the spawn to + // `docker run ...` and skip the Seatbelt path entirely (the two are + // mutually exclusive). Build is NEVER triggered here — if no usable + // image is built, refuse loudly rather than spawning unsandboxed. + let docker_ws = args + .workspace_id + .as_deref() + .and_then(|wid| load_workspaces().into_iter().find(|w| w.id == wid)) + .filter(|w| w.docker_sandbox_enabled && load_settings_inner().docker_sandbox_enabled); + let docker_argv: Option> = if let Some(ws) = docker_ws { + let agent = args.agent_id.clone().unwrap_or_else(|| ws.cli.clone()); + let image = docker::spawn_image_tag().ok_or_else(|| { + "Docker image not built. Open Settings → Docker sandbox and build it first.".to_string() + })?; + // Belt-and-suspenders: remove any stale same-named container left + // by an unclean shutdown so `--name` doesn't collide on respawn. + docker::cleanup_workspace(&ws.id); + let spec = docker::build_spec(&ws, &agent, &image, &args.cwd, ws.docker_extra_args.clone()); + let argv = docker::render_argv(&spec, &args.cmd, &args.args); + dlog(&format!("[pty_spawn] docker ws={} agent={} image={} argv={argv:?}", ws.id, agent, image)); + Some(argv) + } else { + None + }; + let is_docker = docker_argv.is_some(); + // ── Sandbox wrap, if applicable ──────────────────────────────── // If the workspace is flagged sandbox_enabled, provision a fresh // seatbelt profile + network proxy and rewrite (cmd, args) to go through // `sandbox-exec`. The bundle gets parked on the PtySlot so its // Drop impl SIGKILLs the proxy when the PTY closes. - let (effective_cmd, effective_args, sandbox_bundle) = match args + // Docker mode short-circuits this: the container IS the cage, so the + // program becomes `docker` with the rendered run-argv, no seatbelt bundle. + let (effective_cmd, effective_args, sandbox_bundle) = if let Some(argv) = docker_argv { + ("docker".to_string(), argv, None) + } else { match args .workspace_id .as_deref() .and_then(|wid| load_workspaces().into_iter().find(|w| w.id == wid)) @@ -940,7 +987,7 @@ fn pty_spawn( dlog(&format!("[pty_spawn] sandbox=OFF cmd={} args={:?}", args.cmd, args.args)); (args.cmd.clone(), args.args.clone(), None) }, - }; + } }; let mut cmd = CommandBuilder::new(&effective_cmd); for a in &effective_args { @@ -970,7 +1017,10 @@ fn pty_spawn( // tabs, or sandbox=off agents) the CLI is exec'd directly, so without // this it would miss $EDITOR etc. (#17). The per-spawn overlay below // still wins, so explicit overrides hold. - if sandbox_bundle.is_none() { + // Skip for Docker too: the host `docker` process doesn't need the + // secret-bearing login delta (the container only sees what we pass via + // `-e`, which is TERM + config-dir relocation, never secrets). + if sandbox_bundle.is_none() && !is_docker { for (k, v) in shell_env::login_env() { cmd.env(k, v); } @@ -1020,7 +1070,9 @@ fn pty_spawn( // path watcher uses this to filter system-wide deny noise: only // denies whose PID (or some ancestor) is in this set get counted // against this workspace. - if let (Some(pid), Some(wid)) = (child_pid, args.workspace_id.as_deref()) { + // Docker isolates by namespace, not PID ancestry — the seatbelt path + // watcher doesn't apply, so skip registration in Docker mode. + if let (Some(pid), Some(wid), false) = (child_pid, args.workspace_id.as_deref(), is_docker) { sandbox::register_root_pid(wid, pid); } @@ -1663,6 +1715,8 @@ fn workspace_open_repo(project_id: String, cli: Option, name: Option Result Result< resume_override: None, persisted_tabs: Vec::new(), right_split_tabs: Vec::new(), + docker_sandbox_enabled: false, + docker_extra_args: Vec::new(), }; save_workspace(&ws).map_err(|e| e.to_string())?; @@ -3310,6 +3370,30 @@ fn workspace_set_yolo(id: String, yolo: bool) -> Result<(), String> { Ok(()) } +/// Persist a workspace's Docker sandbox choice + extra args. Mutually +/// exclusive with the Seatbelt cage: enabling Docker forces the Seatbelt +/// mode off (and vice versa) so the two never co-apply. On disable, any +/// live container for this workspace is removed. +#[tauri::command] +fn workspace_set_docker(id: String, enabled: bool, extra_args: Vec) -> Result<(), String> { + let mut list = load_workspaces(); + let w = list.iter_mut().find(|w| w.id == id).ok_or("no such ws")?; + w.docker_sandbox_enabled = enabled; + w.docker_extra_args = extra_args; + if enabled { + // Docker is the cage now — turn the Seatbelt path off so the two + // are never both active (effective_sandbox_mode / pty_spawn both + // key off these). + w.sandbox_enabled = false; + w.sandbox_mode = Some(SandboxMode::Off); + } else { + // No longer in Docker mode — tear down any container we left. + docker::cleanup_workspace(&id); + } + save_workspace(w).map_err(|e| e.to_string())?; + Ok(()) +} + /// Increment + persist the workspace's `spawn_count`. Historical metric /// only — resume gating now uses `has_resumable_history` instead. #[tauri::command] @@ -3386,6 +3470,11 @@ fn workspace_archive_sync(id: String, delete_branch: bool) -> Result<(), String> // polling thread will keep trying to sync a worktree that no longer exists. spotlight_stop_for_ws(&id); + // Remove any Docker containers for this workspace (non-fatal). `--rm` + // handles the clean-exit case; this covers crashes / kills where it + // never fired, before we tear down the worktree the container mounts. + docker::cleanup_workspace(&id); + let mut list = load_workspaces(); let w = list.iter_mut().find(|w| w.id == id).ok_or("workspace not found")?; let proj = load_projects().into_iter().find(|p| p.id == w.project_id); @@ -5978,6 +6067,12 @@ pub struct Settings { /// tree across every project. Unioned with each project's committed /// `.termic.yaml` `exclude` list. `.git` is always hidden regardless. pub file_tree_exclude: Vec, + /// Master switch for the experimental Docker sandbox mode. While off, + /// no Docker UI appears anywhere and Docker is never invoked. A + /// workspace's `docker_sandbox_enabled` only takes effect when this is + /// also on. See docs/plans/docker-sandbox/design.md. + #[serde(default)] + pub docker_sandbox_enabled: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -6491,6 +6586,146 @@ fn settings_save(s: Settings) -> Result<(), String> { .map_err(|e| e.to_string()) } +// ─────────────────────────── Docker sandbox ──────────────────────────── + +/// Probe for the `docker` binary + a running daemon. Cheap; no build. +#[tauri::command] +fn docker_check() -> docker::DockerStatus { + docker::check() +} + +/// Current image build state (current/last-built tags, stale flag, +/// dropdown availability). Drives the Settings section + cage dropdown. +#[tauri::command] +fn docker_image_status() -> docker::DockerImageStatus { + docker::image_status() +} + +/// The editable Dockerfile (falls back to the shipped default on first run). +#[tauri::command] +fn docker_get_dockerfile() -> String { + docker::read_dockerfile() +} + +/// The shipped default Dockerfile (for "Reset to default"). +#[tauri::command] +fn docker_default_dockerfile() -> String { + docker::DEFAULT_DOCKERFILE.to_string() +} + +/// Persist an edited Dockerfile. Does NOT build — build is an explicit, +/// separate action so the (slow, 2.8GB) build never blocks the UI. +#[tauri::command] +fn docker_set_dockerfile(contents: String) -> Result<(), String> { + docker::write_dockerfile(&contents) +} + +/// Build the image in the background. Streams build output line-by-line as +/// `docker-build://log` and emits `docker-build://done` with `{ success, +/// tag, error }` when finished. Returns immediately. `no_cache` => +/// `--no-cache --pull` ("Update agents": refresh base + re-fetch agents). +/// +/// IO-heavy + slow, so it runs on a background thread (NEVER the sync Tauri +/// path, which would freeze WKWebView per CLAUDE.md). +#[tauri::command] +fn docker_build_image(app: AppHandle, no_cache: bool) -> Result<(), String> { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + let dockerfile = docker::read_dockerfile(); + let (mut cmd, tag) = docker::build_command(&dockerfile, no_cache)?; + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + thread::spawn(move || { + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + let msg = if e.kind() == std::io::ErrorKind::NotFound { + "docker binary not found".to_string() + } else { + e.to_string() + }; + let _ = app.emit("docker-build://log", serde_json::json!({ "line": format!("[spawn error] {msg}") })); + let _ = app.emit("docker-build://done", serde_json::json!({ "success": false, "tag": tag, "error": msg })); + return; + } + }; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let app_o = app.clone(); + let t_out = stdout.map(|s| thread::spawn(move || { + for line in BufReader::new(s).lines().map_while(|r| r.ok()) { + let _ = app_o.emit("docker-build://log", serde_json::json!({ "line": line })); + } + })); + let app_e = app.clone(); + let t_err = stderr.map(|s| thread::spawn(move || { + // `docker build` writes its progress to stderr — stream it too. + for line in BufReader::new(s).lines().map_while(|r| r.ok()) { + let _ = app_e.emit("docker-build://log", serde_json::json!({ "line": line })); + } + })); + let status = child.wait(); + if let Some(t) = t_out { let _ = t.join(); } + if let Some(t) = t_err { let _ = t.join(); } + let success = status.map(|s| s.success()).unwrap_or(false); + if success { + docker::record_built_tag(&tag); + } + let _ = app.emit("docker-build://done", serde_json::json!({ + "success": success, + "tag": tag, + "error": if success { serde_json::Value::Null } else { serde_json::Value::String("build failed (see log)".into()) }, + })); + }); + Ok(()) +} + +/// Render the exact `docker run` argv + the pretty multi-line preview for a +/// workspace + agent, without spawning. Preview == the real spawn (same +/// `render_argv`), so the dialog is honest. +#[tauri::command] +fn docker_preview_command(workspace_id: String, agent_id: Option) -> Result { + let ws = load_workspaces() + .into_iter() + .find(|w| w.id == workspace_id) + .ok_or_else(|| "workspace not found".to_string())?; + let settings = load_settings_inner(); + let agent = resolve_agent_id(&settings, &agent_id, &ws); + let (cmd, args) = agent_spawn_cmd(&settings, &agent); + let image = docker::spawn_image_tag().unwrap_or_else(|| "termic-sandbox:".to_string()); + let spec = docker::build_spec(&ws, &agent, &image, &ws.path, ws.docker_extra_args.clone()); + Ok(DockerPreview { + argv: docker::render_argv(&spec, &cmd, &args), + preview: docker::render_preview(&spec, &cmd, &args), + mounts: spec.mounts.clone(), + image_built: docker::spawn_image_tag().is_some(), + }) +} + +#[derive(Serialize)] +struct DockerPreview { + argv: Vec, + preview: String, + mounts: Vec, + image_built: bool, +} + +/// Resolve the agent id to actually run for a workspace (explicit override, +/// else the workspace's `cli`). +fn resolve_agent_id(_settings: &Settings, agent_id: &Option, ws: &Workspace) -> String { + agent_id.clone().unwrap_or_else(|| ws.cli.clone()) +} + +/// The base (cmd, args) for an agent id from the registry. Representative +/// for the preview; the live spawn adds resume args on top. +fn agent_spawn_cmd(settings: &Settings, agent_id: &str) -> (String, Vec) { + settings + .agents + .iter() + .find(|a| a.id == agent_id) + .map(|a| (a.command.clone(), a.args.clone())) + .unwrap_or_else(|| (agent_id.to_string(), Vec::new())) +} + /// Replace just the agents list, preserving the rest of settings (repos_dir, /// welcomed, etc.). Used by the Settings → Agents page so the user can edit /// CLI commands, args, and YOLO flags without us shipping a new release every @@ -6933,6 +7168,7 @@ pub fn run() { pty_spawn, pty_write, pty_resize, pty_kill, notify, open_path, reveal_path, home_dir, default_shell, path_exists, path_is_git_repo, log_line, pty_debug_append, terminal_stage_file, install_notification_sound, play_completion_sound, settings_load, settings_save, agents_save, agents_defaults, discover_repos, detect_clis, + docker_check, docker_image_status, docker_get_dockerfile, docker_default_dockerfile, docker_set_dockerfile, docker_build_image, docker_preview_command, workspace_set_docker, automation::automation_result, automation::automation_armed, list_monospace_fonts, @@ -6959,6 +7195,10 @@ pub fn run() { /// so main is left clean. fn cleanup_children(app: &tauri::AppHandle) { use tauri::Manager; + // 0a. Docker containers — `docker rm -f` every termic-labeled container + // (non-fatal). Belt-and-suspenders for the `--rm`-never-fired case + // (crash / SIGKILL) so quitting never orphans a container. + docker::cleanup_all(); // 0. Spotlight sessions — revert main for every active session so the // user's repo is left in a clean state after the app exits. // Drop each session (which stops its polling thread) and revert. diff --git a/src-tauri/src/sandbox.rs b/src-tauri/src/sandbox.rs index b7e6925..3230627 100644 --- a/src-tauri/src/sandbox.rs +++ b/src-tauri/src/sandbox.rs @@ -1821,7 +1821,7 @@ fn compute_home_denies(home: &str, user_allowed: &[String], runtime: &[String]) /// Resolve symlinks so the SBPL rules match what the kernel sees. /// Seatbelt evaluates the *canonical* path; a worktree symlinked /// somewhere else would otherwise fail writes through the symlink. -fn canonicalize_or_keep(p: &str) -> String { +pub(crate) fn canonicalize_or_keep(p: &str) -> String { fs::canonicalize(p) .map(|c| c.to_string_lossy().into_owned()) .unwrap_or_else(|_| p.to_string()) @@ -1838,7 +1838,7 @@ fn canonicalize_or_keep(p: &str) -> String { /// 2. Expect `gitdir: /.git/worktrees/>`. /// 3. Walk up to `/.git` (i.e. trim `/worktrees/` suffix). /// 4. Canonicalize and return. -fn parent_git_dir_for_worktree(workspace_root: &str) -> Option { +pub(crate) fn parent_git_dir_for_worktree(workspace_root: &str) -> Option { let dot_git = std::path::Path::new(workspace_root).join(".git"); // For a regular checkout (is_repo_root), `.git` is a directory and // we don't need to widen — the workspace allow already covers it diff --git a/src/components/dialogs/WorkspaceDockerPanel.tsx b/src/components/dialogs/WorkspaceDockerPanel.tsx new file mode 100644 index 0000000..fd2bc77 --- /dev/null +++ b/src/components/dialogs/WorkspaceDockerPanel.tsx @@ -0,0 +1,147 @@ +// The Docker view inside the workspace sandbox dialog. Three load-bearing +// ideas are stated outright + always visible (not behind a tooltip): +// 1. only the mounted paths are visible to the agent, +// 2. the Docker agent is a SEPARATE identity from the OS agent, +// 3. one login is saved and shared across all Docker workspaces. +// Paired with the annotated mount list + the literal command preview, the +// user can always answer "what can this container see, and why?". +// +// See docs/plans/docker-sandbox/design.md ("How it works, explained IN the +// dialog" + "Mount transparency" + "Command preview formatting"). + +import { useEffect, useState } from "react"; +import { Container, Lock, FolderGit2, KeyRound, Terminal as TerminalIcon } from "lucide-react"; +import { dockerPreviewCommand, type DockerPreview, type DockerMount } from "@/lib/ipc"; + +export function WorkspaceDockerPanel({ + workspaceId, + agentId, + agentName, + extraArgs, + onExtraArgsChange, +}: { + workspaceId: string; + agentId: string; + agentName: string; + extraArgs: string; + onExtraArgsChange: (next: string) => void; +}) { + const [preview, setPreview] = useState(null); + + // Re-render the preview whenever the workspace, agent, or extra args + // change — the preview is produced by the SAME render_argv the spawn + // uses, so what you see is what runs. + useEffect(() => { + let alive = true; + dockerPreviewCommand(workspaceId, agentId) + .then(p => { if (alive) setPreview(p); }) + .catch(() => {}); + return () => { alive = false; }; + }, [workspaceId, agentId, extraArgs]); + + return ( +
+ {/* How it works — always visible explainer. */} +
+
+ + Docker sandbox +
+

+ This agent runs inside a Docker container. It can only touch the files listed below. + Everything else on your Mac is invisible to it. +

+

+ It is a separate agent from your normal one.{" "} + Your Docker {agentName} has its own login, MCP servers, settings, and chat history, kept apart + from the {agentName} you run outside Docker. +

+

+ Log in once.{" "} + The first time, run /login inside the agent. Your login, MCP servers, + and history are saved and shared across all your Docker workspaces for this agent, so you set it up + only once. +

+

Your conversations resume the same way they do today.

+
+ + {/* Mount transparency — every mount, with rw/ro + why + provenance. */} +
+
What the container can see
+
+ {(preview?.mounts ?? []).map((m, i) => )} + {preview && preview.mounts.length === 0 && ( +
No mounts computed.
+ )} +
+
+ + {/* Extra args. */} +
+
Extra docker run args
+
+ Appended to the command below (e.g. --memory 4g, -e FOO=bar, an extra -v). + One token per space, as you'd type them in a shell. +
+ onExtraArgsChange(e.target.value)} + placeholder="--memory 4g" + className="mt-2 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1.5 font-mono text-[13px] text-[var(--color-fg)] outline-none focus:border-[var(--color-accent)]" + /> +
+ + {/* Command preview — the literal argv, multi-line. */} +
+
+ + Command preview + {preview && !preview.image_built && ( + + image not built + + )} +
+
+          {preview?.preview ?? "…"}
+        
+
+ This is exactly what termic runs. The # comments are display-only. +
+
+
+ ); +} + +function mountIcon(m: DockerMount) { + if (m.why.startsWith("git metadata")) return ; + if (m.why.includes("login")) return ; + return ; +} + +function MountRow({ mount: m }: { mount: DockerMount }) { + return ( +
+
{mountIcon(m)}
+
+
+ {m.host} + + {m.container} + + {m.read_only ? "read-only" : "read-write"} + + {m.provenance === "implicit" && ( + auto + )} +
+
{m.why}
+
+
+ ); +} diff --git a/src/components/dialogs/WorkspaceSandboxDialog.tsx b/src/components/dialogs/WorkspaceSandboxDialog.tsx index 6ed4bed..278ed8c 100644 --- a/src/components/dialogs/WorkspaceSandboxDialog.tsx +++ b/src/components/dialogs/WorkspaceSandboxDialog.tsx @@ -12,11 +12,12 @@ import { usePrefs } from "@/store/prefs"; import { AppDialog } from "@/components/ui/Dialog"; import { Button } from "@/components/ui/Button"; import { cn } from "@/lib/utils"; -import { settingsLoad, workspaceSetSandbox, sandboxAvailable } from "@/lib/ipc"; +import { settingsLoad, workspaceSetSandbox, workspaceSetDocker, dockerImageStatus, sandboxAvailable } from "@/lib/ipc"; import { effectiveSandboxMode, type SandboxMode } from "@/lib/types"; -import { AlertTriangle, Shield, Zap, Save, RotateCw } from "lucide-react"; +import { AlertTriangle, Shield, Zap, Save, RotateCw, Container } from "lucide-react"; import { SandboxModeSelector } from "@/components/SandboxModeSelector"; import { SANDBOX_PRESETS } from "@/lib/sandboxPresets"; +import { WorkspaceDockerPanel } from "./WorkspaceDockerPanel"; export function WorkspaceSandboxDialog() { const wsId = useUI(s => s.sandboxForWsId); @@ -56,11 +57,33 @@ export function WorkspaceSandboxDialog() { sandboxAvailable().then(setOsSandboxOk).catch(() => setOsSandboxOk(false)); }, []); + // ── Docker cage state ───────────────────────────────────────────── + // The Seatbelt|Docker choice is only OFFERED when the global master + // switch is on AND a usable image is built (image-exists gating, so the + // user can never pick Docker and then hit a spawn-time "no image" + // refusal). `cage` is the active top-level choice. + const [dockerOffered, setDockerOffered] = useState(false); + const [cage, setCage] = useState<"seatbelt" | "docker">("seatbelt"); + const [dockerExtra, setDockerExtra] = useState(""); + useEffect(() => { + let alive = true; + (async () => { + try { + const s = await settingsLoad(); + const img = await dockerImageStatus(); + if (alive) setDockerOffered(!!s.docker_sandbox_enabled && img.available); + } catch { if (alive) setDockerOffered(false); } + })(); + return () => { alive = false; }; + }, [wsId]); + useEffect(() => { if (!ws) return; setMode(effectiveSandboxMode(ws)); setRwText((ws.sandbox_rw_paths ?? []).join("\n")); setHostsText((ws.sandbox_allowed_hosts ?? []).join("\n")); + setCage(ws.docker_sandbox_enabled ? "docker" : "seatbelt"); + setDockerExtra((ws.docker_extra_args ?? []).join(" ")); setErr(null); setBusy(false); }, [ws?.id]); // eslint-disable-line react-hooks/exhaustive-deps @@ -76,14 +99,54 @@ export function WorkspaceSandboxDialog() { s.split("\n").map(l => l.trim()).filter(Boolean); const arrEq = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i]); - const dirty = ws ? ( - mode !== effectiveSandboxMode(ws) || - !arrEq(splitLines(rwText), ws.sandbox_rw_paths ?? []) || - !arrEq(splitLines(hostsText), ws.sandbox_allowed_hosts ?? []) + const splitArgs = (s: string) => s.split(/\s+/).map(t => t.trim()).filter(Boolean); + const wasDocker = !!ws?.docker_sandbox_enabled; + const dirty = ws ? (cage === "docker" + ? (!wasDocker || + !arrEq(splitArgs(dockerExtra), ws.docker_extra_args ?? [])) + : (wasDocker || + mode !== effectiveSandboxMode(ws) || + !arrEq(splitLines(rwText), ws.sandbox_rw_paths ?? []) || + !arrEq(splitLines(hostsText), ws.sandbox_allowed_hosts ?? [])) ) : false; async function save(restart: boolean) { if (!ws || busy) return; + + // ── Docker cage save path ────────────────────────────────────── + // Mutually exclusive with Seatbelt: workspace_set_docker forces the + // Seatbelt mode off when enabling (and the Rust side cleans up any + // container when disabling). Killing live PTYs so the next spawn + // lands in the container reuses the existing pending-restart plumbing. + if (cage === "docker") { + const ok = await useUI.getState().askConfirm({ + title: `Switch "${ws.name}" to the Docker sandbox?`, + message: restart + ? "The agent will be terminated and AUTO-restarted inside a Docker container. It logs in separately from your normal agent (run /login once inside it)." + : "Saved. Any agent currently running keeps its current cage until it next respawns; new tabs launch in Docker.", + confirmLabel: restart ? "Save & restart" : "Save without restart", + }); + if (!ok) return; + setBusy(true); setErr(null); + try { + if (restart) useUI.getState().markPendingPtyRestart(ws.id); + await workspaceSetDocker(ws.id, true, splitArgs(dockerExtra)); + await loadAll(); + close(); + } catch (e) { + setErr(String(e)); + setBusy(false); + } + return; + } + + // Switching FROM Docker back to a Seatbelt mode: clear the docker + // flag first (removes any container), then fall through to the + // normal Seatbelt save below. + if (wasDocker) { + try { await workspaceSetDocker(ws.id, false, []); } catch {} + } + // Pre-flight confirm. We don't have a live PTY count on the // frontend (the Rust side will tell us when the IPC returns), // so the dialog text is generic. The user is explicitly asking @@ -166,6 +229,47 @@ export function WorkspaceSandboxDialog() { the focus ring off the top row of mode cards. A few px of inset gives the ring room to render. */}
+ {/* Cage selector: Seatbelt | Docker. Only shown when the global + Docker master switch is on AND a usable image is built (so the + user can never pick Docker then hit a spawn-time "no image" + refusal). Picking Docker hides the Seatbelt selector + lists. */} + {dockerOffered && ( +
+
Cage
+
+ {([ + ["seatbelt", "Seatbelt", ], + ["docker", "Docker", ], + ] as const).map(([id, label, icon]) => ( + + ))} +
+
+ )} + + {cage === "docker" && ws && ( + + )} + + {cage === "seatbelt" && (<> {/* On/off panel. Big, color-coded, unambiguous - the prior "Unsandboxed" checkbox was a double-negative trap: users saw the box checked and assumed the cage was ON. State now @@ -372,6 +476,7 @@ export function WorkspaceSandboxDialog() { )} )} + )} {/* "Recent denies" panel removed — the TerminalPane footer now shows a live deny counter chip per workspace, which diff --git a/src/components/settings/DockerSection.tsx b/src/components/settings/DockerSection.tsx new file mode 100644 index 0000000..efe062e --- /dev/null +++ b/src/components/settings/DockerSection.tsx @@ -0,0 +1,307 @@ +// Docker sandbox (experimental) — the global, image-level home for the +// Docker cage. Everything per-machine lives here: the master switch, the +// `docker` availability probe, the editable Dockerfile, and the only place +// the image is built or rebuilt. Per-workspace cage selection lives in the +// workspace sandbox dialog, not here (one image, many workspaces). +// +// Build is deliberately decoupled from spawn: the image is built by an +// explicit action here and never lazily on a PTY spawn (a 2.8GB build on +// the spawn path would freeze the webview). See docs/plans/docker-sandbox. + +import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/Button"; +import { Checkbox } from "@/components/ui/Checkbox"; +import { usePrefs, resolveTheme } from "@/store/prefs"; +import { resolveEditorTheme, editorSurfaceTheme } from "@/lib/editorTheme"; +import { EditorView, keymap } from "@codemirror/view"; +import { EditorState, Compartment } from "@codemirror/state"; +import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; +import { StreamLanguage } from "@codemirror/language"; +import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile"; +import { + settingsLoad, settingsSave, + dockerCheck, dockerImageStatus, dockerGetDockerfile, dockerDefaultDockerfile, + dockerSetDockerfile, dockerBuildImage, onDockerBuildLog, onDockerBuildDone, + type DockerStatus, type DockerImageStatus, +} from "@/lib/ipc"; +import type { Settings } from "@/lib/types"; +import { Loader2, CircleCheck, CircleAlert, Container } from "lucide-react"; + +export function DockerSection() { + const [settings, setSettings] = useState(null); + const [status, setStatus] = useState(null); + const [image, setImage] = useState(null); + + // Dockerfile editor state. + const hostRef = useRef(null); + const viewRef = useRef(null); + const themeComp = useRef(new Compartment()); + const [dockerfile, setDockerfile] = useState(""); + const [savedDockerfile, setSavedDockerfile] = useState(""); + const [dfBusy, setDfBusy] = useState(false); + + // Build state. + const [building, setBuilding] = useState(false); + const [buildLog, setBuildLog] = useState([]); + const [showLog, setShowLog] = useState(false); + const logEndRef = useRef(null); + + const themeId = usePrefs(s => s.editorThemeId); + const fontSize = usePrefs(s => s.editorFontSize); + const themeMode = usePrefs(s => s.themeMode); + const appIsLight = resolveTheme(themeMode) === "light"; + + const enabled = !!settings?.docker_sandbox_enabled; + const dirty = dockerfile !== savedDockerfile; + + // ── Load everything on mount ────────────────────────────────────── + const refresh = () => { + dockerCheck().then(setStatus).catch(() => {}); + dockerImageStatus().then(setImage).catch(() => {}); + }; + useEffect(() => { + settingsLoad().then(setSettings).catch(() => {}); + dockerGetDockerfile().then(df => { setDockerfile(df); setSavedDockerfile(df); }).catch(() => {}); + refresh(); + }, []); + + // ── CodeMirror init (once) ──────────────────────────────────────── + useEffect(() => { + if (!hostRef.current || viewRef.current) return; + const view = new EditorView({ + state: EditorState.create({ + doc: dockerfile, + extensions: [ + history(), + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), + StreamLanguage.define(dockerFile), + EditorView.lineWrapping, + themeComp.current.of([ + resolveEditorTheme(themeId, appIsLight), + editorSurfaceTheme(fontSize, false), + ]), + EditorView.updateListener.of(u => { + if (u.docChanged) setDockerfile(u.state.doc.toString()); + }), + ], + }), + parent: hostRef.current, + }); + viewRef.current = view; + return () => { view.destroy(); viewRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dockerfile.length > 0]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: themeComp.current.reconfigure([ + resolveEditorTheme(themeId, appIsLight), + editorSurfaceTheme(fontSize, false), + ]), + }); + }, [themeId, fontSize, appIsLight]); + + // ── Build log streaming ─────────────────────────────────────────── + useEffect(() => { + if (!building) return; + let unlistenLog: (() => void) | undefined; + let unlistenDone: (() => void) | undefined; + onDockerBuildLog(line => setBuildLog(l => [...l, line])).then(u => (unlistenLog = u)); + onDockerBuildDone(({ success }) => { + setBuilding(false); + setBuildLog(l => [...l, success ? "✓ Build finished." : "✗ Build failed."]); + refresh(); + }).then(u => (unlistenDone = u)); + return () => { unlistenLog?.(); unlistenDone?.(); }; + }, [building]); + + useEffect(() => { logEndRef.current?.scrollIntoView({ block: "end" }); }, [buildLog]); + + // ── Actions ─────────────────────────────────────────────────────── + async function toggleMaster(next: boolean) { + if (!settings) return; + const updated = { ...settings, docker_sandbox_enabled: next }; + setSettings(updated); + await settingsSave(updated); + } + + async function saveDockerfile() { + setDfBusy(true); + try { + await dockerSetDockerfile(dockerfile); + setSavedDockerfile(dockerfile); + refresh(); + } finally { setDfBusy(false); } + } + + async function resetDockerfile() { + const def = await dockerDefaultDockerfile(); + setEditorDoc(def); + setDockerfile(def); + } + + function setEditorDoc(text: string) { + const v = viewRef.current; + if (!v) return; + v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: text } }); + } + + async function build(noCache: boolean) { + // Persist any pending edits first so the build matches the editor. + if (dirty) { await dockerSetDockerfile(dockerfile); setSavedDockerfile(dockerfile); } + setBuildLog([]); + setShowLog(true); + setBuilding(true); + await dockerBuildImage(noCache); + } + + if (!settings) { + return
Loading…
; + } + + return ( +
+
+

+ + Docker sandbox + experimental +

+

+ A stronger cage than Seatbelt: the agent runs inside a Docker container and can only touch the + folders termic mounts (the worktree and its git metadata). Everything else on your Mac is invisible + to it. One image is shared by every Docker workspace; pick Docker per workspace from its sandbox dialog. +

+
+ + {/* Master toggle */} + + + {enabled && ( + <> + {/* Docker availability */} +
+
Docker status
+ +
+ + {/* Dockerfile editor */} +
+
+
+
Dockerfile
+
+ One generic image for all agents. Edit the commented regions to add MCP servers, CLI tools, or + baked skills. Personal logins (agent auth, MCP OAuth) are NOT set up here, just run the agent and + log in once inside Docker; those persist via your mounted config directory. +
+
+
+
+
+ + + {dirty && Unsaved edits} +
+
+ + {/* Image build */} +
+
Image
+ +
+ + + {!status?.daemon && ( + Start Docker to build. + )} + {buildLog.length > 0 && ( + + )} +
+ {showLog && buildLog.length > 0 && ( +
+                {buildLog.join("\n")}
+                
+
+ )} +
+ + )} +
+ ); +} + +function DockerAvailability({ status }: { status: DockerStatus | null }) { + if (!status) return
Checking…
; + if (!status.binary) { + return ( +
+ `docker` not found on PATH. Install Docker Desktop, OrbStack, or colima. +
+ ); + } + if (!status.daemon) { + return ( +
+ Docker is installed but the daemon is not running. Start it to build / run. +
+ ); + } + return ( +
+ Ready{status.version ? ` · ${status.version}` : ""} +
+ ); +} + +function ImageStatusLine({ image, dirty }: { image: DockerImageStatus | null; dirty: boolean }) { + if (!image) return null; + return ( +
+ {image.available ? ( + + + Built · {image.last_built_tag ?? image.current_tag} + + ) : ( + + Not built yet. Build it to use Docker mode in a workspace. + + )} + {(image.stale || dirty) && image.available && ( + + + Dockerfile edited since the last build. Rebuild to apply your changes (workspaces keep using the last built image until then). + + )} +
+ ); +} diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index 673acda..293d47f 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import { useApp } from "@/store/app"; import { Button } from "@/components/ui/Button"; -import { X, Palette, FolderGit2, Settings as SettingsIcon, Keyboard, Terminal, Layers, Library } from "lucide-react"; +import { X, Palette, FolderGit2, Settings as SettingsIcon, Keyboard, Terminal, Layers, Library, Container } from "lucide-react"; import { cn } from "@/lib/utils"; import { AppearanceSection } from "./AppearanceSection"; import { RepositorySection } from "./RepositorySection"; @@ -13,6 +13,7 @@ import { GeneralSection } from "./GeneralSection"; import { ShortcutsSection } from "./ShortcutsSection"; import { AgentsSection } from "./AgentsSection"; import { PromptLibrarySection } from "./PromptLibrarySection"; +import { DockerSection } from "./DockerSection"; export function Settings() { const view = useApp(s => s.view); @@ -72,6 +73,8 @@ export function Settings() { active={tab === "prompts"} onClick={() => openSettings("prompts")} /> } label="Shortcuts" active={tab === "shortcuts"} onClick={() => openSettings("shortcuts")} /> + } label="Docker sandbox" + active={tab === "docker"} onClick={() => openSettings("docker")} />
Projects @@ -106,6 +109,7 @@ export function Settings() { {tab === "agents" && } {tab === "prompts" && } {tab === "shortcuts" && } + {tab === "docker" && } {tab === "repositories" && ( isRepoSelected ? diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 8e2f19c..8b16b26 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -6,7 +6,7 @@ import { useApp, useWorkspaceTabs, useActiveTabId } from "@/store/app"; import { usePrefs } from "@/store/prefs"; import { Button } from "@/components/ui/Button"; import { Tip } from "@/components/ui/Tooltip"; -import { LayoutGrid, History, FolderPlus, Settings, Plus, Archive, Layers, Moon, Cog, MoreVertical, GitBranchPlus, FolderGit2, ChevronRight, ChevronDown, Bell, Bug, Mail, Zap, X, Pencil, Copy, ChevronsDownUp, ChevronsUpDown, Check, AudioWaveform, Radio, SquareChevronRight, Loader2, EyeOff } from "lucide-react"; +import { LayoutGrid, History, FolderPlus, Settings, Plus, Archive, Layers, Moon, Cog, MoreVertical, GitBranchPlus, FolderGit2, ChevronRight, ChevronDown, Bell, Bug, Mail, Zap, X, Pencil, Copy, ChevronsDownUp, ChevronsUpDown, Check, AudioWaveform, Radio, SquareChevronRight, Loader2, EyeOff, Container } from "lucide-react"; import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem, DropdownSeparator, DropdownLabel } from "@/components/ui/Dropdown"; import { ProjectActionsMenuItems } from "./ProjectActionsMenuItems"; import { UpdateCard } from "./UpdateCard"; @@ -1141,7 +1141,7 @@ function WorkspaceRow({ w, compact }: { w: Workspace; compact: boolean }) { // the button visible; unless the collapsed attention/done // badge is active — it lives in the same slot and the // status icon would cover it. - (w.sandbox_enabled || (!!w.yolo && !isSandboxEnforced(effectiveSandboxMode(w)))) && !(collapsed && (hasAttention || hasDone || hasWorking)) + (w.sandbox_enabled || w.docker_sandbox_enabled || (!!w.yolo && !isSandboxEnforced(effectiveSandboxMode(w)))) && !(collapsed && (hasAttention || hasDone || hasWorking)) ? "opacity-100 pointer-events-auto" : "opacity-0 group-hover/wsrow:opacity-100 pointer-events-none group-hover/wsrow:pointer-events-auto", wsRenaming !== null && "pointer-events-none", @@ -1157,6 +1157,16 @@ function WorkspaceRow({ w, compact }: { w: Workspace; compact: boolean }) { {(() => { const wMode = effectiveSandboxMode(w); const stateOpacity = terminalTabs.length > 0 ? "opacity-100" : "opacity-40"; + // Docker mode is its own cage (mutually exclusive with + // Seatbelt) — a distinct whale/container glyph so it's + // obvious at a glance which cage the workspace is in. + if (w.docker_sandbox_enabled) { + return ( + + ); + } if (!!w.yolo && !isSandboxEnforced(wMode)) { return ( diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index d59de7a..c97f581 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -282,6 +282,45 @@ export const workspaceDiff = (id: string) => invoke("workspace_diff" export const workspaceSendDiffToMain = (id: string) => invoke<{ tracked_files: number; untracked_files: number }>("workspace_send_diff_to_main", { id }); +// ─────────────────────────── docker sandbox ────────────────────────── + +export interface DockerStatus { binary: boolean; daemon: boolean; version: string | null } +export interface DockerImageStatus { + current_tag: string; + current_built: boolean; + last_built_tag: string | null; + last_built_exists: boolean; + stale: boolean; + is_default: boolean; + available: boolean; +} +export interface DockerMount { + host: string; container: string; read_only: boolean; + provenance: "implicit" | "user"; why: string; load_bearing: boolean; +} +export interface DockerPreview { + argv: string[]; preview: string; mounts: DockerMount[]; image_built: boolean; +} + +export const dockerCheck = () => invoke("docker_check"); +export const dockerImageStatus = () => invoke("docker_image_status"); +export const dockerGetDockerfile = () => invoke("docker_get_dockerfile"); +export const dockerDefaultDockerfile = () => invoke("docker_default_dockerfile"); +export const dockerSetDockerfile = (contents: string) => invoke("docker_set_dockerfile", { contents }); +/** Kicks off a background build; stream output via onDockerBuildLog / onDockerBuildDone. */ +export const dockerBuildImage = (noCache: boolean) => invoke("docker_build_image", { noCache }); +export const dockerPreviewCommand = (workspaceId: string, agentId?: string) => + invoke("docker_preview_command", { workspaceId, agentId }); +export const workspaceSetDocker = (id: string, enabled: boolean, extraArgs: string[]) => + invoke("workspace_set_docker", { id, enabled, extraArgs }); + +export function onDockerBuildLog(cb: (line: string) => void): Promise { + return listen<{ line: string }>("docker-build://log", e => cb(e.payload.line)); +} +export function onDockerBuildDone(cb: (d: { success: boolean; tag: string; error: string | null }) => void): Promise { + return listen<{ success: boolean; tag: string; error: string | null }>("docker-build://done", e => cb(e.payload)); +} + // ───────────────────────────── spotlight ───────────────────────────── export const workspaceSpotlightStart = (id: string) => invoke("workspace_spotlight_start", { id }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 0c64380..2b174c7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -211,6 +211,14 @@ export interface Workspace { persisted_tabs?: PersistedTab[]; /** Same as `persisted_tabs` but for right-split panel agents. */ right_split_tabs?: PersistedTab[]; + /** Docker sandbox mode for this workspace. Mutually exclusive with the + * Seatbelt sandbox: when true, the agent PTY runs inside `docker run` + * instead of `sandbox-exec`. Only effective when the global + * `Settings.docker_sandbox_enabled` is on and the image is built. */ + docker_sandbox_enabled?: boolean; + /** User-appended `docker run` args (e.g. `--memory 4g`, `-e FOO=bar`). + * Inserted at a fixed point in the rendered argv. */ + docker_extra_args?: string[]; } /** One durable agent tab persisted on a workspace. Mirror of @@ -372,6 +380,10 @@ export interface Settings { * tree across every project. Unioned with each project's committed * `.termic.yaml` `exclude`. `.git` is always hidden regardless. */ file_tree_exclude?: string[]; + /** Master switch for the experimental Docker sandbox mode. While off, + * no Docker UI appears and Docker is never invoked. A workspace's + * `docker_sandbox_enabled` only takes effect when this is also on. */ + docker_sandbox_enabled?: boolean; } export interface DiscoveredRepo { diff --git a/src/store/app.ts b/src/store/app.ts index d0fd2ed..ae01848 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -17,7 +17,7 @@ interface View { * and panel state all stay intact while it's open. */ settingsOpen?: boolean; /** When the Settings overlay is open, which section is selected. */ - settingsTab?: "general" | "appearance" | "agents" | "prompts" | "repositories" | "shortcuts"; + settingsTab?: "general" | "appearance" | "agents" | "prompts" | "repositories" | "shortcuts" | "docker"; /** When viewing a repository's settings, which project id is active. */ settingsRepoId?: string; } From 79a1b964ae2d3cf4769478f0c5b164e5d4a1dad4 Mon Sep 17 00:00:00 2001 From: Simion Agavriloaei Date: Fri, 26 Jun 2026 11:58:33 +0300 Subject: [PATCH 2/2] docs: add newsletter signup plan (Kit, site + in-app) Captures the full design for an email subscriber list: Kit public form endpoint shared by termic.dev and the app, the bottom-left sidebar card (yields to UpdateCard), and the async Rust subscribe command. Implemented once then reverted; this doc is the record to re-apply. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/newsletter.md | 345 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/plans/newsletter.md diff --git a/docs/plans/newsletter.md b/docs/plans/newsletter.md new file mode 100644 index 0000000..4f70f66 --- /dev/null +++ b/docs/plans/newsletter.md @@ -0,0 +1,345 @@ +# Newsletter signup (weekly updates) + +Goal: build an email subscriber list for a weekly "what's changed" update, +collected from **both** the termic.dev site and inside the termic app. + +## Provider: Kit (ConvertKit) + +Chosen for the larger free tier (10k subscribers) over Buttondown (100). + +Key property that makes this simple: Kit's **public form-subscribe endpoint +needs no API key**, so the static site and the desktop app can both POST to +it directly. No backend, no stored secret (which we'd never want to ship in +a desktop binary anyway). + +``` +POST https://app.kit.com/forms//subscriptions +Content-Type: application/json +{ "email_address": "user@example.com" } +``` + +### One-time setup before implementing + +1. Create a Kit account. +2. Grow, Landing Pages and Forms, create an **inline form**. +3. The embed URL ends in the form id, e.g. `.../forms/8284615/subscriptions` + → `FORM_ID = 8284615`. +4. Plug that id into the two `` placeholders below (one in the + Rust const, one in the site form). Same id for both. + +## Part 1: termic.dev site (Astro on Cloudflare Pages) + +Static host, so the plain-HTML-form pattern works with zero Functions. + +- Add a styled `
` with an `email_address` input. +- Best home: `src/components/Footer.astro` (alternatively the hero in + `src/pages/index.astro`). +- Match existing CSS (`src/styles/global.css`). Inline success/error via a + tiny `