From da6b3ff48633bcb31bf259b5759746cc91962f34 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 8 Apr 2026 11:47:21 +0200 Subject: [PATCH 1/3] Use the same template as sbx Signed-off-by: David Gageot --- cmd/root/run.go | 2 +- pkg/sandbox/sandbox.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/root/run.go b/cmd/root/run.go index c01025965..e4fd949eb 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -120,7 +120,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { _ = cmd.PersistentFlags().MarkHidden("force-tui") cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use a simplified TUI with minimal chrome") cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)") - cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "", "Template image for the sandbox (passed to docker sandbox create -t)") + cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "docker/sandbox-templates:docker-agent", "Template image for the sandbox (passed to docker sandbox create -t)") cmd.MarkFlagsMutuallyExclusive("fake", "record") // --exec only diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go index 0d61e4560..348436fd9 100644 --- a/pkg/sandbox/sandbox.go +++ b/pkg/sandbox/sandbox.go @@ -136,7 +136,7 @@ func Ensure(ctx context.Context, wd, extra, template, configDir string) (string, func BuildExecCmd(ctx context.Context, name, wd string, cagentArgs, envFlags, envVars []string) *exec.Cmd { execArgs := []string{"sandbox", "exec", "-it", "-w", wd} execArgs = append(execArgs, envFlags...) - execArgs = append(execArgs, name, "cagent", "run") + execArgs = append(execArgs, name, "docker-agent", "run") execArgs = append(execArgs, cagentArgs...) dockerCmd := exec.CommandContext(ctx, "docker", execArgs...) From ff6fbe12bd73e91eb45f9874e9929138246fb7a0 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 8 Apr 2026 12:06:09 +0200 Subject: [PATCH 2/3] Improve the rendering of the TUI Signed-off-by: David Gageot --- pkg/sandbox/sandbox.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go index 348436fd9..7a4546ad3 100644 --- a/pkg/sandbox/sandbox.go +++ b/pkg/sandbox/sandbox.go @@ -134,12 +134,18 @@ func Ensure(ctx context.Context, wd, extra, template, configDir string) (string, // BuildExecCmd assembles the `docker sandbox exec` command. func BuildExecCmd(ctx context.Context, name, wd string, cagentArgs, envFlags, envVars []string) *exec.Cmd { - execArgs := []string{"sandbox", "exec", "-it", "-w", wd} - execArgs = append(execArgs, envFlags...) - execArgs = append(execArgs, name, "docker-agent", "run") - execArgs = append(execArgs, cagentArgs...) + args := []string{"sandbox", "exec", "-it", "-w", wd} + args = append(args, envFlags...) - dockerCmd := exec.CommandContext(ctx, "docker", execArgs...) + // Improve the rendering of the TUI + args = append(args, "-e", "TERM=xterm-256color") + args = append(args, "-e", "COLORTERM=truecolor") + args = append(args, "-e", "LANG=en_US.UTF-8") + + args = append(args, name, "docker-agent", "run") + args = append(args, cagentArgs...) + + dockerCmd := exec.CommandContext(ctx, "docker", args...) dockerCmd.Stdin = os.Stdin dockerCmd.Stdout = os.Stdout dockerCmd.Stderr = os.Stderr From 83856f5aefd774876bcb6e4f3e2835fef37e5997 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Wed, 8 Apr 2026 15:28:06 +0200 Subject: [PATCH 3/3] Support docker sandbox and sbx Signed-off-by: David Gageot --- cmd/root/run.go | 4 +- cmd/root/sandbox.go | 14 +++--- pkg/sandbox/backend.go | 72 +++++++++++++++++++++++++++ pkg/sandbox/sandbox.go | 98 +++++++++++++++++++++++-------------- pkg/sandbox/sandbox_test.go | 41 +++++++++++++++- 5 files changed, 182 insertions(+), 47 deletions(-) create mode 100644 pkg/sandbox/backend.go diff --git a/cmd/root/run.go b/cmd/root/run.go index e4fd949eb..1e793167f 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -52,6 +52,7 @@ type runExecFlags struct { forceTUI bool sandbox bool sandboxTemplate string + sbx bool // Exec only exec bool @@ -121,6 +122,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use a simplified TUI with minimal chrome") cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)") cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "docker/sandbox-templates:docker-agent", "Template image for the sandbox (passed to docker sandbox create -t)") + cmd.PersistentFlags().BoolVar(&flags.sbx, "sbx", true, "Prefer the sbx CLI backend when available (set --sbx=false to force docker sandbox)") cmd.MarkFlagsMutuallyExclusive("fake", "record") // --exec only @@ -145,7 +147,7 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command } if f.sandbox { - return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate) + return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx) } out := cli.NewPrinter(cmd.OutOrStdout()) diff --git a/cmd/root/sandbox.go b/cmd/root/sandbox.go index b888e980e..c57040abe 100644 --- a/cmd/root/sandbox.go +++ b/cmd/root/sandbox.go @@ -22,13 +22,15 @@ import ( // runInSandbox delegates the current command to a Docker sandbox. // It ensures a sandbox exists (creating or recreating as needed), then -// executes docker agent inside it via `docker sandbox exec`. -func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runConfig *config.RuntimeConfig, template string) error { +// executes docker agent inside it via the sandbox exec command. +func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runConfig *config.RuntimeConfig, template string, preferSbx bool) error { if environment.InSandbox() { return fmt.Errorf("already running inside a Docker sandbox (VM %s)", os.Getenv("SANDBOX_VM_ID")) } - if err := sandbox.CheckAvailable(ctx); err != nil { + backend := sandbox.NewBackend(preferSbx) + + if err := backend.CheckAvailable(ctx); err != nil { return err } @@ -52,7 +54,7 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon return fmt.Errorf("resolving workspace path: %w", err) } - name, err := sandbox.Ensure(ctx, wd, sandbox.ExtraWorkspace(wd, agentRef), template, configDir) + name, err := backend.Ensure(ctx, wd, sandbox.ExtraWorkspace(wd, agentRef), template, configDir) if err != nil { return err } @@ -68,7 +70,7 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon envFlags = append(envFlags, "-e", envModelsGateway+"="+gateway) } - dockerCmd := sandbox.BuildExecCmd(ctx, name, wd, dockerAgentArgs, envFlags, envVars) + dockerCmd := backend.BuildExecCmd(ctx, name, wd, dockerAgentArgs, envFlags, envVars) slog.Debug("Executing in sandbox", "name", name, "args", dockerCmd.Args) if err := dockerCmd.Run(); err != nil { @@ -85,7 +87,7 @@ func dockerAgentArgs(cmd *cobra.Command, args []string, configDir string) []stri var dockerAgentArgs []string hasYolo := false cmd.Flags().Visit(func(f *pflag.Flag) { - if f.Name == "sandbox" || f.Name == "config-dir" { + if f.Name == "sandbox" || f.Name == "sbx" || f.Name == "config-dir" { return } diff --git a/pkg/sandbox/backend.go b/pkg/sandbox/backend.go new file mode 100644 index 000000000..19266aff4 --- /dev/null +++ b/pkg/sandbox/backend.go @@ -0,0 +1,72 @@ +package sandbox + +import ( + "os" + "os/exec" +) + +// Backend describes how to invoke sandbox CLI commands. +// The two supported backends are "docker sandbox" and "sbx". +type Backend struct { + // program is the executable name ("docker" or "sbx"). + program string + // prefix is the sub-command prefix prepended to every command. + // For "docker sandbox" this is ["sandbox"]; for "sbx" it is empty. + prefix []string + // extraEnv holds extra environment variables to set on every command. + extraEnv []string + // vmListKey is the JSON key returned by the "ls" command that holds + // the list of sandboxes ("vms" for docker sandbox, "sandboxes" for sbx). + vmListKey string +} + +// NewBackend returns the appropriate backend. When preferSbx is true +// and the "sbx" binary is on PATH, the sbx backend is used; otherwise +// it falls back to "docker sandbox". +func NewBackend(preferSbx bool) *Backend { + if preferSbx { + if _, err := exec.LookPath("sbx"); err == nil { + return sbxBackend() + } + } + return dockerSandboxBackend() +} + +func dockerSandboxBackend() *Backend { + return &Backend{ + program: "docker", + prefix: []string{"sandbox"}, + vmListKey: "vms", + } +} + +func sbxBackend() *Backend { + return &Backend{ + program: "sbx", + prefix: nil, + extraEnv: []string{"DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND="}, + vmListKey: "sandboxes", + } +} + +// command builds an exec.Cmd for the given sandbox sub-command and arguments. +// For example, command(ctx, "ls", "--json") produces either +// "docker sandbox ls --json" or "sbx ls --json". +func (b *Backend) args(subCmd string, extra ...string) []string { + args := make([]string, 0, len(b.prefix)+1+len(extra)) + args = append(args, b.prefix...) + args = append(args, subCmd) + args = append(args, extra...) + return args +} + +// applyEnv augments the command's environment with any backend-specific +// variables. It must be called on every exec.Cmd created for the backend. +func (b *Backend) applyEnv(cmd *exec.Cmd) { + if len(b.extraEnv) > 0 { + if cmd.Env == nil { + cmd.Env = os.Environ() + } + cmd.Env = append(cmd.Env, b.extraEnv...) + } +} diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go index 7a4546ad3..6beef769d 100644 --- a/pkg/sandbox/sandbox.go +++ b/pkg/sandbox/sandbox.go @@ -21,12 +21,14 @@ import ( // CheckAvailable returns a user-friendly error when Docker is not // installed or the sandbox feature is not supported. -func CheckAvailable(ctx context.Context) error { - if _, err := exec.LookPath("docker"); err != nil { +func (b *Backend) CheckAvailable(ctx context.Context) error { + if _, err := exec.LookPath(b.program); err != nil { return fmt.Errorf("--sandbox requires Docker Desktop: %w\n\nInstall Docker Desktop from https://docs.docker.com/get-docker/", err) } - if err := exec.CommandContext(ctx, "docker", "sandbox", "version").Run(); err != nil { + cmd := exec.CommandContext(ctx, b.program, b.args("version")...) + b.applyEnv(cmd) + if err := cmd.Run(); err != nil { return errors.New("--sandbox requires Docker Desktop with sandbox support\n\n" + "Make sure Docker Desktop is running and up to date.\n" + "For more information, see https://docs.docker.com/ai/sandboxes/") @@ -51,22 +53,34 @@ func (s *Existing) HasWorkspace(dir string) bool { // ForWorkspace returns the existing sandbox whose primary workspace // matches wd, or nil if none exists. -func ForWorkspace(ctx context.Context, wd string) *Existing { - out, err := exec.CommandContext(ctx, "docker", "sandbox", "ls", "--json").Output() +func (b *Backend) ForWorkspace(ctx context.Context, wd string) *Existing { + cmd := exec.CommandContext(ctx, b.program, b.args("ls", "--json")...) + b.applyEnv(cmd) + out, err := cmd.Output() if err != nil { return nil } - var result struct { - VMs []Existing `json:"vms"` + // The JSON key differs between backends: "vms" for docker sandbox, + // "sandboxes" for sbx. + var raw map[string]json.RawMessage + if err := json.Unmarshal(out, &raw); err != nil { + return nil } - if err := json.Unmarshal(out, &result); err != nil { + + listJSON, ok := raw[b.vmListKey] + if !ok { return nil } - for _, vm := range result.VMs { - if len(vm.Workspaces) > 0 && vm.Workspaces[0] == wd { - return &vm + var entries []Existing + if err := json.Unmarshal(listJSON, &entries); err != nil { + return nil + } + + for _, entry := range entries { + if len(entry.Workspaces) > 0 && entry.Workspaces[0] == wd { + return &entry } } return nil @@ -75,7 +89,7 @@ func ForWorkspace(ctx context.Context, wd string) *Existing { // Ensure makes sure a sandbox exists for the given workspace, // creating or recreating it as needed. When template is non-empty it is // passed to `docker sandbox create -t`. Returns the sandbox name. -func Ensure(ctx context.Context, wd, extra, template, configDir string) (string, error) { +func (b *Backend) Ensure(ctx context.Context, wd, extra, template, configDir string) (string, error) { // Resolve wd to an absolute path so that it matches the absolute // workspace paths returned by `docker sandbox ls --json`. absWd, err := filepath.Abs(wd) @@ -84,7 +98,7 @@ func Ensure(ctx context.Context, wd, extra, template, configDir string) (string, } wd = absWd - existing := ForWorkspace(ctx, wd) + existing := b.ForWorkspace(ctx, wd) // If the sandbox exists with the right mounts, reuse it. if existing != nil && @@ -97,34 +111,38 @@ func Ensure(ctx context.Context, wd, extra, template, configDir string) (string, // Remove a stale sandbox whose mounts don't match. if existing != nil { slog.Debug("Removing existing sandbox to change workspace mounts", "name", existing.Name) - _ = exec.CommandContext(ctx, "docker", "sandbox", "rm", existing.Name).Run() + rmCmd := exec.CommandContext(ctx, b.program, b.args("rm", existing.Name)...) + b.applyEnv(rmCmd) + _ = rmCmd.Run() } - // docker sandbox create [-t template] cagent [:ro] - createArgs := []string{"sandbox", "create"} + createExtra := []string{} if template != "" { - createArgs = append(createArgs, "-t", template) + createExtra = append(createExtra, "-t", template) } - createArgs = append(createArgs, "cagent", wd) + createExtra = append(createExtra, "cagent", wd) if extra != "" && extra != wd { - createArgs = append(createArgs, extra+":ro") + createExtra = append(createExtra, extra+":ro") } // Mount config directory read-only so the sandbox can // read the token file and access user config. - createArgs = append(createArgs, configDir+":ro") + createExtra = append(createExtra, configDir+":ro") + + createArgs := b.args("create", createExtra...) slog.Debug("Creating sandbox", "args", createArgs) - createCmd := exec.CommandContext(ctx, "docker", createArgs...) + createCmd := exec.CommandContext(ctx, b.program, createArgs...) + b.applyEnv(createCmd) createCmd.Stdin = os.Stdin createCmd.Stdout = os.Stdout createCmd.Stderr = os.Stderr if err := createCmd.Run(); err != nil { - return "", fmt.Errorf("docker sandbox create failed: %w", err) + return "", fmt.Errorf("sandbox create failed: %w", err) } // Read back the sandbox name that was just created. - created := ForWorkspace(ctx, wd) + created := b.ForWorkspace(ctx, wd) if created == nil { return "", errors.New("sandbox was created but could not be found") } @@ -132,26 +150,30 @@ func Ensure(ctx context.Context, wd, extra, template, configDir string) (string, return created.Name, nil } -// BuildExecCmd assembles the `docker sandbox exec` command. -func BuildExecCmd(ctx context.Context, name, wd string, cagentArgs, envFlags, envVars []string) *exec.Cmd { - args := []string{"sandbox", "exec", "-it", "-w", wd} - args = append(args, envFlags...) +// BuildExecCmd assembles the sandbox exec command. +func (b *Backend) BuildExecCmd(ctx context.Context, name, wd string, cagentArgs, envFlags, envVars []string) *exec.Cmd { + execExtra := []string{"-it", "-w", wd} + execExtra = append(execExtra, envFlags...) // Improve the rendering of the TUI - args = append(args, "-e", "TERM=xterm-256color") - args = append(args, "-e", "COLORTERM=truecolor") - args = append(args, "-e", "LANG=en_US.UTF-8") + execExtra = append(execExtra, + "-e", "TERM=xterm-256color", + "-e", "COLORTERM=truecolor", + "-e", "LANG=en_US.UTF-8", + name, "docker-agent", "run", + ) + execExtra = append(execExtra, cagentArgs...) - args = append(args, name, "docker-agent", "run") - args = append(args, cagentArgs...) + args := b.args("exec", execExtra...) - dockerCmd := exec.CommandContext(ctx, "docker", args...) - dockerCmd.Stdin = os.Stdin - dockerCmd.Stdout = os.Stdout - dockerCmd.Stderr = os.Stderr - dockerCmd.Env = append(os.Environ(), envVars...) + cmd := exec.CommandContext(ctx, b.program, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = append(os.Environ(), envVars...) + b.applyEnv(cmd) - return dockerCmd + return cmd } // StartTokenWriterIfNeeded starts a background goroutine that refreshes diff --git a/pkg/sandbox/sandbox_test.go b/pkg/sandbox/sandbox_test.go index 7721ce8f7..61bcb51e8 100644 --- a/pkg/sandbox/sandbox_test.go +++ b/pkg/sandbox/sandbox_test.go @@ -43,7 +43,8 @@ func TestCheckAvailable(t *testing.T) { } t.Setenv("PATH", fakeDir) - err := sandbox.CheckAvailable(t.Context()) + backend := sandbox.NewBackend(false) + err := backend.CheckAvailable(t.Context()) if tt.wantNoErr { require.NoError(t, err) } else { @@ -92,7 +93,8 @@ func TestForWorkspace(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "docker"), []byte(script), 0o755)) t.Setenv("PATH", fakeDir) - got := sandbox.ForWorkspace(t.Context(), tt.wd) + backend := sandbox.NewBackend(false) + got := backend.ForWorkspace(t.Context(), tt.wd) if tt.wantName == "" { assert.Nil(t, got) } else { @@ -116,6 +118,41 @@ func TestExisting_HasWorkspace(t *testing.T) { assert.False(t, s.HasWorkspace("/other")) } +func TestNewBackend_PrefersSbx(t *testing.T) { + fakeDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "sbx"), []byte("#!/bin/sh\nexit 0\n"), 0o755)) + t.Setenv("PATH", fakeDir) + + // When sbx is available and preferred, CheckAvailable uses sbx. + backend := sandbox.NewBackend(true) + err := backend.CheckAvailable(t.Context()) + require.NoError(t, err) +} + +func TestNewBackend_FallsBackToDocker(t *testing.T) { + fakeDir := t.TempDir() + // Only docker is available, no sbx. + require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755)) + t.Setenv("PATH", fakeDir) + + backend := sandbox.NewBackend(true) + err := backend.CheckAvailable(t.Context()) + require.NoError(t, err) +} + +func TestForWorkspace_SbxBackend(t *testing.T) { + fakeDir := t.TempDir() + jsonData := `{"sandboxes":[{"name":"my-sbx","workspaces":["/my/project"]}]}` + script := fmt.Sprintf("#!/bin/sh\necho '%s'\n", jsonData) + require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "sbx"), []byte(script), 0o755)) + t.Setenv("PATH", fakeDir) + + backend := sandbox.NewBackend(true) + got := backend.ForWorkspace(t.Context(), "/my/project") + require.NotNil(t, got) + assert.Equal(t, "my-sbx", got.Name) +} + func TestExtraWorkspace(t *testing.T) { tests := []struct { name string