Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type runExecFlags struct {
forceTUI bool
sandbox bool
sandboxTemplate string
sbx bool

// Exec only
exec bool
Expand Down Expand Up @@ -120,7 +121,8 @@ 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.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
Expand All @@ -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())
Expand Down
14 changes: 8 additions & 6 deletions cmd/root/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
72 changes: 72 additions & 0 deletions pkg/sandbox/backend.go
Original file line number Diff line number Diff line change
@@ -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...)
}
}
98 changes: 63 additions & 35 deletions pkg/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/")
Expand All @@ -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
}

listJSON, ok := raw[b.vmListKey]
if !ok {
return nil
}
if err := json.Unmarshal(out, &result); err != nil {

var entries []Existing
if err := json.Unmarshal(listJSON, &entries); err != nil {
return nil
}

for _, vm := range result.VMs {
if len(vm.Workspaces) > 0 && vm.Workspaces[0] == wd {
return &vm
for _, entry := range entries {
if len(entry.Workspaces) > 0 && entry.Workspaces[0] == wd {
return &entry
}
}
return nil
Expand All @@ -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)
Expand All @@ -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 &&
Expand All @@ -97,55 +111,69 @@ 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 <wd> [<extra>:ro] <dataDir> <configDir>
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")
}

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 {
execArgs := []string{"sandbox", "exec", "-it", "-w", wd}
execArgs = append(execArgs, envFlags...)
execArgs = append(execArgs, name, "cagent", "run")
execArgs = append(execArgs, cagentArgs...)
// 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
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 := b.args("exec", execExtra...)

dockerCmd := exec.CommandContext(ctx, "docker", execArgs...)
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
Expand Down
41 changes: 39 additions & 2 deletions pkg/sandbox/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading