Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 115 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
name: CI

on:
push:
branches: [master, pi-sandbox-refactor]
pull_request:
branches: [master]

jobs:
rust-tests:
Expand Down Expand Up @@ -123,15 +120,26 @@ jobs:
echo "$output"
echo "$output" | grep -q "claude-code"

- name: Test --with rootfs creation
- name: Install bubblewrap
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq bubblewrap
if ! bwrap --ro-bind / / --dev /dev --proc /proc -- echo "bwrap works"; then
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
fi

- name: Linux end-to-end (create → exec → destroy)
run: |
output=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create \
--with bash,coreutils --network off --name ci-smoke --json)
--with bash,coreutils --network off --name ci-linux-e2e --json)
echo "$output"
session_id=$(echo "$output" | python3 -c "import sys,json; print(json.load(sys.stdin)['sessionId'])")
echo "Session created: $session_id"
echo "Session: $session_id"
NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox exec "$session_id" -- echo "Hello from Linux sandbox"
NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox status "$session_id" --json | \
python3 -c "import sys,json; d=json.load(sys.stdin); assert d['isolation']=='native', f'expected native, got {d[\"isolation\"]}'; print('Isolation: native ✓')"
NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$session_id"
echo "Session destroyed"
echo "Full Linux E2E passed"

- name: Test mutual exclusivity
run: |
Expand Down Expand Up @@ -226,3 +234,103 @@ jobs:
if [ -n "$SESSION_ID" ]; then
NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$SESSION_ID" 2>/dev/null || true
fi

macos-e2e:
name: macOS end-to-end (Docker builder)
# ARM macOS hosted runners do not support nested virtualization reliably.
# Use Intel macOS for Docker-in-VM setup.
runs-on: macos-15-intel
needs: [rust-tests]
steps:
- uses: actions/checkout@v6

- uses: cachix/install-nix-action@v30
with:
extra_nix_config: |
experimental-features = nix-command flakes
extra-substituters = https://cache.numtide.com
extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=

- name: Set up Docker daemon
uses: docker/setup-docker-action@v5

- name: Verify Docker amd64 preflight
shell: bash
run: |
set -euo pipefail
docker info
arch="$(docker run --rm --platform linux/amd64 alpine:3.20 uname -m)"
test "$arch" = "x86_64"

- name: Build nixosandbox CLI (native macOS)
run: nix build --accept-flake-config .#nixosandbox

- name: Test catalog (nix eval — no Docker needed)
run: |
NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d[\"agents\"])} agents, {len(d[\"tools\"])} tools')"

- name: Create sandbox via Docker builder
run: |
output=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create \
--with bash,coreutils --network off --name ci-macos-e2e --json)
echo "$output"
session_id=$(echo "$output" | python3 -c "import sys,json; print(json.load(sys.stdin)['sessionId'])")
echo "SESSION_ID=$session_id" >> "$GITHUB_ENV"

- name: Prepare privileged sidecar for CI
shell: bash
run: |
set -euo pipefail
data_dir="${NIXOSANDBOX_DATA_DIR:-$HOME/.local/share/nixosandbox}"
mkdir -p "$data_dir"

docker rm -f nixosandbox-sidecar >/dev/null 2>&1 || true
docker build --platform linux/amd64 -t nixosandbox-sidecar:latest \
-f "$PWD/docker/nixosandbox-sidecar.Dockerfile" "$PWD"

docker run -d \
--name nixosandbox-sidecar \
--platform linux/amd64 \
--privileged \
--security-opt seccomp=unconfined \
--security-opt apparmor=unconfined \
-v "$data_dir:/nixosandbox/sessions" \
-v "nixosandbox-nix:/nix:ro" \
nixosandbox-sidecar:latest \
sleep infinity

# Preflight bwrap inside sidecar to fail early if mount permissions are still blocked.
docker exec nixosandbox-sidecar sh -lc 'bwrap --dev /dev --proc /proc --ro-bind / / -- true'

- name: Execute command in sandbox via Docker sidecar
shell: bash
run: |
set -euo pipefail
if ! NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox exec "$SESSION_ID" -- echo "Hello from macOS sandbox"; then
echo "::group::docker diagnostics"
docker ps -a || true
docker inspect nixosandbox-sidecar || true
echo "::endgroup::"

if docker ps -a --format '{{.Names}}' | grep -qx nixosandbox-sidecar; then
echo "::group::sidecar diagnostics"
docker exec nixosandbox-sidecar sh -lc 'id; uname -a; cat /proc/self/status | grep "^Cap"; bwrap --version' || true
docker exec nixosandbox-sidecar sh -lc 'bwrap --dev /dev --proc /proc --ro-bind / / -- true' || true
echo "::endgroup::"
fi
exit 1
fi

- name: Verify session status
run: |
NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox status "$SESSION_ID" --json | \
python3 -c "import sys,json; d=json.load(sys.stdin); assert d['isolation']=='docker', f'expected docker, got {d[\"isolation\"]}'; print('Isolation: docker ✓')"

- name: Cleanup
if: always()
run: |
if [ -n "$SESSION_ID" ]; then
NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$SESSION_ID" 2>/dev/null || true
fi
docker rm -f nixosandbox-sidecar 2>/dev/null || true
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,6 @@ crates/nixosandbox/target/
# Protocol tests
tests/protocol/node_modules/
.pi/

# Local git worktrees
.worktrees/
174 changes: 171 additions & 3 deletions crates/nixosandbox/src/docker.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::io::Write;
use std::process::{Command, Stdio};

const SIDECAR_NAME: &str = "nixosandbox-sidecar";
Expand All @@ -7,6 +8,11 @@ const IMAGE_NAME: &str = "nixosandbox-sidecar:latest";
/// Sessions live at `<CONTAINER_DATA_MOUNT>/sessions/<id>/...` inside the container.
const CONTAINER_DATA_MOUNT: &str = "/nixosandbox/sessions";

const BUILDER_IMAGE_NAME: &str = "nixosandbox-builder:latest";
/// Persistent Docker volume storing the Nix store for macOS builds.
/// Shared between the builder (writes) and the sidecar (reads).
const NIX_VOLUME_NAME: &str = "nixosandbox-nix";

/// Information about a running Docker sidecar container.
pub struct DockerSidecar {
pub container_id: String,
Expand Down Expand Up @@ -103,10 +109,16 @@ fn ensure_image() -> Result<(), String> {
}

eprintln!("nixosandbox: building Docker sidecar image (one-time setup)...");
let flake_root = crate::nix::find_flake_root()
.unwrap_or_else(|_| ".".to_string());
let dockerfile = format!("{}/docker/nixosandbox-sidecar.Dockerfile", flake_root);
let output = Command::new("docker")
.args([
"build", "-t", IMAGE_NAME,
"-f", "docker/nixosandbox-sidecar.Dockerfile", ".",
"build",
"--platform", "linux/amd64",
"-t", IMAGE_NAME,
"-f", &dockerfile,
&flake_root,
])
.stdout(Stdio::null())
.stderr(Stdio::piped())
Expand All @@ -122,17 +134,22 @@ fn ensure_image() -> Result<(), String> {
}

/// Create and start a new sidecar container.
///
/// Mounts the shared `nixosandbox-nix` Docker volume at `/nix` so bwrap can
/// access rootfs derivations built by the Docker-based builder.
fn create_sidecar(host_sessions_dir: &str) -> Result<String, String> {
let sessions_volume = format!("{host_sessions_dir}:{CONTAINER_DATA_MOUNT}");
let nix_volume = format!("{NIX_VOLUME_NAME}:/nix:ro");
let output = Command::new("docker")
.args([
"run", "-d",
"--platform", "linux/amd64",
"--name", SIDECAR_NAME,
"--cap-add", "SYS_ADMIN",
"--cap-add", "NET_ADMIN",
"--security-opt", "seccomp=unconfined",
"-v", &sessions_volume,
"-v", "/nix/store:/nix/store:ro",
"-v", &nix_volume,
IMAGE_NAME,
"sleep", "infinity",
])
Expand Down Expand Up @@ -205,6 +222,157 @@ pub fn rewrite_path(path: &str, host_prefix: &str, container_prefix: &str) -> St
}
}

// ---------------------------------------------------------------------------
// Docker-based Nix builder (macOS)
// ---------------------------------------------------------------------------

/// Build the builder Docker image if it doesn't already exist.
/// Uses an inline Dockerfile piped via stdin to avoid path-resolution issues.
fn ensure_builder_image() -> Result<(), String> {
let output = Command::new("docker")
.args(["images", BUILDER_IMAGE_NAME, "--format", "{{.ID}}"])
.output()
.map_err(|e| format!("docker images check failed: {e}"))?;

let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !id.is_empty() {
return Ok(());
}

eprintln!("nixosandbox: building Docker builder image (one-time setup)...");

// Inline Dockerfile — avoids needing to locate the file on disk.
let dockerfile = concat!(
"FROM nixos/nix:latest\n",
"RUN echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf \\\n",
" && echo 'extra-substituters = https://cache.numtide.com' >> /etc/nix/nix.conf \\\n",
" && echo 'extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=' >> /etc/nix/nix.conf \\\n",
" && echo 'filter-syscalls = false' >> /etc/nix/nix.conf\n",
);

let mut child = Command::new("docker")
.args([
"build",
"--platform", "linux/amd64",
"-t", BUILDER_IMAGE_NAME,
"-f", "-", // read Dockerfile from stdin
"/tmp", // build context (unused — Dockerfile has no COPY)
])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("docker build (builder): {e}"))?;

if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(dockerfile.as_bytes())
.map_err(|e| format!("writing builder Dockerfile to stdin: {e}"))?;
}

let output = child
.wait_with_output()
.map_err(|e| format!("docker build (builder) wait: {e}"))?;

if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("docker build (builder) failed: {}", stderr.trim()))
}
}

/// Run `nix build <flake-attr>` inside a Docker container.
///
/// The builder uses a persistent Docker volume (`nixosandbox-nix`) for `/nix`,
/// so subsequent builds are incremental. The flake root is bind-mounted read-only.
pub fn nix_build_in_docker(flake_attr: &str, flake_root: &str) -> Result<String, String> {
if !is_docker_available() {
return Err(
"Docker not available. On macOS, Docker Desktop is required to build Linux sandboxes.\n\
Install Docker Desktop from https://www.docker.com/products/docker-desktop/".to_string()
);
}

ensure_builder_image()?;

let flake_mount = format!("{}:{}:ro", flake_root, flake_root);
let nix_volume = format!("{}:/nix", NIX_VOLUME_NAME);

let output = Command::new("docker")
.args([
"run", "--rm",
"--platform", "linux/amd64",
"-v", &flake_mount,
"-v", &nix_volume,
BUILDER_IMAGE_NAME,
"nix", "build", flake_attr,
"--no-link", "--print-out-paths",
"--accept-flake-config",
"--extra-experimental-features", "nix-command flakes",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("docker run (nix build): {e}"))?;

if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
Err("nix build in Docker produced no output".into())
} else {
Ok(path)
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(format!("nix build in Docker failed: {}", stderr))
}
}

/// Run `nix build --impure --expr <expr>` inside a Docker container.
pub fn nix_build_expr_in_docker(expr: &str, flake_root: &str) -> Result<String, String> {
if !is_docker_available() {
return Err(
"Docker not available. On macOS, Docker Desktop is required to build Linux sandboxes.\n\
Install Docker Desktop from https://www.docker.com/products/docker-desktop/".to_string()
);
}

ensure_builder_image()?;

let flake_mount = format!("{}:{}:ro", flake_root, flake_root);
let nix_volume = format!("{}:/nix", NIX_VOLUME_NAME);

let output = Command::new("docker")
.args([
"run", "--rm",
"--platform", "linux/amd64",
"-v", &flake_mount,
"-v", &nix_volume,
BUILDER_IMAGE_NAME,
"nix", "build", "--impure", "--expr", expr,
"--no-link", "--print-out-paths",
"--accept-flake-config",
"--extra-experimental-features", "nix-command flakes",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("docker run (nix build --expr): {e}"))?;

if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
Err("nix build --expr in Docker produced no output".into())
} else {
Ok(path)
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(format!("nix build --expr in Docker failed: {}", stderr))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading