Skip to content

Commit aeaaca9

Browse files
authored
Merge pull request #2348 from dgageot/better-sandbox
Better sandbox
2 parents f497684 + 83856f5 commit aeaaca9

File tree

5 files changed

+186
-45
lines changed

5 files changed

+186
-45
lines changed

cmd/root/run.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type runExecFlags struct {
5252
forceTUI bool
5353
sandbox bool
5454
sandboxTemplate string
55+
sbx bool
5556

5657
// Exec only
5758
exec bool
@@ -120,7 +121,8 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
120121
_ = cmd.PersistentFlags().MarkHidden("force-tui")
121122
cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use a simplified TUI with minimal chrome")
122123
cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)")
123-
cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "", "Template image for the sandbox (passed to docker sandbox create -t)")
124+
cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "docker/sandbox-templates:docker-agent", "Template image for the sandbox (passed to docker sandbox create -t)")
125+
cmd.PersistentFlags().BoolVar(&flags.sbx, "sbx", true, "Prefer the sbx CLI backend when available (set --sbx=false to force docker sandbox)")
124126
cmd.MarkFlagsMutuallyExclusive("fake", "record")
125127

126128
// --exec only
@@ -145,7 +147,7 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command
145147
}
146148

147149
if f.sandbox {
148-
return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate)
150+
return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx)
149151
}
150152

151153
out := cli.NewPrinter(cmd.OutOrStdout())

cmd/root/sandbox.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import (
2222

2323
// runInSandbox delegates the current command to a Docker sandbox.
2424
// It ensures a sandbox exists (creating or recreating as needed), then
25-
// executes docker agent inside it via `docker sandbox exec`.
26-
func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runConfig *config.RuntimeConfig, template string) error {
25+
// executes docker agent inside it via the sandbox exec command.
26+
func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runConfig *config.RuntimeConfig, template string, preferSbx bool) error {
2727
if environment.InSandbox() {
2828
return fmt.Errorf("already running inside a Docker sandbox (VM %s)", os.Getenv("SANDBOX_VM_ID"))
2929
}
3030

31-
if err := sandbox.CheckAvailable(ctx); err != nil {
31+
backend := sandbox.NewBackend(preferSbx)
32+
33+
if err := backend.CheckAvailable(ctx); err != nil {
3234
return err
3335
}
3436

@@ -52,7 +54,7 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon
5254
return fmt.Errorf("resolving workspace path: %w", err)
5355
}
5456

55-
name, err := sandbox.Ensure(ctx, wd, sandbox.ExtraWorkspace(wd, agentRef), template, configDir)
57+
name, err := backend.Ensure(ctx, wd, sandbox.ExtraWorkspace(wd, agentRef), template, configDir)
5658
if err != nil {
5759
return err
5860
}
@@ -68,7 +70,7 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon
6870
envFlags = append(envFlags, "-e", envModelsGateway+"="+gateway)
6971
}
7072

71-
dockerCmd := sandbox.BuildExecCmd(ctx, name, wd, dockerAgentArgs, envFlags, envVars)
73+
dockerCmd := backend.BuildExecCmd(ctx, name, wd, dockerAgentArgs, envFlags, envVars)
7274
slog.Debug("Executing in sandbox", "name", name, "args", dockerCmd.Args)
7375

7476
if err := dockerCmd.Run(); err != nil {
@@ -85,7 +87,7 @@ func dockerAgentArgs(cmd *cobra.Command, args []string, configDir string) []stri
8587
var dockerAgentArgs []string
8688
hasYolo := false
8789
cmd.Flags().Visit(func(f *pflag.Flag) {
88-
if f.Name == "sandbox" || f.Name == "config-dir" {
90+
if f.Name == "sandbox" || f.Name == "sbx" || f.Name == "config-dir" {
8991
return
9092
}
9193

pkg/sandbox/backend.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package sandbox
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
)
7+
8+
// Backend describes how to invoke sandbox CLI commands.
9+
// The two supported backends are "docker sandbox" and "sbx".
10+
type Backend struct {
11+
// program is the executable name ("docker" or "sbx").
12+
program string
13+
// prefix is the sub-command prefix prepended to every command.
14+
// For "docker sandbox" this is ["sandbox"]; for "sbx" it is empty.
15+
prefix []string
16+
// extraEnv holds extra environment variables to set on every command.
17+
extraEnv []string
18+
// vmListKey is the JSON key returned by the "ls" command that holds
19+
// the list of sandboxes ("vms" for docker sandbox, "sandboxes" for sbx).
20+
vmListKey string
21+
}
22+
23+
// NewBackend returns the appropriate backend. When preferSbx is true
24+
// and the "sbx" binary is on PATH, the sbx backend is used; otherwise
25+
// it falls back to "docker sandbox".
26+
func NewBackend(preferSbx bool) *Backend {
27+
if preferSbx {
28+
if _, err := exec.LookPath("sbx"); err == nil {
29+
return sbxBackend()
30+
}
31+
}
32+
return dockerSandboxBackend()
33+
}
34+
35+
func dockerSandboxBackend() *Backend {
36+
return &Backend{
37+
program: "docker",
38+
prefix: []string{"sandbox"},
39+
vmListKey: "vms",
40+
}
41+
}
42+
43+
func sbxBackend() *Backend {
44+
return &Backend{
45+
program: "sbx",
46+
prefix: nil,
47+
extraEnv: []string{"DOCKER_CLI_PLUGIN_ORIGINAL_CLI_COMMAND="},
48+
vmListKey: "sandboxes",
49+
}
50+
}
51+
52+
// command builds an exec.Cmd for the given sandbox sub-command and arguments.
53+
// For example, command(ctx, "ls", "--json") produces either
54+
// "docker sandbox ls --json" or "sbx ls --json".
55+
func (b *Backend) args(subCmd string, extra ...string) []string {
56+
args := make([]string, 0, len(b.prefix)+1+len(extra))
57+
args = append(args, b.prefix...)
58+
args = append(args, subCmd)
59+
args = append(args, extra...)
60+
return args
61+
}
62+
63+
// applyEnv augments the command's environment with any backend-specific
64+
// variables. It must be called on every exec.Cmd created for the backend.
65+
func (b *Backend) applyEnv(cmd *exec.Cmd) {
66+
if len(b.extraEnv) > 0 {
67+
if cmd.Env == nil {
68+
cmd.Env = os.Environ()
69+
}
70+
cmd.Env = append(cmd.Env, b.extraEnv...)
71+
}
72+
}

pkg/sandbox/sandbox.go

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import (
2121

2222
// CheckAvailable returns a user-friendly error when Docker is not
2323
// installed or the sandbox feature is not supported.
24-
func CheckAvailable(ctx context.Context) error {
25-
if _, err := exec.LookPath("docker"); err != nil {
24+
func (b *Backend) CheckAvailable(ctx context.Context) error {
25+
if _, err := exec.LookPath(b.program); err != nil {
2626
return fmt.Errorf("--sandbox requires Docker Desktop: %w\n\nInstall Docker Desktop from https://docs.docker.com/get-docker/", err)
2727
}
2828

29-
if err := exec.CommandContext(ctx, "docker", "sandbox", "version").Run(); err != nil {
29+
cmd := exec.CommandContext(ctx, b.program, b.args("version")...)
30+
b.applyEnv(cmd)
31+
if err := cmd.Run(); err != nil {
3032
return errors.New("--sandbox requires Docker Desktop with sandbox support\n\n" +
3133
"Make sure Docker Desktop is running and up to date.\n" +
3234
"For more information, see https://docs.docker.com/ai/sandboxes/")
@@ -51,22 +53,34 @@ func (s *Existing) HasWorkspace(dir string) bool {
5153

5254
// ForWorkspace returns the existing sandbox whose primary workspace
5355
// matches wd, or nil if none exists.
54-
func ForWorkspace(ctx context.Context, wd string) *Existing {
55-
out, err := exec.CommandContext(ctx, "docker", "sandbox", "ls", "--json").Output()
56+
func (b *Backend) ForWorkspace(ctx context.Context, wd string) *Existing {
57+
cmd := exec.CommandContext(ctx, b.program, b.args("ls", "--json")...)
58+
b.applyEnv(cmd)
59+
out, err := cmd.Output()
5660
if err != nil {
5761
return nil
5862
}
5963

60-
var result struct {
61-
VMs []Existing `json:"vms"`
64+
// The JSON key differs between backends: "vms" for docker sandbox,
65+
// "sandboxes" for sbx.
66+
var raw map[string]json.RawMessage
67+
if err := json.Unmarshal(out, &raw); err != nil {
68+
return nil
69+
}
70+
71+
listJSON, ok := raw[b.vmListKey]
72+
if !ok {
73+
return nil
6274
}
63-
if err := json.Unmarshal(out, &result); err != nil {
75+
76+
var entries []Existing
77+
if err := json.Unmarshal(listJSON, &entries); err != nil {
6478
return nil
6579
}
6680

67-
for _, vm := range result.VMs {
68-
if len(vm.Workspaces) > 0 && vm.Workspaces[0] == wd {
69-
return &vm
81+
for _, entry := range entries {
82+
if len(entry.Workspaces) > 0 && entry.Workspaces[0] == wd {
83+
return &entry
7084
}
7185
}
7286
return nil
@@ -75,7 +89,7 @@ func ForWorkspace(ctx context.Context, wd string) *Existing {
7589
// Ensure makes sure a sandbox exists for the given workspace,
7690
// creating or recreating it as needed. When template is non-empty it is
7791
// passed to `docker sandbox create -t`. Returns the sandbox name.
78-
func Ensure(ctx context.Context, wd, extra, template, configDir string) (string, error) {
92+
func (b *Backend) Ensure(ctx context.Context, wd, extra, template, configDir string) (string, error) {
7993
// Resolve wd to an absolute path so that it matches the absolute
8094
// workspace paths returned by `docker sandbox ls --json`.
8195
absWd, err := filepath.Abs(wd)
@@ -84,7 +98,7 @@ func Ensure(ctx context.Context, wd, extra, template, configDir string) (string,
8498
}
8599
wd = absWd
86100

87-
existing := ForWorkspace(ctx, wd)
101+
existing := b.ForWorkspace(ctx, wd)
88102

89103
// If the sandbox exists with the right mounts, reuse it.
90104
if existing != nil &&
@@ -97,55 +111,69 @@ func Ensure(ctx context.Context, wd, extra, template, configDir string) (string,
97111
// Remove a stale sandbox whose mounts don't match.
98112
if existing != nil {
99113
slog.Debug("Removing existing sandbox to change workspace mounts", "name", existing.Name)
100-
_ = exec.CommandContext(ctx, "docker", "sandbox", "rm", existing.Name).Run()
114+
rmCmd := exec.CommandContext(ctx, b.program, b.args("rm", existing.Name)...)
115+
b.applyEnv(rmCmd)
116+
_ = rmCmd.Run()
101117
}
102118

103-
// docker sandbox create [-t template] cagent <wd> [<extra>:ro] <dataDir> <configDir>
104-
createArgs := []string{"sandbox", "create"}
119+
createExtra := []string{}
105120
if template != "" {
106-
createArgs = append(createArgs, "-t", template)
121+
createExtra = append(createExtra, "-t", template)
107122
}
108-
createArgs = append(createArgs, "cagent", wd)
123+
createExtra = append(createExtra, "cagent", wd)
109124
if extra != "" && extra != wd {
110-
createArgs = append(createArgs, extra+":ro")
125+
createExtra = append(createExtra, extra+":ro")
111126
}
112127
// Mount config directory read-only so the sandbox can
113128
// read the token file and access user config.
114-
createArgs = append(createArgs, configDir+":ro")
129+
createExtra = append(createExtra, configDir+":ro")
130+
131+
createArgs := b.args("create", createExtra...)
115132
slog.Debug("Creating sandbox", "args", createArgs)
116133

117-
createCmd := exec.CommandContext(ctx, "docker", createArgs...)
134+
createCmd := exec.CommandContext(ctx, b.program, createArgs...)
135+
b.applyEnv(createCmd)
118136
createCmd.Stdin = os.Stdin
119137
createCmd.Stdout = os.Stdout
120138
createCmd.Stderr = os.Stderr
121139

122140
if err := createCmd.Run(); err != nil {
123-
return "", fmt.Errorf("docker sandbox create failed: %w", err)
141+
return "", fmt.Errorf("sandbox create failed: %w", err)
124142
}
125143

126144
// Read back the sandbox name that was just created.
127-
created := ForWorkspace(ctx, wd)
145+
created := b.ForWorkspace(ctx, wd)
128146
if created == nil {
129147
return "", errors.New("sandbox was created but could not be found")
130148
}
131149

132150
return created.Name, nil
133151
}
134152

135-
// BuildExecCmd assembles the `docker sandbox exec` command.
136-
func BuildExecCmd(ctx context.Context, name, wd string, cagentArgs, envFlags, envVars []string) *exec.Cmd {
137-
execArgs := []string{"sandbox", "exec", "-it", "-w", wd}
138-
execArgs = append(execArgs, envFlags...)
139-
execArgs = append(execArgs, name, "cagent", "run")
140-
execArgs = append(execArgs, cagentArgs...)
153+
// BuildExecCmd assembles the sandbox exec command.
154+
func (b *Backend) BuildExecCmd(ctx context.Context, name, wd string, cagentArgs, envFlags, envVars []string) *exec.Cmd {
155+
execExtra := []string{"-it", "-w", wd}
156+
execExtra = append(execExtra, envFlags...)
157+
158+
// Improve the rendering of the TUI
159+
execExtra = append(execExtra,
160+
"-e", "TERM=xterm-256color",
161+
"-e", "COLORTERM=truecolor",
162+
"-e", "LANG=en_US.UTF-8",
163+
name, "docker-agent", "run",
164+
)
165+
execExtra = append(execExtra, cagentArgs...)
166+
167+
args := b.args("exec", execExtra...)
141168

142-
dockerCmd := exec.CommandContext(ctx, "docker", execArgs...)
143-
dockerCmd.Stdin = os.Stdin
144-
dockerCmd.Stdout = os.Stdout
145-
dockerCmd.Stderr = os.Stderr
146-
dockerCmd.Env = append(os.Environ(), envVars...)
169+
cmd := exec.CommandContext(ctx, b.program, args...)
170+
cmd.Stdin = os.Stdin
171+
cmd.Stdout = os.Stdout
172+
cmd.Stderr = os.Stderr
173+
cmd.Env = append(os.Environ(), envVars...)
174+
b.applyEnv(cmd)
147175

148-
return dockerCmd
176+
return cmd
149177
}
150178

151179
// StartTokenWriterIfNeeded starts a background goroutine that refreshes

pkg/sandbox/sandbox_test.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ func TestCheckAvailable(t *testing.T) {
4343
}
4444
t.Setenv("PATH", fakeDir)
4545

46-
err := sandbox.CheckAvailable(t.Context())
46+
backend := sandbox.NewBackend(false)
47+
err := backend.CheckAvailable(t.Context())
4748
if tt.wantNoErr {
4849
require.NoError(t, err)
4950
} else {
@@ -92,7 +93,8 @@ func TestForWorkspace(t *testing.T) {
9293
require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "docker"), []byte(script), 0o755))
9394
t.Setenv("PATH", fakeDir)
9495

95-
got := sandbox.ForWorkspace(t.Context(), tt.wd)
96+
backend := sandbox.NewBackend(false)
97+
got := backend.ForWorkspace(t.Context(), tt.wd)
9698
if tt.wantName == "" {
9799
assert.Nil(t, got)
98100
} else {
@@ -116,6 +118,41 @@ func TestExisting_HasWorkspace(t *testing.T) {
116118
assert.False(t, s.HasWorkspace("/other"))
117119
}
118120

121+
func TestNewBackend_PrefersSbx(t *testing.T) {
122+
fakeDir := t.TempDir()
123+
require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "sbx"), []byte("#!/bin/sh\nexit 0\n"), 0o755))
124+
t.Setenv("PATH", fakeDir)
125+
126+
// When sbx is available and preferred, CheckAvailable uses sbx.
127+
backend := sandbox.NewBackend(true)
128+
err := backend.CheckAvailable(t.Context())
129+
require.NoError(t, err)
130+
}
131+
132+
func TestNewBackend_FallsBackToDocker(t *testing.T) {
133+
fakeDir := t.TempDir()
134+
// Only docker is available, no sbx.
135+
require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755))
136+
t.Setenv("PATH", fakeDir)
137+
138+
backend := sandbox.NewBackend(true)
139+
err := backend.CheckAvailable(t.Context())
140+
require.NoError(t, err)
141+
}
142+
143+
func TestForWorkspace_SbxBackend(t *testing.T) {
144+
fakeDir := t.TempDir()
145+
jsonData := `{"sandboxes":[{"name":"my-sbx","workspaces":["/my/project"]}]}`
146+
script := fmt.Sprintf("#!/bin/sh\necho '%s'\n", jsonData)
147+
require.NoError(t, os.WriteFile(filepath.Join(fakeDir, "sbx"), []byte(script), 0o755))
148+
t.Setenv("PATH", fakeDir)
149+
150+
backend := sandbox.NewBackend(true)
151+
got := backend.ForWorkspace(t.Context(), "/my/project")
152+
require.NotNil(t, got)
153+
assert.Equal(t, "my-sbx", got.Name)
154+
}
155+
119156
func TestExtraWorkspace(t *testing.T) {
120157
tests := []struct {
121158
name string

0 commit comments

Comments
 (0)