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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ CSGClaw gives you one **Manager** and a set of specialized **Workers**, so inste

**Workers** — role-specific executors (frontend, backend, testing, docs, research…). Specialization keeps context clean and reduces role confusion.

**Sandbox** — Worker execution is isolated by the configured sandbox provider. Official bundles default to **BoxLite** when they include a bundled `boxlite`, and default to **Docker** otherwise.
**Sandbox** — Worker execution is isolated by the configured sandbox provider. CSGClaw defaults to **Docker** when the provider is unset, and BoxLite remains available through explicit configuration.

**Interface** — WebUI out of the box; Feishu, WeChat, Matrix, and other channels available as integrations.

Expand Down Expand Up @@ -119,8 +119,8 @@ The key isn't that multiple agents exist — it's that **their collaboration is
**PicoClaw by default, extensible by design.**
CSGClaw uses PicoClaw as its lightweight default Agent Runtime, keeping the Manager fast to start and cheap to run. The runtime remains pluggable, so deployments can integrate alternatives such as OpenClaw when needed.

**BoxLite by default, sandbox-agnostic by design.**
Isolation is non-negotiable. BoxLite is the preferred default because it is lightweight, fast to start, and convenient for local-first workflows. When BoxLite is unavailable, Docker is fully supported as a mature cross-platform fallback, and teams can switch sandbox providers explicitly when their environment requires it.
**Docker by default, sandbox-agnostic by design.**
Isolation is non-negotiable. Docker is the default sandbox provider for broad cross-platform availability, while BoxLite remains supported for environments that prefer its lightweight local runtime. Teams can switch sandbox providers explicitly when their environment requires it.

**WebUI first, channel-agnostic by design.**
Many multi-agent systems are tightly coupled to one messaging protocol. CSGClaw ships with a built-in WebUI so you can start immediately, while keeping other channels (Feishu, WeChat, Matrix) as optional integrations — not assumptions.
Expand Down
6 changes: 3 additions & 3 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ CSGClaw 提供一位 Manager 和一组可分工的 Worker,让你通过统一

**Worker** — 面向具体职责的执行单元(前端、后端、测试、文档、调研……)。角色分工让上下文更干净,协作更容易组织。

**Sandbox** — Worker 执行环境由配置的 sandbox provider 隔离。官方 bundle 在内置 `boxlite` 时默认使用 **BoxLite**,未内置时默认使用 **Docker**。
**Sandbox** — Worker 执行环境由配置的 sandbox provider 隔离。未显式配置 provider 时,CSGClaw 默认使用 **Docker**;BoxLite 仍可通过显式配置启用

**Interface** — 默认提供 WebUI;飞书、微信、Matrix 等通道可按需接入。

Expand Down Expand Up @@ -119,8 +119,8 @@ Manager:已记录,Bob 先修接口,字段确认后 Alice 再更新展示
**默认选择 PicoClaw,同时保留运行时扩展能力。**
CSGClaw 默认使用 PicoClaw 作为轻量化 Agent Runtime,让 Manager 启动更快、占用更低。运行时仍可插拔,需要时可以集成 OpenClaw 等其他实现。

**默认选择 BoxLite,设计上不绑定单一 Sandbox。**
隔离不是可选项。BoxLite 之所以作为默认方案,是因为它轻量、启动快、对本地优先场景更方便;当 BoxLite 不可用时,CSGClaw 也完整支持 Docker 作为更成熟的跨平台回退方案,并允许按环境需要显式切换不同的 sandbox provider。
**默认选择 Docker,设计上不绑定单一 Sandbox。**
隔离不是可选项。Docker 作为默认 sandbox provider 具备更广泛的跨平台可用性;BoxLite 仍然适合偏好轻量本地运行时的环境,并允许按需要显式切换不同的 sandbox provider。

**默认 WebUI,不绑定单一通道。**
很多多智能体系统把某种消息协议当作唯一入口。CSGClaw 自带 WebUI,让你可以立即开始;飞书、微信、Matrix 等通道作为可选集成存在,而不是预设前提。
Expand Down
10 changes: 3 additions & 7 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,9 @@ Official bundles use one of these layouts:
- `csgclaw/bin/csgclaw` plus `csgclaw/bin/boxlite`
- `csgclaw/bin/csgclaw` only

If `[sandbox].provider` is omitted or empty, CSGClaw chooses the default dynamically from the installed bundle:
If `[sandbox].provider` is omitted or empty, CSGClaw defaults to `docker`.

- bundled `boxlite` present: default to `boxlite`
- bundled `boxlite` absent: default to `docker`

That means a generated config can keep the provider empty to follow the bundle default:
That means a generated config can keep the provider empty to follow the default:

```toml
[sandbox]
Expand Down Expand Up @@ -217,8 +214,7 @@ docker_cli_path = "/usr/local/bin/docker"

Current platform expectations:

- Linux amd64, Linux arm64, and macOS arm64 official bundles include `boxlite`, so an empty provider resolves to `boxlite`.
- macOS amd64 and Windows amd64 official bundles do not include `boxlite`, so an empty provider resolves to `docker`.
- An empty provider resolves to `docker`.
- Windows users should have Docker installed and reachable on `PATH`, or set `[sandbox].docker_cli_path` explicitly.

## Hub Configuration
Expand Down
10 changes: 3 additions & 7 deletions docs/config.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,9 @@ CSGClaw 通过配置的 sandbox provider 隔离 Worker 执行环境。当前内
- `csgclaw/bin/csgclaw` 加 `csgclaw/bin/boxlite`
- 只有 `csgclaw/bin/csgclaw`

如果 `[sandbox].provider` 省略或为空,CSGClaw 会根据当前安装 bundle 动态选择默认值:
如果 `[sandbox].provider` 省略或为空,CSGClaw 默认使用 `docker`。

- 内置了 `boxlite`:默认用 `boxlite`
- 没有内置 `boxlite`:默认用 `docker`

这也是为什么生成出来的配置文件可以把 provider 留空,直接跟随 bundle 默认值:
这也是为什么生成出来的配置文件可以把 provider 留空,直接跟随默认值:

```toml
[sandbox]
Expand Down Expand Up @@ -217,8 +214,7 @@ docker_cli_path = "/usr/local/bin/docker"

当前平台上的默认行为如下:

- Linux amd64、Linux arm64、macOS arm64 的官方 bundle 内置 `boxlite`,因此 provider 留空时会解析成 `boxlite`。
- macOS amd64 和 Windows amd64 的官方 bundle 不内置 `boxlite`,因此 provider 留空时会解析成 `docker`。
- provider 留空时会解析成 `docker`。
- Windows 用户需要确保本地 Docker 可用并且能从 `PATH` 找到;如果路径特殊,可以显式设置 `[sandbox].docker_cli_path`。

## Hub 配置
Expand Down
40 changes: 4 additions & 36 deletions internal/agent/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"

"csgclaw/internal/codexcli"
Expand Down Expand Up @@ -53,8 +52,6 @@ var locateCodexCLI = func() (string, error) {
var defaultSandboxProvider sandbox.Provider = unconfiguredSandboxProvider{}
var testDefaultServiceOption ServiceOption

const removeAllRetryAttempts = 12

var errDefaultTemplateRuntimeMismatch = errors.New("default template runtime mismatch")

type unconfiguredSandboxProvider struct{}
Expand Down Expand Up @@ -770,7 +767,7 @@ func (s *Service) cleanupBootstrapManagerForRecreate(ctx context.Context, rt san
if cleanupSkills != nil {
defer cleanupSkills()
}
if err := removeAllWithRetry(managerHome); err != nil {
if err := removeAll(managerHome); err != nil {
return nil, fmt.Errorf("remove bootstrap manager home: %w", err)
}
if restoreSkills != nil {
Expand Down Expand Up @@ -1501,7 +1498,7 @@ func (s *Service) Delete(ctx context.Context, id string) error {
if err != nil {
return err
}
if err := removeAllWithRetry(agentHome); err != nil {
if err := removeAll(agentHome); err != nil {
return fmt.Errorf("remove agent home: %w", err)
}

Expand Down Expand Up @@ -1684,37 +1681,8 @@ func sandboxNameForAgentID(agentID string) string {
return agentruntime.SandboxNameForAgentID(canonicalAgentID(agentID))
}

func removeAllWithRetry(path string) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("path is required")
}

var lastErr error
for attempt := 0; attempt < removeAllRetryAttempts; attempt++ {
if err := osRemoveAll(path); err == nil || os.IsNotExist(err) {
return nil
} else {
lastErr = err
// Defensive retry: BoxLite runtime cleanup can briefly lag behind Close(),
// so agent home removal may transiently fail with "directory not empty".
// If runtime shutdown semantics improve later, prefer fixing that timing
// instead of relying on retries here.
if !isRetryableRemoveAllError(err) || attempt == removeAllRetryAttempts-1 {
return err
}
}
time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond)
}
return lastErr
}

func isRetryableRemoveAllError(err error) bool {
if errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EACCES) {
return true
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "directory not empty") || strings.Contains(lower, "permission denied")
func removeAll(path string) error {
return osRemoveAll(path)
}

func (s *Service) List() []Agent {
Expand Down
2 changes: 2 additions & 0 deletions internal/agent/service_profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,8 @@ func (s *Service) recreate(ctx context.Context, id string, imageFor func(context
}
}

s.stopLifecycleAgent(got.ID)

if testCreateGatewayBoxHook != nil {
rt, err := s.ensureRuntime(got.ID)
if err != nil {
Expand Down
29 changes: 16 additions & 13 deletions internal/agent/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"

Expand Down Expand Up @@ -363,16 +362,19 @@ func (f *fakeInfoInstance) Info(context.Context) (sandbox.Info, error) {
type fakeLifecycleObserver struct {
ensureCalls []Agent
stopCalls []string
events []string
ensureErr error
}

func (f *fakeLifecycleObserver) EnsureAgent(_ context.Context, a Agent) error {
f.ensureCalls = append(f.ensureCalls, a)
f.events = append(f.events, "ensure:"+a.ID)
return f.ensureErr
}

func (f *fakeLifecycleObserver) StopAgent(agentID string) {
f.stopCalls = append(f.stopCalls, agentID)
f.events = append(f.events, "stop:"+agentID)
}

type cancelOnWrite struct {
Expand Down Expand Up @@ -1925,9 +1927,15 @@ func TestRecreateTriggersLifecycleObserver(t *testing.T) {
if got.Avatar != "avatar/cartoon-7.png" {
t.Fatalf("Recreate().Avatar = %q, want %q", got.Avatar, "avatar/cartoon-7.png")
}
if len(observer.stopCalls) != 1 || observer.stopCalls[0] != "agent-alice" {
t.Fatalf("StopAgent() calls = %+v, want one call for agent-alice", observer.stopCalls)
}
if len(observer.ensureCalls) != 1 || observer.ensureCalls[0].ID != "agent-alice" {
t.Fatalf("EnsureAgent() calls = %+v, want one call for agent-alice", observer.ensureCalls)
}
if got, want := strings.Join(observer.events, ","), "stop:agent-alice,ensure:agent-alice"; got != want {
t.Fatalf("lifecycle events = %q, want %q", got, want)
}
}

func TestRecreateProvisionsRuntimeBeforeNew(t *testing.T) {
Expand Down Expand Up @@ -3910,7 +3918,7 @@ func TestDeleteRemovesRuntimeCacheByHomeDir(t *testing.T) {
}
}

func TestDeleteRetriesAgentHomeRemovalOnDirectoryNotEmpty(t *testing.T) {
func TestDeleteReturnsAgentHomeRemovalError(t *testing.T) {
rt := &fakeRuntime{}
SetTestHooks(func(_ *Service, _ string) (sandbox.Runtime, error) { return rt, nil }, nil)
defer ResetTestHooks()
Expand Down Expand Up @@ -3952,25 +3960,20 @@ func TestDeleteRetriesAgentHomeRemovalOnDirectoryNotEmpty(t *testing.T) {
osRemoveAll = func(path string) error {
removeCalls++
if path == agentHome && removeCalls == 1 {
return &os.PathError{Op: "unlinkat", Path: filepath.Join(agentHome, "boxlite", "images"), Err: syscall.ENOTEMPTY}
}
if path == agentHome && removeCalls == 2 {
return &os.PathError{Op: "unlinkat", Path: filepath.Join(agentHome, "boxlite", "images"), Err: syscall.EACCES}
return &os.PathError{Op: "unlinkat", Path: filepath.Join(agentHome, ".codex", "home", "logs_2.sqlite"), Err: errors.New("The process cannot access the file because it is being used by another process.")}
}
return os.RemoveAll(path)
}
defer func() {
osRemoveAll = origRemoveAll
}()

if err := svc.Delete(context.Background(), "u-alice"); err != nil {
t.Fatalf("Delete() error = %v", err)
err = svc.Delete(context.Background(), "u-alice")
if err == nil || !strings.Contains(err.Error(), "being used by another process") {
t.Fatalf("Delete() error = %v, want locked file error", err)
}
if removeCalls < 3 {
t.Fatalf("osRemoveAll() calls = %d, want at least 3", removeCalls)
}
if _, err := os.Stat(agentHome); !os.IsNotExist(err) {
t.Fatalf("agent home still exists after delete: err=%v", err)
if removeCalls != 1 {
t.Fatalf("osRemoveAll() calls = %d, want 1", removeCalls)
}
}

Expand Down
24 changes: 3 additions & 21 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,6 @@ func TestDefaultTasksDirUsesDomainSubdirectory(t *testing.T) {
}

func TestLoadUsesDefaultBootstrapTemplatesWhenSectionIsEmpty(t *testing.T) {
restore := stubSandboxProviderExecutablePath(t, filepath.Join(t.TempDir(), "bin", "csgclaw"))
defer restore()

dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := `[server]
Expand Down Expand Up @@ -720,9 +717,6 @@ models = ["minimax-m2.7"]
}

func TestLoadRejectsRemovedLegacyBoxLiteProvider(t *testing.T) {
restore := stubSandboxProviderExecutablePath(t, filepath.Join(t.TempDir(), "bin", "csgclaw"))
defer restore()

dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := `[server]
Expand Down Expand Up @@ -753,9 +747,6 @@ models = ["minimax-m2.7"]
}

func TestLoadReadsDockerSandboxConfig(t *testing.T) {
restore := stubSandboxProviderExecutablePath(t, filepath.Join(t.TempDir(), "bin", "csgclaw"))
defer restore()

dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := `[server]
Expand Down Expand Up @@ -800,7 +791,7 @@ func TestSandboxEffectiveDockerCLIPathDefault(t *testing.T) {
}
}

func TestLoadUsesBundledBoxLiteWhenSandboxProviderUnset(t *testing.T) {
func TestLoadUsesDockerWhenSandboxProviderUnsetEvenWithBundledBoxLite(t *testing.T) {
dir := t.TempDir()
binDir := filepath.Join(dir, "bundle", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
Expand All @@ -809,9 +800,6 @@ func TestLoadUsesBundledBoxLiteWhenSandboxProviderUnset(t *testing.T) {
if err := os.WriteFile(filepath.Join(binDir, "boxlite"), []byte(""), 0o755); err != nil {
t.Fatalf("WriteFile(boxlite) error = %v", err)
}
restore := stubSandboxProviderExecutablePath(t, filepath.Join(binDir, "csgclaw"))
defer restore()

path := filepath.Join(dir, "config.toml")
content := `[server]
listen_addr = "127.0.0.1:18080"
Expand All @@ -832,7 +820,7 @@ models = ["minimax-m2.7"]
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got, want := cfg.Sandbox.Provider, BoxLiteProvider; got != want {
if got, want := cfg.Sandbox.Provider, DockerProvider; got != want {
t.Fatalf("cfg.Sandbox.Provider = %q, want %q", got, want)
}
}
Expand All @@ -846,9 +834,6 @@ func TestLoadKeepsExplicitDockerProviderWhenBundledBoxLiteIsPresent(t *testing.T
if err := os.WriteFile(filepath.Join(binDir, "boxlite"), []byte(""), 0o755); err != nil {
t.Fatalf("WriteFile(boxlite) error = %v", err)
}
restore := stubSandboxProviderExecutablePath(t, filepath.Join(binDir, "csgclaw"))
defer restore()

path := filepath.Join(dir, "config.toml")
content := `[server]
listen_addr = "127.0.0.1:18080"
Expand Down Expand Up @@ -1503,10 +1488,7 @@ models = ["gpt-test"]
}
}

func TestSaveKeepsSandboxProviderUnsetWhenItUsesDynamicDefault(t *testing.T) {
restore := stubSandboxProviderExecutablePath(t, filepath.Join(t.TempDir(), "bin", "csgclaw"))
defer restore()

func TestSaveKeepsSandboxProviderUnsetWhenItUsesDefault(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := `[server]
Expand Down
Loading
Loading