Skip to content

feat(sandbox): implement DaggerExecutor — isolated container backend for sub-agents #59

Description

@EngineerProjects

Context

Nexus Engine currently runs all bash/shell commands directly on the host OS (via NoopExecutor). For sub-agents that need full autonomy — experimenting aggressively, installing packages, running servers, testing destructive operations — there is no isolation layer: a mistake can damage the user's machine.

The Dagger sandbox backend introduces a second execution path: every command issued by a sub-agent runs inside an isolated OCI container managed by the Dagger engine. The host filesystem is never touched. When the session ends (or the agent calls sandbox_reset), the container is discarded.

This was analysed against dagger/container-use as a reference implementation. We borrow their architecture (Dagger SDK + git-as-state-store) but implement it natively inside nexus-engine without the MCP transport layer.


Current state (merged to dev)

The skeleton is already in internal/sandbox/:

File Status Description
executor.go ✅ merged Executor interface, RunRequest, RunResult, ExecutorConfig, DaggerExecutorConfig
noop.go ✅ merged NoopExecutor — host OS passthrough (current default)
dagger.go ✅ merged stub DaggerExecutor — all methods return ErrDaggerNotImplemented
types.go ✅ existing EnvironmentKind includes EnvironmentDocker

NewExecutor(cfg) already dispatches to newDaggerExecutor when Kind == EnvironmentDocker, but newDaggerExecutor returns ErrDaggerNotImplemented immediately.


Architecture to implement

DaggerExecutor
  │
  ├─ dagger.Connect(ctx) → *dagger.Client        [once per engine session]
  │
  ├─ environments  map[string]*daggerEnv          [keyed by environment ID]
  │     ├─ container  *dagger.Container           [persistent, layered]
  │     ├─ gitRepo    git worktree on host        [tracks file changes]
  │     └─ createdAt / updatedAt
  │
  └─ Run(ctx, RunRequest) → RunResult
        ├─ resolve or create environment
        ├─ container.WithExec([]string{"sh", "-c", command})
        ├─ capture stdout / stderr (Stdout/Stderr methods)
        ├─ persist mutated container state
        ├─ if Background: service binding + goroutine
        └─ if ExposePorts: dag.Host().Tunnel() → Endpoints map

Key behaviours

Persistence across calls: each Run mutates the container by adding a new WithExec layer. The updated *dagger.Container is stored back in the environment struct. This means state accumulates naturally: a pip install in call N is visible in call N+1.

File changes → git commits: after each Run, any modified files are captured with container.Directory("/workdir").Export(ctx, localWorktreePath) and committed to a dedicated git branch (nexus-sandbox/<env-id>). This makes the agent's work recoverable.

Port tunnelling: when DaggerRunOptions.ExposePorts is non-empty, the container runs as a Dagger service and dag.Host().Tunnel() maps container ports to ephemeral host ports. The host:port pairs are returned in RunResult.Endpoints.

Background mode: req.Background == true → start the process as a Dagger service (container.AsService()) rather than waiting for exit.


Implementation steps

Step 1 — Add dependency

go get dagger.io/dagger@latest

Minimum version: v0.18+ (for dag.Host().Tunnel and service binding API).

Step 2 — Implement daggerEnv + environment lifecycle

File: internal/sandbox/dagger.go

type daggerEnv struct {
    id        string
    container *dagger.Container
    workdir   string          // path inside container (/workdir)
    localDir  string          // host worktree path for git commits
    createdAt time.Time
    updatedAt time.Time
}

// resolveEnv returns an existing env or creates a new one.
func (e *DaggerExecutor) resolveEnv(ctx context.Context, id string) (*daggerEnv, error)

// createEnv builds a fresh container from BaseImage + SetupCommands.
func (e *DaggerExecutor) createEnv(ctx context.Context, id string) (*daggerEnv, error)

Step 3 — Implement Healthy

func (e *DaggerExecutor) Healthy(ctx context.Context) error {
    _, err := dagger.Connect(ctx, dagger.WithLogOutput(io.Discard))
    return err
}

Store the client for reuse; reconnect on failure.

Step 4 — Implement Run

func (e *DaggerExecutor) Run(ctx context.Context, req RunRequest) (RunResult, error) {
    env, err := e.resolveEnv(ctx, envID(req))
    // ...
    updated := env.container.WithExec([]string{"sh", "-c", req.Command}, ...)
    stdout, err := updated.Stdout(ctx)
    stderr, _ := updated.Stderr(ctx)
    // persist
    env.container = updated
    // git commit changed files
    // ...
    return RunResult{Stdout: stdout, Stderr: stderr, ExitCode: 0, Duration: dur}, nil
}

Step 5 — Port tunnelling

if len(req.Dagger.ExposePorts) > 0 {
    svc := updated.AsService()
    for _, port := range req.Dagger.ExposePorts {
        tunnel, err := dag.Host().Tunnel(svc, dagger.HostTunnelOpts{Ports: []dagger.PortForward{{Backend: port}}}).Endpoint(ctx)
        result.Endpoints[port] = tunnel
    }
}

Step 6 — Wire DaggerExecutor into bash tool

In internal/tools/bash/tool.go:

  • Accept an optional Executor at construction time
  • If the active executor is DaggerExecutor, route Run() through it
  • Fall back to NoopExecutor if Healthy() returns an error at startup

Step 7 — Config surface

In internal/tools/builtin/config.go (or equivalent):

type Config struct {
    // ...existing fields...
    Sandbox sandbox.ExecutorConfig  // NEW
}

Expose NEXUS_SANDBOX_KIND=docker env var to enable the Dagger backend.

Step 8 — Add notebook_execute / notebook_run integration

The notebook execution tools (internal/tools/notebook/execute.go, run.go) currently connect to an external Jupyter server. When DaggerExecutor is active, they should instead:

  1. Start a Jupyter kernel inside the sandbox container
  2. Route all kernel communication through the container network

Design decisions already made

Decision Rationale
EnvironmentDocker (not EnvironmentDagger) Matches existing EnvironmentKind enum; avoids leaking the backend name into the public API
ErrDaggerNotImplemented sentinel Callers can errors.Is(err, ErrDaggerNotImplemented) to fall back gracefully
No auto-start at NewExecutor Healthy() is called once at startup; avoids blocking the engine boot if Dagger isn't installed
Git as state store Recoverable history, diff-able, no custom serialization needed
Per-session default environment Agents get one container per session by default; DaggerRunOptions.EnvironmentID allows named long-lived environments

Files to touch

  • internal/sandbox/dagger.go — main implementation target (stub today)
  • internal/sandbox/executor.go — interface already done; may need minor additions
  • internal/tools/bash/tool.go — wire executor
  • internal/tools/builtin/config.go — expose Sandbox ExecutorConfig
  • internal/tools/notebook/execute.go — Jupyter-in-container path
  • internal/tools/notebook/run.go — same
  • go.mod / go.sum — add dagger.io/dagger

Out of scope for this issue

  • Landlock executor (Linux kernel Landlock LSM) — separate issue
  • Resource quotas (CPU/RAM/disk limits per container) — follow-up
  • Multi-machine / remote Dagger runners
  • Container checkpointing / snapshots
  • Automatic sandbox level selection based on tool risk score

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions