From 076048c8882dd255de868945189a6d20ee79ed80 Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Fri, 26 Jun 2026 18:47:22 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20--no-sandbox=20to?= =?UTF-8?q?=20run=20without=20the=20gVisor=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agentd unconditionally required `nix` and `runsc` (gVisor) at startup and treated a failed tmux start as fatal, so it could not come up anywhere those are unavailable — e.g. a minimal capstan microVM where the VM boundary is the sandbox and there is no nix/gVisor or sudo/agent user. `--no-sandbox` skips the runsc/nix initialization; sandboxed agents then fail to launch (guarded), while the API and native/tmux agents are unaffected. tmux startup becomes best-effort and non-fatal for the same reason: agentd still serves its control-plane API, and native agents simply can't launch until a tmux server and the agent user are available. Both behaviors are opt-in — default startup is unchanged. Part of PCC-777. --- agentd/agentd.go | 63 ++++++++++++++++++++++++++++++------------- agentd/agentd_test.go | 7 +++++ main.go | 4 +++ 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/agentd/agentd.go b/agentd/agentd.go index a5f4077..f4ac6a5 100644 --- a/agentd/agentd.go +++ b/agentd/agentd.go @@ -105,6 +105,7 @@ type Daemon struct { debug bool // Sandbox configuration. + noSandbox bool // skip the gVisor/nix runtime entirely runscPath string // path to the runsc binary sandboxStateDir string // --root flag for runsc sandboxBundleDir string // base directory for OCI bundles @@ -139,6 +140,14 @@ func NewDaemon(configPath string) *Daemon { } } +// SetNoSandbox disables the gVisor sandbox runtime. When set, agentd skips the +// runsc/nix startup checks and runs without a sandbox runner; sandboxed agents +// then fail to launch (native/tmux agents and the API still work). Intended for +// environments where gVisor is unavailable (e.g. a minimal capstan VM). +func (d *Daemon) SetNoSandbox(noSandbox bool) { + d.noSandbox = noSandbox +} + // SetRunscPath overrides the auto-detected runsc binary path. func (d *Daemon) SetRunscPath(path string) { d.runscPath = path @@ -225,25 +234,34 @@ func (d *Daemon) AgentStatuses() []api.AgentStatus { func (d *Daemon) Run(ctx context.Context) error { log.Println("agentd: initializing agent manager") - // 1. Verify required binaries. agentd runs on stereOS (NixOS) and - // requires both runsc (gVisor) and nix to be available. - if _, err := exec.LookPath("nix"); err != nil { - return fmt.Errorf("nix not found in PATH: %w", err) - } + // 1. Initialize the sandbox runtime, unless disabled. gVisor (runsc) and + // nix are only needed for sandboxed agents; --no-sandbox skips both so + // agentd can run where they're unavailable (e.g. a minimal capstan VM). + // Sandboxed agents then fail to launch (guarded below); native/tmux agents + // and the API still work. + if d.noSandbox { + log.Println("agentd: sandbox runtime disabled (--no-sandbox); sandboxed agents will not launch") + } else { + // Verify required binaries. agentd runs on stereOS (NixOS) and + // requires both runsc (gVisor) and nix to be available. + if _, err := exec.LookPath("nix"); err != nil { + return fmt.Errorf("nix not found in PATH: %w", err) + } - // Initialize sandbox runner. gVisor (runsc) is required — agentd - // cannot start without it since sandboxed is the default agent type. - runner, err := sandbox.NewRunner(d.runscPath, d.sandboxStateDir) - if err != nil { - return fmt.Errorf("sandbox runtime unavailable: %w", err) - } - d.runner = runner - d.runner.Debug = d.debug - log.Printf("agentd: sandbox runtime initialized (runsc=%s, state=%s)", runner.RunscPath, d.sandboxStateDir) + // Initialize sandbox runner. gVisor (runsc) is required — agentd + // cannot start without it since sandboxed is the default agent type. + runner, err := sandbox.NewRunner(d.runscPath, d.sandboxStateDir) + if err != nil { + return fmt.Errorf("sandbox runtime unavailable: %w", err) + } + d.runner = runner + d.runner.Debug = d.debug + log.Printf("agentd: sandbox runtime initialized (runsc=%s, state=%s)", runner.RunscPath, d.sandboxStateDir) - // Clean up any orphaned containers from a previous crash. - if err := d.runner.Cleanup(ctx); err != nil { - log.Printf("agentd: warning: sandbox cleanup: %v", err) + // Clean up any orphaned containers from a previous crash. + if err := d.runner.Cleanup(ctx); err != nil { + log.Printf("agentd: warning: sandbox cleanup: %v", err) + } } // 2. Start tmux server. @@ -253,7 +271,16 @@ func (d *Daemon) Run(ctx context.Context) error { log.Printf("agentd: starting tmux server (run-as=%s)", AgentUser) d.tmux = tmux.NewServerAs(d.tmuxSocketPath, AgentUser) if err := d.tmux.Start(); err != nil { - return fmt.Errorf("starting tmux server: %w", err) + if d.noSandbox { + // Non-fatal: agentd still serves its API and status. Native (tmux) + // agents need a working tmux server + the agent user, so they cannot + // launch until that's available. This lets agentd run in a minimal + // environment (e.g. a capstan VM with no sudo/agent user) for + // control-plane bring-up. + log.Printf("agentd: warning: tmux server unavailable (%v); native agents cannot launch", err) + } else { + return fmt.Errorf("starting tmux server: %w", err) + } } defer func() { log.Println("agentd: stopping tmux server") diff --git a/agentd/agentd_test.go b/agentd/agentd_test.go index 15002db..aedd7ed 100644 --- a/agentd/agentd_test.go +++ b/agentd/agentd_test.go @@ -49,6 +49,13 @@ var _ = Describe("Daemon", func() { }) }) + Describe("SetNoSandbox", func() { + It("should not panic when disabling the sandbox runtime", func() { + d := agentd.NewDaemon("") + Expect(func() { d.SetNoSandbox(true) }).NotTo(Panic()) + }) + }) + Describe("SetLaunchConcurrency", func() { It("should not panic when setting concurrency", func() { d := agentd.NewDaemon("") diff --git a/main.go b/main.go index 860bca4..60d4d9d 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ func main() { runscPath := flag.String("runsc-path", "", "path to runsc binary (auto-detected if empty)") sandboxStateDir := flag.String("sandbox-state-dir", agentd.DefaultSandboxStateDir, "root directory for runsc container state") sandboxBundleDir := flag.String("sandbox-bundle-dir", agentd.DefaultSandboxBundleDir, "base directory for OCI bundles") + noSandbox := flag.Bool("no-sandbox", false, "disable the gVisor sandbox runtime (skips runsc/nix; sandboxed agents won't launch)") debug := flag.Bool("debug", false, "enable debug logging (logs commands, env keys, captures pane output on exit)") flag.Parse() @@ -60,6 +61,9 @@ func main() { if *sandboxBundleDir != agentd.DefaultSandboxBundleDir { daemon.SetSandboxBundleDir(*sandboxBundleDir) } + if *noSandbox { + daemon.SetNoSandbox(true) + } if *debug { daemon.SetDebug(true) } From 00e82f126b797940eb2832169d2ddf17c9485c26 Mon Sep 17 00:00:00 2001 From: Matt Yeazel Date: Fri, 26 Jun 2026 20:50:24 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7=20fix:=20bump=20dagger=20v0.20?= =?UTF-8?q?.6=20=E2=86=92=20v0.20.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daggerverse ghcontrib module now requires dagger v0.20.8, so the PR checks (title conformance, Linear magic word) error out under the pinned v0.20.6 with "module requires dagger v0.20.8, but you have v0.20.6". --- .github/workflows/pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 7621275..ba26e66 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -9,7 +9,7 @@ permissions: pull-requests: read env: - DAGGER_VERSION: "0.20.6" + DAGGER_VERSION: "0.20.8" concurrency: group: ${{ github.workflow }}-${{ github.ref }}