From a368cc670a67fe0bb0eb196d1631da1715ecc1dd Mon Sep 17 00:00:00 2001
From: Wess Cope
Date: Mon, 27 Jul 2026 11:11:50 -0400
Subject: [PATCH 1/5] Share one container between a project and its agent team
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A team launched with relay-team-autonomy runs on the real machine, with the
user's credentials, with permission prompts bypassed. The sandbox puts the
project and its whole roster inside one container instead: one filesystem, one
toolchain, one blast radius.
Self-contained by design — docker or podman is the only dependency. No editor,
no devcontainer CLI, and no image to pull from a registry we would have to
publish and keep current. A project's devcontainer.json and a container an
editor already started are both used when present, and nothing degrades when
they are not.
Three decisions carry the rest:
- One container per project, not per agent. Agents on a team need to see each
other's edits and share a build cache; isolation between them stays in git
worktrees inside the shared mount. Panes are refcounted, so the container
outlives any one of them.
- The project is identity-mounted. Git records absolute paths in a worktree, so
under any other mapping a worktree made on one side is broken on the other.
Equal paths keep the worktree verbs working from both sides and remove the
need for a path translation layer anywhere.
- Relay stays on the host. build::worker resolves the role, renders the harness,
and writes the MCP config exactly as it does for a host launch; --sandbox
changes only the final exec, rewriting the bus URL to the engine's gateway
host and handing the agent its config at the path the container mounts it.
container grows Sandbox, Recipe, Mount, adopt (label discovery plus ownership,
so a container Sinclair did not create is entered and never removed), and a
devcontainer.json reader on config's JSONC parser — all still pure argv
construction. app/sandbox resolves settings into a description and brings it up
off the render path; root/sandbox counts panes and retires the container.
Project resolution walks up to the repository root so every pane in a checkout
shares one container, and refuses $HOME, / and depth-1 paths: the sandbox mounts
what it is given, and mounting a home directory would hand an unattended agent
the whole machine.
UI: a Sandbox submenu under File and AI with a live status line, a Project
sandbox section in the Containers panel, fourteen sandbox-* settings, and six
bindable actions.
---
AGENTS.md | 12 +
CLAUDE.md | 12 +
Cargo.lock | 4 +
README.md | 7 +
crates/app/src/agentpicker.rs | 2 +
crates/app/src/main.rs | 1 +
crates/app/src/relay/agent.rs | 50 ++-
crates/app/src/relay/mod.rs | 9 +-
crates/app/src/relay/team.rs | 23 +-
crates/app/src/root/containers.rs | 6 +-
crates/app/src/root/dispatch.rs | 17 +-
crates/app/src/root/menus.rs | 43 ++-
crates/app/src/root/mod.rs | 24 +-
crates/app/src/root/persist.rs | 25 +-
crates/app/src/root/sandbox.rs | 330 ++++++++++++++++++++
crates/app/src/root/sidebar.rs | 44 ++-
crates/app/src/sandbox/ensure.rs | 184 +++++++++++
crates/app/src/sandbox/mod.rs | 91 ++++++
crates/app/src/sandbox/spec.rs | 180 +++++++++++
crates/app/src/settings/schema/ai.rs | 105 ++++++-
crates/app/src/settings/schema/lists.rs | 30 ++
crates/app/tests/relay.rs | 17 +-
crates/app/tests/sandbox.rs | 232 ++++++++++++++
crates/config/src/action.rs | 26 ++
crates/config/src/apply.rs | 76 +++++
crates/config/src/kind.rs | 14 +
crates/config/src/options.rs | 55 ++++
crates/container/Cargo.toml | 3 +
crates/container/src/adopt.rs | 145 +++++++++
crates/container/src/devcontainer.rs | 211 +++++++++++++
crates/container/src/engine.rs | 15 +
crates/container/src/hash.rs | 13 +
crates/container/src/image.rs | 152 +++++++++
crates/container/src/lib.rs | 38 ++-
crates/container/src/mount.rs | 108 +++++++
crates/container/src/sandbox.rs | 397 ++++++++++++++++++++++++
crates/container/tests/adopt.rs | 78 +++++
crates/container/tests/devcontainer.rs | 113 +++++++
crates/container/tests/image.rs | 86 +++++
crates/container/tests/mount.rs | 32 ++
crates/container/tests/sandbox.rs | 146 +++++++++
crates/relay/Cargo.toml | 3 +
crates/relay/src/cli/build.rs | 9 +
crates/relay/src/cli/launch.rs | 51 ++-
crates/relay/src/cli/mod.rs | 18 ++
crates/relay/src/cli/sandbox.rs | 91 ++++++
crates/relay/src/tools/mod.rs | 5 +
crates/relay/tests/cli/sandbox.rs | 80 +++++
docs/agents.html | 10 +
docs/configuration.html | 24 +-
docs/docs.js | 1 +
docs/index.html | 4 +
docs/keybindings.html | 6 +
docs/relay.md | 35 +++
docs/roadmap.md | 22 ++
docs/sandbox.html | 269 ++++++++++++++++
docs/sandbox.md | 235 ++++++++++++++
docs/tutorials.html | 1 +
58 files changed, 3975 insertions(+), 45 deletions(-)
create mode 100644 crates/app/src/root/sandbox.rs
create mode 100644 crates/app/src/sandbox/ensure.rs
create mode 100644 crates/app/src/sandbox/mod.rs
create mode 100644 crates/app/src/sandbox/spec.rs
create mode 100644 crates/app/tests/sandbox.rs
create mode 100644 crates/container/src/adopt.rs
create mode 100644 crates/container/src/devcontainer.rs
create mode 100644 crates/container/src/hash.rs
create mode 100644 crates/container/src/image.rs
create mode 100644 crates/container/src/mount.rs
create mode 100644 crates/container/src/sandbox.rs
create mode 100644 crates/container/tests/adopt.rs
create mode 100644 crates/container/tests/devcontainer.rs
create mode 100644 crates/container/tests/image.rs
create mode 100644 crates/container/tests/mount.rs
create mode 100644 crates/container/tests/sandbox.rs
create mode 100644 crates/relay/src/cli/sandbox.rs
create mode 100644 crates/relay/tests/cli/sandbox.rs
create mode 100644 docs/sandbox.html
create mode 100644 docs/sandbox.md
diff --git a/AGENTS.md b/AGENTS.md
index 6673908..911cae1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -76,6 +76,16 @@ The workspace is layered bottom-up; each crate depends only on those below it.
- **`cast`** — asciinema v2 `.cast` recording: a `Recorder` writes a header line
plus timestamped output events as bytes arrive (output only; UTF-8 split
across reads is carried over). Used by `terminal` for session capture.
+- **`container`** — container-backed terminals, pure argv construction with no
+ I/O beyond a `$PATH` probe: Docker/Podman detection, the OS profiles behind
+ "OS Tabs", and the project **sandbox** — one long-lived container a human and
+ a whole agent team share. The sandbox identity-mounts the project (a path
+ means the same thing inside and out, so git worktrees stay valid from both
+ sides), generates the image `Recipe` that installs the agent CLIs, and
+ discovers containers a project already has by label so an editor's
+ devcontainer is entered rather than duplicated. `app` drives it; `relay` uses
+ the same builders so a `docker exec` assembled on either side cannot drift.
+ See `docs/sandbox.md`.
- **`input`** — keyboard/mouse encoding to terminal byte sequences (CSI, kitty
keyboard protocol, mouse reporting, bracketed paste).
- **`config`** — layered settings: compiled-in defaults overridden by the
@@ -216,5 +226,7 @@ Keep the vt/terminal layers free of gpui types — the boundary is the bridge.
- `docs/guise.md` — the guise component-library migration: how `vendor/guise` is
wired (the single-gpui patch), the theme bridge, and the surface-by-surface
port status.
+- `docs/sandbox.md` — the shared project sandbox: one container for a human and
+ a whole agent team, the identity mount, the generated image, and adoption.
- `docs/relay.md` — the agent mesh: roles, teams/tiles, the `relay` CLI, and the
MCP coordination tools.
diff --git a/CLAUDE.md b/CLAUDE.md
index 76878d1..d401c3a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -76,6 +76,16 @@ The workspace is layered bottom-up; each crate depends only on those below it.
- **`cast`** — asciinema v2 `.cast` recording: a `Recorder` writes a header line
plus timestamped output events as bytes arrive (output only; UTF-8 split
across reads is carried over). Used by `terminal` for session capture.
+- **`container`** — container-backed terminals, pure argv construction with no
+ I/O beyond a `$PATH` probe: Docker/Podman detection, the OS profiles behind
+ "OS Tabs", and the project **sandbox** — one long-lived container a human and
+ a whole agent team share. The sandbox identity-mounts the project (a path
+ means the same thing inside and out, so git worktrees stay valid from both
+ sides), generates the image `Recipe` that installs the agent CLIs, and
+ discovers containers a project already has by label so an editor's
+ devcontainer is entered rather than duplicated. `app` drives it; `relay` uses
+ the same builders so a `docker exec` assembled on either side cannot drift.
+ See `docs/sandbox.md`.
- **`input`** — keyboard/mouse encoding to terminal byte sequences (CSI, kitty
keyboard protocol, mouse reporting, bracketed paste).
- **`config`** — layered settings: compiled-in defaults overridden by the
@@ -215,5 +225,7 @@ Keep the vt/terminal layers free of gpui types — the boundary is the bridge.
- `docs/guise.md` — the guise component-library migration: how `vendor/guise` is
wired (the single-gpui patch), the theme bridge, and the surface-by-surface
port status.
+- `docs/sandbox.md` — the shared project sandbox: one container for a human and
+ a whole agent team, the identity mount, the generated image, and adoption.
- `docs/relay.md` — the agent mesh: roles, teams/tiles, the `relay` CLI, and the
MCP coordination tools.
diff --git a/Cargo.lock b/Cargo.lock
index 0164a6b..808af08 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1607,6 +1607,9 @@ dependencies = [
[[package]]
name = "container"
version = "1.30.1"
+dependencies = [
+ "config",
+]
[[package]]
name = "convert_case"
@@ -6943,6 +6946,7 @@ dependencies = [
"async-stream",
"axum",
"clap",
+ "container",
"libc",
"serde",
"serde_json",
diff --git a/README.md b/README.md
index bc7ddf4..8f23960 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,7 @@ is migrated automatically on first launch.)
"relay-default-agent": "claude",
"relay-team-window": true, // a team gets its own window
"relay-team-autonomy": true, // team members skip permission prompts
+ "sandbox-enabled": false, // run this project + its agents in one container
// Keybindings — trigger=action[:param]; use =unbind to remove a default
"keybind": [
@@ -312,6 +313,12 @@ and optionally start it on launch. An **AI** menu then appears:
the layout you were working in left alone. Members run unattended (their
permission prompts skipped), since nobody is sitting in each pane to answer
one. Both are settings — `relay-team-window` and `relay-team-autonomy`.
+- **Sandbox ▸** — put this project and its whole team inside **one container**
+ instead of running on the host: a shared filesystem, one toolchain, and a real
+ boundary around agents whose prompts you just turned off. Needs Docker or
+ Podman and nothing else — no VS Code, no `devcontainer` CLI, no image to pull.
+ The project is mounted at its own path, so git worktrees stay valid on both
+ sides. See [`docs/sandbox.md`](docs/sandbox.md).
The same `relay` binary works on its own (`relay start`, `relay launch `,
`relay feed --follow`, `relay ps`, `relay stop`). **Claude** and **Codex** join
diff --git a/crates/app/src/agentpicker.rs b/crates/app/src/agentpicker.rs
index 159f818..0d4673c 100644
--- a/crates/app/src/agentpicker.rs
+++ b/crates/app/src/agentpicker.rs
@@ -208,12 +208,14 @@ impl AgentPickerView {
role: role.clone(),
task: task.clone(),
});
+ let sandbox = crate::root::sandbox::active_ref(cx);
let cmd = crate::relay::launch_agent_command(
&self.opts,
&provider,
&name,
role.as_deref(),
task.as_deref(),
+ sandbox.as_ref(),
);
create(cx, cmd, window);
}
diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
index 981796d..5d83cf2 100644
--- a/crates/app/src/main.rs
+++ b/crates/app/src/main.rs
@@ -42,6 +42,7 @@ mod reload;
mod rename;
mod resume;
mod root;
+mod sandbox;
mod session;
mod sessionstate;
mod sidecar;
diff --git a/crates/app/src/relay/agent.rs b/crates/app/src/relay/agent.rs
index 166d531..5dfef85 100644
--- a/crates/app/src/relay/agent.rs
+++ b/crates/app/src/relay/agent.rs
@@ -130,6 +130,7 @@ pub fn launch_agent_command(
name: &str,
role: Option<&str>,
task: Option<&str>,
+ sandbox: Option<&SandboxRef>,
) -> String {
let r = resolve_provider(opts, provider);
let mut s = format!(
@@ -162,6 +163,9 @@ pub fn launch_agent_command(
for arg in &r.args {
s.push_str(&format!(" --agent-arg {}", sh_quote(arg)));
}
+ if let Some(sandbox) = sandbox {
+ s.push_str(&sandbox.flags());
+ }
keep_open(s)
}
@@ -169,9 +173,13 @@ pub fn launch_agent_command(
/// agent — the quick-launch menu entries. Reuses [`launch_agent_command`] (so the
/// token-optimization threading applies) with a generated unique name, the
/// default `worker` role, and no standing task.
-pub fn quick_launch_command(opts: &config::Options, provider: &str) -> String {
+pub fn quick_launch_command(
+ opts: &config::Options,
+ provider: &str,
+ sandbox: Option<&SandboxRef>,
+) -> String {
let name = unique_agent_name(provider);
- launch_agent_command(opts, provider, &name, None, None)
+ launch_agent_command(opts, provider, &name, None, None, sandbox)
}
/// A friendly display name for a provider, for menus. Built-ins get their brand
@@ -270,3 +278,41 @@ pub(crate) fn keep_open(cmd: String) -> String {
"{cmd} || {{ echo; echo '[relay] launch failed — check Settings → AI (is the server running?)'; exec \"${{SHELL:-/bin/sh}}\"; }}"
)
}
+
+/// Where an agent should run: inside the project's shared sandbox, so it lands
+/// in the same filesystem and toolchain as every other pane on the team.
+///
+/// Relay stays on the host either way — only the agent itself is exec'd inside
+/// the container — so this is a set of flags on the `relay launch` command
+/// rather than a different command.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SandboxRef {
+ pub engine: String,
+ pub name: String,
+ /// Working directory inside the container. With the identity mount the
+ /// sandbox uses, this is the project's own path.
+ pub workdir: String,
+}
+
+impl SandboxRef {
+ /// The `--sandbox …` flags to append to a `relay launch` command line.
+ pub fn flags(&self) -> String {
+ format!(
+ " --sandbox {} --sandbox-engine {} --sandbox-workdir {} --sandbox-relay-dir {}",
+ sh_quote(&self.name),
+ sh_quote(&self.engine),
+ sh_quote(&self.workdir),
+ sh_quote(container::RELAY_DIR),
+ )
+ }
+}
+
+/// `SandboxRef` for a resolved sandbox, or `None` when the agent runs on the
+/// host.
+pub fn sandbox_ref(sandbox: Option<&container::Sandbox>) -> Option {
+ sandbox.map(|s| SandboxRef {
+ engine: s.engine.binary().to_string(),
+ name: s.name.clone(),
+ workdir: s.workdir.clone(),
+ })
+}
diff --git a/crates/app/src/relay/mod.rs b/crates/app/src/relay/mod.rs
index b2b7c9f..4614304 100644
--- a/crates/app/src/relay/mod.rs
+++ b/crates/app/src/relay/mod.rs
@@ -70,7 +70,11 @@ pub fn save_agent_def(def: AgentDef) {
}
/// Build the launch command for a previously-saved agent.
-pub fn launch_saved_command(opts: &config::Options, name: &str) -> Option {
+pub fn launch_saved_command(
+ opts: &config::Options,
+ name: &str,
+ sandbox: Option<&SandboxRef>,
+) -> Option {
let def = list_agent_defs().into_iter().find(|d| d.name == name)?;
Some(launch_agent_command(
opts,
@@ -78,6 +82,7 @@ pub fn launch_saved_command(opts: &config::Options, name: &str) -> Option String {
/// Fixed state directory for the mesh, beside the config file, so every relay
/// call shares one mesh regardless of the calling pane's working directory.
-fn home() -> PathBuf {
+pub(crate) fn home() -> PathBuf {
config::default_path()
.and_then(|p| p.parent().map(|d| d.join("relay")))
.unwrap_or_else(|| PathBuf::from(".relay"))
diff --git a/crates/app/src/relay/team.rs b/crates/app/src/relay/team.rs
index 90d1b70..2cf1d9e 100644
--- a/crates/app/src/relay/team.rs
+++ b/crates/app/src/relay/team.rs
@@ -204,7 +204,12 @@ pub struct TeamPanes {
/// Resolve a roster into panes. The first member is the lead — it takes the
/// window's first pane and stays interactive for the human, and the rest split
/// off it.
-pub fn team_layout(opts: &config::Options, shape: &str, members: &[TeamMember]) -> TeamPanes {
+pub fn team_layout(
+ opts: &config::Options,
+ shape: &str,
+ members: &[TeamMember],
+ sandbox: Option<&SandboxRef>,
+) -> TeamPanes {
TeamPanes {
layout: crate::tiles::generate(shape, members.len()),
names: members.iter().map(|(m, _, _)| m.clone()).collect(),
@@ -212,7 +217,15 @@ pub fn team_layout(opts: &config::Options, shape: &str, members: &[TeamMember])
.iter()
.enumerate()
.map(|(i, (m, role, agent))| {
- Some(launch_member(opts, m, role, agent, i == 0, opts.ai_optimize_tokens))
+ Some(launch_member(
+ opts,
+ m,
+ role,
+ agent,
+ i == 0,
+ opts.ai_optimize_tokens,
+ sandbox,
+ ))
})
.collect(),
}
@@ -235,6 +248,7 @@ pub fn launch_member(
agent: &str,
lead: bool,
optimize: bool,
+ sandbox: Option<&SandboxRef>,
) -> String {
let flag = if lead { " --lead" } else { "" };
let opt = if optimize { " --optimize" } else { "" };
@@ -257,5 +271,10 @@ pub fn launch_member(
cmd.push_str(&format!(" --agent-arg {}", sh_quote(&arg)));
}
}
+ // Every member of a team lands in the same container, which is what makes
+ // it a *team*: one filesystem, one toolchain, one set of worktrees.
+ if let Some(sandbox) = sandbox {
+ cmd.push_str(&sandbox.flags());
+ }
keep_open(cmd)
}
diff --git a/crates/app/src/root/containers.rs b/crates/app/src/root/containers.rs
index ca19deb..0816527 100644
--- a/crates/app/src/root/containers.rs
+++ b/crates/app/src/root/containers.rs
@@ -16,7 +16,11 @@ impl WorkspaceView {
/// container (if it was an ephemeral run-fresh tab) and drop any attach
/// mapping pointing at it. Runs `docker rm -f ` detached, so a closing
/// tab does not block on the engine.
- pub(crate) fn on_item_closed(&mut self, item: ItemId) {
+ pub(crate) fn on_item_closed(&mut self, item: ItemId, cx: &mut Context) {
+ // The shared sandbox is refcounted, not tied to one tab: several panes
+ // and every agent on a team are inside the same container, so it is
+ // only retired when the last of them leaves.
+ self.sandbox_detach(item, cx);
if let Some(name) = self.kill_on_close.remove(&item) {
if let Some(engine) = self.container_engine() {
let _ = std::process::Command::new(engine.binary())
diff --git a/crates/app/src/root/dispatch.rs b/crates/app/src/root/dispatch.rs
index effbcd3..db0a70d 100644
--- a/crates/app/src/root/dispatch.rs
+++ b/crates/app/src/root/dispatch.rs
@@ -78,6 +78,14 @@ impl WorkspaceView {
Action::NewTab => self.newtab(window, cx),
Action::NewContainerTab => crate::ospicker::open(window, cx),
Action::AttachContainer => crate::attachpicker::open(window, cx),
+ Action::SandboxShell => self.open_sandbox_shell(window, cx),
+ Action::ToggleSandbox => self.toggle_sandbox(window, cx),
+ Action::SandboxStart => self.sandbox_start(window, cx),
+ Action::SandboxStop => self.sandbox_stop(cx),
+ Action::SandboxRebuild => self.sandbox_rebuild(window, cx),
+ // Everything worth knowing about the sandbox is in the Containers
+ // panel, so "status" opens it rather than inventing another surface.
+ Action::SandboxStatus => self.toggle_sidebar("left:containers", cx),
Action::CloseSurface | Action::CloseTab => {
let item = self.active_item(cx);
self.close_item(item, window, cx);
@@ -226,14 +234,19 @@ impl WorkspaceView {
Action::CheckUpdates => crate::updateui::check_now(cx),
Action::AgentDef(name) => {
self.with_relay_running(window, cx, move |this, window, cx| {
- if let Some(cmd) = crate::relay::launch_saved_command(&this.opts, &name) {
+ let sandbox = this.sandbox_ref();
+ if let Some(cmd) =
+ crate::relay::launch_saved_command(&this.opts, &name, sandbox.as_ref())
+ {
this.splitcommand(&cmd, SplitAxis::Horizontal, false, window, cx);
}
});
}
Action::LaunchAgent(provider) => {
self.with_relay_running(window, cx, move |this, window, cx| {
- let cmd = crate::relay::quick_launch_command(&this.opts, &provider);
+ let sandbox = this.sandbox_ref();
+ let cmd =
+ crate::relay::quick_launch_command(&this.opts, &provider, sandbox.as_ref());
this.splitcommand(&cmd, SplitAxis::Horizontal, false, window, cx);
});
}
diff --git a/crates/app/src/root/menus.rs b/crates/app/src/root/menus.rs
index 4bfb8d5..f416ce5 100644
--- a/crates/app/src/root/menus.rs
+++ b/crates/app/src/root/menus.rs
@@ -118,10 +118,12 @@ impl WorkspaceView {
// Only built when AI is enabled (see `setmenus`), so the server is available
// on demand — no need to gate the contents on the persistent-mesh setting.
fn ai_menu(&self, a: &mut Vec, cx: &App) -> Menu {
- let mut items: Vec> = Vec::new();
- items.push(Some(MenuItem::submenu(self.agents_submenu(a))));
- items.push(Some(MenuItem::submenu(self.relay_submenu(a, cx))));
- items.push(self.pick(a, "Open Feed", Action::RelayFeed));
+ let mut items: Vec > = vec![
+ Some(MenuItem::submenu(self.agents_submenu(a))),
+ Some(MenuItem::submenu(self.relay_submenu(a, cx))),
+ Some(MenuItem::submenu(self.sandbox_submenu(a))),
+ self.pick(a, "Open Feed", Action::RelayFeed),
+ ];
let mut t: Vec > = vec![self.pick(a, "Build Team\u{2026}", Action::BuildTeam)];
if !self.menu_teams.is_empty() {
t.push(Some(MenuItem::separator()));
@@ -220,6 +222,35 @@ impl WorkspaceView {
)
}
+ /// Sandbox controls (AI → Sandbox). The shared container is a per-project
+ /// setting, so the toggle here writes `sandbox-enabled` rather than
+ /// flipping something that lasts only for this session. The first row is a
+ /// live status line — during a first run it names the step in progress,
+ /// because building an image takes minutes and a frozen menu explains
+ /// nothing.
+ fn sandbox_submenu(&self, a: &mut Vec) -> Menu {
+ let on = self.opts.sandbox_enabled;
+ let up = self.sandbox.is_some();
+ let mut items: Vec> = vec![
+ Some(Self::status_item(&self.sandbox_summary())),
+ Some(MenuItem::separator()),
+ Some(self.pick_checked(a, "Use Sandbox for This Project", Action::ToggleSandbox, on)),
+ ];
+ if on {
+ items.push(Some(MenuItem::separator()));
+ items.push(self.pick(a, "Open Shell in Sandbox", Action::SandboxShell));
+ if up {
+ items.push(self.pick(a, "Stop Sandbox", Action::SandboxStop));
+ } else {
+ items.push(self.pick(a, "Start Sandbox", Action::SandboxStart));
+ }
+ items.push(self.pick(a, "Rebuild Sandbox", Action::SandboxRebuild));
+ items.push(Some(MenuItem::separator()));
+ items.push(self.pick(a, "Show in Containers Panel", Action::SandboxStatus));
+ }
+ Self::menu("Sandbox", items)
+ }
+
/// Top-level Plugins menu: each installed plugin (click opens its primary
/// surface — a webview, else a panel, else its first command), then a
/// "Manage Plugins…" item that opens the Plugins drawer (browse + install).
@@ -304,6 +335,10 @@ impl WorkspaceView {
self.pick(a, "New Tab", Action::NewTab),
self.pick(a, "OS Tabs\u{2026}", Action::NewContainerTab),
self.pick(a, "Attach to Container\u{2026}", Action::AttachContainer),
+ // Also under AI: the sandbox is where a team works, but it is
+ // just as useful to a person alone, and the AI menu only
+ // appears once AI is enabled.
+ Some(MenuItem::submenu(self.sandbox_submenu(a))),
Some(MenuItem::separator()),
self.pick(a, "Notes", Action::Notes),
Some(MenuItem::separator()),
diff --git a/crates/app/src/root/mod.rs b/crates/app/src/root/mod.rs
index 2e4c547..bfa6352 100644
--- a/crates/app/src/root/mod.rs
+++ b/crates/app/src/root/mod.rs
@@ -19,6 +19,7 @@ mod persist;
mod pluginpanel;
mod quickopen;
mod render;
+pub(crate) mod sandbox;
mod savebuffer;
mod sidebar;
mod triggers;
@@ -431,6 +432,22 @@ pub struct WorkspaceView {
/// — `container-persist = false` — are tracked here; persistent ones are
/// left running.
kill_on_close: HashMap,
+ /// This project's shared sandbox, once it has been brought up. One
+ /// container serves every pane and every agent, so it is resolved once per
+ /// window and cached here.
+ sandbox: Option,
+ /// Panes currently attached to the sandbox. The container outlives any one
+ /// of them; the count is what decides when it may be retired.
+ sandbox_panes: HashSet,
+ /// Live progress or the last failure, shown in the AI menu and the
+ /// Containers panel. `None` when the sandbox is simply idle.
+ sandbox_status: Option,
+ /// Advisories from the last resolve (a devcontainer that stops on close, a
+ /// mount that did not parse), surfaced in the Containers panel.
+ sandbox_notes: Vec,
+ /// True while a resolve is in flight, so a second click does not start a
+ /// second build.
+ sandbox_busy: bool,
/// Configured font size, restored by `reset_font_size`.
base_font_size: gpui::Pixels,
/// Config-file watcher; kept alive so live reload keeps working.
@@ -546,6 +563,11 @@ impl WorkspaceView {
spawn_error: None,
container_tabs: HashMap::new(),
kill_on_close: HashMap::new(),
+ sandbox: None,
+ sandbox_panes: HashSet::new(),
+ sandbox_status: None,
+ sandbox_notes: Vec::new(),
+ sandbox_busy: false,
_watch: None,
verified_agents: None,
dark: is_dark(window.appearance()),
@@ -789,7 +811,7 @@ impl WorkspaceView {
self.close_window(window, cx);
return;
}
- self.on_item_closed(item);
+ self.on_item_closed(item, cx);
// Dropping the Item drops the TerminalView (and its pty/subscription).
self.items.borrow_mut().remove(&item);
self.group.update(cx, |g, cx| g.close_item(item, cx));
diff --git a/crates/app/src/root/persist.rs b/crates/app/src/root/persist.rs
index 50b8961..9e5ebba 100644
--- a/crates/app/src/root/persist.rs
+++ b/crates/app/src/root/persist.rs
@@ -330,9 +330,26 @@ impl WorkspaceView {
}
/// Open a Relay team: a tile of agents, each pane launched into the mesh.
- /// The daemon start and the `team info` subprocess both block, so they run
- /// on the background executor; the splits open in the completion callback.
+ ///
+ /// When the project uses a sandbox, it is brought up *first*: every member
+ /// has to land in the same container, and a member launched before it
+ /// exists would end up on the host, working in a different toolchain from
+ /// the rest of its team.
pub(crate) fn open_team(&mut self, name: &str, window: &mut Window, cx: &mut Context) {
+ if self.opts.sandbox_enabled && self.sandbox.is_none() {
+ let name = name.to_string();
+ self.with_sandbox(window, cx, move |this, _sandbox, window, cx| {
+ this.open_team_panes(&name, window, cx);
+ });
+ return;
+ }
+ self.open_team_panes(name, window, cx);
+ }
+
+ /// The team open itself. The daemon start and the `team info` subprocess
+ /// both block, so they run on the background executor; the splits open in
+ /// the completion callback.
+ fn open_team_panes(&mut self, name: &str, window: &mut Window, cx: &mut Context) {
let Some(handle) = window.window_handle().downcast::() else {
return;
};
@@ -355,7 +372,9 @@ impl WorkspaceView {
if members.is_empty() {
return;
}
- let panes = crate::relay::team_layout(&view.opts, &shape, &members);
+ let sandbox = view.sandbox_ref();
+ let panes =
+ crate::relay::team_layout(&view.opts, &shape, &members, sandbox.as_ref());
if view.opts.relay_team_window {
// The team gets a window of its own: every pane in it is a
// member, the dividers between them resize, and the layout
diff --git a/crates/app/src/root/sandbox.rs b/crates/app/src/root/sandbox.rs
new file mode 100644
index 0000000..3f845e4
--- /dev/null
+++ b/crates/app/src/root/sandbox.rs
@@ -0,0 +1,330 @@
+//! Host side of the shared project sandbox.
+//!
+//! One container serves a whole project: the human's panes and every agent on
+//! a team all `exec` into it, so there is a single filesystem and a single
+//! toolchain rather than one per participant. That makes three things this
+//! module's job:
+//!
+//! - **Resolve once.** Bringing the sandbox up can build an image, so it runs
+//! on the background executor and the result is cached on the window.
+//! - **Count panes, not tabs.** The container outlives any single pane. It is
+//! retired when the last one leaves, and only if Sinclair created it.
+//! - **Never touch what it did not create.** A container VS Code built very
+//! likely has the user's editor attached to it.
+
+use super::*;
+use gpui::prelude::*;
+
+/// The active workspace window's sandbox, for surfaces that build a launch
+/// command from outside a window (the New Agent picker). `None` when no
+/// sandbox is up, which is a host launch.
+pub(crate) fn active_ref(app: &mut App) -> Option {
+ let handle = crate::mcpbridge::active_workspace(app)?;
+ handle
+ .update(app, |ws, _window, _cx| ws.sandbox_ref())
+ .ok()
+ .flatten()
+}
+
+impl WorkspaceView {
+ /// The project the sandbox serves: the repository containing the focused
+ /// pane, else that pane's own working directory.
+ ///
+ /// The repository root, not the pane's cwd, because a pane sitting three
+ /// directories down would otherwise get its own sandbox with only that
+ /// subtree mounted — and the team would be split across containers.
+ pub(crate) fn sandbox_project(&self, cx: &App) -> Option {
+ let cwd = self.focused_cwd_path(cx)?;
+ let dir = crate::sandbox::repo_root(&cwd).unwrap_or(cwd);
+ let path = dir.to_string_lossy().trim_end_matches('/').to_string();
+ // A sandbox identity-mounts what it is given. Refusing `$HOME` and `/`
+ // is the difference between mounting a project and handing an agent
+ // with its prompts bypassed the user's entire machine.
+ if path.is_empty() || crate::sandbox::too_broad_to_mount(&path) {
+ return None;
+ }
+ Some(path)
+ }
+
+ /// This window's sandbox as launch flags, or `None` for a host launch.
+ pub(crate) fn sandbox_ref(&self) -> Option {
+ crate::relay::sandbox_ref(self.sandbox.as_ref().map(|r| &r.sandbox))
+ }
+
+ /// One line describing the sandbox for menus and the sidebar.
+ pub(crate) fn sandbox_summary(&self) -> String {
+ if let Some(status) = &self.sandbox_status {
+ return status.clone();
+ }
+ match &self.sandbox {
+ Some(ready) => {
+ let who = if ready.adopted { ready.owner.label() } else { "Sinclair" };
+ format!(
+ "Running \u{00b7} {} pane{} \u{00b7} {who}",
+ self.sandbox_panes.len(),
+ if self.sandbox_panes.len() == 1 { "" } else { "s" }
+ )
+ }
+ None if !self.opts.sandbox_enabled => "Off for this project".to_string(),
+ None if self.container_engine().is_none() => {
+ "No container engine (install Docker or Podman)".to_string()
+ }
+ None => "Not started".to_string(),
+ }
+ }
+
+ /// Resolve settings, the project, and any `devcontainer.json` into a spec.
+ /// Reads the config file from disk, so it is called off the render path.
+ fn sandbox_spec(&self, project: &str) -> crate::sandbox::Spec {
+ let (dc, note) = if self.opts.sandbox_devcontainer {
+ crate::sandbox::read_devcontainer(project)
+ } else {
+ (None, None)
+ };
+ let engine = self.container_engine().unwrap_or(container::Engine::Docker);
+ let relay_dir = crate::relay::home().join("sandbox");
+ let relay_dir = relay_dir.to_string_lossy().into_owned();
+ let _ = std::fs::create_dir_all(&relay_dir);
+ let mut spec = crate::sandbox::build(
+ &self.opts,
+ &crate::sandbox::Env {
+ engine,
+ project,
+ devcontainer: dc.as_ref(),
+ host_user: crate::sandbox::owner_of(project),
+ relay_dir: Some(&relay_dir),
+ default_agent: &self.opts.relay_default_agent,
+ },
+ );
+ if let Some(note) = note {
+ spec.notes.push(note);
+ }
+ spec
+ }
+
+ /// Bring the sandbox up (if it is not already) and hand it to `then`.
+ ///
+ /// The engine calls block — a first run pulls a base image and installs an
+ /// agent CLI — so they happen on the background executor and `then` runs
+ /// back on the window once the container is ready.
+ pub(crate) fn with_sandbox(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context,
+ then: impl FnOnce(&mut Self, container::Sandbox, &mut Window, &mut Context) + 'static,
+ ) {
+ if let Some(ready) = &self.sandbox {
+ let sandbox = ready.sandbox.clone();
+ then(self, sandbox, window, cx);
+ return;
+ }
+ let Some(handle) = window.window_handle().downcast::() else {
+ return;
+ };
+ if self.container_engine().is_none() {
+ self.sandbox_fail("No container engine found. Install Docker or Podman.", cx);
+ return;
+ }
+ let Some(project) = self.sandbox_project(cx) else {
+ self.sandbox_fail(
+ "No project here to sandbox. Open a pane inside a repository — a sandbox \
+ mounts what it is given, and your home directory is too much to hand an \
+ agent.",
+ cx,
+ );
+ return;
+ };
+ if self.sandbox_busy {
+ return;
+ }
+ self.sandbox_busy = true;
+ self.sandbox_status = Some(crate::sandbox::Stage::Looking.label().to_string());
+ cx.notify();
+
+ let spec = self.sandbox_spec(&project);
+ let executor = cx.background_executor().clone();
+
+ // A first run pulls a base image and installs an agent CLI, which is
+ // minutes, not milliseconds. The worker records what it is doing and a
+ // foreground ticker repaints the status line, so the wait is legible
+ // instead of a frozen menu item.
+ let stage = std::sync::Arc::new(std::sync::Mutex::new(crate::sandbox::Stage::Looking));
+ let ticker = stage.clone();
+ let ticks = executor.clone();
+ cx.spawn(async move |_this, cx| loop {
+ ticks.timer(std::time::Duration::from_millis(200)).await;
+ let now = *ticker.lock().unwrap_or_else(|e| e.into_inner());
+ let running = handle
+ .update(cx, |view, _window, cx| {
+ if view.sandbox_busy {
+ view.sandbox_status = Some(now.label().to_string());
+ cx.notify();
+ }
+ view.sandbox_busy
+ })
+ .unwrap_or(false);
+ if !running {
+ break;
+ }
+ })
+ .detach();
+
+ cx.spawn(async move |_this, cx| {
+ let result = executor
+ .spawn(async move {
+ crate::sandbox::ensure(&spec, &move |s| {
+ *stage.lock().unwrap_or_else(|e| e.into_inner()) = s;
+ })
+ })
+ .await;
+ let _ = handle.update(cx, |view, window, cx| {
+ view.sandbox_busy = false;
+ match result {
+ Ok(ready) => {
+ for note in &ready.notes {
+ eprintln!("sinclair: sandbox: {note}");
+ }
+ view.sandbox_notes = ready.notes.clone();
+ view.sandbox_status = None;
+ let sandbox = ready.sandbox.clone();
+ view.sandbox = Some(ready);
+ view.setmenus(cx);
+ then(view, sandbox, window, cx);
+ }
+ Err(e) => view.sandbox_fail(&e, cx),
+ }
+ });
+ })
+ .detach();
+ }
+
+ /// Record a failure where the user can see it: the menu status line, the
+ /// sidebar, and stderr.
+ fn sandbox_fail(&mut self, message: &str, cx: &mut Context) {
+ eprintln!("sinclair: sandbox: {message}");
+ self.sandbox_busy = false;
+ self.sandbox_status = Some(message.to_string());
+ self.setmenus(cx);
+ cx.notify();
+ }
+
+ /// Open an interactive login shell in the sandbox, in a new tab.
+ pub(crate) fn open_sandbox_shell(&mut self, window: &mut Window, cx: &mut Context) {
+ self.with_sandbox(window, cx, |this, sandbox, window, cx| {
+ let cwd = this.sandbox_project(cx);
+ let argv = sandbox.shell_argv(cwd.as_deref());
+ let Some(id) = this.spawn_tab_argv(argv, window, cx) else {
+ return;
+ };
+ this.group.update(cx, |g, cx| g.add_to_focused(id, cx));
+ this.rename_item(id, "Sandbox", cx);
+ this.sandbox_panes.insert(id);
+ // The status line counts attached panes, so it has to be rebuilt
+ // *after* this one joins — `with_sandbox` rebuilds before handing
+ // the sandbox over, and takes a shortcut that skips it entirely
+ // when the container is already up.
+ this.setmenus(cx);
+ this.focusactive(window, cx);
+ cx.notify();
+ });
+ }
+
+ /// Bring the container up without opening anything.
+ pub(crate) fn sandbox_start(&mut self, window: &mut Window, cx: &mut Context) {
+ self.with_sandbox(window, cx, |this, _sandbox, _window, cx| {
+ this.refresh_containers();
+ cx.notify();
+ });
+ }
+
+ /// Stop the container. Panes inside it end with it, which is why the menu
+ /// item says so.
+ pub(crate) fn sandbox_stop(&mut self, cx: &mut Context) {
+ let Some(ready) = self.sandbox.take() else {
+ return;
+ };
+ if !ready.owner.may_remove() {
+ self.sandbox = Some(ready);
+ self.sandbox_fail(
+ "This container was not created by Sinclair, so it is left alone.",
+ cx,
+ );
+ return;
+ }
+ let sandbox = ready.sandbox.clone();
+ let owner = ready.owner;
+ cx.background_executor()
+ .spawn(async move {
+ crate::sandbox::ensure::stop(&sandbox, owner);
+ })
+ .detach();
+ self.sandbox_panes.clear();
+ self.sandbox_status = None;
+ self.setmenus(cx);
+ cx.notify();
+ }
+
+ /// Remove the container so the next open rebuilds it from the current
+ /// settings. Anything written outside the mounted project is lost.
+ pub(crate) fn sandbox_rebuild(&mut self, window: &mut Window, cx: &mut Context) {
+ if let Some(ready) = self.sandbox.take() {
+ if !ready.owner.may_remove() {
+ self.sandbox = Some(ready);
+ self.sandbox_fail(
+ "This container was not created by Sinclair, so it is left alone.",
+ cx,
+ );
+ return;
+ }
+ let sandbox = ready.sandbox.clone();
+ let owner = ready.owner;
+ // Blocking: the next step must not race the removal.
+ crate::sandbox::ensure::remove(&sandbox, owner);
+ }
+ self.sandbox_panes.clear();
+ self.sandbox_start(window, cx);
+ }
+
+ /// Flip `sandbox-enabled` and write it back to the settings file, so the
+ /// menu toggle is a real setting rather than a session-only switch.
+ pub(crate) fn toggle_sandbox(&mut self, window: &mut Window, cx: &mut Context) {
+ let on = !self.opts.sandbox_enabled;
+ self.opts.sandbox_enabled = on;
+ crate::confwrite::upsert("sandbox-enabled", if on { "true" } else { "false" });
+ if !on {
+ self.sandbox_stop(cx);
+ } else {
+ self.sandbox_start(window, cx);
+ }
+ self.setmenus(cx);
+ cx.notify();
+ }
+
+ /// A pane using the sandbox has closed. The container outlives individual
+ /// panes, so this only retires it when the last one leaves *and* the user
+ /// asked for that — and never when Sinclair did not create it.
+ pub(crate) fn sandbox_detach(&mut self, item: ItemId, cx: &mut Context) {
+ if !self.sandbox_panes.remove(&item) {
+ return;
+ }
+ self.setmenus(cx);
+ if !self.sandbox_panes.is_empty() {
+ return;
+ }
+ if self.opts.sandbox_persist {
+ return;
+ }
+ let Some(ready) = &self.sandbox else {
+ return;
+ };
+ if !ready.owner.may_remove() {
+ return;
+ }
+ let sandbox = ready.sandbox.clone();
+ let owner = ready.owner;
+ std::thread::spawn(move || {
+ crate::sandbox::ensure::stop(&sandbox, owner);
+ });
+ self.sandbox = None;
+ }
+}
diff --git a/crates/app/src/root/sidebar.rs b/crates/app/src/root/sidebar.rs
index ee70738..75e481a 100644
--- a/crates/app/src/root/sidebar.rs
+++ b/crates/app/src/root/sidebar.rs
@@ -343,6 +343,43 @@ impl WorkspaceView {
.into_any_element();
};
+ body = body.child(self.sidebar_section("Project sandbox"));
+ body = body.child(self.sidebar_note(&self.sandbox_summary()));
+ if self.opts.sandbox_enabled {
+ let label = if self.sandbox.is_some() {
+ "\u{25b8} Open shell in sandbox"
+ } else {
+ "\u{25b8} Start sandbox"
+ };
+ body = body.child(
+ self.sidebar_row(("sb-sbx-open", 0usize), label.into(), false, false, false)
+ .on_click(cx.listener(|this, _: &gpui::ClickEvent, window, cx| {
+ this.open_sandbox_shell(window, cx);
+ })),
+ );
+ if self.sandbox.is_some() {
+ body = body.child(
+ self.sidebar_row(("sb-sbx-stop", 0usize), "\u{25a0} Stop sandbox".into(), false, false, false)
+ .on_click(cx.listener(|this, _: &gpui::ClickEvent, _w, cx| {
+ this.sandbox_stop(cx);
+ })),
+ );
+ }
+ // Advisories from the last resolve: a devcontainer that stops when
+ // the editor closes, a mount that did not parse. Worth seeing
+ // before a team starts working in here.
+ for note in &self.sandbox_notes {
+ body = body.child(self.sidebar_note(note));
+ }
+ } else {
+ body = body.child(
+ self.sidebar_row(("sb-sbx-on", 0usize), "\u{25b8} Use a sandbox here".into(), false, false, false)
+ .on_click(cx.listener(|this, _: &gpui::ClickEvent, window, cx| {
+ this.toggle_sandbox(window, cx);
+ })),
+ );
+ }
+
body = body.child(self.sidebar_section(&format!("Running \u{00b7} {}", engine.label())));
if self.containers.is_empty() {
body = body.child(self.sidebar_note("No running containers."));
@@ -524,7 +561,12 @@ impl WorkspaceView {
.on_click(cx.listener(move |this, _: &gpui::ClickEvent, window, cx| {
let name = name.clone();
this.with_relay_running(window, cx, move |this, window, cx| {
- if let Some(cmd) = crate::relay::launch_saved_command(&this.opts, &name)
+ let sandbox = this.sandbox_ref();
+ if let Some(cmd) = crate::relay::launch_saved_command(
+ &this.opts,
+ &name,
+ sandbox.as_ref(),
+ )
{
this.splitcommand(&cmd, SplitAxis::Horizontal, false, window, cx);
}
diff --git a/crates/app/src/sandbox/ensure.rs b/crates/app/src/sandbox/ensure.rs
new file mode 100644
index 0000000..8a71cd3
--- /dev/null
+++ b/crates/app/src/sandbox/ensure.rs
@@ -0,0 +1,184 @@
+//! Bringing a sandbox up: the blocking engine calls, in order.
+//!
+//! Resolution always looks before it creates. A project may already have a
+//! container — one Sinclair started earlier, or one VS Code's Dev Containers
+//! extension built — and starting a second beside it would split the team
+//! across two filesystems. Whatever is found is entered; only a project with
+//! nothing running gets a fresh container.
+//!
+//! Every call here blocks on a subprocess, so this runs on the background
+//! executor, never on the render path.
+
+use super::spec::Spec;
+use container::{adopt, Owner, Sandbox, State};
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+/// What the host is doing, reported as it happens so the UI can say something
+/// truthful during a first run that pulls a base image.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Stage {
+ Looking,
+ Building,
+ Creating,
+ Starting,
+ Ready,
+}
+
+impl Stage {
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Looking => "Looking for the sandbox\u{2026}",
+ Self::Building => "Building the sandbox image\u{2026}",
+ Self::Creating => "Creating the sandbox\u{2026}",
+ Self::Starting => "Starting the sandbox\u{2026}",
+ Self::Ready => "Sandbox ready",
+ }
+ }
+}
+
+/// A sandbox that is up and can be exec'd into.
+#[derive(Debug, Clone)]
+pub struct Ready {
+ pub sandbox: Sandbox,
+ /// Who created the container. Sinclair only ever stops or removes its own:
+ /// a container VS Code built very likely has the user's editor attached.
+ pub owner: Owner,
+ /// True when an existing container was entered rather than created.
+ pub adopted: bool,
+ pub notes: Vec,
+}
+
+/// Bring `spec` up, reporting progress through `report`.
+pub fn ensure(spec: &Spec, report: &dyn Fn(Stage)) -> Result {
+ let engine = spec.sandbox.engine;
+ let mut notes = spec.notes.clone();
+
+ report(Stage::Looking);
+ let found = run(&adopt::find_argv(engine, &spec.project))
+ .map(|out| adopt::parse_found(&out))
+ .unwrap_or_default();
+
+ // Someone else's container for this project, already up: enter it rather
+ // than building a parallel one.
+ if let Some(f) = adopt::best(&found).filter(|f| f.owner == Owner::Foreign && f.state.is_running())
+ {
+ notes.push(format!(
+ "Attached to `{}`, a container Sinclair did not create. It will not be stopped or \
+ removed from here.",
+ f.name
+ ));
+ let mut sandbox = spec.sandbox.clone();
+ sandbox.name = f.name.clone();
+ report(Stage::Ready);
+ return Ok(Ready {
+ sandbox,
+ owner: Owner::Foreign,
+ adopted: true,
+ notes,
+ });
+ }
+
+ let sandbox = &spec.sandbox;
+ match state(sandbox)? {
+ State::Running => {}
+ s if s.is_stopped() => {
+ report(Stage::Starting);
+ run(&sandbox.start_argv()).map_err(|e| format!("could not start the sandbox: {e}"))?;
+ }
+ _ => {
+ if let Some(recipe) = &spec.recipe {
+ let tag = recipe.tag();
+ let built = run(&container::exists_argv(engine, &tag))
+ .map(|out| !out.trim().is_empty())
+ .unwrap_or(false);
+ if !built {
+ report(Stage::Building);
+ build(recipe, engine)?;
+ }
+ }
+ report(Stage::Creating);
+ run(&sandbox.create_argv())
+ .map_err(|e| format!("could not create the sandbox: {e}"))?;
+ }
+ }
+
+ report(Stage::Ready);
+ Ok(Ready {
+ sandbox: sandbox.clone(),
+ owner: Owner::Sinclair,
+ adopted: false,
+ notes,
+ })
+}
+
+/// The container's current state, or [`State::Missing`] when the engine cannot
+/// be reached at all (which surfaces as a create attempt and a real error).
+fn state(sandbox: &Sandbox) -> Result {
+ Ok(container::parse_state(&run(&sandbox.state_argv()).unwrap_or_default()))
+}
+
+/// Build the recipe's image, piping the generated Dockerfile in on stdin so
+/// there is no build context on disk to create or clean up.
+fn build(recipe: &container::Recipe, engine: container::Engine) -> Result<(), String> {
+ let argv = recipe.build_argv(engine);
+ let mut child = Command::new(&argv[0])
+ .args(&argv[1..])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::null())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| format!("could not run {}: {e}", argv[0]))?;
+ child
+ .stdin
+ .take()
+ .ok_or("no stdin on the build")?
+ .write_all(recipe.dockerfile().as_bytes())
+ .map_err(|e| format!("could not send the Dockerfile: {e}"))?;
+ let out = child
+ .wait_with_output()
+ .map_err(|e| format!("build failed: {e}"))?;
+ if out.status.success() {
+ return Ok(());
+ }
+ let err = String::from_utf8_lossy(&out.stderr);
+ Err(format!(
+ "could not build the sandbox image: {}",
+ last_line(&err)
+ ))
+}
+
+/// Stop a sandbox, if Sinclair is allowed to. Returns whether it acted.
+pub fn stop(sandbox: &Sandbox, owner: Owner) -> bool {
+ owner.may_remove() && run(&sandbox.stop_argv()).is_ok()
+}
+
+/// Remove a sandbox, if Sinclair is allowed to.
+pub fn remove(sandbox: &Sandbox, owner: Owner) -> bool {
+ owner.may_remove() && run(&sandbox.rm_argv()).is_ok()
+}
+
+/// Run an argv to completion, returning stdout. A non-zero exit is an error
+/// carrying the engine's last line of stderr, which is the part worth showing.
+fn run(argv: &[String]) -> Result {
+ let out = Command::new(&argv[0])
+ .args(&argv[1..])
+ .output()
+ .map_err(|e| format!("could not run {}: {e}", argv[0]))?;
+ if out.status.success() {
+ Ok(String::from_utf8_lossy(&out.stdout).into_owned())
+ } else {
+ Err(last_line(&String::from_utf8_lossy(&out.stderr)))
+ }
+}
+
+/// The last non-empty line of engine output — engines print progress before
+/// the message that actually explains the failure.
+fn last_line(s: &str) -> String {
+ s.lines()
+ .rev()
+ .map(str::trim)
+ .find(|l| !l.is_empty())
+ .unwrap_or("unknown error")
+ .to_string()
+}
diff --git a/crates/app/src/sandbox/mod.rs b/crates/app/src/sandbox/mod.rs
new file mode 100644
index 0000000..044b9a5
--- /dev/null
+++ b/crates/app/src/sandbox/mod.rs
@@ -0,0 +1,91 @@
+//! The shared project sandbox: one container that the human and every agent on
+//! a team work inside together.
+//!
+//! Sinclair creates and owns it using nothing but `docker` or `podman` — no
+//! VS Code, no `devcontainer` CLI, no registry to pull a Sinclair image from.
+//! When a project happens to have a `.devcontainer/devcontainer.json`, or an
+//! editor already has a container up for it, those are used; when it does not,
+//! nothing degrades.
+//!
+//! - [`spec`] resolves settings into a container description (pure).
+//! - [`ensure`] brings that description up (blocking engine calls).
+//!
+//! The host layer that counts attached panes and decides when to retire the
+//! container lives in `root::containers`.
+
+pub mod ensure;
+pub mod spec;
+
+pub use ensure::{ensure, Ready, Stage};
+pub use spec::{build, Env, Spec};
+
+/// Read the project's `devcontainer.json`, if it has one Sinclair can parse.
+///
+/// A malformed file is not an error worth blocking on — the sandbox works
+/// without it — so it comes back as `None` plus a note for the user.
+pub fn read_devcontainer(project: &str) -> (Option, Option) {
+ for path in container::devcontainer::config_paths(project) {
+ let Ok(text) = std::fs::read_to_string(&path) else {
+ continue;
+ };
+ return match container::devcontainer::parse(&text, project) {
+ Ok(dc) => (Some(dc), None),
+ Err(e) => (None, Some(format!("{path}: {e}"))),
+ };
+ }
+ (None, None)
+}
+
+/// The git repository `dir` belongs to, walking up until a `.git` is found.
+///
+/// A sandbox serves a *project*, so it has to resolve to the same directory
+/// from any pane inside it. Keying on the pane's own cwd instead would give a
+/// pane three levels down its own container, with only that subtree mounted.
+pub fn repo_root(dir: &std::path::Path) -> Option {
+ let mut cur = Some(dir);
+ while let Some(d) = cur {
+ if d.join(".git").exists() {
+ return Some(d.to_path_buf());
+ }
+ cur = d.parent();
+ }
+ None
+}
+
+/// Paths too broad to identity-mount into a sandbox.
+///
+/// The sandbox mounts what it is given. Mounting `$HOME` or `/` would hand an
+/// agent running with its permission prompts bypassed the whole machine, which
+/// is the exact thing the sandbox exists to prevent — so a pane that resolves
+/// to one of these gets no sandbox and an explanation instead.
+pub fn too_broad_to_mount(path: &str) -> bool {
+ let path = path.trim_end_matches('/');
+ if path.is_empty() || path == "/" {
+ return true;
+ }
+ // Anything at depth 1 (`/Users`, `/home`, `/opt`) is a shared parent, not a
+ // project. A home directory is depth 2 on macOS and Linux alike.
+ if path.matches('/').count() < 2 {
+ return true;
+ }
+ home_dir().is_some_and(|home| path == home.to_string_lossy().trim_end_matches('/'))
+}
+
+fn home_dir() -> Option {
+ std::env::var_os("HOME").map(std::path::PathBuf::from)
+}
+
+/// `uid:gid` owning `project`, for `sandbox-user: host`.
+pub fn owner_of(project: &str) -> Option {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::MetadataExt;
+ let meta = std::fs::metadata(project).ok()?;
+ Some(format!("{}:{}", meta.uid(), meta.gid()))
+ }
+ #[cfg(not(unix))]
+ {
+ let _ = project;
+ None
+ }
+}
diff --git a/crates/app/src/sandbox/spec.rs b/crates/app/src/sandbox/spec.rs
new file mode 100644
index 0000000..c098281
--- /dev/null
+++ b/crates/app/src/sandbox/spec.rs
@@ -0,0 +1,180 @@
+//! Turning settings (and a project's `devcontainer.json`, when it has one)
+//! into a concrete sandbox description.
+//!
+//! Pure resolution: no engine is contacted and no file is read here, so the
+//! whole policy — which image, which mounts, which user, what to warn about —
+//! is testable without Docker installed.
+
+use container::{Engine, Limits, Mount, Network, Recipe, Sandbox};
+
+/// A resolved sandbox plus everything the host needs to bring it up.
+#[derive(Debug, Clone)]
+pub struct Spec {
+ /// Absolute host path of the project the sandbox serves.
+ pub project: String,
+ pub sandbox: Sandbox,
+ /// The image to build first. `None` when the image is used as-is.
+ pub recipe: Option,
+ /// Things the user should know before agents start working in here.
+ pub notes: Vec,
+}
+
+/// Inputs the caller resolves from the environment before building a spec.
+pub struct Env<'a> {
+ pub engine: Engine,
+ pub project: &'a str,
+ /// The project's parsed `devcontainer.json`, when it has one and
+ /// `sandbox-devcontainer` is on.
+ pub devcontainer: Option<&'a container::DevContainer>,
+ /// `uid:gid` owning the project, used when `sandbox-user` is `host`.
+ pub host_user: Option,
+ /// Host directory holding generated relay MCP configs.
+ pub relay_dir: Option<&'a str>,
+ /// Agent CLIs to install when nothing is configured explicitly.
+ pub default_agent: &'a str,
+}
+
+/// Build the spec for `env` under `opts`.
+pub fn build(opts: &config::Options, env: &Env) -> Spec {
+ let project = env.project.trim_end_matches('/').to_string();
+ let mut notes = Vec::new();
+
+ let (image, recipe) = image_for(opts, env, &mut notes);
+ let mut sandbox = Sandbox::for_project(env.engine, &project, &image)
+ .with_home_volume(&container::home_volume_for(&project));
+ if let Some(dir) = env.relay_dir {
+ sandbox = sandbox.with_relay_dir(dir);
+ }
+
+ for raw in &opts.sandbox_mount {
+ match Mount::parse(raw) {
+ Ok(m) => sandbox.mounts.push(m),
+ Err(e) => notes.push(format!("sandbox-mount `{raw}`: {e}")),
+ }
+ }
+
+ // devcontainer env first, so an explicit `sandbox-env` still wins.
+ if let Some(dc) = env.devcontainer {
+ for (k, v) in &dc.env {
+ sandbox = sandbox.with_env(k, v);
+ }
+ }
+ for raw in &opts.sandbox_env {
+ match raw.split_once('=') {
+ Some((k, v)) if !k.trim().is_empty() => sandbox = sandbox.with_env(k.trim(), v),
+ _ => notes.push(format!("sandbox-env `{raw}`: expected KEY=VALUE")),
+ }
+ }
+
+ sandbox.user = user_for(opts, env, &mut notes);
+ if let Some(raw) = opts.sandbox_network.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ match Network::parse(raw) {
+ Some(n) => sandbox.network = n,
+ None => notes.push(format!("sandbox-network `{raw}`: expected bridge, host, or none")),
+ }
+ }
+ if sandbox.network == Network::None {
+ notes.push(
+ "sandbox-network is `none`: agents in this sandbox cannot reach the relay bus."
+ .to_string(),
+ );
+ }
+ sandbox.limits = Limits {
+ memory: opts.sandbox_memory.clone(),
+ cpus: opts.sandbox_cpus.clone(),
+ // A supervisor can spawn its cap of workers into one container; a pid
+ // ceiling is what keeps a runaway loop off the rest of the machine.
+ pids: Some(4096),
+ };
+
+ if let Some(dc) = env.devcontainer {
+ if dc.stops_on_close() {
+ notes.push(
+ "This project's devcontainer.json stops the container when the editor closes. \
+ Set \"shutdownAction\": \"none\" so closing VS Code does not end a working team."
+ .to_string(),
+ );
+ }
+ }
+
+ Spec {
+ project,
+ sandbox,
+ recipe,
+ notes,
+ }
+}
+
+/// Which image to run, and whether Sinclair has to build it first.
+///
+/// An explicit `sandbox-image` is used verbatim — the user is saying the image
+/// is already right. Otherwise a thin layer is generated, using the project's
+/// devcontainer image as the base when it has one, so agents land in the same
+/// toolchain the humans use with the agent CLIs added on top.
+fn image_for(opts: &config::Options, env: &Env, notes: &mut Vec) -> (String, Option) {
+ if let Some(image) = opts.sandbox_image.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ return (image.to_string(), None);
+ }
+ let mut agents: Vec = opts
+ .sandbox_agents
+ .iter()
+ .map(|a| a.trim().to_string())
+ .filter(|a| !a.is_empty())
+ .collect();
+ if agents.is_empty() {
+ agents.push(env.default_agent.to_string());
+ }
+ let base = env
+ .devcontainer
+ .and_then(|dc| dc.image.clone())
+ .filter(|s| !s.trim().is_empty())
+ .or_else(|| opts.sandbox_base.clone().filter(|s| !s.trim().is_empty()))
+ .unwrap_or_else(|| container::DEFAULT_BASE.to_string());
+ if env.devcontainer.is_some_and(|dc| dc.dockerfile.is_some()) && opts.sandbox_image.is_none() {
+ notes.push(
+ "This project's devcontainer.json builds from a Dockerfile, which Sinclair does not \
+ build. Set `sandbox-image` to the image it produces, or let the default base be used."
+ .to_string(),
+ );
+ }
+ let recipe = Recipe {
+ base,
+ packages: opts.sandbox_packages.clone(),
+ agents,
+ setup: opts.sandbox_setup.clone(),
+ };
+ (recipe.tag(), Some(recipe))
+}
+
+/// The `--user` the container runs as.
+///
+/// On Linux a container running as root writes root-owned files into the bind
+/// mount, i.e. into the user's own repository. `host` resolves to the uid that
+/// owns the project, which avoids it. macOS remaps ownership in the file
+/// sharing layer, so the default there is to leave it alone.
+fn user_for(opts: &config::Options, env: &Env, notes: &mut Vec) -> Option {
+ let raw = opts.sandbox_user.as_deref().map(str::trim).unwrap_or("");
+ match raw {
+ "" => {
+ if cfg!(target_os = "linux") && env.host_user.is_some() {
+ env.host_user.clone()
+ } else {
+ None
+ }
+ }
+ "host" => {
+ if env.host_user.is_none() {
+ notes.push("sandbox-user is `host` but the project's owner could not be read; \
+ running as the image's user."
+ .to_string());
+ }
+ env.host_user.clone()
+ }
+ "root" => None,
+ other => Some(other.to_string()),
+ }
+}
+
+#[cfg(test)]
+#[path = "../../tests/sandbox.rs"]
+mod tests;
diff --git a/crates/app/src/settings/schema/ai.rs b/crates/app/src/settings/schema/ai.rs
index 3166179..f5408bf 100644
--- a/crates/app/src/settings/schema/ai.rs
+++ b/crates/app/src/settings/schema/ai.rs
@@ -1,6 +1,6 @@
//! AI section: MCP, the Relay agent mesh, and the agent tool roster.
-use super::{list, opt, text, toggle, ListKind, Section, Setting};
+use super::{choice, list, opt, strs, text, toggle, ListKind, Section, Setting};
/// Keys the Agent-tools group lays out itself (toggle + Test button + path
/// and flags fields per tool). Kept out of the generic row list so the
@@ -88,6 +88,109 @@ pub(super) fn settings() -> Vec {
s,
|o| o.relay_team_window,
),
+ toggle(
+ "sandbox-enabled",
+ "Shared sandbox",
+ "Run this project's panes and agents inside one container, so a team shares a \
+ filesystem and a toolchain. Needs Docker or Podman.",
+ s,
+ |o| o.sandbox_enabled,
+ ),
+ text(
+ "sandbox-image",
+ "Sandbox image",
+ "Ready-made image to use as-is. Blank builds one from the base below and installs \
+ the agent CLI into it.",
+ s,
+ |o| opt(&o.sandbox_image),
+ "build one",
+ ),
+ text(
+ "sandbox-base",
+ "Sandbox base image",
+ "Base the generated image layers on. The generated layer uses apt, so a non-Debian \
+ base needs a ready-made image instead.",
+ s,
+ |o| opt(&o.sandbox_base),
+ container::DEFAULT_BASE,
+ ),
+ choice(
+ "sandbox-network",
+ "Sandbox network",
+ "How the sandbox reaches the network. `none` is the strictest, and cuts agents off \
+ from the Relay bus.",
+ s,
+ |o| o.sandbox_network.clone().unwrap_or_default(),
+ || strs(&["bridge", "host", "none"]),
+ Some("bridge"),
+ ),
+ text(
+ "sandbox-user",
+ "Sandbox user",
+ "User the container runs as. `host` uses your own uid, which keeps a Linux bind \
+ mount from filling with root-owned files.",
+ s,
+ |o| opt(&o.sandbox_user),
+ "host on Linux, image default on macOS",
+ ),
+ text(
+ "sandbox-memory",
+ "Sandbox memory limit",
+ "Ceiling for the whole sandbox, e.g. 8g. Blank is unlimited.",
+ s,
+ |o| opt(&o.sandbox_memory),
+ "unlimited",
+ ),
+ text(
+ "sandbox-cpus",
+ "Sandbox CPU limit",
+ "Ceiling for the whole sandbox, e.g. 4. Blank is unlimited.",
+ s,
+ |o| opt(&o.sandbox_cpus),
+ "unlimited",
+ ),
+ toggle(
+ "sandbox-persist",
+ "Keep the sandbox running",
+ "Leave the container up when the last pane using it closes. Rebuilding a toolchain \
+ every session is slow, and an idle container costs nothing.",
+ s,
+ |o| o.sandbox_persist,
+ ),
+ toggle(
+ "sandbox-devcontainer",
+ "Use devcontainer.json",
+ "Read a project's .devcontainer/devcontainer.json when it has one, and enter a \
+ container an editor already started for it.",
+ s,
+ |o| o.sandbox_devcontainer,
+ ),
+ list(
+ ListKind::SandboxAgents,
+ "Agent CLIs installed into the generated sandbox image. Empty installs the \
+ default agent.",
+ s,
+ ),
+ list(
+ ListKind::SandboxPackages,
+ "Extra apt packages baked into the generated sandbox image.",
+ s,
+ ),
+ list(
+ ListKind::SandboxSetup,
+ "Extra commands baked into the generated sandbox image, one layer each.",
+ s,
+ ),
+ list(
+ ListKind::SandboxMount,
+ "Extra sandbox mounts: source:target[:ro]. A bare path mounts at itself.",
+ s,
+ ),
+ list(
+ ListKind::SandboxEnv,
+ "Extra environment inside the sandbox: KEY=VALUE.",
+ s,
+ ),
toggle(
"agent-claude",
"Claude Code",
diff --git a/crates/app/src/settings/schema/lists.rs b/crates/app/src/settings/schema/lists.rs
index 091165f..96b95d9 100644
--- a/crates/app/src/settings/schema/lists.rs
+++ b/crates/app/src/settings/schema/lists.rs
@@ -17,6 +17,11 @@ pub enum ListKind {
Snippet,
Profile,
Container,
+ SandboxMount,
+ SandboxEnv,
+ SandboxPackages,
+ SandboxSetup,
+ SandboxAgents,
}
impl ListKind {
@@ -34,6 +39,11 @@ impl ListKind {
ListKind::Snippet => "snippet",
ListKind::Profile => "profile",
ListKind::Container => "container",
+ ListKind::SandboxMount => "sandbox-mount",
+ ListKind::SandboxEnv => "sandbox-env",
+ ListKind::SandboxPackages => "sandbox-packages",
+ ListKind::SandboxSetup => "sandbox-setup",
+ ListKind::SandboxAgents => "sandbox-agents",
}
}
@@ -50,6 +60,11 @@ impl ListKind {
ListKind::Snippet => "Snippets",
ListKind::Profile => "Profiles",
ListKind::Container => "OS profiles",
+ ListKind::SandboxMount => "Sandbox mounts",
+ ListKind::SandboxEnv => "Sandbox environment",
+ ListKind::SandboxPackages => "Sandbox packages",
+ ListKind::SandboxSetup => "Sandbox setup commands",
+ ListKind::SandboxAgents => "Sandbox agent CLIs",
}
}
@@ -66,6 +81,11 @@ impl ListKind {
ListKind::Snippet => "Add snippet",
ListKind::Profile => "Add profile",
ListKind::Container => "Add profile",
+ ListKind::SandboxMount => "Add mount",
+ ListKind::SandboxEnv => "Add variable",
+ ListKind::SandboxPackages => "Add package",
+ ListKind::SandboxSetup => "Add command",
+ ListKind::SandboxAgents => "Add agent",
}
}
@@ -82,6 +102,11 @@ impl ListKind {
ListKind::Snippet => "deploy | git push origin main",
ListKind::Profile => "prod | ssh prod.example.com",
ListKind::Container => "ubuntu | ubuntu:24.04 | bash",
+ ListKind::SandboxMount => "~/.ssh:/sandbox/home/.ssh:ro",
+ ListKind::SandboxEnv => "DATABASE_URL=postgres://localhost/dev",
+ ListKind::SandboxPackages => "ripgrep",
+ ListKind::SandboxSetup => "cargo install just",
+ ListKind::SandboxAgents => "claude",
}
}
@@ -106,6 +131,11 @@ impl ListKind {
ListKind::Snippet => o.snippet.clone(),
ListKind::Profile => o.profile.clone(),
ListKind::Container => o.container.clone(),
+ ListKind::SandboxMount => o.sandbox_mount.clone(),
+ ListKind::SandboxEnv => o.sandbox_env.clone(),
+ ListKind::SandboxPackages => o.sandbox_packages.clone(),
+ ListKind::SandboxSetup => o.sandbox_setup.clone(),
+ ListKind::SandboxAgents => o.sandbox_agents.clone(),
}
}
diff --git a/crates/app/tests/relay.rs b/crates/app/tests/relay.rs
index 9b428e7..7b71a12 100644
--- a/crates/app/tests/relay.rs
+++ b/crates/app/tests/relay.rs
@@ -81,6 +81,7 @@ fn launch_member_quotes_hostile_values() {
"$(whoami)",
true,
false,
+ None,
);
// Every interpolated value stays a single quoted shell token.
assert!(cmd.contains(" launch 'x'\\''; rm -rf /;'\\'''"), "member not quoted: {cmd}");
@@ -91,7 +92,7 @@ fn launch_member_quotes_hostile_values() {
#[test]
fn launch_member_omits_empty_agent_flag() {
- let cmd = launch_member(&config::Options::default(), "lead", "supervisor", " ", false, true);
+ let cmd = launch_member(&config::Options::default(), "lead", "supervisor", " ", false, true, None);
assert!(!cmd.contains("--agent "));
assert!(cmd.contains("--optimize"));
assert!(cmd.contains(" launch 'lead' --role 'supervisor'"));
@@ -105,10 +106,10 @@ fn launch_member_omits_empty_agent_flag() {
fn team_members_launch_unattended_by_default() {
let opts = config::Options::default();
assert!(opts.relay_team_autonomy);
- let cmd = launch_member(&opts, "backend", "backend", "", false, false);
+ let cmd = launch_member(&opts, "backend", "backend", "", false, false, None);
assert!(cmd.contains(" --skip-permissions"), "no bypass: {cmd}");
// Including the lead, so the whole team behaves the same way.
- let lead = launch_member(&opts, "lead", "supervisor", "", true, false);
+ let lead = launch_member(&opts, "lead", "supervisor", "", true, false, None);
assert!(lead.contains(" --skip-permissions"), "lead differs: {lead}");
}
@@ -118,7 +119,7 @@ fn team_autonomy_off_keeps_the_prompts() {
relay_team_autonomy: false,
..config::Options::default()
};
- let cmd = launch_member(&opts, "backend", "backend", "claude", false, false);
+ let cmd = launch_member(&opts, "backend", "backend", "claude", false, false, None);
assert!(!cmd.contains("--skip-permissions"), "bypassed anyway: {cmd}");
}
@@ -130,12 +131,12 @@ fn launch_member_forwards_configured_provider_flags() {
agent_claude_args: Some("--append-system-prompt \"be terse\"".into()),
..config::Options::default()
};
- let cmd = launch_member(&opts, "backend", "backend", "claude", false, false);
+ let cmd = launch_member(&opts, "backend", "backend", "claude", false, false, None);
assert!(cmd.contains("--agent-arg '--append-system-prompt'"), "{cmd}");
assert!(cmd.contains("--agent-arg 'be terse'"), "{cmd}");
// A member inheriting its role's agent has no provider to look flags up
// under, so it gets none — the permission bypass covers it instead.
- let inherited = launch_member(&opts, "backend", "backend", "", false, false);
+ let inherited = launch_member(&opts, "backend", "backend", "", false, false, None);
assert!(!inherited.contains("--agent-arg"), "{inherited}");
}
@@ -149,7 +150,7 @@ fn team_layout_gives_every_member_a_pane() {
.iter()
.map(|n| (n.to_string(), "worker".to_string(), String::new()))
.collect();
- let panes = team_layout(&opts, "columns", &members);
+ let panes = team_layout(&opts, "columns", &members, None);
assert_eq!(panes.layout.leaves(), members.len(), "a member with no pane can't launch");
assert_eq!(panes.commands.len(), members.len());
assert!(panes.commands.iter().all(Option::is_some));
@@ -169,7 +170,7 @@ fn team_layout_gives_every_member_a_pane() {
#[test]
fn team_layout_handles_a_lone_member() {
let members = vec![("solo".to_string(), "supervisor".to_string(), String::new())];
- let panes = team_layout(&config::Options::default(), "grid", &members);
+ let panes = team_layout(&config::Options::default(), "grid", &members, None);
assert_eq!(panes.layout.leaves(), 1);
assert_eq!(panes.commands.len(), 1);
assert_eq!(panes.names, vec!["solo"]);
diff --git a/crates/app/tests/sandbox.rs b/crates/app/tests/sandbox.rs
new file mode 100644
index 0000000..fc16e23
--- /dev/null
+++ b/crates/app/tests/sandbox.rs
@@ -0,0 +1,232 @@
+use super::*;
+use container::{DevContainer, Engine, Network};
+// Project resolution lives beside `spec`, not in it.
+use crate::sandbox::{repo_root, too_broad_to_mount};
+
+const PROJECT: &str = "/Users/wess/code/api";
+
+fn env<'a>(dc: Option<&'a DevContainer>) -> Env<'a> {
+ Env {
+ engine: Engine::Docker,
+ project: PROJECT,
+ devcontainer: dc,
+ host_user: Some("501:20".to_string()),
+ relay_dir: None,
+ default_agent: "claude",
+ }
+}
+
+fn opts() -> config::Options {
+ config::Options::default()
+}
+
+#[test]
+fn defaults_build_an_image_with_the_default_agent() {
+ let s = build(&opts(), &env(None));
+ let recipe = s.recipe.expect("a recipe when no image is pinned");
+ assert!(recipe.dockerfile().contains("@anthropic-ai/claude-code"));
+ assert_eq!(s.sandbox.image, recipe.tag());
+}
+
+#[test]
+fn an_explicit_image_is_used_verbatim_and_nothing_is_built() {
+ let mut o = opts();
+ o.sandbox_image = Some("ghcr.io/me/dev:latest".to_string());
+ let s = build(&o, &env(None));
+ assert_eq!(s.sandbox.image, "ghcr.io/me/dev:latest");
+ assert!(s.recipe.is_none());
+}
+
+#[test]
+fn the_project_is_identity_mounted() {
+ // The whole point: a path means the same thing inside and out, so a git
+ // worktree created on either side resolves from the other.
+ let s = build(&opts(), &env(None));
+ assert_eq!(s.sandbox.workdir, PROJECT);
+ assert!(s.sandbox.mounts.iter().any(|m| m.is_identity() && m.source == PROJECT));
+}
+
+#[test]
+fn the_agent_home_is_a_named_volume() {
+ // So one `claude login` survives recreating the container.
+ let s = build(&opts(), &env(None));
+ assert!(s
+ .sandbox
+ .mounts
+ .iter()
+ .any(|m| m.target == container::HOME_DIR && m.source.ends_with("-home")));
+}
+
+#[test]
+fn a_devcontainer_image_becomes_the_base_layer() {
+ // Agents land in the project's own toolchain, with the CLI added on top.
+ let dc = DevContainer {
+ image: Some("node:22".to_string()),
+ ..DevContainer::default()
+ };
+ let s = build(&opts(), &env(Some(&dc)));
+ let recipe = s.recipe.expect("still builds, to add the agent CLI");
+ assert!(recipe.dockerfile().starts_with("FROM node:22\n"));
+}
+
+#[test]
+fn a_devcontainer_that_stops_on_close_is_called_out() {
+ let dc = DevContainer::default();
+ let s = build(&opts(), &env(Some(&dc)));
+ assert!(s.notes.iter().any(|n| n.contains("shutdownAction")));
+}
+
+#[test]
+fn shutdown_action_none_is_not_flagged() {
+ let dc = DevContainer {
+ shutdown_action: Some("none".to_string()),
+ ..DevContainer::default()
+ };
+ let s = build(&opts(), &env(Some(&dc)));
+ assert!(!s.notes.iter().any(|n| n.contains("shutdownAction")));
+}
+
+#[test]
+fn devcontainer_env_reaches_the_container() {
+ let dc = DevContainer {
+ env: vec![("TOKEN".to_string(), "abc".to_string())],
+ ..DevContainer::default()
+ };
+ let s = build(&opts(), &env(Some(&dc)));
+ assert!(s.sandbox.env.contains(&("TOKEN".to_string(), "abc".to_string())));
+}
+
+#[test]
+fn settings_env_wins_over_the_devcontainer() {
+ let dc = DevContainer {
+ env: vec![("TOKEN".to_string(), "from-dc".to_string())],
+ ..DevContainer::default()
+ };
+ let mut o = opts();
+ o.sandbox_env = vec!["TOKEN=from-settings".to_string()];
+ let s = build(&o, &env(Some(&dc)));
+ assert!(s.sandbox.env.contains(&("TOKEN".to_string(), "from-settings".to_string())));
+ assert_eq!(s.sandbox.env.iter().filter(|(k, _)| k == "TOKEN").count(), 1);
+}
+
+#[test]
+fn bad_mounts_and_env_are_notes_not_failures() {
+ let mut o = opts();
+ o.sandbox_mount = vec!["/a:relative".to_string(), "/good:/mnt".to_string()];
+ o.sandbox_env = vec!["NOEQUALS".to_string()];
+ let s = build(&o, &env(None));
+ assert!(s.sandbox.mounts.iter().any(|m| m.target == "/mnt"));
+ assert_eq!(s.notes.iter().filter(|n| n.contains("sandbox-mount")).count(), 1);
+ assert!(s.notes.iter().any(|n| n.contains("sandbox-env")));
+}
+
+#[test]
+fn network_none_warns_that_the_bus_is_unreachable() {
+ let mut o = opts();
+ o.sandbox_network = Some("none".to_string());
+ let s = build(&o, &env(None));
+ assert_eq!(s.sandbox.network, Network::None);
+ assert!(s.notes.iter().any(|n| n.contains("relay bus")));
+}
+
+#[test]
+fn an_unknown_network_falls_back_with_a_note() {
+ let mut o = opts();
+ o.sandbox_network = Some("quantum".to_string());
+ let s = build(&o, &env(None));
+ assert_eq!(s.sandbox.network, Network::Bridge);
+ assert!(s.notes.iter().any(|n| n.contains("sandbox-network")));
+}
+
+#[test]
+fn user_host_resolves_to_the_projects_owner() {
+ let mut o = opts();
+ o.sandbox_user = Some("host".to_string());
+ assert_eq!(build(&o, &env(None)).sandbox.user.as_deref(), Some("501:20"));
+}
+
+#[test]
+fn user_root_leaves_the_flag_off() {
+ let mut o = opts();
+ o.sandbox_user = Some("root".to_string());
+ assert!(build(&o, &env(None)).sandbox.user.is_none());
+}
+
+#[test]
+fn an_explicit_uid_passes_through() {
+ let mut o = opts();
+ o.sandbox_user = Some("1000:1000".to_string());
+ assert_eq!(build(&o, &env(None)).sandbox.user.as_deref(), Some("1000:1000"));
+}
+
+#[test]
+fn a_pid_ceiling_is_always_set() {
+ // A supervisor can spawn its cap of workers into this one container.
+ assert!(build(&opts(), &env(None)).sandbox.limits.pids.is_some());
+}
+
+#[test]
+fn limits_come_from_settings() {
+ let mut o = opts();
+ o.sandbox_memory = Some("8g".to_string());
+ o.sandbox_cpus = Some("4".to_string());
+ let s = build(&o, &env(None));
+ assert_eq!(s.sandbox.limits.memory.as_deref(), Some("8g"));
+ assert_eq!(s.sandbox.limits.cpus.as_deref(), Some("4"));
+}
+
+#[test]
+fn a_dockerfile_devcontainer_says_what_to_do_instead() {
+ let dc = DevContainer {
+ dockerfile: Some("Dockerfile".to_string()),
+ ..DevContainer::default()
+ };
+ let s = build(&opts(), &env(Some(&dc)));
+ assert!(s.notes.iter().any(|n| n.contains("sandbox-image")));
+}
+
+#[test]
+fn a_trailing_slash_does_not_make_a_second_sandbox() {
+ let mut e = env(None);
+ let a = build(&opts(), &e).sandbox.name;
+ e.project = "/Users/wess/code/api/";
+ assert_eq!(a, build(&opts(), &e).sandbox.name);
+}
+
+#[test]
+fn home_and_shallow_paths_are_refused() {
+ // The sandbox identity-mounts what it is given. Handing it $HOME would give
+ // an agent with its prompts bypassed the user's whole machine.
+ let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/wess".into());
+ assert!(too_broad_to_mount(&home));
+ assert!(too_broad_to_mount(&format!("{home}/")));
+ assert!(too_broad_to_mount("/"));
+ assert!(too_broad_to_mount(""));
+ assert!(too_broad_to_mount("/Users"));
+ assert!(too_broad_to_mount("/home"));
+}
+
+#[test]
+fn a_real_project_path_is_allowed() {
+ assert!(!too_broad_to_mount("/Users/wess/code/api"));
+ assert!(!too_broad_to_mount("/srv/www/site"));
+}
+
+#[test]
+fn repo_root_walks_up_from_a_subdirectory() {
+ // Any pane inside a repo must resolve to the same project, or a pane three
+ // directories down would get a container of its own.
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(|p| p.parent())
+ .unwrap()
+ .to_path_buf();
+ let deep = root.join("crates/app/src/root");
+ assert_eq!(repo_root(&deep).as_deref(), Some(root.as_path()));
+ assert_eq!(repo_root(&root).as_deref(), Some(root.as_path()));
+}
+
+#[test]
+fn no_repo_above_a_path_is_none() {
+ assert!(repo_root(std::path::Path::new("/")).is_none());
+}
diff --git a/crates/config/src/action.rs b/crates/config/src/action.rs
index 959f78f..a1c28db 100644
--- a/crates/config/src/action.rs
+++ b/crates/config/src/action.rs
@@ -167,6 +167,20 @@ pub enum Action {
/// Open the picker listing already-running containers to attach a tab to
/// one of them (an interactive shell via `exec`). See the `container` crate.
AttachContainer,
+ /// Open a shell in this project's shared sandbox, starting (or building)
+ /// the container first if it is not already up.
+ SandboxShell,
+ /// Turn the sandbox on or off for this project, writing `sandbox-enabled`.
+ ToggleSandbox,
+ /// Bring the sandbox container up without opening a pane.
+ SandboxStart,
+ /// Stop the sandbox container. Panes inside it end with it.
+ SandboxStop,
+ /// Rebuild the sandbox image and recreate the container from it. Anything
+ /// written outside the mounted project is lost, which is the point.
+ SandboxRebuild,
+ /// Show what the sandbox is doing right now: image, state, attached panes.
+ SandboxStatus,
CloseSurface,
/// Close the current tab and all its panes.
CloseTab,
@@ -348,6 +362,12 @@ impl Action {
"attach_container" | "attach_to_container" => {
only(Self::AttachContainer, &name, param)
}
+ "sandbox_shell" => only(Self::SandboxShell, &name, param),
+ "toggle_sandbox" => only(Self::ToggleSandbox, &name, param),
+ "sandbox_start" => only(Self::SandboxStart, &name, param),
+ "sandbox_stop" => only(Self::SandboxStop, &name, param),
+ "sandbox_rebuild" => only(Self::SandboxRebuild, &name, param),
+ "sandbox_status" => only(Self::SandboxStatus, &name, param),
"close_surface" => only(Self::CloseSurface, &name, param),
"close_tab" => only(Self::CloseTab, &name, param),
"close_window" => only(Self::CloseWindow, &name, param),
@@ -501,6 +521,12 @@ impl Action {
Self::NewTab => "new_tab".into(),
Self::NewContainerTab => "new_container_tab".into(),
Self::AttachContainer => "attach_container".into(),
+ Self::SandboxShell => "sandbox_shell".into(),
+ Self::ToggleSandbox => "toggle_sandbox".into(),
+ Self::SandboxStart => "sandbox_start".into(),
+ Self::SandboxStop => "sandbox_stop".into(),
+ Self::SandboxRebuild => "sandbox_rebuild".into(),
+ Self::SandboxStatus => "sandbox_status".into(),
Self::CloseSurface => "close_surface".into(),
Self::CloseTab => "close_tab".into(),
Self::CloseWindow => "close_window".into(),
diff --git a/crates/config/src/apply.rs b/crates/config/src/apply.rs
index 5deebc0..2bbb466 100644
--- a/crates/config/src/apply.rs
+++ b/crates/config/src/apply.rs
@@ -461,6 +461,82 @@ pub fn apply(opts: &mut Options, d: &Options, key: &str, val: &str) -> Result<()
value::parse_bool(val).ok_or_else(|| bad("boolean", val))?
};
}
+ "sandbox-enabled" => {
+ opts.sandbox_enabled = if empty {
+ d.sandbox_enabled
+ } else {
+ value::parse_bool(val).ok_or_else(|| bad("boolean", val))?
+ };
+ }
+ "sandbox-persist" => {
+ opts.sandbox_persist = if empty {
+ d.sandbox_persist
+ } else {
+ value::parse_bool(val).ok_or_else(|| bad("boolean", val))?
+ };
+ }
+ "sandbox-devcontainer" => {
+ opts.sandbox_devcontainer = if empty {
+ d.sandbox_devcontainer
+ } else {
+ value::parse_bool(val).ok_or_else(|| bad("boolean", val))?
+ };
+ }
+ "sandbox-image" => {
+ opts.sandbox_image = if empty { d.sandbox_image.clone() } else { Some(val.to_string()) };
+ }
+ "sandbox-base" => {
+ opts.sandbox_base = if empty { d.sandbox_base.clone() } else { Some(val.to_string()) };
+ }
+ "sandbox-user" => {
+ opts.sandbox_user = if empty { d.sandbox_user.clone() } else { Some(val.to_string()) };
+ }
+ "sandbox-network" => {
+ opts.sandbox_network =
+ if empty { d.sandbox_network.clone() } else { Some(val.to_string()) };
+ }
+ "sandbox-memory" => {
+ opts.sandbox_memory =
+ if empty { d.sandbox_memory.clone() } else { Some(val.to_string()) };
+ }
+ "sandbox-cpus" => {
+ opts.sandbox_cpus = if empty { d.sandbox_cpus.clone() } else { Some(val.to_string()) };
+ }
+ "sandbox-packages" => {
+ if empty {
+ opts.sandbox_packages = d.sandbox_packages.clone();
+ } else {
+ opts.sandbox_packages.push(val.to_string());
+ }
+ }
+ "sandbox-setup" => {
+ if empty {
+ opts.sandbox_setup = d.sandbox_setup.clone();
+ } else {
+ opts.sandbox_setup.push(val.to_string());
+ }
+ }
+ "sandbox-mount" => {
+ if empty {
+ opts.sandbox_mount = d.sandbox_mount.clone();
+ } else {
+ opts.sandbox_mount.push(val.to_string());
+ }
+ }
+ "sandbox-env" => {
+ if empty {
+ opts.sandbox_env = d.sandbox_env.clone();
+ } else {
+ opts.sandbox_env.push(val.to_string());
+ }
+ }
+ "sandbox-agents" => {
+ if empty {
+ opts.sandbox_agents = d.sandbox_agents.clone();
+ } else {
+ opts.sandbox_agents.push(val.to_string());
+ }
+ }
"keybind" => {
if empty {
opts.keybind = d.keybind.clone();
diff --git a/crates/config/src/kind.rs b/crates/config/src/kind.rs
index 745ec9d..6f23c60 100644
--- a/crates/config/src/kind.rs
+++ b/crates/config/src/kind.rs
@@ -92,6 +92,20 @@ pub const KEYS: &[(&str, Kind)] = &[
("container", Kind::List),
("container-engine", Kind::Str),
("container-persist", Kind::Bool),
+ ("sandbox-enabled", Kind::Bool),
+ ("sandbox-image", Kind::Str),
+ ("sandbox-base", Kind::Str),
+ ("sandbox-packages", Kind::List),
+ ("sandbox-setup", Kind::List),
+ ("sandbox-mount", Kind::List),
+ ("sandbox-env", Kind::List),
+ ("sandbox-agents", Kind::List),
+ ("sandbox-user", Kind::Str),
+ ("sandbox-network", Kind::Str),
+ ("sandbox-memory", Kind::Str),
+ ("sandbox-cpus", Kind::Str),
+ ("sandbox-persist", Kind::Bool),
+ ("sandbox-devcontainer", Kind::Bool),
("keybind", Kind::List),
("ai-enabled", Kind::Bool),
("ai-optimize-tokens", Kind::Bool),
diff --git a/crates/config/src/options.rs b/crates/config/src/options.rs
index 8891f28..9a3fb99 100644
--- a/crates/config/src/options.rs
+++ b/crates/config/src/options.rs
@@ -232,6 +232,47 @@ pub struct Options {
/// When false (default) a fresh container is removed on tab close; when
/// true it is kept. Per-profile `persist`/`ephemeral` overrides this.
pub container_persist: bool,
+ /// File key: `sandbox-enabled` - run this project's panes and agents inside
+ /// one shared container. Off by default; nothing about the terminal changes
+ /// until it is turned on.
+ pub sandbox_enabled: bool,
+ /// File key: `sandbox-image` - a ready-made image to use as-is. When set,
+ /// Sinclair builds nothing and trusts the image to carry the agent CLIs.
+ pub sandbox_image: Option,
+ /// File key: `sandbox-base` - base image the generated sandbox image layers
+ /// on. `None` means the built-in default (a Debian slim). The generated
+ /// layer is `apt-get`-shaped, so a non-Debian base needs `sandbox-image`.
+ pub sandbox_base: Option,
+ /// File key: `sandbox-packages` - extra apt packages for the built image.
+ pub sandbox_packages: Vec,
+ /// File key: `sandbox-setup` - extra shell commands baked into the image,
+ /// one `RUN` layer each.
+ pub sandbox_setup: Vec,
+ /// File key: `sandbox-mount` - extra mounts, `source:target[:ro]`. A bare
+ /// path is mounted at itself.
+ pub sandbox_mount: Vec,
+ /// File key: `sandbox-env` - extra environment, `KEY=VALUE` entries.
+ pub sandbox_env: Vec,
+ /// File key: `sandbox-agents` - agent CLIs to install into the image.
+ /// Empty means just the configured default agent.
+ pub sandbox_agents: Vec,
+ /// File key: `sandbox-user` - the `--user` the container runs as. `host`
+ /// resolves to the current uid:gid, which is what keeps a Linux bind mount
+ /// from filling with root-owned files. Empty runs as the image's user.
+ pub sandbox_user: Option,
+ /// File key: `sandbox-network` - `bridge` (default), `host`, or `none`.
+ pub sandbox_network: Option,
+ /// File key: `sandbox-memory` - `--memory` ceiling, e.g. `8g`.
+ pub sandbox_memory: Option,
+ /// File key: `sandbox-cpus` - `--cpus` ceiling, e.g. `4`.
+ pub sandbox_cpus: Option,
+ /// File key: `sandbox-persist` - keep the container when the last pane
+ /// using it closes. On by default: rebuilding a toolchain per session is
+ /// slow, and the container costs nothing while idle.
+ pub sandbox_persist: bool,
+ /// File key: `sandbox-devcontainer` - honour a project's
+ /// `.devcontainer/devcontainer.json` when one exists. On by default.
+ pub sandbox_devcontainer: bool,
/// File key: `keybind`, raw strings (accumulated, parsed later).
pub keybind: Vec,
/// File key: `ai-enabled` - master switch for all AI features.
@@ -422,6 +463,20 @@ impl Default for Options {
container: Vec::new(),
container_engine: None,
container_persist: false,
+ sandbox_enabled: false,
+ sandbox_image: None,
+ sandbox_base: None,
+ sandbox_packages: Vec::new(),
+ sandbox_setup: Vec::new(),
+ sandbox_mount: Vec::new(),
+ sandbox_env: Vec::new(),
+ sandbox_agents: Vec::new(),
+ sandbox_user: None,
+ sandbox_network: None,
+ sandbox_memory: None,
+ sandbox_cpus: None,
+ sandbox_persist: true,
+ sandbox_devcontainer: true,
keybind: Vec::new(),
ai_enabled: false,
ai_optimize_tokens: false,
diff --git a/crates/container/Cargo.toml b/crates/container/Cargo.toml
index ded48e8..1430485 100644
--- a/crates/container/Cargo.toml
+++ b/crates/container/Cargo.toml
@@ -6,3 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
+# For the JSONC reader only: `devcontainer.json` allows comments and trailing
+# commas, which is exactly what config's settings parser already handles.
+config = { path = "../config" }
diff --git a/crates/container/src/adopt.rs b/crates/container/src/adopt.rs
new file mode 100644
index 0000000..1934599
--- /dev/null
+++ b/crates/container/src/adopt.rs
@@ -0,0 +1,145 @@
+//! Finding a container that already belongs to a project.
+//!
+//! A project may already have a container up: one Sinclair started earlier, or
+//! one VS Code's Dev Containers extension built. Starting a second beside it
+//! wastes a toolchain and splits the team across two filesystems, so resolution
+//! always looks before it creates.
+//!
+//! Discovery keys on `devcontainer.local_folder`, the label the Dev Containers
+//! extension and the `devcontainer` CLI both stamp with the host workspace
+//! path. Sinclair stamps the same label on the sandboxes it creates, so one
+//! query finds either kind, and [`Owner`] says which — because that decides
+//! whether Sinclair may ever stop or remove it.
+
+use crate::engine::Engine;
+use crate::sandbox::{parse_state, State, LABEL_OWNER, OWNER_SINCLAIR};
+
+/// Field separator for the `--format` template, matching [`crate::list`].
+const SEP: &str = "\u{1f}";
+
+/// Who created a container, and therefore who may destroy it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Owner {
+ /// Sinclair created it; Sinclair may stop and remove it.
+ Sinclair,
+ /// Something else created it — VS Code, the `devcontainer` CLI, compose.
+ /// Sinclair may enter it and must never stop or remove it: the user's
+ /// editor session is very likely attached to the same container.
+ Foreign,
+}
+
+impl Owner {
+ /// True when Sinclair is allowed to tear this container down.
+ pub fn may_remove(self) -> bool {
+ matches!(self, Self::Sinclair)
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Sinclair => "Sinclair",
+ Self::Foreign => "external",
+ }
+ }
+}
+
+/// A container discovered for a project.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Found {
+ pub id: String,
+ pub name: String,
+ pub image: String,
+ pub state: State,
+ pub owner: Owner,
+ /// `devcontainer.config_file`, when the creator recorded one.
+ pub config_file: Option,
+}
+
+/// Argv listing every container (running or not) claimed by `project`.
+pub fn find_argv(engine: Engine, project: &str) -> Vec {
+ vec![
+ engine.binary().to_string(),
+ "ps".to_string(),
+ "-a".to_string(),
+ "--filter".to_string(),
+ format!("label=devcontainer.local_folder={}", project.trim_end_matches('/')),
+ "--format".to_string(),
+ format!("{{{{.ID}}}}{SEP}{{{{.Names}}}}{SEP}{{{{.Image}}}}{SEP}{{{{.State}}}}{SEP}{{{{.Labels}}}}"),
+ ]
+}
+
+/// Parse the output of [`find_argv`].
+pub fn parse_found(output: &str) -> Vec {
+ output
+ .lines()
+ .filter_map(|line| {
+ let line = line.trim_end_matches('\r');
+ if line.trim().is_empty() {
+ return None;
+ }
+ let mut f = line.split(SEP);
+ let id = f.next()?.trim();
+ if id.is_empty() {
+ return None;
+ }
+ let name = f.next().unwrap_or("").trim();
+ let image = f.next().unwrap_or("").trim();
+ let state = parse_state(f.next().unwrap_or(""));
+ let labels = parse_labels(f.next().unwrap_or(""));
+ let owner = if label(&labels, LABEL_OWNER).as_deref() == Some(OWNER_SINCLAIR) {
+ Owner::Sinclair
+ } else {
+ Owner::Foreign
+ };
+ Some(Found {
+ id: id.to_string(),
+ name: name.to_string(),
+ image: image.to_string(),
+ // A container the engine reports but has no state for is gone.
+ state: if state == State::Missing { State::Exited } else { state },
+ owner,
+ config_file: label(&labels, "devcontainer.config_file"),
+ })
+ })
+ .collect()
+}
+
+/// Pick the container to use out of everything found for a project: a running
+/// one first, then a stopped one, preferring Sinclair's own within each group
+/// so a foreign container is never restarted on the user's behalf.
+pub fn best(found: &[Found]) -> Option<&Found> {
+ let rank = |f: &Found| match (f.state.is_running(), f.owner) {
+ (true, Owner::Sinclair) => 0,
+ (true, Owner::Foreign) => 1,
+ (false, Owner::Sinclair) => 2,
+ (false, Owner::Foreign) => 3,
+ };
+ found.iter().min_by_key(|f| rank(f))
+}
+
+/// Parse the engine's comma-separated `k=v` label list.
+pub fn parse_labels(raw: &str) -> Vec<(String, String)> {
+ raw.split(',')
+ .filter_map(|pair| {
+ let pair = pair.trim();
+ if pair.is_empty() {
+ return None;
+ }
+ match pair.split_once('=') {
+ Some((k, v)) => Some((k.trim().to_string(), v.trim().to_string())),
+ None => Some((pair.to_string(), String::new())),
+ }
+ })
+ .collect()
+}
+
+fn label(labels: &[(String, String)], key: &str) -> Option {
+ labels
+ .iter()
+ .find(|(k, _)| k == key)
+ .map(|(_, v)| v.clone())
+ .filter(|v| !v.is_empty())
+}
+
+#[cfg(test)]
+#[path = "../tests/adopt.rs"]
+mod tests;
diff --git a/crates/container/src/devcontainer.rs b/crates/container/src/devcontainer.rs
new file mode 100644
index 0000000..ea1f55b
--- /dev/null
+++ b/crates/container/src/devcontainer.rs
@@ -0,0 +1,211 @@
+//! Reading a project's `devcontainer.json`.
+//!
+//! Sinclair does not need this file — it builds and owns its own sandbox, and
+//! nothing degrades when the file is absent, which is the common case. When a
+//! project *does* have one, honouring it means the agents work in the same
+//! environment the humans already use, so it is read opportunistically.
+//!
+//! Only the fields that change how a container is created or entered are
+//! extracted. Features, customizations, and port forwarding belong to the Dev
+//! Containers tooling and are ignored here.
+
+use config::json::{self, Value};
+
+/// The subset of `devcontainer.json` that affects a sandbox.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct DevContainer {
+ pub name: Option,
+ /// `image`, when the container runs a prebuilt image.
+ pub image: Option,
+ /// `build.dockerfile`, relative to the config file.
+ pub dockerfile: Option,
+ /// `workspaceFolder`: where the project is mounted inside.
+ pub workspace_folder: Option,
+ /// `workspaceMount`, verbatim (engine `--mount` syntax).
+ pub workspace_mount: Option,
+ pub remote_user: Option,
+ /// `containerEnv` + `remoteEnv`, merged in that order.
+ pub env: Vec<(String, String)>,
+ /// `mounts`, verbatim.
+ pub mounts: Vec,
+ /// `runArgs`, passed through to the engine.
+ pub run_args: Vec,
+ /// `shutdownAction`: `none` keeps the container up when the editor closes.
+ pub shutdown_action: Option,
+ /// `postCreateCommand`, normalised to a list of shell commands.
+ pub post_create: Vec,
+}
+
+/// Candidate config paths inside a project, in the order the Dev Containers
+/// tooling looks for them.
+pub fn config_paths(project: &str) -> Vec {
+ let root = project.trim_end_matches('/');
+ vec![
+ format!("{root}/.devcontainer/devcontainer.json"),
+ format!("{root}/.devcontainer.json"),
+ format!("{root}/.devcontainer/devcontainer.jsonc"),
+ ]
+}
+
+impl DevContainer {
+ /// Where the project lands inside the container. An explicit
+ /// `workspaceFolder` wins; otherwise the tooling's default is
+ /// `/workspaces/`.
+ pub fn workspace_folder_for(&self, project: &str) -> String {
+ if let Some(w) = self
+ .workspace_folder
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ {
+ return w.to_string();
+ }
+ format!("/workspaces/{}", basename(project))
+ }
+
+ /// True when the project's mount lands at the same path it has on the host,
+ /// which is the arrangement that keeps git worktrees valid on both sides.
+ pub fn is_identity_mapped(&self, project: &str) -> bool {
+ self.workspace_folder_for(project) == project.trim_end_matches('/')
+ }
+
+ /// True when closing the editor stops the container — which would kill a
+ /// working agent team mid-task. `shutdownAction: "none"` avoids it.
+ pub fn stops_on_close(&self) -> bool {
+ !matches!(
+ self.shutdown_action.as_deref().map(str::trim),
+ Some("none") | Some("None")
+ )
+ }
+}
+
+/// Parse a `devcontainer.json`, resolving the `${…}` variables that depend on
+/// where the project lives. Unknown variables are left alone.
+pub fn parse(text: &str, project: &str) -> Result {
+ let value = json::parse(text).map_err(|e| format!("line {}: {}", e.line, e.message))?;
+ let Value::Obj(members) = value else {
+ return Err("expected a `{ ... }` object".to_string());
+ };
+ let mut dc = DevContainer::default();
+ let mut container_env = Vec::new();
+ let mut remote_env = Vec::new();
+ for m in &members {
+ match m.key.as_str() {
+ "name" => dc.name = string(&m.value),
+ "image" => dc.image = string(&m.value),
+ "build" => {
+ if let Value::Obj(build) = &m.value {
+ for b in build {
+ if b.key == "dockerfile" {
+ dc.dockerfile = string(&b.value);
+ }
+ }
+ }
+ }
+ "workspaceFolder" => dc.workspace_folder = string(&m.value),
+ "workspaceMount" => dc.workspace_mount = string(&m.value),
+ // `remoteUser` is the one the tooling actually enters as, so it
+ // wins over `containerUser` whichever order they appear in.
+ "remoteUser" => dc.remote_user = string(&m.value),
+ "containerUser" if dc.remote_user.is_none() => {
+ dc.remote_user = string(&m.value)
+ }
+ "containerEnv" => container_env = pairs(&m.value),
+ "remoteEnv" => remote_env = pairs(&m.value),
+ "mounts" => dc.mounts = strings(&m.value),
+ "runArgs" => dc.run_args = strings(&m.value),
+ "shutdownAction" => dc.shutdown_action = string(&m.value),
+ "postCreateCommand" => dc.post_create = commands(&m.value),
+ _ => {}
+ }
+ }
+ dc.env = container_env;
+ for (k, v) in remote_env {
+ dc.env.retain(|(x, _)| *x != k);
+ dc.env.push((k, v));
+ }
+ Ok(substitute(dc, project))
+}
+
+/// Replace the location variables the spec defines. `containerWorkspaceFolder`
+/// resolves against the folder this config itself selects, so it is applied in
+/// a second pass once that is known.
+fn substitute(mut dc: DevContainer, project: &str) -> DevContainer {
+ let project = project.trim_end_matches('/');
+ let local = |s: &str| {
+ s.replace("${localWorkspaceFolder}", project)
+ .replace("${localWorkspaceFolderBasename}", basename(project))
+ };
+ dc.workspace_folder = dc.workspace_folder.as_deref().map(local);
+ dc.workspace_mount = dc.workspace_mount.as_deref().map(local);
+ dc.image = dc.image.as_deref().map(local);
+
+ let container = dc.workspace_folder_for(project);
+ let all = |s: &str| {
+ local(s)
+ .replace("${containerWorkspaceFolder}", &container)
+ .replace("${containerWorkspaceFolderBasename}", basename(&container))
+ };
+ dc.mounts = dc.mounts.iter().map(|s| all(s)).collect();
+ dc.run_args = dc.run_args.iter().map(|s| all(s)).collect();
+ dc.post_create = dc.post_create.iter().map(|s| all(s)).collect();
+ dc.env = dc.env.iter().map(|(k, v)| (k.clone(), all(v))).collect();
+ dc
+}
+
+fn basename(path: &str) -> &str {
+ path.trim_end_matches('/')
+ .rsplit('/')
+ .find(|s| !s.is_empty())
+ .unwrap_or("workspace")
+}
+
+fn string(v: &Value) -> Option {
+ match v {
+ Value::Str(s) => Some(s.clone()),
+ _ => None,
+ }
+}
+
+fn strings(v: &Value) -> Vec {
+ match v {
+ Value::Arr(items) => items.iter().filter_map(string).collect(),
+ Value::Str(s) => vec![s.clone()],
+ _ => Vec::new(),
+ }
+}
+
+fn pairs(v: &Value) -> Vec<(String, String)> {
+ match v {
+ Value::Obj(members) => members
+ .iter()
+ .filter_map(|m| string(&m.value).map(|s| (m.key.clone(), s)))
+ .collect(),
+ _ => Vec::new(),
+ }
+}
+
+/// `postCreateCommand` may be a string, an argv array, or an object of named
+/// commands. All three normalise to a list of shell command lines.
+fn commands(v: &Value) -> Vec {
+ match v {
+ Value::Str(s) => vec![s.clone()],
+ Value::Arr(items) => {
+ let argv: Vec = items.iter().filter_map(string).collect();
+ if argv.is_empty() {
+ Vec::new()
+ } else {
+ vec![argv.join(" ")]
+ }
+ }
+ Value::Obj(members) => members
+ .iter()
+ .flat_map(|m| commands(&m.value))
+ .collect(),
+ _ => Vec::new(),
+ }
+}
+
+#[cfg(test)]
+#[path = "../tests/devcontainer.rs"]
+mod tests;
diff --git a/crates/container/src/engine.rs b/crates/container/src/engine.rs
index b824af8..8f6e964 100644
--- a/crates/container/src/engine.rs
+++ b/crates/container/src/engine.rs
@@ -27,6 +27,21 @@ impl Engine {
}
}
+ /// The hostname a container uses to reach a service on the host.
+ ///
+ /// Each engine ships its own name for this, and getting it wrong is the
+ /// difference between an agent reaching the relay bus and every one of its
+ /// `wait` calls failing. On a bridged network the name still needs an
+ /// `--add-host …:host-gateway` entry to resolve (see
+ /// [`crate::Sandbox::gateway`]); Docker Desktop provides it already, but
+ /// adding it is harmless there and required on Linux.
+ pub fn gateway_host(self) -> &'static str {
+ match self {
+ Self::Docker => "host.docker.internal",
+ Self::Podman => "host.containers.internal",
+ }
+ }
+
/// Parse an explicit engine preference (config `container-engine`).
/// `auto` (or empty) yields `None`, meaning "detect".
pub fn parse(s: &str) -> Option {
diff --git a/crates/container/src/hash.rs b/crates/container/src/hash.rs
new file mode 100644
index 0000000..945abeb
--- /dev/null
+++ b/crates/container/src/hash.rs
@@ -0,0 +1,13 @@
+//! A tiny non-cryptographic hash, used to derive stable names from paths and
+//! image recipes. FNV-1a: it only has to separate two inputs, not resist
+//! anyone, and it keeps this crate dependency-free.
+
+/// Eight hex characters of FNV-1a over `s`.
+pub(crate) fn short(s: &str) -> String {
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ for b in s.as_bytes() {
+ hash ^= *b as u64;
+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+ format!("{hash:016x}")[..8].to_string()
+}
diff --git a/crates/container/src/image.rs b/crates/container/src/image.rs
new file mode 100644
index 0000000..51f4637
--- /dev/null
+++ b/crates/container/src/image.rs
@@ -0,0 +1,152 @@
+//! Building the sandbox image.
+//!
+//! A sandbox is only useful if the agent CLI exists inside it, and a stock
+//! `debian:bookworm-slim` has no `claude`. Host binaries cannot be borrowed —
+//! a macOS Mach-O executable will not run in a Linux container — so the image
+//! has to provide them.
+//!
+//! Rather than depend on a registry Sinclair would have to publish and keep
+//! current, a [`Recipe`] generates a thin Dockerfile layered on whatever base
+//! the user names and builds it locally on first use. The tag embeds a hash of
+//! the recipe, so the build runs again only when the recipe actually changes
+//! and is otherwise an instant cache hit.
+//!
+//! The generated `RUN` lines assume a Debian/Ubuntu base (`apt-get`). Point
+//! `sandbox-image` at a ready-made image to skip generation entirely.
+
+use crate::engine::Engine;
+use crate::sandbox::{HOME_DIR, RELAY_DIR};
+
+/// The default base image: small, Debian, and `apt-get`-shaped so the
+/// generated layer applies cleanly.
+pub const DEFAULT_BASE: &str = "debian:bookworm-slim";
+
+/// Packages every sandbox gets: enough to clone, build, and authenticate.
+const BASE_PACKAGES: [&str; 8] = [
+ "ca-certificates",
+ "curl",
+ "git",
+ "openssh-client",
+ "less",
+ "procps",
+ "nodejs",
+ "npm",
+];
+
+/// The npm package that provides each agent CLI. An agent that is not listed
+/// installs nothing — add it with a `setup` line instead.
+fn agent_package(agent: &str) -> Option<&'static str> {
+ match agent.trim().to_ascii_lowercase().as_str() {
+ "claude" => Some("@anthropic-ai/claude-code"),
+ "codex" => Some("@openai/codex"),
+ "gemini" => Some("@google/gemini-cli"),
+ _ => None,
+ }
+}
+
+/// What to build on top of a base image.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Recipe {
+ /// Base image. Empty means [`DEFAULT_BASE`].
+ pub base: String,
+ /// Extra apt packages beyond [`BASE_PACKAGES`].
+ pub packages: Vec,
+ /// Agent CLIs to install (`claude`, `codex`, `gemini`).
+ pub agents: Vec,
+ /// Extra shell commands, each becoming its own `RUN` layer. This is the
+ /// escape hatch for toolchains Sinclair knows nothing about.
+ pub setup: Vec,
+}
+
+impl Recipe {
+ /// A recipe installing `agents` on the default base.
+ pub fn with_agents(agents: &[String]) -> Self {
+ Self {
+ agents: agents.to_vec(),
+ ..Self::default()
+ }
+ }
+
+ fn base_image(&self) -> &str {
+ let base = self.base.trim();
+ if base.is_empty() {
+ DEFAULT_BASE
+ } else {
+ base
+ }
+ }
+
+ /// The Dockerfile this recipe generates, fed to the engine on stdin.
+ pub fn dockerfile(&self) -> String {
+ let mut out = String::new();
+ out.push_str(&format!("FROM {}\n", self.base_image()));
+ out.push_str("ENV DEBIAN_FRONTEND=noninteractive\n");
+
+ let mut packages: Vec = BASE_PACKAGES.iter().map(|s| s.to_string()).collect();
+ for p in &self.packages {
+ let p = p.trim();
+ if !p.is_empty() && !packages.iter().any(|x| x == p) {
+ packages.push(p.to_string());
+ }
+ }
+ out.push_str(&format!(
+ "RUN apt-get update \\\n && apt-get install -y --no-install-recommends {} \\\n && rm -rf /var/lib/apt/lists/*\n",
+ packages.join(" ")
+ ));
+
+ let npm: Vec<&str> = self.agents.iter().filter_map(|a| agent_package(a)).collect();
+ if !npm.is_empty() {
+ out.push_str(&format!("RUN npm install -g {}\n", npm.join(" ")));
+ }
+
+ // A fresh named volume is initialised from the image's contents at the
+ // mount point, permissions included. Creating $HOME world-writable here
+ // is what lets the sandbox run as an arbitrary `--user` uid on Linux
+ // and still write its credentials.
+ out.push_str(&format!(
+ "RUN mkdir -p {HOME_DIR} {RELAY_DIR} && chmod 0777 {HOME_DIR}\n"
+ ));
+ out.push_str(&format!("ENV HOME={HOME_DIR}\n"));
+
+ for line in &self.setup {
+ let line = line.trim();
+ if !line.is_empty() {
+ out.push_str(&format!("RUN {line}\n"));
+ }
+ }
+ out
+ }
+
+ /// The image tag for this recipe. Changing the recipe changes the tag, so
+ /// a stale image is never silently reused.
+ pub fn tag(&self) -> String {
+ format!("sinclair-sandbox:{}", crate::hash::short(&self.dockerfile()))
+ }
+
+ /// Argv building this recipe. The Dockerfile goes in on stdin (`-`), so
+ /// there is no build context directory and nothing to clean up.
+ pub fn build_argv(&self, engine: Engine) -> Vec {
+ vec![
+ engine.binary().to_string(),
+ "build".to_string(),
+ "-t".to_string(),
+ self.tag(),
+ "-".to_string(),
+ ]
+ }
+}
+
+/// Argv checking whether an image is present locally. Empty output means it is
+/// not built yet; the exit status is zero either way.
+pub fn exists_argv(engine: Engine, tag: &str) -> Vec {
+ vec![
+ engine.binary().to_string(),
+ "images".to_string(),
+ "-q".to_string(),
+ tag.to_string(),
+ ]
+}
+
+#[cfg(test)]
+#[path = "../tests/image.rs"]
+mod tests;
diff --git a/crates/container/src/lib.rs b/crates/container/src/lib.rs
index 0106a69..5816a95 100644
--- a/crates/container/src/lib.rs
+++ b/crates/container/src/lib.rs
@@ -1,5 +1,5 @@
-//! Container-backed terminal tabs: spin up a fresh OS userland (Debian,
-//! Ubuntu, …) in a tab by exec-ing a shell inside a Docker/Podman container.
+//! Container-backed terminals: throwaway OS tabs, and the shared project
+//! sandbox that a human and a team of agents work inside together.
//!
//! This crate is pure logic with no I/O beyond a `$PATH` probe for the engine
//! binary. It knows how to:
@@ -7,12 +7,17 @@
//! - pick a container [`Engine`] (Docker or Podman),
//! - merge built-in OS [`Profile`]s with user-configured ones,
//! - turn a chosen profile into a [`Target`] and an `argv` that launches the
-//! container as the backing process of a tab.
+//! container as the backing process of a tab,
+//! - describe a project [`Sandbox`] — one long-lived container several panes
+//! share — and build the `argv`s that create, enter, and retire it,
+//! - generate the image [`Recipe`] that puts the agent CLIs inside it,
+//! - find a container a project already has ([`adopt`]) and read its
+//! [`devcontainer`] config when one exists.
//!
//! The heavy lifting (the actual VM/container) lives entirely inside the
//! engine's child process, so the host only ever spawns an argv on a pty —
//! the same seam every other tab uses. A later "Depth 2" can swap the argv for
-//! a native engine-socket transport without touching the profile/picker model.
+//! a native engine-socket transport without touching the models here.
//!
//! ```
//! let engine = container::Engine::Docker;
@@ -20,13 +25,38 @@
//! let target = container::Target::from_profile(engine, profile, false, None);
//! assert_eq!(target.argv()[0], "docker");
//! ```
+//!
+//! ```
+//! // The sandbox identity-mounts the project, so a path means the same thing
+//! // inside and out and git worktrees stay valid from both sides.
+//! let sandbox = container::Sandbox::for_project(
+//! container::Engine::Docker,
+//! "/Users/wess/code/api",
+//! "sinclair-sandbox:abc",
+//! );
+//! assert!(sandbox.mounts[0].is_identity());
+//! ```
+pub mod adopt;
+pub mod devcontainer;
mod engine;
+mod hash;
+mod image;
mod list;
+mod mount;
mod profile;
+mod sandbox;
mod target;
+pub use adopt::{Found, Owner};
+pub use devcontainer::DevContainer;
pub use engine::Engine;
+pub use image::{exists_argv, Recipe, DEFAULT_BASE};
pub use list::{attach_argv, parse_ps, ps_argv, Running};
+pub use mount::Mount;
pub use profile::{builtin, parse_profile, profiles, Profile};
+pub use sandbox::{
+ home_volume_for, name_for, parse_state, state_argv, Limits, Network, Sandbox, State, HOME_DIR,
+ LABEL_OWNER, LABEL_PROJECT, LABEL_SANDBOX, OWNER_SINCLAIR, RELAY_DIR,
+};
pub use target::Target;
diff --git a/crates/container/src/mount.rs b/crates/container/src/mount.rs
new file mode 100644
index 0000000..47b8267
--- /dev/null
+++ b/crates/container/src/mount.rs
@@ -0,0 +1,108 @@
+//! Bind mounts and named volumes attached to a container.
+//!
+//! Both share one engine flag (`-v source:target[:ro]`); a `source` that looks
+//! like a path is a bind mount, anything else names a volume. The distinction
+//! matters to the engine, not to us, so one type covers both.
+
+/// One `-v` entry: where it comes from on the host (or which named volume) and
+/// where it lands inside the container.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Mount {
+ /// Host path (absolute) or the name of an engine-managed volume.
+ pub source: String,
+ /// Absolute path inside the container.
+ pub target: String,
+ pub readonly: bool,
+}
+
+impl Mount {
+ /// A read-write mount.
+ pub fn rw(source: impl Into, target: impl Into) -> Self {
+ Self {
+ source: source.into(),
+ target: target.into(),
+ readonly: false,
+ }
+ }
+
+ /// A read-only mount, for anything the container should see but not edit.
+ pub fn ro(source: impl Into, target: impl Into) -> Self {
+ Self {
+ source: source.into(),
+ target: target.into(),
+ readonly: true,
+ }
+ }
+
+ /// Mount a host path at the *same* absolute path inside the container.
+ ///
+ /// This is what keeps git worktrees working from both sides: a worktree
+ /// records absolute paths in `.git`, so a pointer written inside the
+ /// container only resolves on the host when the two agree. With an identity
+ /// mount they always do, and no path translation layer is needed anywhere.
+ pub fn identity(path: impl Into) -> Self {
+ let path = path.into();
+ Self {
+ source: path.clone(),
+ target: path,
+ readonly: false,
+ }
+ }
+
+ /// True when this mount lands at the same path it came from.
+ pub fn is_identity(&self) -> bool {
+ self.source == self.target
+ }
+
+ /// The value for the engine's `-v` flag.
+ pub fn arg(&self) -> String {
+ if self.readonly {
+ format!("{}:{}:ro", self.source, self.target)
+ } else {
+ format!("{}:{}", self.source, self.target)
+ }
+ }
+
+ /// Parse a config entry: `source:target[:ro]`. A lone path is mounted at
+ /// itself (identity), which is the form users want most of the time.
+ ///
+ /// Windows-style drive letters are not supported; sandboxes are Unix-only.
+ pub fn parse(raw: &str) -> Result {
+ let raw = raw.trim();
+ if raw.is_empty() {
+ return Err("empty mount".to_string());
+ }
+ let parts: Vec<&str> = raw.split(':').map(str::trim).collect();
+ match parts.as_slice() {
+ [one] => Ok(Self::identity(*one)),
+ [source, target] => Self::checked(source, target, false),
+ [source, target, mode] => match mode.to_ascii_lowercase().as_str() {
+ "ro" => Self::checked(source, target, true),
+ "rw" => Self::checked(source, target, false),
+ other => Err(format!("unknown mount mode `{other}` (ro|rw)")),
+ },
+ _ => Err(format!("`{raw}` has too many `:` parts (source:target[:ro])")),
+ }
+ }
+
+ fn checked(source: &str, target: &str, readonly: bool) -> Result {
+ if source.is_empty() {
+ return Err("mount is missing a source".to_string());
+ }
+ if target.is_empty() {
+ return Err("mount is missing a target path".to_string());
+ }
+ if !target.starts_with('/') {
+ return Err(format!("mount target `{target}` must be absolute"));
+ }
+ Ok(Self {
+ source: source.to_string(),
+ target: target.to_string(),
+ readonly,
+ })
+ }
+}
+
+#[cfg(test)]
+#[path = "../tests/mount.rs"]
+mod tests;
diff --git a/crates/container/src/sandbox.rs b/crates/container/src/sandbox.rs
new file mode 100644
index 0000000..b5f78d3
--- /dev/null
+++ b/crates/container/src/sandbox.rs
@@ -0,0 +1,397 @@
+//! The shared sandbox: one long-lived container per project that the human and
+//! every agent on a team work inside.
+//!
+//! The shape differs from a one-off OS tab ([`crate::Target`]) in three ways,
+//! and each one exists so several panes can share a single container:
+//!
+//! - It is **created detached** running a keepalive loop, so its life is not
+//! tied to whichever pane happened to start it. Panes attach with `exec`.
+//! - It **identity-mounts** the project, so a path means the same thing on both
+//! sides and git worktrees created from either side resolve from the other.
+//! - It is **named deterministically** from the project path, so reopening a
+//! project finds the container that is already up instead of starting a
+//! second one.
+//!
+//! Everything here is argv construction. Running the commands, tracking how
+//! many panes are attached, and deciding when to tear the container down are
+//! the host's job.
+
+use crate::engine::Engine;
+use crate::mount::Mount;
+
+/// Label marking a container as a Sinclair sandbox.
+pub const LABEL_SANDBOX: &str = "sinclair.sandbox";
+/// Label recording the host project path a sandbox belongs to.
+pub const LABEL_PROJECT: &str = "sinclair.project";
+/// Label recording who created the container, which decides who may remove it.
+pub const LABEL_OWNER: &str = "sinclair.owner";
+/// Value of [`LABEL_OWNER`] for containers Sinclair created itself.
+pub const OWNER_SINCLAIR: &str = "sinclair";
+
+/// Where the agent's `$HOME` lives inside the sandbox.
+///
+/// It is a named volume rather than part of the image so a `claude login` (and
+/// the folder-trust answers, and shell history) survives rebuilding the image
+/// or recreating the container.
+pub const HOME_DIR: &str = "/sandbox/home";
+
+/// Where the per-agent relay MCP configs are mounted.
+pub const RELAY_DIR: &str = "/sandbox/relay";
+
+/// The container's process: a keepalive loop, so the sandbox outlives any one
+/// pane. `sleep infinity` is GNU-only — busybox (Alpine) rejects it — so this
+/// spells the loop out and works on every base image.
+const KEEPALIVE: [&str; 3] = ["sh", "-c", "while :; do sleep 3600; done"];
+
+/// How the sandbox reaches the network, and with it the relay bus.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Network {
+ /// The engine's default bridge. Combined with a gateway host entry this
+ /// reaches a relay bound to host loopback.
+ #[default]
+ Bridge,
+ /// Share the host's network namespace: loopback inside *is* host loopback,
+ /// so the bus is reachable with no rebinding and nothing is exposed. Linux
+ /// only in practice.
+ Host,
+ /// No network at all. The strictest sandbox; agents cannot reach the bus.
+ None,
+}
+
+impl Network {
+ pub fn parse(s: &str) -> Option {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "bridge" | "bus" | "default" => Some(Self::Bridge),
+ "host" => Some(Self::Host),
+ "none" | "off" => Some(Self::None),
+ _ => None,
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Bridge => "bridge",
+ Self::Host => "host",
+ Self::None => "none",
+ }
+ }
+}
+
+/// Resource ceilings, so a supervisor that spawns its cap of workers cannot
+/// take the host down with it.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Limits {
+ /// `--memory`, e.g. `8g`.
+ pub memory: Option,
+ /// `--cpus`, e.g. `4`.
+ pub cpus: Option,
+ /// `--pids-limit`.
+ pub pids: Option,
+}
+
+impl Limits {
+ fn args(&self) -> Vec {
+ let mut out = Vec::new();
+ if let Some(m) = self.memory.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ out.push("--memory".to_string());
+ out.push(m.to_string());
+ }
+ if let Some(c) = self.cpus.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ out.push("--cpus".to_string());
+ out.push(c.to_string());
+ }
+ if let Some(p) = self.pids {
+ out.push("--pids-limit".to_string());
+ out.push(p.to_string());
+ }
+ out
+ }
+}
+
+/// The engine's view of a container that may or may not exist.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum State {
+ Missing,
+ Created,
+ Running,
+ Paused,
+ Exited,
+}
+
+impl State {
+ /// True when a pane can `exec` into it right now.
+ pub fn is_running(self) -> bool {
+ matches!(self, Self::Running)
+ }
+
+ /// True when the container exists but needs `start` first.
+ pub fn is_stopped(self) -> bool {
+ matches!(self, Self::Created | Self::Exited | Self::Paused)
+ }
+}
+
+/// A resolved sandbox: everything needed to create it and to exec into it.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Sandbox {
+ pub engine: Engine,
+ /// Container name — deterministic, from the project path (see [`name_for`]).
+ pub name: String,
+ /// The image to run. Either a user-supplied reference or the tag of an
+ /// image Sinclair built (see [`crate::Recipe`]).
+ pub image: String,
+ /// Default working directory for panes: the project root as seen inside.
+ pub workdir: String,
+ pub mounts: Vec,
+ pub env: Vec<(String, String)>,
+ /// `--user`, e.g. `501:20`. Left unset the container runs as the image's
+ /// user (usually root), which is right on macOS where the file-sharing
+ /// layer remaps ownership, and wrong on Linux where it would leave
+ /// root-owned files in the user's repo.
+ pub user: Option,
+ pub network: Network,
+ /// Add a `host.docker.internal` entry pointing at the host gateway, so a
+ /// relay bound to host loopback is reachable from a bridged container.
+ pub gateway: bool,
+ pub limits: Limits,
+ pub labels: Vec<(String, String)>,
+}
+
+impl Sandbox {
+ /// A sandbox for `project` (an absolute host path), identity-mounting it.
+ pub fn for_project(engine: Engine, project: &str, image: &str) -> Self {
+ Self {
+ engine,
+ name: name_for(project),
+ image: image.to_string(),
+ workdir: project.to_string(),
+ mounts: vec![Mount::identity(project)],
+ env: vec![("HOME".to_string(), HOME_DIR.to_string())],
+ user: None,
+ network: Network::default(),
+ gateway: true,
+ limits: Limits::default(),
+ labels: vec![
+ (LABEL_SANDBOX.to_string(), "1".to_string()),
+ (LABEL_PROJECT.to_string(), project.to_string()),
+ (LABEL_OWNER.to_string(), OWNER_SINCLAIR.to_string()),
+ // Stamped so VS Code's Dev Containers extension and the
+ // `devcontainer` CLI find this container for the same folder
+ // rather than building a second one beside it.
+ (
+ "devcontainer.local_folder".to_string(),
+ project.to_string(),
+ ),
+ ],
+ }
+ }
+
+ /// Attach the named volume holding the agent's `$HOME`.
+ pub fn with_home_volume(mut self, volume: &str) -> Self {
+ self.mounts.push(Mount::rw(volume, HOME_DIR));
+ self
+ }
+
+ /// Mount the directory holding generated relay MCP configs, so an agent
+ /// launched into the sandbox can read the one addressed to it.
+ pub fn with_relay_dir(mut self, host_dir: &str) -> Self {
+ self.mounts.push(Mount::ro(host_dir, RELAY_DIR));
+ self
+ }
+
+ /// Add an environment variable, replacing any existing entry for the key.
+ pub fn with_env(mut self, key: &str, value: &str) -> Self {
+ self.env.retain(|(k, _)| k != key);
+ self.env.push((key.to_string(), value.to_string()));
+ self
+ }
+
+ /// Create the container detached, running the keepalive loop.
+ pub fn create_argv(&self) -> Vec {
+ let mut argv = vec![
+ self.engine.binary().to_string(),
+ "run".to_string(),
+ "-d".to_string(),
+ // Reap the zombies a long-lived exec host accumulates.
+ "--init".to_string(),
+ "--name".to_string(),
+ self.name.clone(),
+ "-w".to_string(),
+ self.workdir.clone(),
+ ];
+ for m in &self.mounts {
+ argv.push("-v".to_string());
+ argv.push(m.arg());
+ }
+ for (k, v) in &self.env {
+ argv.push("-e".to_string());
+ argv.push(format!("{k}={v}"));
+ }
+ if let Some(u) = self.user.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ argv.push("--user".to_string());
+ argv.push(u.to_string());
+ }
+ match self.network {
+ Network::Bridge => {
+ if self.gateway {
+ argv.push("--add-host".to_string());
+ argv.push(format!("{}:host-gateway", self.engine.gateway_host()));
+ }
+ }
+ Network::Host => {
+ argv.push("--network".to_string());
+ argv.push("host".to_string());
+ }
+ Network::None => {
+ argv.push("--network".to_string());
+ argv.push("none".to_string());
+ }
+ }
+ argv.extend(self.limits.args());
+ for (k, v) in &self.labels {
+ argv.push("--label".to_string());
+ argv.push(format!("{k}={v}"));
+ }
+ argv.push(self.image.clone());
+ argv.extend(KEEPALIVE.iter().map(|s| s.to_string()));
+ argv
+ }
+
+ /// Attach an interactive login shell, preferring bash. A login shell is
+ /// what picks up the PATH the image's profile sets, so an agent sees the
+ /// same toolchain a human does in the same container.
+ pub fn shell_argv(&self, cwd: Option<&str>) -> Vec {
+ let mut argv = self.exec_prefix(cwd);
+ argv.push("sh".to_string());
+ argv.push("-c".to_string());
+ argv.push(
+ "command -v bash >/dev/null 2>&1 && exec bash -l || exec sh -l".to_string(),
+ );
+ argv
+ }
+
+ /// Run `command` inside the sandbox through a login shell.
+ pub fn exec_argv(&self, command: &str, cwd: Option<&str>) -> Vec {
+ let mut argv = self.exec_prefix(cwd);
+ argv.push("sh".to_string());
+ argv.push("-lc".to_string());
+ argv.push(command.to_string());
+ argv
+ }
+
+ /// `engine exec -it [-u …] -w … [-e …] NAME`, the head every attach shares.
+ fn exec_prefix(&self, cwd: Option<&str>) -> Vec {
+ let mut argv = vec![
+ self.engine.binary().to_string(),
+ "exec".to_string(),
+ "-it".to_string(),
+ ];
+ if let Some(u) = self.user.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ argv.push("--user".to_string());
+ argv.push(u.to_string());
+ }
+ argv.push("-w".to_string());
+ argv.push(cwd.unwrap_or(&self.workdir).to_string());
+ for (k, v) in &self.env {
+ argv.push("-e".to_string());
+ argv.push(format!("{k}={v}"));
+ }
+ argv.push(self.name.clone());
+ argv
+ }
+
+ pub fn start_argv(&self) -> Vec {
+ vec![
+ self.engine.binary().to_string(),
+ "start".to_string(),
+ self.name.clone(),
+ ]
+ }
+
+ pub fn stop_argv(&self) -> Vec {
+ vec![
+ self.engine.binary().to_string(),
+ "stop".to_string(),
+ self.name.clone(),
+ ]
+ }
+
+ pub fn rm_argv(&self) -> Vec {
+ vec![
+ self.engine.binary().to_string(),
+ "rm".to_string(),
+ "-f".to_string(),
+ self.name.clone(),
+ ]
+ }
+
+ pub fn state_argv(&self) -> Vec {
+ state_argv(self.engine, &self.name)
+ }
+}
+
+/// Argv asking the engine for one container's state. Uses `ps -a` rather than
+/// `inspect` so a missing container is empty output and a zero exit, not an
+/// error the caller has to special-case.
+pub fn state_argv(engine: Engine, name: &str) -> Vec {
+ vec![
+ engine.binary().to_string(),
+ "ps".to_string(),
+ "-a".to_string(),
+ "--filter".to_string(),
+ format!("name=^{name}$"),
+ "--format".to_string(),
+ "{{.State}}".to_string(),
+ ]
+}
+
+/// Parse the output of [`state_argv`].
+pub fn parse_state(output: &str) -> State {
+ match output.trim().lines().next().unwrap_or("").trim().to_ascii_lowercase().as_str() {
+ "running" | "up" => State::Running,
+ "created" | "configured" => State::Created,
+ "paused" => State::Paused,
+ "" => State::Missing,
+ _ => State::Exited,
+ }
+}
+
+/// The deterministic container name for a project path: a readable slug of the
+/// final path component plus a hash of the whole path, so two checkouts named
+/// `api` in different places never collide and reopening either finds its own.
+pub fn name_for(project: &str) -> String {
+ let base = project
+ .trim_end_matches('/')
+ .rsplit('/')
+ .find(|s| !s.is_empty())
+ .unwrap_or("project");
+ format!("sinclair-sbx-{}-{}", slug(base), crate::hash::short(project))
+}
+
+/// The named volume holding the agent `$HOME` for a project.
+pub fn home_volume_for(project: &str) -> String {
+ format!("{}-home", name_for(project))
+}
+
+/// Lowercase, keeping only characters an engine accepts in a container name.
+fn slug(s: &str) -> String {
+ let out: String = s
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() {
+ c.to_ascii_lowercase()
+ } else {
+ '-'
+ }
+ })
+ .collect();
+ let out = out.trim_matches('-').to_string();
+ if out.is_empty() {
+ "project".to_string()
+ } else {
+ out.chars().take(24).collect()
+ }
+}
+
+#[cfg(test)]
+#[path = "../tests/sandbox.rs"]
+mod tests;
diff --git a/crates/container/tests/adopt.rs b/crates/container/tests/adopt.rs
new file mode 100644
index 0000000..2a77ff8
--- /dev/null
+++ b/crates/container/tests/adopt.rs
@@ -0,0 +1,78 @@
+use super::*;
+
+const SEP: &str = "\u{1f}";
+
+fn row(id: &str, name: &str, state: &str, labels: &str) -> String {
+ format!("{id}{SEP}{name}{SEP}img{SEP}{state}{SEP}{labels}\n")
+}
+
+#[test]
+fn find_argv_filters_on_the_devcontainer_label() {
+ let argv = find_argv(Engine::Docker, "/Users/wess/code/api/");
+ assert!(argv.contains(&"-a".to_string()));
+ assert!(argv.contains(&"label=devcontainer.local_folder=/Users/wess/code/api".to_string()));
+ assert!(argv.last().unwrap().contains("{{.Labels}}"));
+}
+
+#[test]
+fn sinclair_owned_is_recognised() {
+ let out = row("abc", "sinclair-sbx-api-1", "running", "sinclair.owner=sinclair,sinclair.sandbox=1");
+ let found = parse_found(&out);
+ assert_eq!(found[0].owner, Owner::Sinclair);
+ assert!(found[0].owner.may_remove());
+ assert!(found[0].state.is_running());
+}
+
+#[test]
+fn a_vscode_container_is_foreign_and_untouchable() {
+ // No sinclair.owner label: VS Code built it, so the user's editor is very
+ // likely attached and Sinclair must not stop it.
+ let out = row(
+ "def",
+ "vsc-api-1234",
+ "running",
+ "devcontainer.local_folder=/Users/wess/code/api,devcontainer.config_file=/Users/wess/code/api/.devcontainer/devcontainer.json",
+ );
+ let found = parse_found(&out);
+ assert_eq!(found[0].owner, Owner::Foreign);
+ assert!(!found[0].owner.may_remove());
+ assert_eq!(
+ found[0].config_file.as_deref(),
+ Some("/Users/wess/code/api/.devcontainer/devcontainer.json")
+ );
+}
+
+#[test]
+fn best_prefers_running_then_our_own() {
+ let out = format!(
+ "{}{}",
+ row("stopped", "a", "exited", "sinclair.owner=sinclair"),
+ row("live", "b", "running", "devcontainer.local_folder=/x")
+ );
+ let found = parse_found(&out);
+ assert_eq!(best(&found).unwrap().id, "live");
+}
+
+#[test]
+fn best_prefers_our_own_among_running() {
+ let out = format!(
+ "{}{}",
+ row("foreign", "a", "running", "devcontainer.local_folder=/x"),
+ row("ours", "b", "running", "sinclair.owner=sinclair")
+ );
+ assert_eq!(best(&parse_found(&out)).unwrap().id, "ours");
+}
+
+#[test]
+fn nothing_found_is_none() {
+ assert!(best(&parse_found("")).is_none());
+ assert!(parse_found("\n \n").is_empty());
+}
+
+#[test]
+fn labels_parse() {
+ let l = parse_labels("a=1,b=two words,c");
+ assert_eq!(l[0], ("a".to_string(), "1".to_string()));
+ assert_eq!(l[1], ("b".to_string(), "two words".to_string()));
+ assert_eq!(l[2], ("c".to_string(), String::new()));
+}
diff --git a/crates/container/tests/devcontainer.rs b/crates/container/tests/devcontainer.rs
new file mode 100644
index 0000000..eb618fd
--- /dev/null
+++ b/crates/container/tests/devcontainer.rs
@@ -0,0 +1,113 @@
+use super::*;
+
+const PROJECT: &str = "/Users/wess/code/api";
+
+#[test]
+fn config_paths_cover_both_layouts() {
+ let p = config_paths("/repo/");
+ assert_eq!(p[0], "/repo/.devcontainer/devcontainer.json");
+ assert_eq!(p[1], "/repo/.devcontainer.json");
+}
+
+#[test]
+fn comments_and_trailing_commas_are_accepted() {
+ let dc = parse(
+ r#"{
+ // the project's dev image
+ "image": "node:22",
+ "remoteUser": "node",
+ }"#,
+ PROJECT,
+ )
+ .unwrap();
+ assert_eq!(dc.image.as_deref(), Some("node:22"));
+ assert_eq!(dc.remote_user.as_deref(), Some("node"));
+}
+
+#[test]
+fn default_workspace_folder_follows_the_spec() {
+ let dc = parse(r#"{"image":"x"}"#, PROJECT).unwrap();
+ assert_eq!(dc.workspace_folder_for(PROJECT), "/workspaces/api");
+ assert!(!dc.is_identity_mapped(PROJECT));
+}
+
+#[test]
+fn identity_mapping_is_detected() {
+ let dc = parse(
+ r#"{
+ "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind",
+ "workspaceFolder": "${localWorkspaceFolder}"
+ }"#,
+ PROJECT,
+ )
+ .unwrap();
+ assert_eq!(dc.workspace_folder_for(PROJECT), PROJECT);
+ assert!(dc.is_identity_mapped(PROJECT));
+ assert!(dc.workspace_mount.unwrap().contains("source=/Users/wess/code/api,target=/Users/wess/code/api"));
+}
+
+#[test]
+fn shutdown_action_decides_whether_closing_the_editor_kills_agents() {
+ assert!(parse(r#"{"image":"x"}"#, PROJECT).unwrap().stops_on_close());
+ assert!(parse(r#"{"shutdownAction":"stopContainer"}"#, PROJECT).unwrap().stops_on_close());
+ assert!(!parse(r#"{"shutdownAction":"none"}"#, PROJECT).unwrap().stops_on_close());
+}
+
+#[test]
+fn env_merges_with_remote_winning() {
+ let dc = parse(
+ r#"{"containerEnv":{"A":"1","B":"2"},"remoteEnv":{"B":"override"}}"#,
+ PROJECT,
+ )
+ .unwrap();
+ assert!(dc.env.contains(&("A".to_string(), "1".to_string())));
+ assert!(dc.env.contains(&("B".to_string(), "override".to_string())));
+ assert_eq!(dc.env.iter().filter(|(k, _)| k == "B").count(), 1);
+}
+
+#[test]
+fn basename_variables_resolve() {
+ let dc = parse(
+ r#"{"containerEnv":{"NAME":"${localWorkspaceFolderBasename}"},"runArgs":["--name","${containerWorkspaceFolderBasename}"]}"#,
+ PROJECT,
+ )
+ .unwrap();
+ assert_eq!(dc.env[0].1, "api");
+ assert_eq!(dc.run_args[1], "api");
+}
+
+#[test]
+fn dockerfile_build_is_read() {
+ let dc = parse(r#"{"build":{"dockerfile":"Dockerfile"}}"#, PROJECT).unwrap();
+ assert_eq!(dc.dockerfile.as_deref(), Some("Dockerfile"));
+}
+
+#[test]
+fn post_create_normalises_all_three_shapes() {
+ assert_eq!(
+ parse(r#"{"postCreateCommand":"npm i"}"#, PROJECT).unwrap().post_create,
+ vec!["npm i"]
+ );
+ assert_eq!(
+ parse(r#"{"postCreateCommand":["npm","i"]}"#, PROJECT).unwrap().post_create,
+ vec!["npm i"]
+ );
+ assert_eq!(
+ parse(r#"{"postCreateCommand":{"deps":"npm i","build":"npm run build"}}"#, PROJECT)
+ .unwrap()
+ .post_create
+ .len(),
+ 2
+ );
+}
+
+#[test]
+fn malformed_json_is_a_friendly_error() {
+ let err = parse("{ nope", PROJECT).unwrap_err();
+ assert!(err.contains("line"));
+}
+
+#[test]
+fn a_non_object_is_rejected() {
+ assert!(parse("[]", PROJECT).is_err());
+}
diff --git a/crates/container/tests/image.rs b/crates/container/tests/image.rs
new file mode 100644
index 0000000..3ef6fc5
--- /dev/null
+++ b/crates/container/tests/image.rs
@@ -0,0 +1,86 @@
+use super::*;
+
+#[test]
+fn default_base_when_unset() {
+ let d = Recipe::default().dockerfile();
+ assert!(d.starts_with(&format!("FROM {DEFAULT_BASE}\n")));
+}
+
+#[test]
+fn base_is_overridable() {
+ let r = Recipe {
+ base: "ubuntu:24.04".to_string(),
+ ..Recipe::default()
+ };
+ assert!(r.dockerfile().starts_with("FROM ubuntu:24.04\n"));
+}
+
+#[test]
+fn agents_install_their_npm_packages() {
+ let r = Recipe::with_agents(&["claude".to_string(), "codex".to_string()]);
+ let d = r.dockerfile();
+ assert!(d.contains("npm install -g @anthropic-ai/claude-code @openai/codex"));
+}
+
+#[test]
+fn unknown_agents_install_nothing() {
+ let r = Recipe::with_agents(&["ollama".to_string()]);
+ assert!(!r.dockerfile().contains("npm install -g"));
+}
+
+#[test]
+fn home_is_world_writable_for_arbitrary_uids() {
+ // A fresh named volume inherits these permissions, which is what lets the
+ // sandbox run as --user on Linux and still write credentials.
+ let d = Recipe::default().dockerfile();
+ assert!(d.contains(&format!("chmod 0777 {HOME_DIR}")));
+ assert!(d.contains(&format!("ENV HOME={HOME_DIR}")));
+}
+
+#[test]
+fn extra_packages_merge_without_duplicating() {
+ let r = Recipe {
+ packages: vec!["git".to_string(), "ripgrep".to_string()],
+ ..Recipe::default()
+ };
+ let d = r.dockerfile();
+ assert!(d.contains("ripgrep"));
+ assert_eq!(d.matches(" git ").count(), 1);
+}
+
+#[test]
+fn setup_lines_become_run_layers() {
+ let r = Recipe {
+ setup: vec!["cargo install just".to_string(), " ".to_string()],
+ ..Recipe::default()
+ };
+ let d = r.dockerfile();
+ assert!(d.contains("RUN cargo install just\n"));
+ assert!(!d.contains("RUN \n"));
+}
+
+#[test]
+fn tag_tracks_the_recipe() {
+ let a = Recipe::default().tag();
+ let b = Recipe::default().tag();
+ let c = Recipe::with_agents(&["claude".to_string()]).tag();
+ assert_eq!(a, b, "an unchanged recipe must be a cache hit");
+ assert_ne!(a, c, "a changed recipe must not reuse a stale image");
+ assert!(a.starts_with("sinclair-sandbox:"));
+}
+
+#[test]
+fn build_reads_the_dockerfile_from_stdin() {
+ let argv = Recipe::default().build_argv(Engine::Docker);
+ assert_eq!(argv[0], "docker");
+ assert_eq!(argv[1], "build");
+ assert_eq!(argv.last().unwrap(), "-", "no build context directory");
+}
+
+#[test]
+fn exists_argv_is_quiet() {
+ assert_eq!(
+ exists_argv(Engine::Podman, "sinclair-sandbox:abc"),
+ vec!["podman", "images", "-q", "sinclair-sandbox:abc"]
+ );
+}
diff --git a/crates/container/tests/mount.rs b/crates/container/tests/mount.rs
new file mode 100644
index 0000000..0666622
--- /dev/null
+++ b/crates/container/tests/mount.rs
@@ -0,0 +1,32 @@
+use super::*;
+
+#[test]
+fn arg_shapes() {
+ assert_eq!(Mount::rw("/a", "/b").arg(), "/a:/b");
+ assert_eq!(Mount::ro("/a", "/b").arg(), "/a:/b:ro");
+}
+
+#[test]
+fn identity_mounts_at_itself() {
+ let m = Mount::identity("/Users/wess/code");
+ assert_eq!(m.source, m.target);
+ assert!(m.is_identity());
+ assert_eq!(m.arg(), "/Users/wess/code:/Users/wess/code");
+}
+
+#[test]
+fn parse_forms() {
+ assert_eq!(Mount::parse("/repo").unwrap(), Mount::identity("/repo"));
+ assert_eq!(Mount::parse("/a:/b").unwrap(), Mount::rw("/a", "/b"));
+ assert_eq!(Mount::parse(" /a : /b : ro ").unwrap(), Mount::ro("/a", "/b"));
+ assert_eq!(Mount::parse("cache:/work/.cache").unwrap().source, "cache");
+}
+
+#[test]
+fn parse_rejects_bad_entries() {
+ assert!(Mount::parse("").is_err());
+ assert!(Mount::parse("/a:rel").is_err());
+ assert!(Mount::parse("/a:/b:wat").is_err());
+ assert!(Mount::parse("/a:/b:ro:extra").is_err());
+ assert!(Mount::parse(":/b").is_err());
+}
diff --git a/crates/container/tests/sandbox.rs b/crates/container/tests/sandbox.rs
new file mode 100644
index 0000000..638167d
--- /dev/null
+++ b/crates/container/tests/sandbox.rs
@@ -0,0 +1,146 @@
+use super::*;
+
+fn sbx() -> Sandbox {
+ Sandbox::for_project(Engine::Docker, "/Users/wess/code/api", "sinclair-sandbox:abc")
+}
+
+#[test]
+fn name_is_deterministic_and_project_scoped() {
+ let a = name_for("/Users/wess/code/api");
+ let b = name_for("/Users/wess/code/api");
+ let c = name_for("/Users/wess/other/api");
+ assert_eq!(a, b, "the same project must resolve to the same container");
+ assert_ne!(a, c, "two checkouts named `api` must not collide");
+ assert!(a.starts_with("sinclair-sbx-api-"));
+}
+
+#[test]
+fn trailing_slash_is_the_same_project() {
+ assert!(name_for("/Users/wess/code/api/").starts_with("sinclair-sbx-api-"));
+}
+
+#[test]
+fn project_is_identity_mounted() {
+ let s = sbx();
+ assert_eq!(s.workdir, "/Users/wess/code/api");
+ assert!(s.mounts[0].is_identity());
+}
+
+#[test]
+fn create_argv_is_detached_and_keeps_alive() {
+ let argv = sbx().create_argv();
+ assert_eq!(&argv[..3], &["docker", "run", "-d"]);
+ assert!(argv.contains(&"--init".to_string()));
+ // Not `sleep infinity` — busybox rejects it.
+ assert_eq!(&argv[argv.len() - 3..], &["sh", "-c", "while :; do sleep 3600; done"]);
+}
+
+#[test]
+fn create_argv_carries_labels_and_gateway() {
+ let argv = sbx().create_argv().join(" ");
+ assert!(argv.contains("sinclair.sandbox=1"));
+ assert!(argv.contains("sinclair.project=/Users/wess/code/api"));
+ assert!(argv.contains("sinclair.owner=sinclair"));
+ // Stamped so VS Code finds this container instead of building its own.
+ assert!(argv.contains("devcontainer.local_folder=/Users/wess/code/api"));
+ assert!(argv.contains("--add-host host.docker.internal:host-gateway"));
+}
+
+#[test]
+fn podman_uses_its_own_gateway_name() {
+ let s = Sandbox::for_project(Engine::Podman, "/repo", "img");
+ assert!(s.create_argv().join(" ").contains("host.containers.internal:host-gateway"));
+}
+
+#[test]
+fn host_network_skips_the_gateway_entry() {
+ let mut s = sbx();
+ s.network = Network::Host;
+ let argv = s.create_argv().join(" ");
+ assert!(argv.contains("--network host"));
+ assert!(!argv.contains("--add-host"));
+}
+
+#[test]
+fn limits_and_user_reach_the_argv() {
+ let mut s = sbx();
+ s.user = Some("501:20".to_string());
+ s.limits = Limits {
+ memory: Some("8g".to_string()),
+ cpus: Some("4".to_string()),
+ pids: Some(512),
+ };
+ let argv = s.create_argv().join(" ");
+ assert!(argv.contains("--user 501:20"));
+ assert!(argv.contains("--memory 8g"));
+ assert!(argv.contains("--cpus 4"));
+ assert!(argv.contains("--pids-limit 512"));
+}
+
+#[test]
+fn shell_argv_is_a_login_shell_in_the_project() {
+ let argv = sbx().shell_argv(None);
+ assert_eq!(&argv[..3], &["docker", "exec", "-it"]);
+ assert!(argv.contains(&"/Users/wess/code/api".to_string()));
+ assert!(argv.last().unwrap().contains("exec bash -l"));
+}
+
+#[test]
+fn exec_argv_honors_a_worktree_cwd() {
+ let argv = sbx().exec_argv("claude --version", Some("/Users/wess/code/api/.worktrees/ui"));
+ let w = argv.iter().position(|a| a == "-w").unwrap();
+ assert_eq!(argv[w + 1], "/Users/wess/code/api/.worktrees/ui");
+ assert_eq!(&argv[argv.len() - 2..], &["-lc", "claude --version"]);
+}
+
+#[test]
+fn home_env_points_at_the_volume() {
+ let s = sbx().with_home_volume("vol");
+ assert!(s.env.iter().any(|(k, v)| k == "HOME" && v == HOME_DIR));
+ assert!(s.mounts.iter().any(|m| m.source == "vol" && m.target == HOME_DIR));
+ // Env reaches an exec too, not just create — a bare `docker exec` would
+ // otherwise land in the image's HOME and miss the agent's credentials.
+ assert!(s.shell_argv(None).join(" ").contains(&format!("HOME={HOME_DIR}")));
+}
+
+#[test]
+fn relay_dir_is_mounted_read_only() {
+ let s = sbx().with_relay_dir("/host/relay");
+ let m = s.mounts.iter().find(|m| m.target == RELAY_DIR).unwrap();
+ assert!(m.readonly);
+}
+
+#[test]
+fn states_parse() {
+ assert_eq!(parse_state(""), State::Missing);
+ assert_eq!(parse_state("running\n"), State::Running);
+ assert_eq!(parse_state("exited"), State::Exited);
+ assert_eq!(parse_state("created"), State::Created);
+ assert_eq!(parse_state("paused"), State::Paused);
+ assert!(State::Running.is_running());
+ assert!(State::Exited.is_stopped());
+ assert!(!State::Missing.is_stopped());
+}
+
+#[test]
+fn state_argv_anchors_the_name() {
+ let argv = state_argv(Engine::Docker, "sinclair-sbx-api-1234");
+ assert!(argv.contains(&"name=^sinclair-sbx-api-1234$".to_string()));
+ assert!(argv.contains(&"-a".to_string()));
+}
+
+#[test]
+fn network_parses() {
+ assert_eq!(Network::parse("host"), Some(Network::Host));
+ assert_eq!(Network::parse(" None "), Some(Network::None));
+ assert_eq!(Network::parse("bridge"), Some(Network::Bridge));
+ assert_eq!(Network::parse("wat"), None);
+}
+
+#[test]
+fn home_volume_tracks_the_container_name() {
+ assert_eq!(
+ home_volume_for("/Users/wess/code/api"),
+ format!("{}-home", name_for("/Users/wess/code/api"))
+ );
+}
diff --git a/crates/relay/Cargo.toml b/crates/relay/Cargo.toml
index 7450e8c..f1200a0 100644
--- a/crates/relay/Cargo.toml
+++ b/crates/relay/Cargo.toml
@@ -22,6 +22,9 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
clap = { version = "4", features = ["derive"] }
+# Sandbox launches: the same argv builders the app uses, so a `docker exec`
+# assembled here and one assembled there cannot drift.
+container = { path = "../container" }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
diff --git a/crates/relay/src/cli/build.rs b/crates/relay/src/cli/build.rs
index 66b9be7..f6289aa 100644
--- a/crates/relay/src/cli/build.rs
+++ b/crates/relay/src/cli/build.rs
@@ -42,6 +42,11 @@ pub struct Options<'a> {
pub bin: Option<&'a str>,
/// Custom launch template ({prompt} {mcp} {url} {name}).
pub custom: Option<&'a str>,
+ /// Directory the MCP config is visible at from the agent's side. Set when
+ /// the agent runs in a sandbox: the file is written to the host state dir
+ /// as always, but the path handed to the agent is where the container has
+ /// it mounted. `None` means the agent shares the host filesystem.
+ pub mcp_dir_as_seen: Option<&'a str>,
}
/// A fully resolved worker command.
@@ -89,6 +94,10 @@ pub fn worker(endpoint: &str, token: &str, o: &Options) -> Result {
let interactive = !o.headless && (o.lead || role.as_ref().is_some_and(|r| r.driver));
let mcp = paths::write_mcp_config(endpoint, o.name, token)?;
+ let mcp = match o.mcp_dir_as_seen {
+ Some(dir) => format!("{}/{}.mcp.json", dir.trim_end_matches('/'), o.name),
+ None => mcp,
+ };
let prompt = agent::harness_prompt(
o.name,
o.role,
diff --git a/crates/relay/src/cli/launch.rs b/crates/relay/src/cli/launch.rs
index 6127eac..1aa8ba0 100644
--- a/crates/relay/src/cli/launch.rs
+++ b/crates/relay/src/cli/launch.rs
@@ -1,4 +1,4 @@
-use super::{build, http, paths, LaunchArgs};
+use super::{build, http, paths, sandbox::Sandbox, LaunchArgs};
use anyhow::{anyhow, Result};
use std::io::Write;
use std::process::Command;
@@ -9,8 +9,18 @@ use std::process::Command;
/// uses; the CLI's own defaults are stated here.
pub async fn launch(a: LaunchArgs) -> Result<()> {
let info = paths::read_info()?;
- let endpoint = paths::endpoint(&info.addr);
let name = resolve_name(a.name.as_deref())?;
+ let sandbox = Sandbox::resolve(
+ a.sandbox.as_deref(),
+ a.sandbox_engine.as_deref(),
+ a.sandbox_workdir.as_deref(),
+ );
+ // The bus is on the host either way; a sandboxed agent needs the address
+ // that reaches it from inside the container.
+ let endpoint = match &sandbox {
+ Some(s) => s.endpoint(&paths::endpoint(&info.addr)),
+ None => paths::endpoint(&info.addr),
+ };
let cwd = a.cwd.clone().unwrap_or_else(|| {
std::env::current_dir()
@@ -42,15 +52,30 @@ pub async fn launch(a: LaunchArgs) -> Result<()> {
extra_args: &a.agent_args,
bin: a.bin.as_deref(),
custom: a.cmd.as_deref(),
+ mcp_dir_as_seen: sandbox
+ .as_ref()
+ .and(a.sandbox_relay_dir.as_deref())
+ .or(sandbox.as_ref().map(|_| container::RELAY_DIR)),
},
)?;
+ // A sandboxed agent runs inside the container; relay stays on the host.
+ // The wrap carries the agent's environment as `-e` flags, so nothing is
+ // lost by the engine sitting between the two.
+ let (program, args) = match &sandbox {
+ Some(s) => {
+ let argv = s.wrap(&built.program, &built.args, &built.env);
+ (argv[0].clone(), argv[1..].to_vec())
+ }
+ None => (built.program.clone(), built.args.clone()),
+ };
+
if a.background {
let body = serde_json::json!({
"name": name,
"role": a.role,
- "program": built.program,
- "args": built.args,
+ "program": program,
+ "args": args,
"cwd": cwd,
"keep_alive": true,
"session_id": built.session_id,
@@ -73,25 +98,25 @@ pub async fn launch(a: LaunchArgs) -> Result<()> {
}
} else {
let label = if a.cmd.is_some() { "custom" } else { built.agent.as_str() };
- println!("launching {label} as '{name}' on {endpoint} …");
+ match &sandbox {
+ Some(s) => println!("launching {label} as '{name}' in sandbox {} on {endpoint} …", s.name),
+ None => println!("launching {label} as '{name}' on {endpoint} …"),
+ }
// Foreground: replace this process on Unix; on Windows, run it to
// completion and exit with its status (there is no exec()).
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
- let err = Command::new(&built.program)
- .args(&built.args)
- .current_dir(&cwd)
- .exec();
- Err(anyhow!("failed to exec {}: {err}", built.program))
+ let err = Command::new(&program).args(&args).current_dir(&cwd).exec();
+ Err(anyhow!("failed to exec {program}: {err}"))
}
#[cfg(windows)]
{
- let status = Command::new(&built.program)
- .args(&built.args)
+ let status = Command::new(&program)
+ .args(&args)
.current_dir(&cwd)
.status()
- .map_err(|e| anyhow!("failed to run {}: {e}", built.program))?;
+ .map_err(|e| anyhow!("failed to run {program}: {e}"))?;
std::process::exit(status.code().unwrap_or(1));
}
}
diff --git a/crates/relay/src/cli/mod.rs b/crates/relay/src/cli/mod.rs
index c375791..d107c72 100644
--- a/crates/relay/src/cli/mod.rs
+++ b/crates/relay/src/cli/mod.rs
@@ -8,6 +8,7 @@ pub mod layered;
pub mod paths;
pub mod ps;
pub mod role;
+pub mod sandbox;
pub mod server;
pub mod team;
pub mod watch;
@@ -244,6 +245,23 @@ pub struct LaunchArgs {
/// watching, such as a team member's split.
#[arg(long = "skip-permissions")]
pub skip_permissions: bool,
+ /// Run the agent inside this container instead of on the host — the
+ /// project's shared sandbox, so the whole team works in one filesystem and
+ /// one toolchain. Relay itself stays on the host: only the agent is
+ /// exec'd inside.
+ #[arg(long)]
+ pub sandbox: Option,
+ /// Container engine for `--sandbox`: docker (default) or podman.
+ #[arg(long = "sandbox-engine")]
+ pub sandbox_engine: Option,
+ /// Working directory inside the sandbox. With the identity mount Sinclair
+ /// uses, this is the project's own path.
+ #[arg(long = "sandbox-workdir")]
+ pub sandbox_workdir: Option,
+ /// Directory the sandbox mounts the generated MCP configs at, so the agent
+ /// is handed the path as *it* sees it rather than the host's.
+ #[arg(long = "sandbox-relay-dir")]
+ pub sandbox_relay_dir: Option,
}
pub async fn run(cli: Cli) -> Result<()> {
diff --git a/crates/relay/src/cli/sandbox.rs b/crates/relay/src/cli/sandbox.rs
new file mode 100644
index 0000000..fffe679
--- /dev/null
+++ b/crates/relay/src/cli/sandbox.rs
@@ -0,0 +1,91 @@
+//! Launching an agent inside a project's shared sandbox container.
+//!
+//! Relay itself stays on the host. Only the *last* step changes: instead of
+//! exec-ing the agent CLI directly, it is exec'd inside the container. Role
+//! resolution, the harness prompt, channel merging, and the MCP config are all
+//! built by [`crate::cli::build::worker`] exactly as they are for a host
+//! launch, so the two planes cannot drift.
+//!
+//! Two things have to be restated from the container's point of view:
+//!
+//! - **The bus URL.** Relay binds host loopback, which inside a bridged
+//! container is the container's own loopback. The endpoint handed to the
+//! agent names the engine's gateway host instead.
+//! - **The MCP config path.** It is written into a directory the sandbox
+//! mounts, and the agent is given the path *as mounted*, not the host one.
+
+use container::Engine;
+
+/// Where an agent runs.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Sandbox {
+ pub engine: Engine,
+ /// Container name or id to exec into.
+ pub name: String,
+ /// Working directory inside the container.
+ pub workdir: String,
+}
+
+impl Sandbox {
+ /// Resolve the launch flags into a target, or `None` for a host launch.
+ pub fn resolve(name: Option<&str>, engine: Option<&str>, workdir: Option<&str>) -> Option {
+ let name = name.map(str::trim).filter(|s| !s.is_empty())?;
+ Some(Self {
+ engine: Engine::parse(engine.unwrap_or("")).unwrap_or(Engine::Docker),
+ name: name.to_string(),
+ workdir: workdir.map(str::trim).filter(|s| !s.is_empty()).unwrap_or(".").to_string(),
+ })
+ }
+
+ /// The bus URL as seen from inside the container. A loopback endpoint is
+ /// rewritten to the engine's gateway host; anything else (a LAN address, a
+ /// name the container can already resolve) is left alone.
+ pub fn endpoint(&self, endpoint: &str) -> String {
+ let gateway = self.engine.gateway_host();
+ endpoint
+ .replace("//127.0.0.1:", &format!("//{gateway}:"))
+ .replace("//localhost:", &format!("//{gateway}:"))
+ .replace("//0.0.0.0:", &format!("//{gateway}:"))
+ }
+
+ /// Wrap a resolved command so it runs inside the container.
+ ///
+ /// The agent is exec'd through a login shell: the image's profile is what
+ /// puts its toolchain on `PATH`, and an agent that cannot find the tools a
+ /// human sees in the same container is worse than no sandbox at all.
+ pub fn wrap(&self, program: &str, args: &[String], env: &[(String, String)]) -> Vec {
+ let mut argv = vec![
+ self.engine.binary().to_string(),
+ "exec".to_string(),
+ "-it".to_string(),
+ "-w".to_string(),
+ self.workdir.clone(),
+ ];
+ for (k, v) in env {
+ argv.push("-e".to_string());
+ argv.push(format!("{k}={v}"));
+ }
+ argv.push(self.name.clone());
+ argv.push("sh".to_string());
+ argv.push("-lc".to_string());
+ let mut line = quote(program);
+ for a in args {
+ line.push(' ');
+ line.push_str("e(a));
+ }
+ // `exec` so the agent is the shell's own process: signals and the exit
+ // status reach it directly rather than being absorbed by a wrapper.
+ argv.push(format!("exec {line}"));
+ argv
+ }
+}
+
+/// Single-quote for `sh -lc`. Prompts contain newlines and quotes, so nothing
+/// may be passed through unquoted.
+fn quote(s: &str) -> String {
+ format!("'{}'", s.replace('\'', r"'\''"))
+}
+
+#[cfg(test)]
+#[path = "../../tests/cli/sandbox.rs"]
+mod tests;
diff --git a/crates/relay/src/tools/mod.rs b/crates/relay/src/tools/mod.rs
index 93df98c..31235bd 100644
--- a/crates/relay/src/tools/mod.rs
+++ b/crates/relay/src/tools/mod.rs
@@ -366,6 +366,11 @@ async fn dispatch(app: &App, me: &str, name: &str, args: &Value) -> Value {
extra_args: &[],
bin: None,
custom: None,
+ // A worker spawned over MCP runs on the host, not in the
+ // project sandbox: the daemon is not told which container
+ // a session belongs to. With the sandbox's identity mount
+ // both see the same files; the toolchain is the host's.
+ mcp_dir_as_seen: None,
},
) {
Ok(b) => b,
diff --git a/crates/relay/tests/cli/sandbox.rs b/crates/relay/tests/cli/sandbox.rs
new file mode 100644
index 0000000..80424ac
--- /dev/null
+++ b/crates/relay/tests/cli/sandbox.rs
@@ -0,0 +1,80 @@
+use super::*;
+
+fn sbx() -> Sandbox {
+ Sandbox::resolve(Some("sinclair-sbx-api-1"), Some("docker"), Some("/repo")).unwrap()
+}
+
+#[test]
+fn no_name_means_a_host_launch() {
+ assert!(Sandbox::resolve(None, Some("docker"), None).is_none());
+ assert!(Sandbox::resolve(Some(" "), None, None).is_none());
+}
+
+#[test]
+fn engine_defaults_to_docker() {
+ let s = Sandbox::resolve(Some("c"), None, None).unwrap();
+ assert_eq!(s.engine, Engine::Docker);
+ assert_eq!(Sandbox::resolve(Some("c"), Some("podman"), None).unwrap().engine, Engine::Podman);
+}
+
+#[test]
+fn loopback_endpoints_become_the_gateway_host() {
+ // Inside a bridged container, 127.0.0.1 is the container itself; the bus
+ // lives on the host.
+ let s = sbx();
+ assert_eq!(
+ s.endpoint("http://127.0.0.1:7777/mcp"),
+ "http://host.docker.internal:7777/mcp"
+ );
+ assert_eq!(
+ s.endpoint("http://localhost:7777/mcp"),
+ "http://host.docker.internal:7777/mcp"
+ );
+}
+
+#[test]
+fn podman_uses_its_own_gateway_name() {
+ let s = Sandbox::resolve(Some("c"), Some("podman"), None).unwrap();
+ assert_eq!(
+ s.endpoint("http://127.0.0.1:7777/mcp"),
+ "http://host.containers.internal:7777/mcp"
+ );
+}
+
+#[test]
+fn a_routable_endpoint_is_left_alone() {
+ let s = sbx();
+ assert_eq!(s.endpoint("http://10.0.0.4:7777/mcp"), "http://10.0.0.4:7777/mcp");
+}
+
+#[test]
+fn wrap_execs_through_a_login_shell_in_the_workdir() {
+ let argv = sbx().wrap("claude", &["--version".to_string()], &[]);
+ assert_eq!(&argv[..3], &["docker", "exec", "-it"]);
+ let w = argv.iter().position(|a| a == "-w").unwrap();
+ assert_eq!(argv[w + 1], "/repo");
+ assert_eq!(argv[argv.len() - 3], "sh");
+ assert_eq!(argv[argv.len() - 2], "-lc");
+ assert_eq!(argv[argv.len() - 1], "exec 'claude' '--version'");
+}
+
+#[test]
+fn env_reaches_the_container() {
+ // Codex takes its bearer token by env var; it has to survive the wrap.
+ let argv = sbx().wrap("codex", &[], &[("RELAY_TOKEN".to_string(), "secret".to_string())]);
+ assert!(argv.windows(2).any(|w| w[0] == "-e" && w[1] == "RELAY_TOKEN=secret"));
+}
+
+#[test]
+fn prompts_with_quotes_and_newlines_survive() {
+ let prompt = "You're the lead.\nReport to 'supervisor'.";
+ let argv = sbx().wrap("claude", &["-p".to_string(), prompt.to_string()], &[]);
+ let line = argv.last().unwrap();
+ assert!(line.contains(r"'\''"), "single quotes must be escaped, not dropped");
+ assert!(line.contains('\n'), "the prompt keeps its newlines");
+}
+
+#[test]
+fn workdir_defaults_when_unset() {
+ assert_eq!(Sandbox::resolve(Some("c"), None, None).unwrap().workdir, ".");
+}
diff --git a/docs/agents.html b/docs/agents.html
index 0e671fd..5284061 100644
--- a/docs/agents.html
+++ b/docs/agents.html
@@ -199,6 +199,15 @@ Agent CLI flags
}
The most common use is running Claude Code with --dangerously-skip-permissions so it doesn't stop to ask. Values with spaces can be quoted; Ollama has no flags field (it is driven by relay's own bridge, not a CLI).
+ The shared sandbox
+ A team working in your checkout is a team working on your real machine, with your real credentials, and — with relay-team-autonomy on — with their permission prompts bypassed. Turning on File ▸ Sandbox ▸ Use Sandbox for This Project puts the project and its whole roster inside one container instead: one filesystem, one toolchain, one blast radius. It needs Docker or Podman and nothing else.
+ Relay itself stays on the host. Role resolution, the harness prompt, and the MCP config are built exactly as they are for a host launch; only the final exec changes, so a wedged container can never take the mesh down. Opening a team brings the container up first , so no member is launched onto the host while the rest of its team is inside.
+relay launch alice --role frontend \
+ --sandbox sinclair-sbx-api-9f3c1a20 --sandbox-engine docker \
+ --sandbox-workdir /Users/you/code/api
+ Two things are restated from inside the container: the bus URL (a loopback endpoint becomes the engine's gateway host — host.docker.internal, or host.containers.internal under Podman) and the MCP config path (written to the host state dir as always, handed to the agent as /sandbox/relay/<name>.mcp.json, where the sandbox mounts it read-only).
+ Full walkthrough: Share one container with your agents .
+
Notes & limits
Authentication. Every request to /mcp and /control/* must carry the server's bearer token (Authorization: Bearer …); only /health is open. The token is generated at startup and stored in server.json, which — along with the bus DB and logs — is written owner-only (0600) inside a 0700 state dir, so only the same user can read it. The CLI, the app, and launched agents pick it up automatically (the latter via the generated *.mcp.json).
@@ -206,6 +215,7 @@ Notes & limits
Ordering. A new agent only sees messages sent after it registers. Start workers before dispatching, or re-register (the same name keeps its read cursor).
Context. A long-running agent holds one growing context for its whole shift; restart it for a fresh one. A background claude worker is the exception — it carries a fixed session id and relaunches with --resume, so pausing the mesh (or restarting Sinclair with session-restore on) brings it back with its transcript intact rather than cold. Other providers keep fresh context until they expose an equivalent.
Codex/Gemini MCP wiring is unverified — see the tiers above.
+ Sandboxed spawns. A worker a supervisor starts with the MCP spawn tool runs on the host , not in the project sandbox: the daemon is not told which container a session belongs to. Because the sandbox identity-mounts the project, it still sees the same files; its toolchain is the host's.
Internals
diff --git a/docs/configuration.html b/docs/configuration.html
index 399c1ee..07e4771 100644
--- a/docs/configuration.html
+++ b/docs/configuration.html
@@ -24,7 +24,7 @@ Where the config lives
Format
The file is JSON with comments: one object, one member per setting. // comment lines are allowed and ignored.
- Most keys take a single scalar value. A handful of keys are lists and take a JSON array, one entry per value. The list keys are font-family, font-feature, keybind, palette, plugin, container, agent-custom, redact, trigger, snippet, and profile.
+ Most keys take a single scalar value. A handful of keys are lists and take a JSON array, one entry per value. The list keys are font-family, font-feature, keybind, palette, plugin, container, sandbox-mount, sandbox-env, sandbox-packages, sandbox-setup, sandbox-agents, agent-custom, redact, trigger, snippet, and profile.
Bad lines never abort the load. An unknown key, a malformed value, or a typo becomes a friendly diagnostic surfaced on startup, and the rest of the file is applied normally.
// ~/.config/sinclair/settings.json
{
@@ -189,6 +189,28 @@ AI & relay
+ Shared sandbox
+ One container per project, shared by your panes and every agent on a team. Needs Docker or Podman and nothing else. See Share one container with your agents .
+
+ Key Type Default Description
+
+ sandbox-enabledbool falseRun this project's panes and agents inside one shared container.
+ sandbox-imagestring none Ready-made image, used as-is. Nothing is built; the image must carry the agent CLI.
+ sandbox-basestring debian:bookworm-slimBase image the generated sandbox image layers on. The generated layer uses apt-get.
+ sandbox-packagesstring (repeatable) none Extra apt packages baked into the generated image.
+ sandbox-setupstring (repeatable) none Extra commands baked into the generated image, one layer each.
+ sandbox-agentsstring (repeatable) the default agent Agent CLIs to install into the image: claude, codex, gemini.
+ sandbox-mountstring (repeatable) none Extra mounts, source:target[:ro]. A bare path mounts at itself.
+ sandbox-envstring (repeatable) none Extra environment inside the sandbox, KEY=VALUE.
+ sandbox-userstring host uid on Linux User the container runs as. host is your own uid:gid, which keeps a Linux bind mount from filling with root-owned files.
+ sandbox-networkstring bridgebridge, host, or none. none cuts agents off from the relay bus.
+ sandbox-memorystring unlimited --memory ceiling for the whole sandbox, e.g. 8g.
+ sandbox-cpusstring unlimited --cpus ceiling for the whole sandbox, e.g. 4.
+ sandbox-persistbool trueKeep the container running when the last pane using it closes.
+ sandbox-devcontainerbool trueRead a project's devcontainer.json, and enter a container an editor already started for it.
+
+
+
Agents
Key Type Default Description
diff --git a/docs/docs.js b/docs/docs.js
index 15b3d10..462f63c 100644
--- a/docs/docs.js
+++ b/docs/docs.js
@@ -33,6 +33,7 @@ const MANIFEST = [
pages: [
{ slug: "agentteams", title: "Run an agent team", href: "agentteams.html" },
{ slug: "worktreeagents", title: "Parallel agents in worktrees", href: "worktreeagents.html" },
+ { slug: "sandbox", title: "Share one container", href: "sandbox.html" },
{ slug: "statushooks", title: "Agent status at a glance", href: "statushooks.html" },
{ slug: "automation", title: "Automate with MCP", href: "automation.html" },
],
diff --git a/docs/index.html b/docs/index.html
index 398d8b3..edb71e4 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -289,6 +289,10 @@ A clean machine in a tab
Guardrails & glance
Redact secrets from every copy, fire notifications on regex output triggers, timestamp and annotate lines, and read who's working, blocked, done, or idle across every tab from the Activity panel's status dots.
+
+ One container, whole team
+ Put a project and its entire agent team inside one shared sandbox container: a single filesystem, a single toolchain, and a real boundary around agents whose permission prompts you just turned off. Docker or Podman is the only thing you need — the project is mounted at its own path, so git worktrees stay valid on both sides.
+
A worktree per agent
Spin up a git worktree in a new tab with one action — or let an agent create its own over MCP — so several agents work the same repo on isolated branches without colliding. New worktrees fire plugin triggers, so setup runs itself.
diff --git a/docs/keybindings.html b/docs/keybindings.html
index a71d9e0..b3c4aad 100644
--- a/docs/keybindings.html
+++ b/docs/keybindings.html
@@ -224,6 +224,12 @@ Action catalog
relay_start— Start the relay daemon
relay_stop— Stop the relay daemon
relay_restart— Restart the relay daemon
+ sandbox_shell— Open a shell in this project's shared sandbox, starting or building it first
+ toggle_sandbox— Turn the shared sandbox on or off for this project (writes sandbox-enabled)
+ sandbox_start— Bring the sandbox container up without opening a pane
+ sandbox_stop— Stop the sandbox container; panes inside it end with it
+ sandbox_rebuild— Recreate the sandbox from current settings, discarding anything outside the project
+ sandbox_status— Show the sandbox in the Containers panel
tileLAYOUT_IDApply a saved tile layout
save_layout— Save the current layout
worktree_create (worktree_new) PATH; append @branch to name the branchCreate a git worktree branched from HEAD and open it in a new tab
diff --git a/docs/relay.md b/docs/relay.md
index 41c07f3..95db8a2 100644
--- a/docs/relay.md
+++ b/docs/relay.md
@@ -305,6 +305,39 @@ no one there to answer a prompt.
In **Settings → AI → Agent tools**, each tool has an enable toggle and a **Test**
button that checks it's reachable (CLI `--version`, or the Ollama API port).
+## The shared sandbox
+
+A team working in your checkout is a team working on your real machine, with
+your real credentials, and — with `relay-team-autonomy` on — with their
+permission prompts bypassed. Turning on **File ▸ Sandbox ▸ Use Sandbox for This
+Project** puts the project and its whole roster inside one container instead:
+one filesystem, one toolchain, one blast radius.
+
+Relay itself stays on the host. `build::worker` resolves the role, renders the
+harness, and writes the MCP config exactly as it does for a host launch; only
+the final exec changes, so a wedged container can never take the mesh down.
+
+```
+relay launch alice --role frontend \
+ --sandbox sinclair-sbx-api-9f3c1a20 --sandbox-engine docker \
+ --sandbox-workdir /Users/you/code/api
+```
+
+Two things are restated from inside the container: the bus URL (a loopback
+endpoint becomes the engine's gateway host — `host.docker.internal`, or
+`host.containers.internal` under Podman) and the MCP config path (written to the
+host state dir as always, handed to the agent as `/sandbox/relay/.mcp.json`
+where the sandbox mounts it read-only).
+
+Opening a team brings the container up *first*, so no member is launched onto
+the host while the rest of its team is inside. One exception, worth knowing: a
+worker started with the MCP `spawn` tool runs on the **host** — the daemon is
+not told which container a session belongs to. Because the sandbox
+identity-mounts the project, it still sees the same files; its toolchain is the
+host's.
+
+See [`docs/sandbox.md`](sandbox.md) for the full design and settings.
+
## Notes & limits
- **Authentication.** Every request to `/mcp` and `/control/*` must carry the
@@ -375,6 +408,8 @@ button that checks it's reachable (CLI `--version`, or the Ollama API port).
restart, so its work continues rather than starting cold. Foreground
(human-driven) agents restart fresh. See `docs/pauseresume.md`.
- **Codex/Gemini MCP** wiring is unverified — see above.
+- **Sandbox.** A team can share one container instead of running on the host;
+ see [The shared sandbox](#the-shared-sandbox) above and `docs/sandbox.md`.
## Internals
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 530dd6b..8a1d1fb 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -388,3 +388,25 @@ Conventions (non-negotiable):
Known gap: the permission bypass does not cover Claude Code's separate
first-run *folder trust* dialog, which still stops every pane the first time a
team runs in an unfamiliar directory.
+- 2026-07-27: the shared project sandbox. A project's panes and its whole agent
+ team can now run inside **one container** instead of on the host, turning
+ `relay-team-autonomy`'s permission bypass from a promise into a boundary.
+ Self-contained: `docker` or `podman` is the only dependency — no VS Code, no
+ `devcontainer` CLI, no Sinclair image to pull. The `container` crate grew
+ `Sandbox` (a detached, keepalive-backed container with mounts, env, user,
+ network, and resource ceilings), `Recipe` (a generated Dockerfile that layers
+ the agent CLIs onto a configurable base, tagged by recipe hash so a rebuild
+ only happens when the recipe changes), `Mount`, `adopt` (label discovery plus
+ ownership, so a container VS Code created is entered and never removed), and
+ a `devcontainer.json` reader on `config`'s JSONC parser. The project is
+ **identity-mounted** — the same absolute path inside and out — which is what
+ keeps git worktrees valid from both sides and removes any need for a path
+ translation layer. Relay stays on the host: `relay launch --sandbox` changes
+ only the final exec, rewriting the bus URL to the engine's gateway host and
+ handing the agent its MCP config at the path the container mounts it. Opening
+ a team brings the container up first. UI: a Sandbox submenu under both File
+ and AI with a live status line, a Sandbox section in the Containers panel,
+ fourteen `sandbox-*` settings in Settings → AI, and six bindable actions.
+ Known gap: a worker started with the MCP `spawn` tool runs on the host — the
+ daemon is not told which container a session belongs to. See
+ `docs/sandbox.md`.
diff --git a/docs/sandbox.html b/docs/sandbox.html
new file mode 100644
index 0000000..3a567db
--- /dev/null
+++ b/docs/sandbox.html
@@ -0,0 +1,269 @@
+
+
+
+
+
+ Share one container with your agents — Sinclair docs
+
+
+
+
+
+
+
+ Skip to content
+
+
+ Tutorial
+ Share one container with your agents
+
+ A team of agents working in your checkout is a team working on your real machine,
+ with your real credentials, usually with their permission prompts turned off. The
+ sandbox puts the whole project — your panes and every agent on the team — inside a
+ single container instead. One filesystem, one toolchain, one blast radius. This
+ walkthrough turns it on, opens a shell in it, sends a team into it, and shows what
+ it does and does not protect.
+
+
+
+
The sandbox needs Docker or Podman — nothing else. No VS Code,
+ no devcontainer CLI, no image to pull from a registry Sinclair
+ publishes. If your project happens to have a devcontainer.json, it is
+ read; if it does not, nothing about this is different. On macOS, ⌘ is the
+ modifier — on Linux, read it as ⌃ (Ctrl).
+
+
+ 1. Turn it on
+
+ Open File ▸ Sandbox (it is also under AI ▸ Sandbox
+ once AI is enabled) and pick Use Sandbox for This Project . The first row of
+ that menu is a live status line, so it is always the place to look when you want to
+ know what the sandbox is doing.
+
+
+ The toggle writes a real setting, so it survives a restart:
+
+// ~/.config/sinclair/settings.json
+{
+ "sandbox-enabled": true
+}
+
+ "This project" means the repository the focused pane is in —
+ found by walking up to the nearest .git, so every pane in a checkout
+ lands in the same container instead of a pane three directories down getting one of
+ its own. Each project gets its own container, named after the path, so two checkouts
+ called api in different places never share one, and reopening either
+ finds the container it already had rather than starting a second.
+
+
+
Careful
+
Paths too broad to mount are refused: /, anything one level down
+ (/Users, /home), and your home directory. A sandbox mounts
+ what it is given, and mounting $HOME would hand an agent with its
+ prompts turned off your whole machine. Open a pane inside a repository first.
+
+
+ 2. Open a shell in it
+
+ Sandbox ▸ Open Shell in Sandbox gives you a normal tab whose shell
+ is inside the container. Scrollback, search, copy mode, recording, themes: all of it
+ works, because the pane is an ordinary pty like every other.
+
+
+ The first time, this takes a few minutes. Sinclair generates a small Dockerfile,
+ layers your agent CLI onto a Debian base, and builds it locally — there is no image
+ to download from us. The menu status line names the step it is on
+ (Building the sandbox image… ) the whole time. Afterwards the image is
+ cached and opening a shell is instant.
+
+
+
Tip
+
The Containers panel in the sidebar (⌘ the activity
+ bar's ❖ icon) has the same controls plus anything Sinclair wants to warn you about,
+ and it is the one place that shows how many panes are currently inside.
+
+
+ 3. Your paths still mean what they meant
+
+ The project is mounted at its own path . If your repo is
+ /Users/you/code/api on the host, it is
+ /Users/you/code/api inside the container too — not
+ /workspaces/api.
+
+
+ That is not a cosmetic choice. Git records absolute paths when it
+ creates a worktree: the main repo's .git/worktrees/<name>/gitdir
+ and the worktree's own .git file point at each other. Under any other
+ mapping, a worktree made inside the container is broken on the host and vice versa.
+ With the paths equal, the worktree workflow keeps
+ working unchanged from both sides — which matters, because giving each agent its own
+ worktree is exactly how you keep a team from editing over each other inside
+ the shared container.
+
+
+
Careful
+
Create worktrees under the project, not beside it. The tutorial's
+ ../wt/thing convention puts them outside the mount, where no agent can
+ see them. .worktrees/thing is inside, and works.
+
+
+ 4. Send a team in
+
+ With the sandbox on, AI ▸ Teams ▸ any team brings the container up
+ first , then opens the roster. Every member is exec'd into that same
+ container, so the whole team shares a filesystem, a build cache, and one toolchain.
+ A quick-launched agent (AI ▸ Agents ▸ Launch … ) and a saved agent
+ definition go to the same place.
+
+
+ Relay itself stays on the host. Only the agent process runs inside, which is why a
+ crash in the container can never take the mesh — or your terminal — down with it.
+ The bus is still reachable: Sinclair points each agent at the engine's gateway host
+ (host.docker.internal, or host.containers.internal under
+ Podman) instead of a loopback address that would mean the container itself.
+
+ Standalone, the same thing from the CLI:
+relay launch alice --role frontend \
+ --sandbox sinclair-sbx-api-9f3c1a20 \
+ --sandbox-engine docker \
+ --sandbox-workdir /Users/you/code/api
+
+ 5. Signing in, once
+
+ An agent inside a Linux container cannot read a credential your macOS keychain is
+ holding, so the first agent you launch there will ask you to sign in. It only
+ happens once: the agent's home directory is a named volume, so the login — along
+ with folder-trust answers and shell history — survives rebuilding the image and
+ recreating the container.
+
+
+ 6. What it actually protects
+
+ Sinclair launches unattended team members with their permission prompts bypassed,
+ because a member sitting on a dialog in a split nobody is watching is a member doing
+ nothing. That trade is very different depending on where it happens:
+
+
+ Only the project is mounted. The rest of your home directory is
+ not there to delete.
+ Only the credentials you mount are present. Nothing reaches
+ your keychain, your other repos, or your cloud config unless you put it in
+ sandbox-mount yourself.
+ Resources are capped. A supervisor can spawn up to eight
+ workers into one container; a pid ceiling is always set, and
+ sandbox-memory / sandbox-cpus bound the rest.
+
+
+
Careful
+
Never mount /var/run/docker.sock into the sandbox. It is a common
+ devcontainer convenience and it is a one-line container escape: it hands an agent
+ running with prompts bypassed root on your host, which is the whole thing you were
+ trying to prevent.
+
+
+ 7. Make it your environment
+
+ The generated image is deliberately thin. Point it somewhere better, or add to it:
+
+{
+ "sandbox-enabled": true,
+
+ // Use an image you already maintain, as-is. Sinclair builds nothing —
+ // it trusts this image to carry the agent CLI.
+ // "sandbox-image": "ghcr.io/you/dev:latest",
+
+ // Or keep the generated layer and change what it is built on / with.
+ "sandbox-base": "debian:bookworm-slim",
+ "sandbox-packages": ["ripgrep", "build-essential"],
+ "sandbox-setup": ["curl -sSf https://sh.rustup.rs | sh -s -- -y"],
+
+ // Extra mounts: source:target[:ro]. A bare path mounts at itself.
+ "sandbox-mount": ["~/.gitconfig:/sandbox/home/.gitconfig:ro"],
+ "sandbox-env": ["DATABASE_URL=postgres://host.docker.internal/dev"],
+
+ // Ceilings for everything in the container, together.
+ "sandbox-memory": "8g",
+ "sandbox-cpus": "4"
+}
+
+
The generated layer uses apt-get, so it wants a Debian or Ubuntu
+ base. For anything else — Alpine, a language-specific image, your company's build
+ image — set sandbox-image and install the agent CLI in that image
+ yourself.
+
+
+ 8. If the project has a devcontainer.json
+
+ It is read, and used where it helps. The image it names becomes the base Sinclair
+ layers the agent CLI onto, so your agents land in the toolchain your team already
+ agreed on. Its containerEnv and remoteEnv reach the
+ container, with your own sandbox-env winning on conflicts.
+
+
+ If an editor already has a container up for this folder, Sinclair enters
+ that one instead of building a parallel one — and will never stop or remove
+ it, because your editor session is very likely attached to it. The Containers panel
+ says so when that happens.
+
+
+
Tip
+
Two lines in your devcontainer.json make it behave much better with
+ agents: "shutdownAction": "none" so closing your editor does not stop a
+ container with a team working in it, and the identity mount below so worktrees stay
+ valid on both sides.
+
+// .devcontainer/devcontainer.json
+{
+ "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind",
+ "workspaceFolder": "${localWorkspaceFolder}",
+ "shutdownAction": "none"
+}
+
+ Turn the whole behaviour off with "sandbox-devcontainer": false if you
+ would rather Sinclair ignored the file.
+
+
+ 9. Cleaning up
+
+ The container outlives any single pane — several panes and every agent are inside
+ it, so closing one tab must not take the others down. By default it also outlives
+ the last one: rebuilding a toolchain every session is slow and an idle container
+ costs nothing. Set "sandbox-persist": false to have it stop when the
+ last pane leaves.
+
+
+ Stop Sandbox — stops the container now. Panes inside it end
+ with it.
+ Rebuild Sandbox — removes the container and brings a fresh one
+ up from your current settings. Anything written outside the mounted project is
+ gone, which is the point.
+
+
+ Neither ever touches a container Sinclair did not create.
+
+
+ Known limits
+
+ A worker a supervisor starts with the MCP spawn tool runs on the
+ host , not in the sandbox: the relay daemon is not told which container a
+ session belongs to. Thanks to the identity mount it sees the same files; its
+ toolchain is the host's.
+ The generated image assumes an apt-get base. Use
+ sandbox-image otherwise.
+ A devcontainer.json that builds from a Dockerfile is not built for
+ you — set sandbox-image to the image it produces.
+
+
+ Where next
+
+
+
+
+
+
diff --git a/docs/sandbox.md b/docs/sandbox.md
new file mode 100644
index 0000000..bae9d0d
--- /dev/null
+++ b/docs/sandbox.md
@@ -0,0 +1,235 @@
+# The shared sandbox
+
+One container per project, shared by the human and every agent on a team.
+
+A team of agents working in your checkout is a team working on your real
+machine, with your real credentials, usually with permission prompts bypassed
+(`relay-team-autonomy` is on by default, because a member stuck on a dialog in a
+split nobody is watching does nothing). The sandbox turns that trade from a
+promise into a boundary: only the project is mounted, only the credentials you
+mount are present, and resources are capped.
+
+It is **self-contained**. `docker` or `podman` is the only dependency — no VS
+Code, no `devcontainer` CLI, no image to pull from a registry Sinclair
+publishes. A project's `devcontainer.json` and an editor's running container are
+both used when they exist, and nothing degrades when they do not.
+
+- User guide: [`docs/sandbox.html`](sandbox.html).
+- The mesh the sandbox holds: [`docs/relay.md`](relay.md).
+
+## Enable it
+
+**File ▸ Sandbox** (or **AI ▸ Sandbox**) ▸ *Use Sandbox for This Project*. It
+writes a real setting:
+
+```jsonc
+// ~/.config/sinclair/settings.json
+{ "sandbox-enabled": true }
+```
+
+"This project" is the **repository** containing the focused pane — resolved by
+walking up to the nearest `.git`, so every pane in a checkout lands in the same
+container rather than a pane three directories down getting one of its own. A
+pane with no repository above it falls back to its own working directory.
+
+Paths too broad to mount are refused outright: `/`, anything at depth 1
+(`/Users`, `/home`), and the user's home directory. The sandbox identity-mounts
+what it is given, and mounting `$HOME` would hand an agent running with its
+prompts bypassed the whole machine — the exact thing the sandbox exists to
+prevent.
+
+The container name is derived from that path (`sinclair-sbx--`), so
+it is stable across sessions, unique per checkout, and reopening a project finds
+the container it already had.
+
+## The three decisions
+
+Everything else follows from these.
+
+### One container per project, not per agent
+
+Agents on a team need to see each other's edits, share a build cache, and read
+each other's test output. Isolation between members stays where it already
+works: a git worktree each, *inside* the shared mount.
+
+The corollary is that a pane is not what owns the container. Panes are counted;
+the container outlives any single one, and is only retired when the last leaves
+— and only if `sandbox-persist` is off, and only if Sinclair created it.
+
+### The project is identity-mounted
+
+`/Users/you/code/api` on the host is `/Users/you/code/api` inside, not
+`/workspaces/api`.
+
+Git records **absolute** paths in a worktree: `.git/worktrees//gitdir` in the
+main repo and the `.git` file in the worktree point at each other. Under any
+other mapping a worktree created on one side is broken on the other, and there
+is no fixing it after the fact. Equal paths mean the worktree verbs
+(`worktree_create` and friends) keep working unchanged from both sides, and no
+path-translation layer is needed anywhere in the codebase.
+
+Worktrees must live *under* the project (`.worktrees/`), not beside it —
+`../wt/x` is outside the mount and invisible to every agent.
+
+### Relay stays on the host
+
+Only the agent process runs inside. `build::worker` resolves the role, renders
+the harness, and writes the MCP config exactly as it does for a host launch;
+`relay launch --sandbox` changes only the final exec. That keeps one pipeline
+for both planes, and means a wedged container cannot take the mesh down.
+
+Two things are restated from the container's point of view:
+
+- **The bus URL** — a loopback endpoint is rewritten to the engine's gateway
+ host (`host.docker.internal`, or `host.containers.internal` under Podman),
+ because inside a bridged container `127.0.0.1` is the container itself.
+- **The MCP config path** — written to the host state dir as always, and handed
+ to the agent as `/sandbox/relay/.mcp.json`, where the sandbox mounts
+ that directory read-only.
+
+## The image
+
+There is no Sinclair image to pull. A stock `debian:bookworm-slim` has no
+`claude`, and a host binary cannot be borrowed (a macOS Mach-O will not run in a
+Linux container), so the image has to provide the agent CLI.
+
+`container::Recipe` generates a thin Dockerfile on top of whatever base is
+configured and builds it locally, piping the Dockerfile in on stdin so there is
+no build context on disk. The tag embeds a hash of the recipe: change the recipe
+and it rebuilds, leave it alone and it is an instant cache hit.
+
+```dockerfile
+FROM debian:bookworm-slim
+ENV DEBIAN_FRONTEND=noninteractive
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates curl git openssh-client less procps nodejs npm ...
+RUN npm install -g @anthropic-ai/claude-code
+RUN mkdir -p /sandbox/home /sandbox/relay && chmod 0777 /sandbox/home
+ENV HOME=/sandbox/home
+```
+
+The `chmod 0777` is load-bearing. A fresh named volume is initialised from the
+image's contents at its mount point, permissions included, which is what lets
+the container run as an arbitrary `--user` uid on Linux and still write its
+credentials.
+
+Set `sandbox-image` to skip generation entirely and use an image as-is. The
+generated layer is `apt-get`-shaped, so a non-Debian base needs that.
+
+## Credentials
+
+The agent's `$HOME` is a named volume (`-home` at
+`/sandbox/home`), not part of the image. A macOS keychain credential is not
+readable from a Linux container, so the first agent launched inside will ask you
+to sign in — once. The volume carries that login, folder-trust answers, and
+shell history across image rebuilds and container recreation.
+
+## Adopting what is already there
+
+Resolution looks before it creates:
+
+1. Anything labelled `devcontainer.local_folder=` and running that
+ Sinclair did **not** create is entered, and is never stopped or removed —
+ the user's editor is very likely attached to it.
+2. Otherwise Sinclair's own container for the project is started if stopped,
+ created if missing.
+
+Sinclair stamps `devcontainer.local_folder` on the containers it creates too, so
+VS Code's *Reopen in Container* finds the one already running rather than
+building a second beside it. `sinclair.owner=sinclair` is what separates the two
+cases, and `Owner::may_remove` is checked before every stop and remove.
+
+### What is read from `devcontainer.json`
+
+`image` (becomes the recipe's base, so agents get the project's toolchain *plus*
+the agent CLI), `containerEnv`/`remoteEnv` (settings win on conflicts),
+`workspaceFolder`/`workspaceMount` (to detect identity mapping),
+`remoteUser`, `mounts`, `runArgs`, `shutdownAction`, `postCreateCommand`.
+
+A `shutdownAction` other than `none` is reported as an advisory: closing the
+editor would stop a container with a team working in it. Two lines make a
+project's devcontainer behave well with agents:
+
+```jsonc
+{
+ "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind",
+ "workspaceFolder": "${localWorkspaceFolder}",
+ "shutdownAction": "none"
+}
+```
+
+`sandbox-devcontainer: false` turns the whole behaviour off.
+
+## Settings
+
+| key | default | what it does |
+|-----|---------|--------------|
+| `sandbox-enabled` | `false` | Run this project's panes and agents in one container. |
+| `sandbox-image` | — | Ready-made image, used as-is. Nothing is built. |
+| `sandbox-base` | `debian:bookworm-slim` | Base the generated image layers on. |
+| `sandbox-packages` | — | Extra apt packages in the generated image. |
+| `sandbox-setup` | — | Extra commands baked in, one `RUN` layer each. |
+| `sandbox-agents` | the default agent | Agent CLIs to install (`claude`, `codex`, `gemini`). |
+| `sandbox-mount` | — | Extra mounts, `source:target[:ro]`. A bare path mounts at itself. |
+| `sandbox-env` | — | Extra environment, `KEY=VALUE`. |
+| `sandbox-user` | host uid on Linux | `--user` for the container. `host` is your own uid:gid. |
+| `sandbox-network` | `bridge` | `bridge`, `host`, or `none`. |
+| `sandbox-memory` | unlimited | `--memory` ceiling, e.g. `8g`. |
+| `sandbox-cpus` | unlimited | `--cpus` ceiling, e.g. `4`. |
+| `sandbox-persist` | `true` | Keep the container when the last pane closes. |
+| `sandbox-devcontainer` | `true` | Read `devcontainer.json` when the project has one. |
+
+Actions, bindable and in the command palette: `sandbox_shell`,
+`toggle_sandbox`, `sandbox_start`, `sandbox_stop`, `sandbox_rebuild`,
+`sandbox_status`.
+
+## Networking
+
+The bus lives on the host in every arrangement.
+
+- **macOS** — `host.docker.internal` reaches host loopback through Docker
+ Desktop, so relay stays bound to `127.0.0.1` and nothing is exposed.
+- **Linux** — Sinclair adds `--add-host=:host-gateway` itself, which
+ resolves to the bridge IP. A relay bound strictly to `127.0.0.1` is not
+ reachable there; use `sandbox-network: host` (loopback inside *is* host
+ loopback, and nothing is exposed) or bind relay to the bridge gateway
+ address. Never `0.0.0.0` — the bearer token is the only gate, with no
+ transport encryption.
+- **Podman** resolves `host.containers.internal` natively.
+
+`sandbox-network: none` is the strictest sandbox and cuts agents off from the
+bus entirely; the resolver says so as an advisory rather than failing.
+
+## Safety
+
+- Only the project is mounted. Nothing else of yours is there to damage.
+- Only credentials you mount are present.
+- A pid ceiling is always set (a supervisor can spawn eight workers into one
+ container); `sandbox-memory` and `sandbox-cpus` bound the rest.
+- **Never mount `/var/run/docker.sock`.** It is a routine devcontainer
+ convenience and a one-line container escape — it hands an agent running with
+ prompts bypassed root on the host, defeating the entire feature.
+
+## Limits
+
+- A worker started with the MCP `spawn` tool runs on the **host**: the relay
+ daemon is not told which container a session belongs to. The identity mount
+ means it sees the same files; its toolchain is the host's.
+- The generated image assumes `apt-get`. Use `sandbox-image` otherwise.
+- A `devcontainer.json` that builds from a Dockerfile is not built — point
+ `sandbox-image` at the image it produces.
+- Unix only, like the rest of the container support.
+
+## Where the code is
+
+- **`crates/container`** — pure argv construction, no I/O beyond a `$PATH`
+ probe. `sandbox.rs` (the container model and its create/exec/stop/remove
+ argvs), `image.rs` (the `Recipe`), `mount.rs`, `adopt.rs` (label discovery and
+ ownership), `devcontainer.rs` (the JSONC reader, on `config`'s parser).
+- **`crates/app/src/sandbox/`** — `spec.rs` resolves settings + project +
+ devcontainer into a description (pure, tested); `ensure.rs` runs the engine
+ calls in order.
+- **`crates/app/src/root/sandbox.rs`** — the host layer: resolve once per
+ window off the render path, count attached panes, retire on the last one.
+- **`crates/relay/src/cli/sandbox.rs`** — the exec wrap and the endpoint
+ rewrite for a sandboxed launch.
diff --git a/docs/tutorials.html b/docs/tutorials.html
index 641c876..535f890 100644
--- a/docs/tutorials.html
+++ b/docs/tutorials.html
@@ -52,6 +52,7 @@ Run agents like a team
From c2799d4c61b9916709b2e99abc31a3c728c05f48 Mon Sep 17 00:00:00 2001
From: Wess Cope
Date: Mon, 27 Jul 2026 11:27:00 -0400
Subject: [PATCH 2/5] Put the sandbox where users will find it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three surfaces were missing, all of them the ones people actually reach for.
The command palette listed OS Tabs and Attach to Container but nothing for the
sandbox, so the feature was menu-only.
Progress and failures lived in a menu the user had to know to open. Bringing a
sandbox up the first time builds an image — minutes — and clicking the item
looked identical to a hang. Resolving now reveals the Containers drawer, where
the live status line and the resolve advisories already were.
A failure was worse than invisible: sandbox_fail wrote to stderr and a status
line nobody was looking at, so opening a shell from a pane with no repository
above it did nothing at all. It now surfaces the reason on screen.
show_sandbox_panel reveals rather than toggles — it is only ever called to
show something, and collapsing an open drawer would be the opposite of the
point.
---
crates/app/src/root/boot.rs | 6 ++++++
crates/app/src/root/sandbox.rs | 24 ++++++++++++++++++++++--
2 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/crates/app/src/root/boot.rs b/crates/app/src/root/boot.rs
index 721e58b..2dde973 100644
--- a/crates/app/src/root/boot.rs
+++ b/crates/app/src/root/boot.rs
@@ -32,6 +32,12 @@ pub(crate) fn palette_catalog() -> Vec<(&'static str, Action)> {
("New Tab", Action::NewTab),
("OS Tabs", Action::NewContainerTab),
("Attach to Container", Action::AttachContainer),
+ ("Sandbox: Open Shell", Action::SandboxShell),
+ ("Sandbox: Use for This Project", Action::ToggleSandbox),
+ ("Sandbox: Start", Action::SandboxStart),
+ ("Sandbox: Stop", Action::SandboxStop),
+ ("Sandbox: Rebuild", Action::SandboxRebuild),
+ ("Sandbox: Show Status", Action::SandboxStatus),
("Close Pane", Action::CloseSurface),
("Close Tab", Action::CloseTab),
("Close Window", Action::CloseWindow),
diff --git a/crates/app/src/root/sandbox.rs b/crates/app/src/root/sandbox.rs
index 3f845e4..960be82 100644
--- a/crates/app/src/root/sandbox.rs
+++ b/crates/app/src/root/sandbox.rs
@@ -139,6 +139,10 @@ impl WorkspaceView {
}
self.sandbox_busy = true;
self.sandbox_status = Some(crate::sandbox::Stage::Looking.label().to_string());
+ // Bringing a sandbox up the first time builds an image: minutes, not
+ // milliseconds. Put the live status on screen rather than leaving it in
+ // a menu the user would have to know to open.
+ self.show_sandbox_panel(cx);
cx.notify();
let spec = self.sandbox_spec(&project);
@@ -198,16 +202,32 @@ impl WorkspaceView {
.detach();
}
- /// Record a failure where the user can see it: the menu status line, the
- /// sidebar, and stderr.
+ /// Record a failure where the user can see it. An action invoked from a
+ /// menu that then does nothing visible is indistinguishable from a broken
+ /// build, so this opens the panel holding the reason as well as writing it
+ /// to the status line and stderr.
fn sandbox_fail(&mut self, message: &str, cx: &mut Context) {
eprintln!("sinclair: sandbox: {message}");
self.sandbox_busy = false;
self.sandbox_status = Some(message.to_string());
+ self.show_sandbox_panel(cx);
self.setmenus(cx);
cx.notify();
}
+ /// Show the Containers drawer, where the sandbox status and its advisories
+ /// live. Unlike the sidebar toggle this never collapses an open panel — it
+ /// is called to reveal something, never to hide it.
+ fn show_sandbox_panel(&mut self, cx: &mut Context) {
+ if self.left_panel != Some(SidebarPanel::Containers)
+ && self.right_panel != Some(SidebarPanel::Containers)
+ {
+ self.left_panel = Some(SidebarPanel::Containers);
+ }
+ self.refresh_containers();
+ cx.notify();
+ }
+
/// Open an interactive login shell in the sandbox, in a new tab.
pub(crate) fn open_sandbox_shell(&mut self, window: &mut Window, cx: &mut Context) {
self.with_sandbox(window, cx, |this, sandbox, window, cx| {
From c097015c6276495fa14ed9c87119c88b3c653baa Mon Sep 17 00:00:00 2001
From: Wess Cope
Date: Mon, 27 Jul 2026 13:21:10 -0400
Subject: [PATCH 3/5] Right-click a tab to rename it, and make the rename
dialog visible
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Issue #15 asks for tab renaming. It was already there — `change_tab_title`, in
the View menu, in the command palette, documented — but two things stood
between the user and it.
The rename dialog never painted. Its host element had no size, so the modal's
absolutely-positioned backdrop, painted in the deferred pass, had nothing to
resolve against: the dialog was created and re-rendered every frame and was
never on screen. That is the whole of the reported problem — renaming looked
missing because it was invisible. Filling the window fixes it.
`tab-title-show-host` was dead. It parsed, it wrote to settings.json, it had a
toggle in Settings and a line in the help text promising "tabs show just the
path" — and nothing read it. The tab label now honours it, with a strip that
only fires on something genuinely shaped like `user@host:`, so
`https://example.com:8080` and `nvim: main.rs` are left alone. A title the user
typed is never rewritten. The flag rides in a cell the reload path updates, so
toggling it does not need a restart.
Right-click on a tab now opens a menu — rename, split, close, close others —
because the View menu is not where anyone looks with their hand already on a
tab. Handlers defer their action: running straight into the same window
re-enters an update in progress, which is refused, and the menu closes having
done nothing.
Window dragging comes from the guise side: every top-row tab bar drags now, not
just the corner one.
---
crates/app/src/rename.rs | 7 +++
crates/app/src/root/mod.rs | 48 ++++++++++++++++---
crates/app/src/root/render.rs | 5 ++
crates/app/src/root/tabmenu.rs | 86 ++++++++++++++++++++++++++++++++++
crates/app/src/tabbar.rs | 35 +++++++++++++-
crates/app/src/view/mod.rs | 6 +++
crates/app/tests/tabbar.rs | 24 ++++++++++
vendor/guise | 2 +-
8 files changed, 204 insertions(+), 9 deletions(-)
create mode 100644 crates/app/src/root/tabmenu.rs
diff --git a/crates/app/src/rename.rs b/crates/app/src/rename.rs
index d0dfb89..5410098 100644
--- a/crates/app/src/rename.rs
+++ b/crates/app/src/rename.rs
@@ -96,7 +96,14 @@ impl RenameDialog {
impl Render for RenameDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
let root = self.root.clone();
+ // The modal's backdrop positions itself absolutely and is painted in
+ // the deferred pass; a zero-sized host leaves it with nothing to
+ // resolve against, so the dialog never appears. Fill the window.
div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .size_full()
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
let ks = &ev.keystroke;
if ks.key == "escape" || (ks.modifiers.platform && ks.key == "w") {
diff --git a/crates/app/src/root/mod.rs b/crates/app/src/root/mod.rs
index bfa6352..8e22c44 100644
--- a/crates/app/src/root/mod.rs
+++ b/crates/app/src/root/mod.rs
@@ -23,6 +23,7 @@ pub(crate) mod sandbox;
mod savebuffer;
mod sidebar;
mod triggers;
+mod tabmenu;
mod tabs;
mod worktrees;
@@ -188,6 +189,16 @@ impl PaneContent {
}
}
+ /// True when the title was typed by the user rather than set by the
+ /// program in the pane. An explicit name is never rewritten.
+ fn has_title_override(&self, cx: &App) -> bool {
+ match self {
+ PaneContent::Terminal(v) => v.read(cx).has_title_override(),
+ // A webview's title is its own; nothing shell-shaped to strip.
+ PaneContent::Webview(_) => true,
+ }
+ }
+
fn needs_attention(&self, cx: &App) -> bool {
match self {
PaneContent::Terminal(v) => v.read(cx).needs_attention(),
@@ -403,6 +414,12 @@ pub struct WorkspaceView {
catalog_loading: bool,
/// The guise Spotlight quick-open overlay (cmd+P), rebuilt each open.
spotlight: Option>,
+ /// Live `tab-title-show-host`, shared with the pane group's tab-label
+ /// closure so a config reload reaches it without rebuilding the group.
+ show_host: Rc>,
+ /// The open tab context menu (right-click on a tab). Rebuilt per open, so
+ /// its entries can close over the tab that was clicked.
+ tab_menu: Option>,
/// The active in-window dialog (rename), as a guise Modal
/// overlay. `None` when no dialog is open.
modal: Option,
@@ -525,7 +542,8 @@ impl WorkspaceView {
// throwaway group over a placeholder id, assemble `this`, spawn the real
// first terminal, then rebuild the group around it below.
let placeholder = item_ids.next();
- let group = Self::build_group(placeholder, items.clone(), cx);
+ let show_host = Rc::new(std::cell::Cell::new(opts.tab_title_show_host));
+ let group = Self::build_group(placeholder, items.clone(), show_host.clone(), cx);
let group_sub = cx.subscribe_in(&group, window, |this, _g, ev: &PaneGroupEvent, window, cx| {
this.on_group_event(ev.clone(), window, cx);
});
@@ -554,6 +572,8 @@ impl WorkspaceView {
catalog_status: None,
catalog_loading: false,
spotlight: None,
+ show_host,
+ tab_menu: None,
modal: None,
containers: Vec::new(),
engine: None,
@@ -621,7 +641,8 @@ impl WorkspaceView {
}
};
// Rebuild the group around the real first item (the placeholder held none).
- this.group = Self::build_group(first, this.items.clone(), cx);
+ this.group =
+ Self::build_group(first, this.items.clone(), this.show_host.clone(), cx);
this._group_sub = cx.subscribe_in(&this.group, window, |this, _g, ev: &PaneGroupEvent, window, cx| {
this.on_group_event(ev.clone(), window, cx);
});
@@ -671,6 +692,7 @@ impl WorkspaceView {
fn build_group(
first: ItemId,
items: Rc>,
+ show_host: Rc>,
cx: &mut Context,
) -> Entity {
// The group doubles as the window titlebar: reserve the top-left inset
@@ -697,12 +719,20 @@ impl WorkspaceView {
})
.on_item_title({
let items = items.clone();
+ let show_host = show_host.clone();
move |id, cx| {
- items
- .borrow()
- .get(&id)
- .map(|it| SharedString::from(it.content.title(cx)))
- .unwrap_or_default()
+ let items = items.borrow();
+ let Some(item) = items.get(&id) else {
+ return SharedString::default();
+ };
+ let title = item.content.title(cx);
+ // Shells write `user@host:path`; most people want the
+ // path. A title the user typed is left exactly as typed.
+ if show_host.get() || item.content.has_title_override(cx) {
+ SharedString::from(title)
+ } else {
+ SharedString::from(crate::tabbar::strip_host(&title).to_string())
+ }
}
})
.on_item_dot({
@@ -734,6 +764,9 @@ impl WorkspaceView {
cx.notify();
}
PaneGroupEvent::TearOff(item) => self.tear_off_to_window(item, window, cx),
+ PaneGroupEvent::ContextMenu { item, position } => {
+ self.open_tab_menu(item, position, window, cx)
+ }
}
}
@@ -916,6 +949,7 @@ impl WorkspaceView {
};
self.base_font_size = px(opts.font_size.max(1.0));
self.opts = opts;
+ self.show_host.set(self.opts.tab_title_show_host);
if plugins_changed {
self.plugins = loadplugins(&self.opts);
self.rebuild_webview_hosts(cx);
diff --git a/crates/app/src/root/render.rs b/crates/app/src/root/render.rs
index 3bd0aea..898ee21 100644
--- a/crates/app/src/root/render.rs
+++ b/crates/app/src/root/render.rs
@@ -126,6 +126,11 @@ impl Render for WorkspaceView {
}
// The active in-window dialog (rename), if any.
+ // The context menu renders nothing while closed, so it can stay in the
+ // tree until the next open replaces it.
+ if let Some(menu) = self.tab_menu.as_ref() {
+ base = base.child(menu.clone());
+ }
if let Some(modal) = self.modal.as_ref() {
base = base.child(modal.clone());
}
diff --git a/crates/app/src/root/tabmenu.rs b/crates/app/src/root/tabmenu.rs
new file mode 100644
index 0000000..4807f43
--- /dev/null
+++ b/crates/app/src/root/tabmenu.rs
@@ -0,0 +1,86 @@
+//! The tab context menu.
+//!
+//! Renaming a tab, splitting from it, and closing it were all reachable only
+//! from the View menu or the command palette — the two places nobody looks
+//! when their hand is already on a tab. Right-click is where people expect
+//! these, so this builds the menu the pane group asks for when a tab is
+//! right-clicked (`PaneGroupEvent::ContextMenu`).
+//!
+//! The group reports the gesture and nothing else: what a tab's menu offers is
+//! the host's business, which is why the entries are assembled here.
+
+use super::*;
+use gpui::prelude::*;
+
+impl WorkspaceView {
+ /// Open the context menu for `item` at the pointer.
+ ///
+ /// The menu is rebuilt each time rather than kept around: its entries close
+ /// over the item that was clicked, and "Close Other Tabs" has to be hidden
+ /// when there are no others.
+ pub(crate) fn open_tab_menu(
+ &mut self,
+ item: ItemId,
+ position: gpui::Point,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(handle) = window.window_handle().downcast::() else {
+ return;
+ };
+ let others = self.group.read(cx).items().len() > 1;
+ // Item handlers run *inside* this window's update, so the action is
+ // deferred: dispatching straight into the same window re-enters it,
+ // the update is refused, and the menu closes having done nothing.
+ let run = move |action: Action| {
+ move |_w: &mut Window, cx: &mut App| {
+ let action = action.clone();
+ cx.defer(move |cx| {
+ let _ = handle.update(cx, |view, w, cx| view.dispatch(action, w, cx));
+ });
+ }
+ };
+ let close_others = move |_w: &mut Window, cx: &mut App| {
+ cx.defer(move |cx| {
+ let _ = handle
+ .update(cx, |view, window, cx| view.close_other_items(item, window, cx));
+ });
+ };
+
+ let menu = cx.new(|cx| {
+ let mut menu = guise::ContextMenu::new(cx)
+ .item("Rename Tab\u{2026}", run(Action::ChangeTabTitle))
+ .divider()
+ .item("Split Right", run(Action::NewSplit(SplitDirection::Right)))
+ .item("Split Down", run(Action::NewSplit(SplitDirection::Down)))
+ .divider();
+ if others {
+ menu = menu.item("Close Other Tabs", close_others);
+ }
+ menu.danger_item("Close Tab", run(Action::CloseTab))
+ });
+ menu.update(cx, |m, cx| m.show(position, window, cx));
+ self.tab_menu = Some(menu);
+ cx.notify();
+ }
+
+ /// Close every item except `keep`. Collected first: closing mutates the
+ /// group, so iterating it while closing would walk a list being rewritten.
+ pub(crate) fn close_other_items(
+ &mut self,
+ keep: ItemId,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let doomed: Vec = self
+ .group
+ .read(cx)
+ .items()
+ .into_iter()
+ .filter(|id| *id != keep)
+ .collect();
+ for id in doomed {
+ self.close_item(id, window, cx);
+ }
+ }
+}
diff --git a/crates/app/src/tabbar.rs b/crates/app/src/tabbar.rs
index 4ecafed..d4ae597 100644
--- a/crates/app/src/tabbar.rs
+++ b/crates/app/src/tabbar.rs
@@ -1,4 +1,4 @@
-//! Small color helper shared by the titlebar and sidebar chrome. (The
+//! Small helpers for the tab strip and the chrome around it. (The
//! window-level tab strip is gone: tabs now live per-pane inside the
//! `guise::PaneGroup`.)
@@ -11,6 +11,39 @@ pub fn blend(a: Rgb, b: Rgb, t: f32) -> Rgb {
Rgb::new(mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b))
}
+/// Drop the `user@host:` a shell writes into its terminal title, leaving just
+/// the part people actually read (usually the path). Backs
+/// `tab-title-show-host`.
+///
+/// Deliberately narrow, because a title is arbitrary text: it only fires when
+/// everything before the first `:` looks like `user@host` — one `@`, both
+/// sides non-empty, and no slash or whitespace anywhere. That leaves
+/// `https://example.com:8080`, `nvim: main.rs`, and anything with a path in
+/// front of the colon alone.
+pub fn strip_host(title: &str) -> &str {
+ let Some(colon) = title.find(':') else {
+ return title;
+ };
+ let (prefix, rest) = title.split_at(colon);
+ let rest = &rest[1..];
+ if rest.is_empty() {
+ return title;
+ }
+ let Some((user, host)) = prefix.split_once('@') else {
+ return title;
+ };
+ let looks_like_login = !user.is_empty()
+ && !host.is_empty()
+ && !host.contains('@')
+ && !prefix.contains('/')
+ && !prefix.chars().any(char::is_whitespace);
+ if looks_like_login {
+ rest
+ } else {
+ title
+ }
+}
+
#[cfg(test)]
#[path = "../tests/tabbar.rs"]
mod tests;
diff --git a/crates/app/src/view/mod.rs b/crates/app/src/view/mod.rs
index 956b1ab..93b2774 100644
--- a/crates/app/src/view/mod.rs
+++ b/crates/app/src/view/mod.rs
@@ -534,6 +534,12 @@ impl TerminalView {
}
}
+ /// True when the title was set by hand rather than by the program running
+ /// in the pane. An explicit name is never rewritten.
+ pub fn has_title_override(&self) -> bool {
+ self.override_title.is_some()
+ }
+
/// Override the pane title (empty string clears it back to the vt title).
pub fn set_title_override(&mut self, title: &str, cx: &mut Context) {
self.override_title = (!title.trim().is_empty()).then(|| title.trim().to_string());
diff --git a/crates/app/tests/tabbar.rs b/crates/app/tests/tabbar.rs
index 73e6fa6..fbce939 100644
--- a/crates/app/tests/tabbar.rs
+++ b/crates/app/tests/tabbar.rs
@@ -19,3 +19,27 @@ fn blend_mixes_channels_independently() {
let m = blend(a, b, 0.1);
assert_eq!(m, Rgb::new(11, 190, 26));
}
+
+#[test]
+fn strips_a_shell_login_prefix() {
+ assert_eq!(strip_host("wess@wess:~/Desktop/Dev/sinclair"), "~/Desktop/Dev/sinclair");
+ assert_eq!(strip_host("wess@wess:~"), "~");
+ assert_eq!(strip_host("root@1cffe899fb41:/work"), "/work");
+}
+
+#[test]
+fn leaves_titles_that_only_look_similar() {
+ // A URL: everything before the first colon is a scheme, not a login.
+ assert_eq!(strip_host("https://example.com:8080"), "https://example.com:8080");
+ // A program prefix.
+ assert_eq!(strip_host("nvim: src/main.rs"), "nvim: src/main.rs");
+ // A path before the colon.
+ assert_eq!(strip_host("/srv/a@b:c"), "/srv/a@b:c");
+ // No colon at all.
+ assert_eq!(strip_host("wess@wess"), "wess@wess");
+ // Nothing after the colon to keep.
+ assert_eq!(strip_host("a@b:"), "a@b:");
+ // Plain paths pass through.
+ assert_eq!(strip_host("~/Desktop/Dev/sinclair"), "~/Desktop/Dev/sinclair");
+ assert_eq!(strip_host(""), "");
+}
diff --git a/vendor/guise b/vendor/guise
index 984dd9a..105c298 160000
--- a/vendor/guise
+++ b/vendor/guise
@@ -1 +1 @@
-Subproject commit 984dd9a58efee0fd22610b798fed47d58e2ab20c
+Subproject commit 105c29834b0bbdf12e96536246e4aa821e807c1d
From b7accbc1f37a13329aea69b6632729195defd7b6 Mon Sep 17 00:00:00 2001
From: Wess Cope
Date: Mon, 27 Jul 2026 13:38:30 -0400
Subject: [PATCH 4/5] Document dev containers, and honour the fields that were
only parsed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`mounts` and `remoteUser` were read out of a project's devcontainer.json and
then dropped on the floor. Both are now applied: mounts through a parser for
the engine's `type=bind,source=…,target=…` form, which is what the spec uses
and not the `-v` shorthand the settings key takes, and `remoteUser` as the
container's user unless `sandbox-user` overrides it. A project that names a
user has decided who its tooling runs as.
New tutorial, docs/devcontainers.html: opening a shell inside the environment a
repo already defines, exactly which fields are read and which are ignored,
sharing the container an editor started (and why Sinclair never stops one it
did not create), the two lines that make a devcontainer suit agents as well as
people, and what happens when there is no devcontainer.json at all. Linked from
the tutorials index, the docs overview, the nav manifest, and both directions
between it and the sandbox page.
The tab work is documented too: right-clicking a tab, dragging the window from
any top-row tab bar, and what `tab-title-show-host` actually does now that
something reads it — its reference entry promised the opposite of the old
behaviour.
---
AGENTS.md | 3 +-
CLAUDE.md | 3 +-
README.md | 2 +-
crates/app/src/sandbox/spec.rs | 19 +++
crates/app/tests/sandbox.rs | 40 ++++++
crates/container/src/mount.rs | 30 +++++
crates/container/tests/mount.rs | 27 ++++
docs/configuration.html | 6 +-
docs/devcontainers.html | 212 ++++++++++++++++++++++++++++++++
docs/docs.html | 2 +
docs/docs.js | 1 +
docs/index.html | 2 +-
docs/roadmap.md | 20 +++
docs/sandbox.html | 7 ++
docs/sandbox.md | 22 +++-
docs/tutorials.html | 1 +
docs/workspace.html | 26 +++-
docs/workspacesetup.html | 10 +-
18 files changed, 415 insertions(+), 18 deletions(-)
create mode 100644 docs/devcontainers.html
diff --git a/AGENTS.md b/AGENTS.md
index 911cae1..b2ca6b7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -227,6 +227,7 @@ Keep the vt/terminal layers free of gpui types — the boundary is the bridge.
wired (the single-gpui patch), the theme bridge, and the surface-by-surface
port status.
- `docs/sandbox.md` — the shared project sandbox: one container for a human and
- a whole agent team, the identity mount, the generated image, and adoption.
+ a whole agent team, the identity mount, the generated image, adoption, and
+ which `devcontainer.json` fields are honoured.
- `docs/relay.md` — the agent mesh: roles, teams/tiles, the `relay` CLI, and the
MCP coordination tools.
diff --git a/CLAUDE.md b/CLAUDE.md
index d401c3a..177b317 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -226,6 +226,7 @@ Keep the vt/terminal layers free of gpui types — the boundary is the bridge.
wired (the single-gpui patch), the theme bridge, and the surface-by-surface
port status.
- `docs/sandbox.md` — the shared project sandbox: one container for a human and
- a whole agent team, the identity mount, the generated image, and adoption.
+ a whole agent team, the identity mount, the generated image, adoption, and
+ which `devcontainer.json` fields are honoured.
- `docs/relay.md` — the agent mesh: roles, teams/tiles, the `relay` CLI, and the
MCP coordination tools.
diff --git a/README.md b/README.md
index 8f23960..0546127 100644
--- a/README.md
+++ b/README.md
@@ -174,7 +174,7 @@ is migrated automatically on first launch.)
"confirm-quit": true, // warn if a process is still running
"shell-integration": true, // OSC 133/7 prompt-jump + cwd hooks
"session-restore": false, // reopen tabs/splits on launch
- "tab-title-show-host": false, // keep user@host: in tab titles
+ "tab-title-show-host": false, // keep user@host: in tab titles (off: just the path)
// AI — opt-in (also editable in Settings → AI); see docs/relay.md
"ai-enabled": true,
diff --git a/crates/app/src/sandbox/spec.rs b/crates/app/src/sandbox/spec.rs
index c098281..cf71c63 100644
--- a/crates/app/src/sandbox/spec.rs
+++ b/crates/app/src/sandbox/spec.rs
@@ -46,6 +46,16 @@ pub fn build(opts: &config::Options, env: &Env) -> Spec {
sandbox = sandbox.with_relay_dir(dir);
}
+ // A project's devcontainer mounts come first, so an explicit
+ // `sandbox-mount` for the same target is the one that wins.
+ if let Some(dc) = env.devcontainer {
+ for raw in &dc.mounts {
+ match Mount::parse_mount_spec(raw) {
+ Ok(m) => sandbox.mounts.push(m),
+ Err(e) => notes.push(format!("devcontainer.json mounts `{raw}`: {e}")),
+ }
+ }
+ }
for raw in &opts.sandbox_mount {
match Mount::parse(raw) {
Ok(m) => sandbox.mounts.push(m),
@@ -156,6 +166,15 @@ fn user_for(opts: &config::Options, env: &Env, notes: &mut Vec) -> Optio
let raw = opts.sandbox_user.as_deref().map(str::trim).unwrap_or("");
match raw {
"" => {
+ // A project that names a `remoteUser` has decided who its tooling
+ // runs as; follow it rather than guessing.
+ if let Some(user) = env
+ .devcontainer
+ .and_then(|dc| dc.remote_user.clone())
+ .filter(|u| !u.trim().is_empty())
+ {
+ return Some(user);
+ }
if cfg!(target_os = "linux") && env.host_user.is_some() {
env.host_user.clone()
} else {
diff --git a/crates/app/tests/sandbox.rs b/crates/app/tests/sandbox.rs
index fc16e23..3e959c9 100644
--- a/crates/app/tests/sandbox.rs
+++ b/crates/app/tests/sandbox.rs
@@ -230,3 +230,43 @@ fn repo_root_walks_up_from_a_subdirectory() {
fn no_repo_above_a_path_is_none() {
assert!(repo_root(std::path::Path::new("/")).is_none());
}
+
+#[test]
+fn devcontainer_mounts_reach_the_sandbox() {
+ let dc = DevContainer {
+ mounts: vec!["type=bind,source=/host/cache,target=/work/.cache".to_string()],
+ ..DevContainer::default()
+ };
+ let s = build(&opts(), &env(Some(&dc)));
+ assert!(s.sandbox.mounts.iter().any(|m| m.target == "/work/.cache"));
+}
+
+#[test]
+fn a_malformed_devcontainer_mount_is_a_note_not_a_failure() {
+ let dc = DevContainer {
+ mounts: vec!["type=bind,target=/nope".to_string()],
+ ..DevContainer::default()
+ };
+ let s = build(&opts(), &env(Some(&dc)));
+ assert!(s.notes.iter().any(|n| n.contains("devcontainer.json mounts")));
+}
+
+#[test]
+fn remote_user_is_followed_when_settings_do_not_override() {
+ let dc = DevContainer {
+ remote_user: Some("node".to_string()),
+ ..DevContainer::default()
+ };
+ assert_eq!(build(&opts(), &env(Some(&dc))).sandbox.user.as_deref(), Some("node"));
+}
+
+#[test]
+fn an_explicit_sandbox_user_beats_remote_user() {
+ let dc = DevContainer {
+ remote_user: Some("node".to_string()),
+ ..DevContainer::default()
+ };
+ let mut o = opts();
+ o.sandbox_user = Some("1000:1000".to_string());
+ assert_eq!(build(&o, &env(Some(&dc))).sandbox.user.as_deref(), Some("1000:1000"));
+}
diff --git a/crates/container/src/mount.rs b/crates/container/src/mount.rs
index 47b8267..0b474fe 100644
--- a/crates/container/src/mount.rs
+++ b/crates/container/src/mount.rs
@@ -85,6 +85,36 @@ impl Mount {
}
}
+ /// Parse a `devcontainer.json` `mounts` entry, which uses the engine's
+ /// `--mount` syntax (`type=bind,source=…,target=…`) rather than the
+ /// `-v` shorthand [`Self::parse`] takes. Keys may appear in any order;
+ /// anything else in the entry (`consistency`, `type`) is ignored, since it
+ /// does not change where the mount lands.
+ pub fn parse_mount_spec(raw: &str) -> Result {
+ let (mut source, mut target, mut readonly) = (None, None, false);
+ for field in raw.split(',') {
+ let field = field.trim();
+ if field.eq_ignore_ascii_case("readonly") || field.eq_ignore_ascii_case("ro") {
+ readonly = true;
+ continue;
+ }
+ let Some((key, value)) = field.split_once('=') else {
+ continue;
+ };
+ match key.trim().to_ascii_lowercase().as_str() {
+ "source" | "src" => source = Some(value.trim()),
+ "target" | "destination" | "dst" => target = Some(value.trim()),
+ "readonly" | "ro" => readonly = !matches!(value.trim(), "false" | "0"),
+ _ => {}
+ }
+ }
+ match (source, target) {
+ (Some(s), Some(t)) => Self::checked(s, t, readonly),
+ (None, _) => Err(format!("`{raw}` has no source")),
+ (_, None) => Err(format!("`{raw}` has no target")),
+ }
+ }
+
fn checked(source: &str, target: &str, readonly: bool) -> Result {
if source.is_empty() {
return Err("mount is missing a source".to_string());
diff --git a/crates/container/tests/mount.rs b/crates/container/tests/mount.rs
index 0666622..a18a8c5 100644
--- a/crates/container/tests/mount.rs
+++ b/crates/container/tests/mount.rs
@@ -30,3 +30,30 @@ fn parse_rejects_bad_entries() {
assert!(Mount::parse("/a:/b:ro:extra").is_err());
assert!(Mount::parse(":/b").is_err());
}
+
+#[test]
+fn parses_the_devcontainer_mount_syntax() {
+ let m = Mount::parse_mount_spec("type=bind,source=/host/cache,target=/work/.cache").unwrap();
+ assert_eq!(m, Mount::rw("/host/cache", "/work/.cache"));
+}
+
+#[test]
+fn mount_spec_fields_are_order_free_and_readonly_is_honoured() {
+ let m = Mount::parse_mount_spec("target=/etc/x,readonly,source=/host/x,type=bind").unwrap();
+ assert_eq!(m, Mount::ro("/host/x", "/etc/x"));
+ assert!(!Mount::parse_mount_spec("source=/a,target=/b,readonly=false").unwrap().readonly);
+ // Named volumes use the same shape.
+ assert_eq!(
+ Mount::parse_mount_spec("type=volume,source=node_modules,target=/work/node_modules")
+ .unwrap()
+ .source,
+ "node_modules"
+ );
+}
+
+#[test]
+fn mount_spec_reports_what_is_missing() {
+ assert!(Mount::parse_mount_spec("type=bind,target=/b").unwrap_err().contains("source"));
+ assert!(Mount::parse_mount_spec("type=bind,source=/a").unwrap_err().contains("target"));
+ assert!(Mount::parse_mount_spec("source=/a,target=rel").is_err());
+}
diff --git a/docs/configuration.html b/docs/configuration.html
index 07e4771..bcc8343 100644
--- a/docs/configuration.html
+++ b/docs/configuration.html
@@ -150,7 +150,7 @@ Shell integration & sessions
autosuggest-pathsbool trueComplete filesystem paths from the working directory.
autosuggest-assistbool trueRank suggestions with the local assist model rather than plain recency.
session-restorebool falseRestore windows and splits from the previous run.
- tab-title-show-hostbool falseShow the hostname in tab titles.
+ tab-title-show-hostbool falseKeep the user@host: prefix shells write into their title. Off by default, so tabs show just the path. A label you set by hand is never rewritten.
@@ -189,8 +189,8 @@ AI & relay
- Shared sandbox
- One container per project, shared by your panes and every agent on a team. Needs Docker or Podman and nothing else. See Share one container with your agents .
+ Shared sandbox
+ One container per project, shared by your panes and every agent on a team. Needs Docker or Podman and nothing else. See Share one container with your agents and Work in your project's dev container .
Key Type Default Description
diff --git a/docs/devcontainers.html b/docs/devcontainers.html
new file mode 100644
index 0000000..170b262
--- /dev/null
+++ b/docs/devcontainers.html
@@ -0,0 +1,212 @@
+
+
+
+
+
+ Work in your project's dev container — Sinclair docs
+
+
+
+
+
+
+
+ Skip to content
+
+
+ Tutorial
+ Work in your project's dev container
+
+ A lot of repositories already say what they need to be built in — a
+ devcontainer.json naming an image, a user, some mounts. Sinclair
+ reads it, so a terminal in that project can be a terminal inside that
+ environment rather than one beside it. This walkthrough opens a shell in a
+ project's dev container, shares the container your editor already has running,
+ and writes a devcontainer.json that works for people and coding
+ agents alike.
+
+
+
+
Sinclair needs Docker or Podman and nothing else. It does
+ not need VS Code or the devcontainer CLI installed, and it
+ never requires a project to have a devcontainer.json — without one
+ it builds its own environment. The file is an input, not a dependency. See
+ Share one container with your agents for the
+ no-devcontainer path.
+
+
+ 1. Open a shell inside it
+
+ From a pane in the project, open File ▸ Sandbox and pick
+ Use Sandbox for This Project , then Open Shell in Sandbox .
+ Sinclair reads the project's devcontainer.json, brings a container
+ up from it, and gives you an ordinary tab whose shell is inside.
+
+ Config files are looked for in the order the Dev Containers tooling uses:
+.devcontainer/devcontainer.json
+.devcontainer.json
+.devcontainer/devcontainer.jsonc
+
+ Comments and trailing commas are fine — the file is parsed as JSONC, the same
+ way Sinclair parses its own settings.
+
+
+ 2. What Sinclair takes from the file
+
+ Only the fields that change how a container is created or entered. Anything
+ else in your devcontainer.json is ignored rather than guessed at.
+
+
+
+ Field What it does here
+
+ imageBecomes the base of the image Sinclair builds, so your agents get the project's toolchain plus the agent CLI on top.
+ containerEnv, remoteEnvMerged into the container's environment. Your own sandbox-env wins on conflicts.
+ mountsApplied, in the engine's type=bind,source=…,target=… form. An entry that doesn't parse becomes a note, not a failure.
+ remoteUser / containerUserThe user the container runs as, unless sandbox-user says otherwise.
+ shutdownActionRead to warn you — see step 4.
+ workspaceFolder, workspaceMountRead only to detect whether the project is mounted at its own path — see step 3.
+
+
+
+
+ The ${localWorkspaceFolder},
+ ${localWorkspaceFolderBasename},
+ ${containerWorkspaceFolder}, and
+ ${containerWorkspaceFolderBasename} variables all resolve.
+
+
+
Not used: features,
+ customizations, forwardPorts, and
+ postCreateCommand belong to the Dev Containers tooling, and
+ runArgs is left alone rather than passing arbitrary engine flags
+ through. A build.dockerfile is not built for you — point
+ sandbox-image at the image it produces and Sinclair uses it
+ as-is.
+
+
+ 3. One thing worth changing: mount at your own path
+
+ By default the Dev Containers convention mounts your repo at
+ /workspaces/<name>. That is fine for a person, and awkward for
+ a coding agent: git records absolute paths when it creates a
+ worktree, so a worktree made inside the container has a .git
+ pointer that resolves nowhere on the host, and one made on the host resolves
+ nowhere inside.
+
+
+ Two spec-legal lines make the container path equal the host path, and the whole
+ problem disappears:
+
+// .devcontainer/devcontainer.json
+{
+ "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind",
+ "workspaceFolder": "${localWorkspaceFolder}"
+}
+
+ When Sinclair creates the container itself it does this anyway. The lines matter
+ for the container your editor creates, so both tools agree.
+
+
+ 4. Don't let closing the editor kill a working team
+
+ shutdownAction defaults to stopContainer: close your
+ editor window and the container stops. If a team of agents is working in that
+ container, they stop with it, mid-task.
+
+{
+ "shutdownAction": "none"
+}
+
+ Sinclair reads this field and says so in the Containers panel
+ when it isn't none, so you find out before it costs you a run
+ rather than after.
+
+
+ 5. Sharing one container with your editor
+
+ Sinclair looks before it creates. If a container is already running for this
+ folder — one VS Code's Dev Containers extension built, or one
+ devcontainer up started — Sinclair enters that one
+ instead of building a parallel environment beside it.
+
+
+ It also never stops or removes a container it did not create. Your editor is very
+ likely attached to it, and Stop Sandbox pulling the floor out from under
+ your editor session would be a bad surprise. The Containers panel tells you when
+ the sandbox is one Sinclair adopted.
+
+
+ Discovery keys on the label the Dev Containers tooling stamps with the host
+ workspace path:
+
+docker ps -a --filter label=devcontainer.local_folder=$PWD
+
+ Sinclair stamps that same label on the containers it creates, so it works in both
+ directions: open the project in VS Code afterwards and Reopen in Container
+ finds the one already running rather than building a second.
+
+
+ 6. If the project has no devcontainer.json
+
+ Nothing degrades. Sinclair generates a small image itself — a Debian base plus
+ git, node, and your agent CLI — and builds it locally on first use. If you'd
+ rather write a dev container the whole team shares, this is a reasonable
+ starting point that works for people and agents:
+
+// .devcontainer/devcontainer.json
+{
+ "name": "api",
+ "image": "mcr.microsoft.com/devcontainers/javascript-node:22",
+
+ // Same path inside and out, so git worktrees stay valid on both sides.
+ "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind",
+ "workspaceFolder": "${localWorkspaceFolder}",
+
+ // Closing your editor shouldn't stop a container with agents working in it.
+ "shutdownAction": "none",
+
+ "remoteUser": "node",
+ "containerEnv": { "DATABASE_URL": "postgres://host.docker.internal/dev" },
+ "mounts": ["type=volume,source=api-node-modules,target=${containerWorkspaceFolder}/node_modules"]
+}
+
+ Sinclair layers the agent CLI onto that image, so you do not need to install
+ claude in it yourself.
+
+
+
Careful
+
Don't mount /var/run/docker.sock. It's a common dev container
+ convenience and a one-line container escape — it hands anything in the container
+ root on your host, which matters a great deal once agents are running there with
+ their permission prompts turned off.
+
+
+ 7. Turning it off
+
+ To ignore a project's devcontainer.json and let Sinclair build its
+ own environment:
+
+{
+ "sandbox-devcontainer": false
+}
+
+ The rest of the sandbox — mounts, environment, resource ceilings, the image —
+ is configured the same way either way. Every key is in
+ the settings reference .
+
+
+ Where next
+
+
+
+
+
+
diff --git a/docs/docs.html b/docs/docs.html
index 6329dd3..234cf5f 100644
--- a/docs/docs.html
+++ b/docs/docs.html
@@ -58,6 +58,8 @@ Extending & agents
Prompt Designer Design your shell prompt visually and apply it to your shell.
MCP & automation Drive the terminal from Claude and other MCP clients.
Agent mesh Run coordinated agent teams over the local relay.
+ Dev containers Work inside the environment your repo already defines, and share the container your editor started.
+ Shared sandbox Put a project and its whole agent team inside one container: a shared filesystem and a real boundary.
Reference
diff --git a/docs/docs.js b/docs/docs.js
index 462f63c..d70b8a8 100644
--- a/docs/docs.js
+++ b/docs/docs.js
@@ -24,6 +24,7 @@ const MANIFEST = [
{ slug: "awareness", title: "Triggers & notifications", href: "awareness.html" },
{ slug: "recording", title: "Record & share", href: "recording.html" },
{ slug: "ostabs", title: "Linux in a tab", href: "ostabs.html" },
+ { slug: "devcontainers", title: "Dev containers", href: "devcontainers.html" },
{ slug: "images", title: "Images in the terminal", href: "images.html" },
{ slug: "assist", title: "Assist: type less", href: "assist.html" },
],
diff --git a/docs/index.html b/docs/index.html
index edb71e4..b660aed 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -291,7 +291,7 @@ Guardrails & glance
One container, whole team
- Put a project and its entire agent team inside one shared sandbox container: a single filesystem, a single toolchain, and a real boundary around agents whose permission prompts you just turned off. Docker or Podman is the only thing you need — the project is mounted at its own path, so git worktrees stay valid on both sides.
+ Put a project and its entire agent team inside one shared sandbox container: a single filesystem, a single toolchain, and a real boundary around agents whose permission prompts you just turned off. Docker or Podman is the only thing you need — and if your repo has a devcontainer.json, that is the environment they land in.
A worktree per agent
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8a1d1fb..791089b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -410,3 +410,23 @@ Conventions (non-negotiable):
Known gap: a worker started with the MCP `spawn` tool runs on the host — the
daemon is not told which container a session belongs to. See
`docs/sandbox.md`.
+- 2026-07-27: tab renaming, tab context menu, and window dragging (issue #15).
+ Renaming a tab was already an action, a View-menu item, a palette entry, and a
+ documented keybinding — and the dialog **never painted**: its host element had
+ no size, so the modal's absolutely-positioned backdrop, drawn in the deferred
+ pass, had nothing to resolve against. The dialog was created and re-rendered
+ every frame, off screen; the feature looked missing because it was invisible.
+ `tab-title-show-host` was dead in the same way — parsed, written to
+ `settings.json`, a toggle in Settings and a promise in the help text, and no
+ consumer. Tab labels now honour it, with a strip that only fires on something
+ genuinely shaped like `user@host:` (so `https://example.com:8080` and
+ `nvim: main.rs` survive) and never rewrites a label the user typed; the flag
+ rides in a cell the reload path updates, so toggling needs no restart.
+ Right-clicking a tab now opens a menu — rename, split, close, close others —
+ built by the host from a new `PaneGroupEvent::ContextMenu`; its handlers defer
+ their action, since dispatching straight into the same window re-enters an
+ update in progress. Window dragging: every tab bar along the layout's top edge
+ now drags, not just the top-right one, and the space reserved for the window
+ controls drags rather than being dead padding. Dev containers: `mounts` and
+ `remoteUser` from a project's `devcontainer.json` are now applied, not just
+ parsed. See `docs/devcontainers.html`.
diff --git a/docs/sandbox.html b/docs/sandbox.html
index 3a567db..68a30fd 100644
--- a/docs/sandbox.html
+++ b/docs/sandbox.html
@@ -199,6 +199,11 @@ 8. If the project has a devcontainer.json
agreed on. Its containerEnv and remoteEnv reach the
container, with your own sandbox-env winning on conflicts.
+
+ Mounts and remoteUser from the file are applied too. The full list
+ of what is and is not read is in
+ Work in your project's dev container .
+
If an editor already has a container up for this folder, Sinclair enters
that one instead of building a parallel one — and will never stop or remove
@@ -259,6 +264,8 @@
Where next
Run an agent team — the roster the sandbox holds.
Parallel agents in worktrees — how members stay
out of each other's way inside one container.
+ Work in your project's dev container — using
+ the environment your repo already defines.
Linux in a tab — throwaway containers, the sandbox's
disposable sibling.
diff --git a/docs/sandbox.md b/docs/sandbox.md
index bae9d0d..eed084e 100644
--- a/docs/sandbox.md
+++ b/docs/sandbox.md
@@ -141,10 +141,24 @@ cases, and `Owner::may_remove` is checked before every stop and remove.
### What is read from `devcontainer.json`
-`image` (becomes the recipe's base, so agents get the project's toolchain *plus*
-the agent CLI), `containerEnv`/`remoteEnv` (settings win on conflicts),
-`workspaceFolder`/`workspaceMount` (to detect identity mapping),
-`remoteUser`, `mounts`, `runArgs`, `shutdownAction`, `postCreateCommand`.
+Applied:
+
+| field | effect |
+|-------|--------|
+| `image` | becomes the recipe's base, so agents get the project's toolchain *plus* the agent CLI |
+| `containerEnv`, `remoteEnv` | merged into the container env; `sandbox-env` wins on conflicts |
+| `mounts` | applied, in the engine's `type=bind,source=…,target=…` form; an entry that does not parse becomes a note |
+| `remoteUser` / `containerUser` | the user the container runs as, unless `sandbox-user` overrides |
+
+Read but not applied: `workspaceFolder`/`workspaceMount` (only to detect
+identity mapping), `shutdownAction` (an advisory, below).
+
+Ignored: `features`, `customizations`, `forwardPorts`, `postCreateCommand`, and
+`runArgs` — the last deliberately, rather than passing arbitrary engine flags
+through. A `build.dockerfile` is not built; point `sandbox-image` at the image
+it produces.
+
+User guide: [`docs/devcontainers.html`](devcontainers.html).
A `shutdownAction` other than `none` is reported as an advisory: closing the
editor would stop a container with a team working in it. Two lines make a
diff --git a/docs/tutorials.html b/docs/tutorials.html
index 535f890..1dcb215 100644
--- a/docs/tutorials.html
+++ b/docs/tutorials.html
@@ -38,6 +38,7 @@ Master the terminal
Clipboard, snippets & pickers Clipboard history, secret redaction, snippets, launch profiles, and the emoji picker.
Triggers & notifications Regex output triggers, desktop notifications, line timestamps, annotations, badges, and the Activity dashboard.
Record & share Capture a pane, export it to a GIF or video with your exact fonts, and save the whole buffer to a file.
+ Work in your project's dev container Open a terminal inside the environment your repo already defines, share the container your editor started, and write a devcontainer.json that suits people and agents alike.
Run a throwaway Linux in a tab Open a fresh Debian, Ubuntu, Alpine, Fedora, or Arch userland as a tab, keep the ones you need, and attach to running containers.
Images in the terminal Render pictures inline with sixel — file previews, plots, and thumbnails that scroll with your output.
Assist: type less, understand more Ghost-text completions, one-key explanations of output, and composing commands from a description — all local and offline.
diff --git a/docs/workspace.html b/docs/workspace.html
index db3c9dc..38e7160 100644
--- a/docs/workspace.html
+++ b/docs/workspace.html
@@ -52,9 +52,20 @@ Tabs
repository, the current branch is shown alongside it. A background tab
that posts a notification grows a small yellow attention dot so you
can see at a glance which session wants you. Set a fixed label for the
- active tab with change_tab_title, and add the
- tab-title-show-host config option to keep the
- user@host prefix in the title (handy over SSH).
+ active tab with change_tab_title. Shells write their
+ title as user@host:path; Sinclair shows just the path, so
+ a tab reads ~/code/api rather than
+ you@laptop:~/code/api. Set
+ "tab-title-show-host": true to keep the prefix (handy
+ over SSH, where knowing which machine a pane is on matters). A label
+ you type yourself is never rewritten.
+
+
+ Right-click a tab for the things you want with your
+ hand already on it: Rename Tab… , Split Right ,
+ Split Down , Close Other Tabs , and Close Tab .
+ The same actions are in the View menu and the command palette
+ (⌘P ).
@@ -66,11 +77,18 @@
Tabs
new_tab⌘T Open a new tab
goto_tab:N⌘1 –⌘9 Jump to tab N
move_tab:DELTA— Reorder the active tab
- change_tab_title— Set a custom tab label
+ change_tab_title— Set a custom tab label (also on the tab's right-click menu)
close_tab⌘⌥W Close the current tab
+
+ Drag the window by its tab bar. The tab strip doubles
+ as the titlebar, so dragging any empty part of it — beside the tabs, or
+ the space reserved for the window controls — moves the window. In a
+ split, every pane whose tab bar sits along the top edge does this, not
+ just the one in the corner.
+
Tear a tab off into its own window. Drag a tab out of
the tab bar and release it outside the window, and Sinclair re-homes that
diff --git a/docs/workspacesetup.html b/docs/workspacesetup.html
index c8626ae..7f47b44 100644
--- a/docs/workspacesetup.html
+++ b/docs/workspacesetup.html
@@ -45,9 +45,13 @@
1. Open a tab per project
Each tab titles itself after the focused pane's working directory, and when
that directory is a git repository the current branch is shown alongside it —
so a glance at the tab bar tells you which project and which branch you're on.
- Pin a fixed label to the active tab with change_tab_title, and add
- the tab-title-show-host config option to keep the
- user@host prefix in the title (handy over SSH).
+ Pin a fixed label to the active tab with change_tab_title — or
+ just right-click the tab and pick Rename Tab… , which
+ is also where Split Right , Close Other Tabs , and
+ Close Tab live. Shells write their title as
+ user@host:path and Sinclair shows only the path; set
+ "tab-title-show-host": true to keep the prefix (handy over SSH).
+ A label you type is never rewritten.
From d312c27feda3fcd737c1c215877b991faa70df7f Mon Sep 17 00:00:00 2001
From: Wess Cope
Date: Mon, 27 Jul 2026 13:56:53 -0400
Subject: [PATCH 5/5] Version bump to 1.31.0
---
Cargo.lock | 36 ++++++++++++++++++------------------
Cargo.toml | 2 +-
2 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 808af08..c2fd309 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -258,7 +258,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "app"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"assist",
"block2 0.6.2",
@@ -376,7 +376,7 @@ dependencies = [
[[package]]
name = "assist"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"candle-core",
]
@@ -1230,7 +1230,7 @@ dependencies = [
[[package]]
name = "cast"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"serde_json",
]
@@ -1572,7 +1572,7 @@ dependencies = [
[[package]]
name = "config"
-version = "1.30.1"
+version = "1.31.0"
[[package]]
name = "console_error_panic_hook"
@@ -1606,7 +1606,7 @@ dependencies = [
[[package]]
name = "container"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"config",
]
@@ -2563,7 +2563,7 @@ dependencies = [
[[package]]
name = "export"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"cast",
"fontdue",
@@ -4419,7 +4419,7 @@ dependencies = [
[[package]]
name = "input"
-version = "1.30.1"
+version = "1.31.0"
[[package]]
name = "instant"
@@ -4806,7 +4806,7 @@ dependencies = [
[[package]]
name = "libsinclair"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"cast",
"futures",
@@ -5005,7 +5005,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
[[package]]
name = "macros"
-version = "1.30.1"
+version = "1.31.0"
[[package]]
name = "malloc_buf"
@@ -5060,7 +5060,7 @@ dependencies = [
[[package]]
name = "mcp"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"serde_json",
]
@@ -5340,7 +5340,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "notes"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"axum",
"include_dir",
@@ -6239,7 +6239,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plugin"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"serde",
"toml 0.8.23",
@@ -6247,7 +6247,7 @@ dependencies = [
[[package]]
name = "pluginrt"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6511,7 +6511,7 @@ dependencies = [
[[package]]
name = "pty"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"rustix 0.38.44",
"windows 0.58.0",
@@ -8202,7 +8202,7 @@ dependencies = [
[[package]]
name = "terminal"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"cast",
"pty",
@@ -8211,7 +8211,7 @@ dependencies = [
[[package]]
name = "theme"
-version = "1.30.1"
+version = "1.31.0"
[[package]]
name = "thiserror"
@@ -8808,7 +8808,7 @@ checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
[[package]]
name = "updater"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"serde_json",
]
@@ -9025,7 +9025,7 @@ dependencies = [
[[package]]
name = "vt"
-version = "1.30.1"
+version = "1.31.0"
dependencies = [
"bitflags 2.13.0",
"flate2",
diff --git a/Cargo.toml b/Cargo.toml
index b7da8c7..090c718 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,7 +39,7 @@ async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd
block = { path = "thirdparty/block" }
[workspace.package]
-version = "1.30.1"
+version = "1.31.0"
edition = "2021"
license = "Apache-2.0"