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:
- Start a Jupyter kernel inside the sandbox container
- 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
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/:executor.goExecutorinterface,RunRequest,RunResult,ExecutorConfig,DaggerExecutorConfignoop.goNoopExecutor— host OS passthrough (current default)dagger.goDaggerExecutor— all methods returnErrDaggerNotImplementedtypes.goEnvironmentKindincludesEnvironmentDockerNewExecutor(cfg)already dispatches tonewDaggerExecutorwhenKind == EnvironmentDocker, butnewDaggerExecutorreturnsErrDaggerNotImplementedimmediately.Architecture to implement
Key behaviours
Persistence across calls: each
Runmutates the container by adding a newWithExeclayer. The updated*dagger.Containeris stored back in the environment struct. This means state accumulates naturally: apip installin call N is visible in call N+1.File changes → git commits: after each
Run, any modified files are captured withcontainer.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.ExposePortsis non-empty, the container runs as a Dagger service anddag.Host().Tunnel()maps container ports to ephemeral host ports. The host:port pairs are returned inRunResult.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
Minimum version:
v0.18+(fordag.Host().Tunneland service binding API).Step 2 — Implement
daggerEnv+ environment lifecycleFile:
internal/sandbox/dagger.goStep 3 — Implement
HealthyStore the client for reuse; reconnect on failure.
Step 4 — Implement
RunStep 5 — Port tunnelling
Step 6 — Wire
DaggerExecutorinto bash toolIn
internal/tools/bash/tool.go:Executorat construction timeDaggerExecutor, routeRun()through itNoopExecutorifHealthy()returns an error at startupStep 7 — Config surface
In
internal/tools/builtin/config.go(or equivalent):Expose
NEXUS_SANDBOX_KIND=dockerenv var to enable the Dagger backend.Step 8 — Add
notebook_execute/notebook_runintegrationThe notebook execution tools (
internal/tools/notebook/execute.go,run.go) currently connect to an external Jupyter server. WhenDaggerExecutoris active, they should instead:Design decisions already made
EnvironmentDocker(notEnvironmentDagger)EnvironmentKindenum; avoids leaking the backend name into the public APIErrDaggerNotImplementedsentinelerrors.Is(err, ErrDaggerNotImplemented)to fall back gracefullyNewExecutorHealthy()is called once at startup; avoids blocking the engine boot if Dagger isn't installedDaggerRunOptions.EnvironmentIDallows named long-lived environmentsFiles to touch
internal/sandbox/dagger.go— main implementation target (stub today)internal/sandbox/executor.go— interface already done; may need minor additionsinternal/tools/bash/tool.go— wire executorinternal/tools/builtin/config.go— exposeSandbox ExecutorConfiginternal/tools/notebook/execute.go— Jupyter-in-container pathinternal/tools/notebook/run.go— samego.mod/go.sum— adddagger.io/daggerOut of scope for this issue