diff --git a/cmd/cli/commands/configure.go b/cmd/cli/commands/configure.go index 90fe8d0cd..c6ee97ee2 100644 --- a/cmd/cli/commands/configure.go +++ b/cmd/cli/commands/configure.go @@ -11,10 +11,9 @@ func newConfigureCmd() *cobra.Command { var flags ConfigureFlags c := &cobra.Command{ - Use: "configure [--context-size=] [--speculative-draft-model=] [--hf_overrides=] [--gpu-memory-utilization=] [--mode=] [--think] [--keep-alive=] MODEL [-- ]", - Aliases: []string{"config"}, - Short: "Manage model runtime configurations", - Hidden: true, + Use: "configure [--context-size=] [--speculative-draft-model=] [--hf_overrides=] [--gpu-memory-utilization=] [--mode=] [--think] [--keep-alive=] MODEL [-- ]", + Short: "Manage model runtime configurations", + Hidden: true, Args: func(cmd *cobra.Command, args []string) error { argsBeforeDash := cmd.ArgsLenAtDash() if argsBeforeDash == -1 { diff --git a/cmd/cli/commands/launch.go b/cmd/cli/commands/launch.go index 1570c31db..55df05387 100644 --- a/cmd/cli/commands/launch.go +++ b/cmd/cli/commands/launch.go @@ -137,6 +137,23 @@ Examples: appArgs = args[dashIdx:] } + // If a sandbox tool is configured, launch host apps through it before + // resolving runner endpoints. This keeps sandbox launch independent of + // whether the host app binary itself is installed. + if _, ok := hostApps[app]; ok { + sandboxTool, err := readSandboxToolConfig() + if err != nil { + return err + } + + if sandboxTool != "" { + if err := validateSandboxTool(sandboxTool); err != nil { + return err + } + return launchSandboxedHostApp(cmd, sandboxTool, app, appArgs, dryRun) + } + } + runner, err := getStandaloneRunner(cmd.Context()) if err != nil { return fmt.Errorf("unable to determine standalone runner endpoint: %w", err) @@ -155,9 +172,11 @@ Examples: if ca, ok := containerApps[app]; ok { return launchContainerApp(cmd, ca, ep.container, image, port, detach, appArgs, dryRun) } + if cli, ok := hostApps[app]; ok { return launchHostApp(cmd, app, ep.host, cli, model, runner, appArgs, dryRun) } + return fmt.Errorf("unsupported app %q (supported: %s)", app, strings.Join(supportedApps, ", ")) }, } @@ -170,6 +189,31 @@ Examples: return c } +func launchSandboxedHostApp(cmd *cobra.Command, sandboxTool, app string, appArgs []string, dryRun bool) error { + if err := validateSandboxTool(sandboxTool); err != nil { + return err + } + + args := append([]string{app}, appArgs...) + + switch sandboxTool { + case "sbx": + if dryRun { + cmd.Printf("sbx %s\n", strings.Join(args, " ")) + return nil + } + + launchCmd := exec.Command("sbx", args...) + launchCmd.Stdin = os.Stdin + launchCmd.Stdout = os.Stdout + launchCmd.Stderr = os.Stderr + + return launchCmd.Run() + default: + return fmt.Errorf("unsupported sandbox tool %q", sandboxTool) + } +} + // listSupportedApps prints all supported apps with their descriptions and install status. func listSupportedApps(cmd *cobra.Command) error { cmd.Println("Supported apps:") diff --git a/cmd/cli/commands/root.go b/cmd/cli/commands/root.go index 358e04e2f..f6a10442e 100644 --- a/cmd/cli/commands/root.go +++ b/cmd/cli/commands/root.go @@ -120,6 +120,7 @@ func NewRootCmd(cli *command.DockerCli) *cobra.Command { newShowCmd(), newComposeCmd(), newLaunchCmd(), + newSandboxConfigCmd(), newTagCmd(), newConfigureCmd(), newPSCmd(), diff --git a/cmd/cli/commands/sandbox.go b/cmd/cli/commands/sandbox.go new file mode 100644 index 000000000..39cf2206c --- /dev/null +++ b/cmd/cli/commands/sandbox.go @@ -0,0 +1,126 @@ +package commands + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" +) + +var allowedSandboxTools = map[string]struct{}{ + "sbx": {}, +} + +func newSandboxConfigCmd() *cobra.Command { + return &cobra.Command{ + Use: "config ", + Short: "Set model runner configuration values", + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + key := args[0] + value := args[1] + + if key != "sandbox.tool" { + return fmt.Errorf("unsupported config key %q", key) + } + + if err := validateSandboxTool(value); err != nil { + return err + } + + return writeSandboxToolConfig(value) + }, + } +} + +func validateSandboxTool(tool string) error { + if _, ok := allowedSandboxTools[tool]; !ok { + return fmt.Errorf("unsupported sandbox tool %q", tool) + } + + return nil +} + +func dmrConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("unable to determine config directory: %w", err) + } + + return filepath.Join(configDir, "dmr", "config.toml"), nil +} + +func writeSandboxToolConfig(tool string) error { + path, err := dmrConfigPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("unable to create config directory: %w", err) + } + + content := fmt.Sprintf("[sandbox]\ntool = %q\n", tool) + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("unable to write config: %w", err) + } + + return nil +} + +func readSandboxToolConfig() (string, error) { + path, err := dmrConfigPath() + if err != nil { + return "", err + } + + file, err := os.Open(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("unable to read config: %w", err) + } + defer file.Close() + + inSandboxSection := false + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + inSandboxSection = line == "[sandbox]" + continue + } + + if !inSandboxSection { + continue + } + + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + + if strings.TrimSpace(key) != "tool" { + continue + } + + return strings.Trim(strings.TrimSpace(value), `"`), nil + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("unable to parse config: %w", err) + } + + return "", nil +} diff --git a/cmd/cli/commands/sandbox_test.go b/cmd/cli/commands/sandbox_test.go new file mode 100644 index 000000000..4ba5731f2 --- /dev/null +++ b/cmd/cli/commands/sandbox_test.go @@ -0,0 +1,156 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteAndReadSandboxToolConfig(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + if err := writeSandboxToolConfig("sbx"); err != nil { + t.Fatalf("writeSandboxToolConfig() error = %v", err) + } + + got, err := readSandboxToolConfig() + if err != nil { + t.Fatalf("readSandboxToolConfig() error = %v", err) + } + + if got != "sbx" { + t.Fatalf("readSandboxToolConfig() = %q, want %q", got, "sbx") + } + + configPath, err := dmrConfigPath() + if err != nil { + t.Fatalf("dmrConfigPath() error = %v", err) + } + + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", configPath, err) + } + + want := "[sandbox]\ntool = \"sbx\"\n" + if string(content) != want { + t.Fatalf("config content = %q, want %q", string(content), want) + } +} + +func TestReadSandboxToolConfigMissingFile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + got, err := readSandboxToolConfig() + if err != nil { + t.Fatalf("readSandboxToolConfig() error = %v", err) + } + + if got != "" { + t.Fatalf("readSandboxToolConfig() = %q, want empty string", got) + } +} + +func TestValidateSandboxToolAllowsSbx(t *testing.T) { + if err := validateSandboxTool("sbx"); err != nil { + t.Fatalf("validateSandboxTool() error = %v", err) + } +} + +func TestValidateSandboxToolRejectsUnsupportedTool(t *testing.T) { + err := validateSandboxTool("firejail") + if err == nil { + t.Fatal("validateSandboxTool() error = nil, want error") + } +} + +func TestSandboxConfigCommandRejectsUnsupportedKey(t *testing.T) { + cmd := newSandboxConfigCmd() + cmd.SetArgs([]string{"unsupported.key", "sbx"}) + + if err := cmd.Execute(); err == nil { + t.Fatal("config command error = nil, want error") + } +} + +func TestSandboxConfigCommandRejectsUnsupportedTool(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + cmd := newSandboxConfigCmd() + cmd.SetArgs([]string{"sandbox.tool", "firejail"}) + + if err := cmd.Execute(); err == nil { + t.Fatal("config command error = nil, want error") + } +} + +func TestSandboxConfigCommandWritesConfig(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + cmd := newSandboxConfigCmd() + cmd.SetArgs([]string{"sandbox.tool", "sbx"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("config command error = %v", err) + } + + got, err := readSandboxToolConfig() + if err != nil { + t.Fatalf("readSandboxToolConfig() error = %v", err) + } + + if got != "sbx" { + t.Fatalf("readSandboxToolConfig() = %q, want %q", got, "sbx") + } +} + +func TestLaunchCommandRequiresConfiguredSandboxTool(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + cmd := newLaunchCmd() + cmd.SetArgs([]string{"opencode"}) + + if err := cmd.Execute(); err == nil { + t.Fatal("launch command error = nil, want error") + } +} + +func TestLaunchCommandUsesConfiguredSandboxTool(t *testing.T) { + configDir := t.TempDir() + binDir := t.TempDir() + outputPath := filepath.Join(t.TempDir(), "output.txt") + + t.Setenv("XDG_CONFIG_HOME", configDir) + t.Setenv("TEST_OUTPUT", outputPath) + + sbxPath := filepath.Join(binDir, "sbx") + sbxScript := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$TEST_OUTPUT\"\n" + + if err := os.WriteFile(sbxPath, []byte(sbxScript), 0o755); err != nil { + t.Fatalf("WriteFile(%q) error = %v", sbxPath, err) + } + + oldPath := os.Getenv("PATH") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath) + + if err := writeSandboxToolConfig("sbx"); err != nil { + t.Fatalf("writeSandboxToolConfig() error = %v", err) + } + + cmd := newLaunchCmd() + cmd.SetArgs([]string{"opencode", "--", "--help"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("launch command error = %v", err) + } + + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", outputPath, err) + } + + want := "opencode\n--help\n" + if string(content) != want { + t.Fatalf("sandbox output = %q, want %q", string(content), want) + } +} diff --git a/cmd/cli/docs/reference/docker_model.yaml b/cmd/cli/docs/reference/docker_model.yaml index 6d1588f6f..830cd66df 100644 --- a/cmd/cli/docs/reference/docker_model.yaml +++ b/cmd/cli/docs/reference/docker_model.yaml @@ -7,6 +7,7 @@ pname: docker plink: docker.yaml cname: - docker model bench + - docker model config - docker model context - docker model df - docker model gateway @@ -37,6 +38,7 @@ cname: - docker model version clink: - docker_model_bench.yaml + - docker_model_config.yaml - docker_model_context.yaml - docker_model_df.yaml - docker_model_gateway.yaml diff --git a/cmd/cli/docs/reference/docker_model_config.yaml b/cmd/cli/docs/reference/docker_model_config.yaml new file mode 100644 index 000000000..bf6651f73 --- /dev/null +++ b/cmd/cli/docs/reference/docker_model_config.yaml @@ -0,0 +1,13 @@ +command: docker model config +short: Set model runner configuration values +long: Set model runner configuration values +usage: docker model config +pname: docker model +plink: docker_model.yaml +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/cmd/cli/docs/reference/docker_model_configure.yaml b/cmd/cli/docs/reference/docker_model_configure.yaml index 77f914fdc..9849785b9 100644 --- a/cmd/cli/docs/reference/docker_model_configure.yaml +++ b/cmd/cli/docs/reference/docker_model_configure.yaml @@ -1,5 +1,4 @@ command: docker model configure -aliases: docker model configure, docker model config short: Manage model runtime configurations long: Manage model runtime configurations usage: docker model configure [--context-size=] [--speculative-draft-model=] [--hf_overrides=] [--gpu-memory-utilization=] [--mode=] [--think] [--keep-alive=] MODEL [-- ] diff --git a/cmd/cli/docs/reference/model.md b/cmd/cli/docs/reference/model.md index e26c01924..b344269a1 100644 --- a/cmd/cli/docs/reference/model.md +++ b/cmd/cli/docs/reference/model.md @@ -8,6 +8,7 @@ Docker Model Runner | Name | Description | |:------------------------------------------------|:-----------------------------------------------------------------------| | [`bench`](model_bench.md) | Benchmark a model's performance at different concurrency levels | +| [`config`](model_config.md) | Set model runner configuration values | | [`context`](model_context.md) | Manage Docker Model Runner contexts | | [`df`](model_df.md) | Show Docker Model Runner disk usage | | [`gateway`](model_gateway.md) | Run an OpenAI-compatible LLM gateway | diff --git a/cmd/cli/docs/reference/model_config.md b/cmd/cli/docs/reference/model_config.md new file mode 100644 index 000000000..cbaaad8d3 --- /dev/null +++ b/cmd/cli/docs/reference/model_config.md @@ -0,0 +1,8 @@ +# docker model config + + +Set model runner configuration values + + + +