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: 3 additions & 3 deletions cli/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
21 changes: 21 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ type SandboxConfig struct {
HomeDirName string
StoragePath string
DebianRegistriesOverride []string
DockerCLIPath string
}

func (c SandboxConfig) Resolved() SandboxConfig {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
48 changes: 48 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions internal/config/model_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
64 changes: 64 additions & 0 deletions internal/sandbox/dockercli/errors.go
Original file line number Diff line number Diff line change
@@ -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")
}
31 changes: 31 additions & 0 deletions internal/sandbox/dockercli/options.go
Original file line number Diff line number Diff line change
@@ -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
}
77 changes: 77 additions & 0 deletions internal/sandbox/dockercli/parse.go
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 39 additions & 0 deletions internal/sandbox/dockercli/parse_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading