From 1b33e212d31c1cf11807c79ca85f4bfa2f000d9b Mon Sep 17 00:00:00 2001 From: "593164063@qq.com" Date: Fri, 8 May 2026 20:01:21 +0800 Subject: [PATCH] feat(sandbox): add Docker CLI provider and fix UI version label - Implement internal/sandbox/dockercli for docker run/inspect/exec lifecycle - Register provider=docker with optional docker_cli_path in config - Extend SandboxConfig validation and tests; update serve test for unknown providers - Fix duplicate v in sidebar version when API returns git-describe v-prefixed string Co-authored-by: Cursor --- cli/serve/serve_test.go | 6 +- internal/config/config.go | 21 ++ internal/config/config_test.go | 48 +++ internal/config/model_validation.go | 4 +- internal/sandbox/dockercli/errors.go | 64 ++++ internal/sandbox/dockercli/options.go | 31 ++ internal/sandbox/dockercli/parse.go | 77 ++++ internal/sandbox/dockercli/parse_test.go | 39 +++ internal/sandbox/dockercli/provider.go | 222 ++++++++++++ internal/sandbox/dockercli/provider_test.go | 329 ++++++++++++++++++ internal/sandbox/dockercli/run_args.go | 59 ++++ internal/sandbox/dockercli/runner.go | 76 ++++ internal/sandboxproviders/docker_provider.go | 13 + .../sandboxproviders/docker_provider_test.go | 80 +++++ internal/sandboxproviders/registry_test.go | 9 +- web/static/app.js | 11 +- 16 files changed, 1082 insertions(+), 7 deletions(-) create mode 100644 internal/sandbox/dockercli/errors.go create mode 100644 internal/sandbox/dockercli/options.go create mode 100644 internal/sandbox/dockercli/parse.go create mode 100644 internal/sandbox/dockercli/parse_test.go create mode 100644 internal/sandbox/dockercli/provider.go create mode 100644 internal/sandbox/dockercli/provider_test.go create mode 100644 internal/sandbox/dockercli/run_args.go create mode 100644 internal/sandbox/dockercli/runner.go create mode 100644 internal/sandboxproviders/docker_provider.go create mode 100644 internal/sandboxproviders/docker_provider_test.go diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index 0334c888..c5cef5cf 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -952,14 +952,14 @@ func TestSandboxServiceOptionsSupportsConfiguredProvider(t *testing.T) { func TestNewAgentServiceRejectsUnsupportedSandboxProvider(t *testing.T) { _, err := newAgentService(config.Config{ Sandbox: config.SandboxConfig{ - Provider: "docker", - HomeDirName: "docker", + Provider: "not-a-sandbox-backend", + HomeDirName: "runtime", }, }) if err == nil { t.Fatal("newAgentService() error = nil, want unsupported sandbox provider") } - if !strings.Contains(err.Error(), `unsupported sandbox provider "docker"`) { + if !strings.Contains(err.Error(), `unsupported sandbox provider "not-a-sandbox-backend"`) { t.Fatalf("newAgentService() error = %q, want unsupported sandbox provider", err) } } diff --git a/internal/config/config.go b/internal/config/config.go index 037be21a..06e4c436 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -64,6 +64,7 @@ type SandboxConfig struct { HomeDirName string StoragePath string DebianRegistriesOverride []string + DockerCLIPath string } func (c SandboxConfig) Resolved() SandboxConfig { @@ -86,6 +87,16 @@ func (c SandboxConfig) EffectiveDebianRegistries() []string { return append([]string(nil), c.DebianRegistriesOverride...) } +// EffectiveDockerCLIPath returns the docker binary path for [sandbox].provider = docker. +// When unset, it defaults to "docker" (PATH lookup). +func (c SandboxConfig) EffectiveDockerCLIPath() string { + p := strings.TrimSpace(c.DockerCLIPath) + if p != "" { + return p + } + return "docker" +} + type ChannelsConfig struct { FeishuAdminOpenID string Feishu map[string]FeishuConfig @@ -130,6 +141,7 @@ const ( DefaultAccessToken = "your_access_token" DefaultManagerImage = "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.4.29.0" CSGHubProvider = "csghub" + DockerProvider = "docker" BoxLiteCLIProvider = "boxlite-cli" DefaultSandboxHomeDirName = "boxlite" RuntimeHomeDirName = DefaultSandboxHomeDirName @@ -316,6 +328,9 @@ func Load(path string) (Config, error) { return Config{}, fmt.Errorf("parse sandbox.debian_registries_override: %w", parseErr) } cfg.Sandbox.DebianRegistriesOverride = registries + case "docker_cli_path": + cfg.raw.sandbox.DockerCLIPath = parseRawStringValue(rawValue) + cfg.Sandbox.DockerCLIPath = value } case section == "channels.feishu": switch key { @@ -427,6 +442,9 @@ home_dir_name = %q if strings.TrimSpace(resolvedSandbox.StoragePath) != "" { sandboxSection = strings.Replace(sandboxSection, "[sandbox]\n", fmt.Sprintf("[sandbox]\nstorage_path = %q\n", cfg.rawOrResolvedString(cfg.raw.sandbox.StoragePath, loadedRaw.sandbox.StoragePath, resolvedSandbox.StoragePath)), 1) } + if strings.TrimSpace(resolvedSandbox.DockerCLIPath) != "" { + sandboxSection = strings.Replace(sandboxSection, "[sandbox]\n", fmt.Sprintf("[sandbox]\ndocker_cli_path = %q\n", cfg.rawOrResolvedString(cfg.raw.sandbox.DockerCLIPath, loadedRaw.sandbox.DockerCLIPath, resolvedSandbox.DockerCLIPath)), 1) + } overrideRegistries := cfg.rawOrResolvedStringArray(cfg.raw.sandbox.DebianRegistriesOverride, loadedRaw.sandbox.DebianRegistriesOverride, resolvedSandbox.DebianRegistriesOverride) sandboxSection += fmt.Sprintf("debian_registries_override = %s\n", formatStringArray(overrideRegistries)) b.WriteString(sandboxSection) @@ -762,6 +780,9 @@ func (c Config) resolvedRawValues() *rawConfigValues { if len(c.raw.sandbox.DebianRegistriesOverride) > 0 { out.sandbox.DebianRegistriesOverride = append([]string(nil), c.Sandbox.DebianRegistriesOverride...) } + if c.raw.sandbox.DockerCLIPath != "" { + out.sandbox.DockerCLIPath = c.Sandbox.DockerCLIPath + } if c.raw.modelsDefault != "" { out.modelsDefault = c.Models.Default } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2499ae6f..5caaba17 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -143,6 +143,54 @@ models = ["minimax-m2.7"] } } +func TestLoadReadsDockerSandboxConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := `[server] +listen_addr = "127.0.0.1:18080" + +[sandbox] +provider = "docker" +home_dir_name = "docker-runtime" +docker_cli_path = "/custom/docker" + +[models] +default = "default.minimax-m2.7" + +[models.providers.default] +base_url = "http://127.0.0.1:4000" +api_key = "sk" +models = ["minimax-m2.7"] +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got, want := cfg.Sandbox.Provider, DockerProvider; got != want { + t.Fatalf("cfg.Sandbox.Provider = %q, want %q", got, want) + } + if got, want := cfg.Sandbox.HomeDirName, "docker-runtime"; got != want { + t.Fatalf("cfg.Sandbox.HomeDirName = %q, want %q", got, want) + } + if got, want := cfg.Sandbox.DockerCLIPath, "/custom/docker"; got != want { + t.Fatalf("cfg.Sandbox.DockerCLIPath = %q, want %q", got, want) + } + if got, want := cfg.Sandbox.EffectiveDockerCLIPath(), "/custom/docker"; got != want { + t.Fatalf("EffectiveDockerCLIPath() = %q, want %q", got, want) + } +} + +func TestSandboxEffectiveDockerCLIPathDefault(t *testing.T) { + cfg := SandboxConfig{Provider: DockerProvider}.Resolved() + if got, want := cfg.EffectiveDockerCLIPath(), "docker"; got != want { + t.Fatalf("EffectiveDockerCLIPath() = %q, want %q", got, want) + } +} + func TestLoadExpandsEnvironmentVariablesInConfigValues(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") diff --git a/internal/config/model_validation.go b/internal/config/model_validation.go index abbf4c7e..9fc27ceb 100644 --- a/internal/config/model_validation.go +++ b/internal/config/model_validation.go @@ -101,10 +101,10 @@ func (c ModelConfig) Validate() error { func (c SandboxConfig) Validate() error { cfg := c.Resolved() switch cfg.Provider { - case BoxLiteCLIProvider, CSGHubProvider: + case BoxLiteCLIProvider, CSGHubProvider, DockerProvider: return nil default: - return fmt.Errorf("unsupported sandbox provider %q; supported values are %q or %q", cfg.Provider, BoxLiteCLIProvider, CSGHubProvider) + return fmt.Errorf("unsupported sandbox provider %q; supported values are %q, %q, or %q", cfg.Provider, BoxLiteCLIProvider, CSGHubProvider, DockerProvider) } } diff --git a/internal/sandbox/dockercli/errors.go b/internal/sandbox/dockercli/errors.go new file mode 100644 index 00000000..1bf34ed0 --- /dev/null +++ b/internal/sandbox/dockercli/errors.go @@ -0,0 +1,64 @@ +package dockercli + +import ( + "errors" + "fmt" + "os/exec" + "strings" + + "csgclaw/internal/sandbox" +) + +type ExitError struct { + Op string + ExitCode int + Stderr string + Err error +} + +func (e *ExitError) Error() string { + msg := strings.TrimSpace(e.Stderr) + if msg == "" { + msg = "command failed" + } + if e.Op == "" { + return fmt.Sprintf("docker exited with code %d: %s", e.ExitCode, msg) + } + return fmt.Sprintf("%s: docker exited with code %d: %s", e.Op, e.ExitCode, msg) +} + +func (e *ExitError) Unwrap() error { + return e.Err +} + +func wrapRunError(op string, result CommandResult, err error) error { + if err == nil { + return nil + } + stderr := strings.TrimSpace(string(result.Stderr)) + if isNotFound(stderr) { + return fmt.Errorf("%s: %w: %w", op, sandbox.ErrNotFound, &ExitError{ + Op: op, + ExitCode: result.ExitCode, + Stderr: stderr, + Err: err, + }) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) || result.ExitCode != 0 { + return &ExitError{ + Op: op, + ExitCode: result.ExitCode, + Stderr: stderr, + Err: err, + } + } + return fmt.Errorf("%s: %w", op, err) +} + +func isNotFound(stderr string) bool { + text := strings.ToLower(stderr) + return strings.Contains(text, "no such container") || + strings.Contains(text, "no such object") || + strings.Contains(text, "not found") +} diff --git a/internal/sandbox/dockercli/options.go b/internal/sandbox/dockercli/options.go new file mode 100644 index 00000000..fa1da23e --- /dev/null +++ b/internal/sandbox/dockercli/options.go @@ -0,0 +1,31 @@ +package dockercli + +import ( + "strings" +) + +const defaultCLIPath = "docker" + +type ProviderOption func(*Provider) + +func WithPath(path string) ProviderOption { + return func(p *Provider) { + p.path = strings.TrimSpace(path) + } +} + +func WithRunner(runner Runner) ProviderOption { + return func(p *Provider) { + if runner != nil { + p.runner = runner + } + } +} + +func resolvePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return defaultCLIPath + } + return path +} diff --git a/internal/sandbox/dockercli/parse.go b/internal/sandbox/dockercli/parse.go new file mode 100644 index 00000000..a9f25cbc --- /dev/null +++ b/internal/sandbox/dockercli/parse.go @@ -0,0 +1,77 @@ +package dockercli + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "csgclaw/internal/sandbox" +) + +type inspectContainer struct { + ID string `json:"Id"` + Name string `json:"Name"` + Created string `json:"Created"` + State struct { + Status string `json:"Status"` + Running bool `json:"Running"` + } `json:"State"` +} + +func parseInspect(data []byte) (sandbox.Info, error) { + var containers []inspectContainer + if err := json.Unmarshal(data, &containers); err != nil { + return sandbox.Info{}, fmt.Errorf("parse docker inspect json: %w", err) + } + if len(containers) == 0 { + return sandbox.Info{}, sandbox.ErrNotFound + } + c := containers[0] + createdAt, err := parseCreatedAt(c.Created) + if err != nil { + return sandbox.Info{}, err + } + name := strings.TrimPrefix(c.Name, "/") + status := c.State.Status + if status == "" && c.State.Running { + status = "running" + } + return sandbox.Info{ + ID: c.ID, + Name: name, + State: mapState(status), + CreatedAt: createdAt, + }, nil +} + +func parseCreatedAt(value string) (time.Time, error) { + if strings.TrimSpace(value) == "" { + return time.Time{}, nil + } + createdAt, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + createdAt, err = time.Parse(time.RFC3339, value) + } + if err != nil { + return time.Time{}, fmt.Errorf("parse docker created time %q: %w", value, err) + } + return createdAt, nil +} + +func mapState(status string) sandbox.State { + switch strings.ToLower(strings.TrimSpace(status)) { + case "created": + return sandbox.StateCreated + case "running": + return sandbox.StateRunning + case "paused", "restarting": + return sandbox.StateUnknown + case "removing": + return sandbox.StateUnknown + case "exited", "dead": + return sandbox.StateExited + default: + return sandbox.StateUnknown + } +} diff --git a/internal/sandbox/dockercli/parse_test.go b/internal/sandbox/dockercli/parse_test.go new file mode 100644 index 00000000..80a191ab --- /dev/null +++ b/internal/sandbox/dockercli/parse_test.go @@ -0,0 +1,39 @@ +package dockercli + +import ( + "testing" + "time" + + "csgclaw/internal/sandbox" +) + +func TestParseInspectDockerJSON(t *testing.T) { + data := []byte(`[{"Id":"abc123","Name":"/agent-one","Created":"2026-04-18T07:31:25.471080Z","State":{"Status":"running","Running":true}}]`) + info, err := parseInspect(data) + if err != nil { + t.Fatalf("parseInspect() error = %v", err) + } + if info.ID != "abc123" { + t.Fatalf("ID = %q", info.ID) + } + if info.Name != "agent-one" { + t.Fatalf("Name = %q", info.Name) + } + if info.State != sandbox.StateRunning { + t.Fatalf("State = %q", info.State) + } + wantCreated, err := time.Parse(time.RFC3339Nano, "2026-04-18T07:31:25.471080Z") + if err != nil { + t.Fatalf("Parse want time: %v", err) + } + if !info.CreatedAt.Equal(wantCreated) { + t.Fatalf("CreatedAt = %v, want %v", info.CreatedAt, wantCreated) + } +} + +func TestParseInspectEmptyArrayNotFound(t *testing.T) { + _, err := parseInspect([]byte(`[]`)) + if !sandbox.IsNotFound(err) { + t.Fatalf("error = %v, want not found", err) + } +} diff --git a/internal/sandbox/dockercli/provider.go b/internal/sandbox/dockercli/provider.go new file mode 100644 index 00000000..0ba1a96f --- /dev/null +++ b/internal/sandbox/dockercli/provider.go @@ -0,0 +1,222 @@ +// Package dockercli adapts the Docker CLI to the generic sandbox interfaces. +package dockercli + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "csgclaw/internal/sandbox" +) + +const providerName = "docker" + +type Provider struct { + path string + runner Runner +} + +func NewProvider(opts ...ProviderOption) Provider { + p := Provider{ + path: defaultCLIPath, + runner: execRunner{}, + } + for _, opt := range opts { + opt(&p) + } + p.path = resolvePath(p.path) + if p.runner == nil { + p.runner = execRunner{} + } + return p +} + +func (Provider) Name() string { + return providerName +} + +func (p Provider) Open(_ context.Context, _ string) (sandbox.Runtime, error) { + return &Runtime{ + path: p.path, + runner: p.runner, + }, nil +} + +type Runtime struct { + path string + runner Runner +} + +var _ sandbox.Provider = Provider{} +var _ sandbox.Runtime = (*Runtime)(nil) + +func (r *Runtime) Create(ctx context.Context, spec sandbox.CreateSpec) (sandbox.Instance, error) { + if err := r.valid(); err != nil { + return nil, err + } + args, err := runArgs(spec) + if err != nil { + return nil, err + } + result, err := r.run(ctx, args, nil, nil) + if err != nil { + return nil, wrapRunError("docker run", result, err) + } + id := strings.TrimSpace(string(result.Stdout)) + if id == "" { + id = spec.Name + } + return &Instance{runtime: r, idOrName: id}, nil +} + +func (r *Runtime) Get(ctx context.Context, idOrName string) (sandbox.Instance, error) { + if err := r.valid(); err != nil { + return nil, err + } + info, err := r.inspect(ctx, idOrName) + if err != nil { + return nil, err + } + id := info.ID + if id == "" { + id = idOrName + } + return &Instance{runtime: r, idOrName: id}, nil +} + +func (r *Runtime) Remove(ctx context.Context, idOrName string, opts sandbox.RemoveOptions) error { + if err := r.valid(); err != nil { + return err + } + if strings.TrimSpace(idOrName) == "" { + return fmt.Errorf("docker container id or name is required") + } + args := []string{"rm"} + if opts.Force { + args = append(args, "-f") + } + args = append(args, idOrName) + result, err := r.run(ctx, args, nil, nil) + return wrapRunError("docker rm", result, err) +} + +func (r *Runtime) Close() error { + return nil +} + +func (r *Runtime) valid() error { + if r == nil || r.runner == nil { + return fmt.Errorf("invalid docker runtime") + } + if strings.TrimSpace(r.path) == "" { + return fmt.Errorf("docker path is required") + } + return nil +} + +func (r *Runtime) inspect(ctx context.Context, idOrName string) (sandbox.Info, error) { + if strings.TrimSpace(idOrName) == "" { + return sandbox.Info{}, fmt.Errorf("docker container id or name is required") + } + result, err := r.run(ctx, []string{"inspect", idOrName}, nil, nil) + if err != nil { + return sandbox.Info{}, wrapRunError("docker inspect", result, err) + } + info, err := parseInspect(result.Stdout) + if err != nil { + if sandbox.IsNotFound(err) { + return sandbox.Info{}, fmt.Errorf("docker inspect: %w", err) + } + return sandbox.Info{}, err + } + return info, nil +} + +func (r *Runtime) run(ctx context.Context, args []string, stdout, stderr interface{ Write([]byte) (int, error) }) (CommandResult, error) { + req := CommandRequest{ + Path: r.path, + Args: args, + Stdout: stdout, + Stderr: stderr, + } + return r.runner.Run(ctx, req) +} + +type Instance struct { + runtime *Runtime + idOrName string +} + +var _ sandbox.Instance = (*Instance)(nil) + +func (i *Instance) Start(ctx context.Context) error { + if err := i.valid(); err != nil { + return err + } + result, err := i.runtime.run(ctx, []string{"start", i.idOrName}, nil, nil) + return wrapRunError("docker start", result, err) +} + +func (i *Instance) Stop(ctx context.Context, opts sandbox.StopOptions) error { + if err := i.valid(); err != nil { + return err + } + if opts.Force { + result, err := i.runtime.run(ctx, []string{"kill", i.idOrName}, nil, nil) + return wrapRunError("docker kill", result, err) + } + args := []string{"stop"} + if opts.Timeout > 0 { + sec := int(opts.Timeout.Round(time.Second) / time.Second) + if sec < 1 { + sec = 1 + } + args = append(args, "-t", strconv.Itoa(sec)) + } + args = append(args, i.idOrName) + result, err := i.runtime.run(ctx, args, nil, nil) + return wrapRunError("docker stop", result, err) +} + +func (i *Instance) Info(ctx context.Context) (sandbox.Info, error) { + if err := i.valid(); err != nil { + return sandbox.Info{}, err + } + return i.runtime.inspect(ctx, i.idOrName) +} + +func (i *Instance) Run(ctx context.Context, spec sandbox.CommandSpec) (sandbox.CommandResult, error) { + if err := i.valid(); err != nil { + return sandbox.CommandResult{}, err + } + if strings.TrimSpace(spec.Name) == "" { + return sandbox.CommandResult{}, fmt.Errorf("invalid sandbox command: name is required") + } + args := []string{"exec", i.idOrName, spec.Name} + args = append(args, spec.Args...) + result, err := i.runtime.run(ctx, args, spec.Stdout, spec.Stderr) + out := sandbox.CommandResult{ExitCode: result.ExitCode} + if err != nil { + return out, wrapRunError("docker exec", result, err) + } + return out, nil +} + +func (i *Instance) Close() error { + return nil +} + +func (i *Instance) valid() error { + if i == nil || i.runtime == nil { + return fmt.Errorf("invalid docker container") + } + if err := i.runtime.valid(); err != nil { + return err + } + if strings.TrimSpace(i.idOrName) == "" { + return fmt.Errorf("docker container id or name is required") + } + return nil +} diff --git a/internal/sandbox/dockercli/provider_test.go b/internal/sandbox/dockercli/provider_test.go new file mode 100644 index 00000000..2354ec3b --- /dev/null +++ b/internal/sandbox/dockercli/provider_test.go @@ -0,0 +1,329 @@ +package dockercli + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "reflect" + "strings" + "testing" + "time" + + "csgclaw/internal/sandbox" +) + +func TestProviderImplementsSandboxProvider(t *testing.T) { + var _ sandbox.Provider = NewProvider() + if got, want := NewProvider().Name(), "docker"; got != want { + t.Fatalf("Name() = %q, want %q", got, want) + } +} + +func TestCreateBuildsRunCLIArgs(t *testing.T) { + runner := &fakeRunner{ + results: []fakeResult{{result: CommandResult{Stdout: []byte("container-id\n")}}}, + } + rt, err := NewProvider( + WithPath("/usr/local/bin/docker"), + WithRunner(runner), + ).Open(context.Background(), "/tmp/ignored") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + + inst, err := rt.Create(context.Background(), sandbox.CreateSpec{ + Image: "alpine", + Name: "agent", + Detach: true, + AutoRemove: true, + Env: map[string]string{"B": "two", "A": "one"}, + Cmd: []string{"sh", "-lc", "echo ok"}, + Mounts: []sandbox.Mount{ + {HostPath: "/host/rw", GuestPath: "/guest/rw"}, + {HostPath: "/host/ro", GuestPath: "/guest/ro", ReadOnly: true}, + }, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if inst == nil { + t.Fatal("Create() instance = nil") + } + + want := []string{ + "run", + "--name", "agent", + "--detach", + "--rm", + "-e", "A=one", + "-e", "B=two", + "-v", "/host/rw:/guest/rw", + "-v", "/host/ro:/guest/ro:ro", + "alpine", + "sh", + "-lc", + "echo ok", + } + if got := runner.requests[0].Args; !reflect.DeepEqual(got, want) { + t.Fatalf("Create() args = %#v, want %#v", got, want) + } + if got, want := runner.requests[0].Path, "/usr/local/bin/docker"; got != want { + t.Fatalf("Create() path = %q, want %q", got, want) + } +} + +func TestCreateRejectsUnsupportedOptions(t *testing.T) { + tests := []struct { + name string + spec sandbox.CreateSpec + want string + }{ + {name: "image", spec: sandbox.CreateSpec{}, want: "image is required"}, + {name: "entrypoint", spec: sandbox.CreateSpec{Image: "alpine", Entrypoint: []string{"sh"}}, want: "entrypoint"}, + {name: "env", spec: sandbox.CreateSpec{Image: "alpine", Env: map[string]string{"": "x"}}, want: "env"}, + {name: "mount host", spec: sandbox.CreateSpec{Image: "alpine", Mounts: []sandbox.Mount{{GuestPath: "/guest"}}}, want: "host path"}, + {name: "mount guest", spec: sandbox.CreateSpec{Image: "alpine", Mounts: []sandbox.Mount{{HostPath: "/host"}}}, want: "guest path"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runArgs(tt.spec) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("runArgs() error = %v, want containing %q", err, tt.want) + } + }) + } +} + +func TestInstanceMethodsBuildCLIArgs(t *testing.T) { + runner := &fakeRunner{ + results: []fakeResult{ + {result: CommandResult{}}, + {result: CommandResult{}}, + {result: CommandResult{Stdout: []byte(`[{"Id":"box-id","Name":"/agent","Created":"2026-04-18T07:31:25.471080+00:00","State":{"Status":"running"}}]`)}}, + {result: CommandResult{}}, + }, + } + rt, err := NewProvider(WithRunner(runner)).Open(context.Background(), "/tmp/x") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + inst := &Instance{runtime: rt.(*Runtime), idOrName: "box-id"} + + if err := inst.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + if err := inst.Stop(context.Background(), sandbox.StopOptions{}); err != nil { + t.Fatalf("Stop() error = %v", err) + } + if _, err := inst.Info(context.Background()); err != nil { + t.Fatalf("Info() error = %v", err) + } + if _, err := inst.Run(context.Background(), sandbox.CommandSpec{Name: "sh", Args: []string{"-lc", "echo ok"}}); err != nil { + t.Fatalf("Run() error = %v", err) + } + + wants := [][]string{ + {"start", "box-id"}, + {"stop", "box-id"}, + {"inspect", "box-id"}, + {"exec", "box-id", "sh", "-lc", "echo ok"}, + } + for idx, want := range wants { + if got := runner.requests[idx].Args; !reflect.DeepEqual(got, want) { + t.Fatalf("request %d args = %#v, want %#v", idx, got, want) + } + } +} + +func TestStopWithTimeoutUsesFlag(t *testing.T) { + runner := &fakeRunner{results: []fakeResult{{result: CommandResult{}}}} + rt, err := NewProvider(WithRunner(runner)).Open(context.Background(), "/tmp/x") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + inst := &Instance{runtime: rt.(*Runtime), idOrName: "c1"} + if err := inst.Stop(context.Background(), sandbox.StopOptions{Timeout: 1500 * time.Millisecond}); err != nil { + t.Fatalf("Stop() error = %v", err) + } + want := []string{"stop", "-t", "2", "c1"} + if got := runner.requests[0].Args; !reflect.DeepEqual(got, want) { + t.Fatalf("args = %#v, want %#v", got, want) + } +} + +func TestStopForceUsesKill(t *testing.T) { + runner := &fakeRunner{results: []fakeResult{{result: CommandResult{}}}} + rt, err := NewProvider(WithRunner(runner)).Open(context.Background(), "/tmp/x") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + inst := &Instance{runtime: rt.(*Runtime), idOrName: "c1"} + if err := inst.Stop(context.Background(), sandbox.StopOptions{Force: true}); err != nil { + t.Fatalf("Stop() error = %v", err) + } + want := []string{"kill", "c1"} + if got := runner.requests[0].Args; !reflect.DeepEqual(got, want) { + t.Fatalf("args = %#v, want %#v", got, want) + } +} + +func TestRunForwardsOutputAndPreservesExitCode(t *testing.T) { + runner := &fakeRunner{ + results: []fakeResult{{ + result: CommandResult{Stdout: []byte("out"), Stderr: []byte("err"), ExitCode: 7}, + err: &exec.ExitError{}, + writeStdout: []byte("out"), + writeStderr: []byte("err"), + }}, + } + rt, err := NewProvider(WithRunner(runner)).Open(context.Background(), "/tmp/x") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + inst := &Instance{runtime: rt.(*Runtime), idOrName: "box-id"} + var stdout bytes.Buffer + var stderr bytes.Buffer + + result, err := inst.Run(context.Background(), sandbox.CommandSpec{ + Name: "sh", + Args: []string{"-lc", "echo out; echo err >&2; exit 7"}, + Stdout: &stdout, + Stderr: &stderr, + }) + if err == nil { + t.Fatal("Run() error = nil, want exit error") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("Run() error = %T, want *ExitError", err) + } + if result.ExitCode != 7 || exitErr.ExitCode != 7 { + t.Fatalf("exit code = result %d error %d, want 7", result.ExitCode, exitErr.ExitCode) + } + if stdout.String() != "out" || stderr.String() != "err" { + t.Fatalf("forwarded output stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestNotFoundErrorsMapToSandboxNotFound(t *testing.T) { + runner := &fakeRunner{ + results: []fakeResult{{ + result: CommandResult{Stderr: []byte("Error: No such container: missing\n"), ExitCode: 1}, + err: &exec.ExitError{}, + }}, + } + rt, err := NewProvider(WithRunner(runner)).Open(context.Background(), "/tmp/x") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + _, err = rt.Get(context.Background(), "missing") + if !sandbox.IsNotFound(err) { + t.Fatalf("Get() error = %v, want sandbox not found", err) + } +} + +func TestNilHandles(t *testing.T) { + ctx := context.Background() + rt := (*Runtime)(nil) + if _, err := rt.Create(ctx, sandbox.CreateSpec{}); err == nil { + t.Fatal("nil runtime Create should fail") + } + if _, err := rt.Get(ctx, "box"); err == nil { + t.Fatal("nil runtime Get should fail") + } + if err := rt.Remove(ctx, "box", sandbox.RemoveOptions{}); err == nil { + t.Fatal("nil runtime Remove should fail") + } + + inst := (*Instance)(nil) + if err := inst.Start(ctx); err == nil { + t.Fatal("nil instance Start should fail") + } + if err := inst.Stop(ctx, sandbox.StopOptions{}); err == nil { + t.Fatal("nil instance Stop should fail") + } + if _, err := inst.Info(ctx); err == nil { + t.Fatal("nil instance Info should fail") + } + if _, err := inst.Run(ctx, sandbox.CommandSpec{Name: "true"}); err == nil { + t.Fatal("nil instance Run should fail") + } +} + +func TestIntegrationCreateStartExecRemove(t *testing.T) { + if os.Getenv("CSGCLAW_DOCKER_CLI_INTEGRATION") != "1" { + t.Skip("set CSGCLAW_DOCKER_CLI_INTEGRATION=1 to run docker CLI integration test") + } + path := strings.TrimSpace(os.Getenv("CSGCLAW_DOCKER_CLI_PATH")) + if path == "" { + path = "docker" + } + + ctx := context.Background() + rt, err := NewProvider(WithPath(path)).Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer rt.Close() + + name := "csgclaw-docker-cli-it" + inst, err := rt.Create(ctx, sandbox.CreateSpec{ + Image: "alpine:latest", + Name: name, + Detach: true, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + defer func() { + _ = rt.Remove(context.Background(), name, sandbox.RemoveOptions{Force: true}) + }() + if _, err := inst.Info(ctx); err != nil { + t.Fatalf("Info() error = %v", err) + } + var stdout bytes.Buffer + result, err := inst.Run(ctx, sandbox.CommandSpec{ + Name: "sh", + Args: []string{"-lc", "echo ok"}, + Stdout: &stdout, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if result.ExitCode != 0 || strings.TrimSpace(stdout.String()) != "ok" { + t.Fatalf("Run() result exit=%d stdout=%q, want exit 0 stdout ok", result.ExitCode, stdout.String()) + } + if err := rt.Remove(ctx, name, sandbox.RemoveOptions{Force: true}); err != nil { + t.Fatalf("Remove() error = %v", err) + } +} + +type fakeRunner struct { + requests []CommandRequest + results []fakeResult +} + +type fakeResult struct { + result CommandResult + err error + writeStdout []byte + writeStderr []byte +} + +func (r *fakeRunner) Run(_ context.Context, req CommandRequest) (CommandResult, error) { + r.requests = append(r.requests, req) + if len(r.results) == 0 { + return CommandResult{}, nil + } + result := r.results[0] + r.results = r.results[1:] + if req.Stdout != nil && len(result.writeStdout) > 0 { + _, _ = req.Stdout.Write(result.writeStdout) + } + if req.Stderr != nil && len(result.writeStderr) > 0 { + _, _ = req.Stderr.Write(result.writeStderr) + } + return result.result, result.err +} diff --git a/internal/sandbox/dockercli/run_args.go b/internal/sandbox/dockercli/run_args.go new file mode 100644 index 00000000..f6f56124 --- /dev/null +++ b/internal/sandbox/dockercli/run_args.go @@ -0,0 +1,59 @@ +package dockercli + +import ( + "fmt" + "sort" + "strings" + + "csgclaw/internal/sandbox" +) + +func runArgs(spec sandbox.CreateSpec) ([]string, error) { + if strings.TrimSpace(spec.Image) == "" { + return nil, fmt.Errorf("invalid sandbox image: image is required") + } + if len(spec.Entrypoint) > 0 { + return nil, fmt.Errorf("unsupported sandbox option: entrypoint") + } + + args := []string{"run"} + if strings.TrimSpace(spec.Name) != "" { + args = append(args, "--name", spec.Name) + } + if spec.Detach { + args = append(args, "--detach") + } + if spec.AutoRemove { + args = append(args, "--rm") + } + + keys := make([]string, 0, len(spec.Env)) + for key := range spec.Env { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("invalid sandbox env: key is required") + } + args = append(args, "-e", key+"="+spec.Env[key]) + } + + for _, mount := range spec.Mounts { + if strings.TrimSpace(mount.HostPath) == "" { + return nil, fmt.Errorf("invalid sandbox mount: host path is required") + } + if strings.TrimSpace(mount.GuestPath) == "" { + return nil, fmt.Errorf("invalid sandbox mount: guest path is required") + } + value := mount.HostPath + ":" + mount.GuestPath + if mount.ReadOnly { + value += ":ro" + } + args = append(args, "-v", value) + } + + args = append(args, spec.Image) + args = append(args, spec.Cmd...) + return args, nil +} diff --git a/internal/sandbox/dockercli/runner.go b/internal/sandbox/dockercli/runner.go new file mode 100644 index 00000000..a2930f35 --- /dev/null +++ b/internal/sandbox/dockercli/runner.go @@ -0,0 +1,76 @@ +package dockercli + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os/exec" +) + +type Runner interface { + Run(ctx context.Context, req CommandRequest) (CommandResult, error) +} + +type CommandRequest struct { + Path string + Args []string + Env []string + Stdout io.Writer + Stderr io.Writer +} + +type CommandResult struct { + Stdout []byte + Stderr []byte + ExitCode int +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, req CommandRequest) (CommandResult, error) { + if req.Path == "" { + return CommandResult{ExitCode: -1}, fmt.Errorf("docker path is required") + } + + cmd := exec.CommandContext(ctx, req.Path, req.Args...) + if len(req.Env) > 0 { + cmd.Env = append(cmd.Environ(), req.Env...) + } + slog.DebugContext(ctx, fmt.Sprintf("running docker command: %s", cmd.String())) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + if req.Stdout != nil { + cmd.Stdout = io.MultiWriter(&stdout, req.Stdout) + } + cmd.Stderr = &stderr + if req.Stderr != nil { + cmd.Stderr = io.MultiWriter(&stderr, req.Stderr) + } + + err := cmd.Run() + result := CommandResult{ + Stdout: stdout.Bytes(), + Stderr: stderr.Bytes(), + ExitCode: 0, + } + if cmd.ProcessState != nil { + result.ExitCode = cmd.ProcessState.ExitCode() + } + if ctxErr := ctx.Err(); ctxErr != nil { + if result.ExitCode == 0 { + result.ExitCode = -1 + } + return result, fmt.Errorf("docker command canceled: %w", ctxErr) + } + if err != nil { + if result.ExitCode == 0 { + result.ExitCode = -1 + } + return result, err + } + return result, nil +} diff --git a/internal/sandboxproviders/docker_provider.go b/internal/sandboxproviders/docker_provider.go new file mode 100644 index 00000000..2bfd5d2e --- /dev/null +++ b/internal/sandboxproviders/docker_provider.go @@ -0,0 +1,13 @@ +package sandboxproviders + +import ( + "csgclaw/internal/agent" + "csgclaw/internal/config" + "csgclaw/internal/sandbox/dockercli" +) + +func init() { + Register(config.DockerProvider, func(cfg config.SandboxConfig) (agent.ServiceOption, error) { + return agent.WithSandboxProvider(dockercli.NewProvider(dockercli.WithPath(cfg.EffectiveDockerCLIPath()))), nil + }) +} diff --git a/internal/sandboxproviders/docker_provider_test.go b/internal/sandboxproviders/docker_provider_test.go new file mode 100644 index 00000000..17ceab69 --- /dev/null +++ b/internal/sandboxproviders/docker_provider_test.go @@ -0,0 +1,80 @@ +package sandboxproviders + +import ( + "reflect" + "testing" + "unsafe" + + "csgclaw/internal/agent" + "csgclaw/internal/config" + "csgclaw/internal/sandbox" + "csgclaw/internal/sandbox/dockercli" +) + +func TestDockerProviderFactoryUsesConfiguredPath(t *testing.T) { + factory, ok := factories[config.DockerProvider] + if !ok { + t.Fatalf("docker provider factory not registered") + } + + opt, err := factory(config.SandboxConfig{ + Provider: config.DockerProvider, + DockerCLIPath: "/opt/homebrew/bin/docker", + }) + if err != nil { + t.Fatalf("factory() error = %v", err) + } + + provider := sandboxProviderFromOption(t, opt) + dockerProvider, ok := provider.(dockercli.Provider) + if !ok { + t.Fatalf("provider = %T, want dockercli.Provider", provider) + } + if got, want := dockerProviderPath(t, dockerProvider), "/opt/homebrew/bin/docker"; got != want { + t.Fatalf("provider path = %q, want %q", got, want) + } + if got, want := provider.Name(), config.DockerProvider; got != want { + t.Fatalf("provider.Name() = %q, want %q", got, want) + } +} + +func TestDockerProviderFactoryDefaultsPath(t *testing.T) { + factory := factories[config.DockerProvider] + opt, err := factory(config.SandboxConfig{Provider: config.DockerProvider}) + if err != nil { + t.Fatalf("factory() error = %v", err) + } + provider := sandboxProviderFromOption(t, opt) + dockerProvider := provider.(dockercli.Provider) + if got, want := dockerProviderPath(t, dockerProvider), "docker"; got != want { + t.Fatalf("provider path = %q, want %q", got, want) + } +} + +func dockerProviderPath(t *testing.T, provider dockercli.Provider) string { + t.Helper() + value := reflect.ValueOf(&provider).Elem().FieldByName("path") + return reflect.NewAt(value.Type(), unsafe.Pointer(value.UnsafeAddr())).Elem().String() +} + +func TestDockerServiceOptionWiresProvider(t *testing.T) { + opt, err := ServiceOptions(config.SandboxConfig{ + Provider: config.DockerProvider, + HomeDirName: "docker-runtime", + }) + if err != nil { + t.Fatalf("ServiceOptions() error = %v", err) + } + if len(opt) != 2 { + t.Fatalf("len(opt) = %d, want 2", len(opt)) + } + svc := &agent.Service{} + if err := opt[0](svc); err != nil { + t.Fatalf("sandbox option error = %v", err) + } + field := reflect.ValueOf(svc).Elem().FieldByName("sandbox") + got := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface().(sandbox.Provider) + if _, ok := got.(dockercli.Provider); !ok { + t.Fatalf("sandbox provider = %T, want dockercli.Provider", got) + } +} diff --git a/internal/sandboxproviders/registry_test.go b/internal/sandboxproviders/registry_test.go index 4fb60e00..1592cb64 100644 --- a/internal/sandboxproviders/registry_test.go +++ b/internal/sandboxproviders/registry_test.go @@ -12,7 +12,7 @@ func TestSupportedProvidersAlwaysIncludeBoxLiteCLI(t *testing.T) { if !slices.Contains(supported, config.BoxLiteCLIProvider) { t.Fatalf("SupportedProviders() = %v, want %q to be compiled in", supported, config.BoxLiteCLIProvider) } - if len(supported) != 2 { + if len(supported) != 3 { t.Fatalf("SupportedProviders() = %v, want exactly the compiled providers", supported) } } @@ -23,3 +23,10 @@ func TestSupportedProvidersIncludeCSGHubWithoutBuildTag(t *testing.T) { t.Fatalf("SupportedProviders() = %v, want %q to be compiled in", supported, config.CSGHubProvider) } } + +func TestSupportedProvidersIncludeDocker(t *testing.T) { + supported := SupportedProviders() + if !slices.Contains(supported, config.DockerProvider) { + t.Fatalf("SupportedProviders() = %v, want %q to be compiled in", supported, config.DockerProvider) + } +} diff --git a/web/static/app.js b/web/static/app.js index 3b3b6745..f960ba22 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -48,6 +48,15 @@ function safeParseEventData(raw) { } } +// API returns Version from git describe (e.g. "v0.2.1-5-gabc-dirty") or "dev"; avoid "vv" in the UI. +function formatSidebarVersionLabel(version) { + const raw = typeof version === "string" ? version.trim() : ""; + if (!raw) { + return "csgclaw dev"; + } + return raw.startsWith("v") ? `csgclaw ${raw}` : `csgclaw v${raw}`; +} + function subscribeIMEvents(onEvent) { if (typeof window.SharedWorker === "function") { try { @@ -2568,7 +2577,7 @@ function App() {
- ${`csgclaw v${appVersion}`} + ${formatSidebarVersionLabel(appVersion)} ${upgradeStatus?.update_available || upgradeBusy || upgradeStatus?.upgrading || upgradePhase === "done" || upgradePhase === "error" ? html`