diff --git a/Makefile b/Makefile index 29e2fc964..5cf36dcc7 100644 --- a/Makefile +++ b/Makefile @@ -96,6 +96,9 @@ build-all: generate GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) @echo "All builds complete" +build-docker-images: + docker build -t picoclaw-sandbox:bookworm-slim . + ## install: Install picoclaw to system and copy builtin skills install: build @echo "Installing $(BINARY_NAME)..." diff --git a/pkg/agent/sandbox/container.go b/pkg/agent/sandbox/container.go index 4d221e5d4..0e1ec4ab1 100644 --- a/pkg/agent/sandbox/container.go +++ b/pkg/agent/sandbox/container.go @@ -73,7 +73,7 @@ const defaultSandboxRegistryFile = "containers.json" // NewContainerSandbox creates a container sandbox with normalized defaults and precomputed config hash. func NewContainerSandbox(cfg ContainerSandboxConfig) *ContainerSandbox { if strings.TrimSpace(cfg.Image) == "" { - cfg.Image = "debian:bookworm-slim" + cfg.Image = "picoclaw-sandbox:bookworm-slim" } if strings.TrimSpace(cfg.ContainerPrefix) == "" { cfg.ContainerPrefix = "picoclaw-sandbox-" @@ -204,7 +204,7 @@ func (c *ContainerSandbox) ExecStream( execCtx := ctx cancel := func() {} if req.TimeoutMs > 0 { - execCtx, cancel = context.WithTimeout(ctx, durationMs(req.TimeoutMs)) + execCtx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutMs)*time.Millisecond) } else if _, hasDeadline := ctx.Deadline(); !hasDeadline { execCtx, cancel = context.WithTimeout(ctx, 30*time.Second) } @@ -334,6 +334,7 @@ func (c *ContainerSandbox) createAndStart(ctx context.Context) error { cfg := &container.Config{ Image: c.cfg.Image, Cmd: []string{"sleep", "infinity"}, + Entrypoint: []string{}, WorkingDir: c.cfg.Workdir, User: strings.TrimSpace(c.cfg.User), Env: c.containerEnv(), @@ -396,7 +397,7 @@ func (c *ContainerSandbox) registryPath() string { } func (c *ContainerSandbox) sandboxStateDir() string { - return filepath.Join(resolvePicoClawHomeDir(), "sandbox") + return filepath.Join(resolvePicoClawHomeDir(), "sandboxes") } func resolvePicoClawHomeDir() string { @@ -890,6 +891,8 @@ func computeContainerConfigHash(cfg ContainerSandboxConfig) string { DNS []string `json:"dns"` ExtraHosts []string `json:"extra_hosts"` Binds []string `json:"binds"` + Cmd []string `json:"cmd"` + Entrypoint []string `json:"entrypoint"` }{ Image: strings.TrimSpace(cfg.Image), ContainerPrefix: strings.TrimSpace(cfg.ContainerPrefix), @@ -915,6 +918,8 @@ func computeContainerConfigHash(cfg ContainerSandboxConfig) string { DNS: cfg.DNS, ExtraHosts: cfg.ExtraHosts, Binds: cfg.Binds, + Cmd: []string{"sleep", "infinity"}, + Entrypoint: []string{}, } raw, _ := json.Marshal(payload) return computeConfigHash(string(raw)) diff --git a/pkg/agent/sandbox/container_test.go b/pkg/agent/sandbox/container_test.go index a7cd315cb..fd867bbb1 100644 --- a/pkg/agent/sandbox/container_test.go +++ b/pkg/agent/sandbox/container_test.go @@ -270,7 +270,7 @@ func TestContainerSandbox_RegistryPath_UsesSandboxStateDir(t *testing.T) { Workspace: "/tmp/ws", WorkspaceRoot: "/tmp/sbx", }) - want := filepath.Join(home, ".picoclaw", "sandbox", "containers.json") + want := filepath.Join(home, ".picoclaw", "sandboxes", "containers.json") if got := sb.registryPath(); got != want { t.Fatalf("registryPath = %q, want %q", got, want) } @@ -280,7 +280,7 @@ func TestContainerSandbox_RegistryPath_UsesPicoClawHomeOverride(t *testing.T) { picoHome := t.TempDir() t.Setenv("PICOCLAW_HOME", picoHome) sb := NewContainerSandbox(ContainerSandboxConfig{}) - want := filepath.Join(picoHome, "sandbox", "containers.json") + want := filepath.Join(picoHome, "sandboxes", "containers.json") if got := sb.registryPath(); got != want { t.Fatalf("registryPath = %q, want %q", got, want) } diff --git a/pkg/agent/sandbox/host.go b/pkg/agent/sandbox/host.go index c12c05ce9..cb176c2ce 100644 --- a/pkg/agent/sandbox/host.go +++ b/pkg/agent/sandbox/host.go @@ -11,6 +11,7 @@ import ( "runtime" "strings" "sync" + "time" ) type HostSandbox struct { @@ -75,7 +76,7 @@ func (h *HostSandbox) ExecStream( cmdCtx := ctx cancel := func() {} if req.TimeoutMs > 0 { - cmdCtx, cancel = context.WithTimeout(ctx, durationMs(req.TimeoutMs)) + cmdCtx, cancel = context.WithTimeout(ctx, time.Duration(req.TimeoutMs)*time.Millisecond) } defer cancel() @@ -165,8 +166,8 @@ func (h *HostSandbox) ExecStream( exitCode := 0 if waitErr != nil { - var ee *exec.ExitError - if ok := asExitError(waitErr, &ee); ok { + ee, ok := waitErr.(*exec.ExitError) + if ok { exitCode = ee.ExitCode() } else { return nil, waitErr diff --git a/pkg/agent/sandbox/host_test.go b/pkg/agent/sandbox/host_test.go index 96d6cd4dc..b00e470b5 100644 --- a/pkg/agent/sandbox/host_test.go +++ b/pkg/agent/sandbox/host_test.go @@ -121,13 +121,6 @@ func TestUnavailableSandboxAndUtilHelpers(t *testing.T) { if err := sb.Fs().WriteFile(context.Background(), "a.txt", []byte("x"), true); err == nil { t.Fatal("expected Fs().WriteFile error") } - - if got := durationMs(123).Milliseconds(); got != 123 { - t.Fatalf("durationMs() got %d, want 123", got) - } - if asExitError(errors.New("x"), nil) { - t.Fatal("asExitError should be false for non-exit errors") - } } func TestHostFS_ReadFileWriteFile_Restricted(t *testing.T) { diff --git a/pkg/agent/sandbox/manager.go b/pkg/agent/sandbox/manager.go index ed4b83f54..1eb95aae4 100644 --- a/pkg/agent/sandbox/manager.go +++ b/pkg/agent/sandbox/manager.go @@ -32,7 +32,7 @@ func NewFromConfigWithAgent(workspace string, restrict bool, cfg *config.Config, scope := "agent" workspaceAccess := "none" workspaceRoot := "~/.picoclaw/sandboxes" - image := "debian:bookworm-slim" + image := "picoclaw-sandbox:bookworm-slim" containerPrefix := "picoclaw-sandbox-" pruneIdleHours := 24 pruneMaxAgeDays := 7 diff --git a/pkg/agent/sandbox/runtime_context.go b/pkg/agent/sandbox/runtime_context.go deleted file mode 100644 index c163aff5a..000000000 --- a/pkg/agent/sandbox/runtime_context.go +++ /dev/null @@ -1,35 +0,0 @@ -package sandbox - -import "context" - -type sessionContextKey struct{} - -// WithSessionKey returns a derived context carrying the current routing session key. -func WithSessionKey(ctx context.Context, sessionKey string) context.Context { - return context.WithValue(ctx, sessionContextKey{}, sessionKey) -} - -// SessionKeyFromContext returns the session key attached by WithSessionKey. -func SessionKeyFromContext(ctx context.Context) string { - if ctx == nil { - return "" - } - v, _ := ctx.Value(sessionContextKey{}).(string) - return v -} - -type sandboxContextKey struct{} - -// WithSandbox returns a derived context carrying the current sandbox instance. -func WithSandbox(ctx context.Context, sb Sandbox) context.Context { - return context.WithValue(ctx, sandboxContextKey{}, sb) -} - -// SandboxFromContext returns the sandbox instance attached by WithSandbox. -func SandboxFromContext(ctx context.Context) Sandbox { - if ctx == nil { - return nil - } - v, _ := ctx.Value(sandboxContextKey{}).(Sandbox) - return v -} diff --git a/pkg/agent/sandbox/sandbox.go b/pkg/agent/sandbox/sandbox.go index d8076987e..d1021bd8b 100644 --- a/pkg/agent/sandbox/sandbox.go +++ b/pkg/agent/sandbox/sandbox.go @@ -74,6 +74,38 @@ type ExecEvent struct { ExitCode int } +type sessionContextKey struct{} + +// WithSessionKey returns a derived context carrying the current routing session key. +func WithSessionKey(ctx context.Context, sessionKey string) context.Context { + return context.WithValue(ctx, sessionContextKey{}, sessionKey) +} + +// SessionKeyFromContext returns the session key attached by WithSessionKey. +func SessionKeyFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + v, _ := ctx.Value(sessionContextKey{}).(string) + return v +} + +type sandboxContextKey struct{} + +// WithSandbox returns a derived context carrying the current sandbox instance. +func WithSandbox(ctx context.Context, sb Sandbox) context.Context { + return context.WithValue(ctx, sandboxContextKey{}, sb) +} + +// SandboxFromContext returns the sandbox instance attached by WithSandbox. +func SandboxFromContext(ctx context.Context) Sandbox { + if ctx == nil { + return nil + } + v, _ := ctx.Value(sandboxContextKey{}).(Sandbox) + return v +} + // FsBridge abstracts sandbox-scoped file I/O. type FsBridge interface { // ReadFile reads a file from sandbox-visible filesystem. diff --git a/pkg/agent/sandbox/util.go b/pkg/agent/sandbox/util.go deleted file mode 100644 index 98adbc290..000000000 --- a/pkg/agent/sandbox/util.go +++ /dev/null @@ -1,18 +0,0 @@ -package sandbox - -import ( - "os/exec" - "time" -) - -func durationMs(ms int64) time.Duration { - return time.Duration(ms) * time.Millisecond -} - -func asExitError(err error, target **exec.ExitError) bool { - ee, ok := err.(*exec.ExitError) - if ok { - *target = ee - } - return ok -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 94feb93b9..40e5d17bf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -177,7 +177,7 @@ type AgentDefaults struct { MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - Sandbox AgentSandboxConfig `json:"sandbox"` + Sandbox AgentSandboxConfig `json:"sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX"` } type ChannelsConfig struct { @@ -508,22 +508,22 @@ type AgentSandboxDockerConfig struct { ContainerPrefix string `json:"container_prefix" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_CONTAINER_PREFIX"` Workdir string `json:"workdir" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_WORKDIR"` ReadOnlyRoot bool `json:"read_only_root" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_READ_ONLY_ROOT"` - Tmpfs []string `json:"tmpfs"` + Tmpfs []string `json:"tmpfs" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_TMPFS"` Network string `json:"network" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_NETWORK"` User string `json:"user" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_USER"` - CapDrop []string `json:"cap_drop"` - Env map[string]string `json:"env"` + CapDrop []string `json:"cap_drop" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_CAP_DROP"` + Env map[string]string `json:"env" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_ENV"` SetupCommand string `json:"setup_command" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_SETUP_COMMAND"` PidsLimit int64 `json:"pids_limit" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_PIDS_LIMIT"` Memory string `json:"memory" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_MEMORY"` MemorySwap string `json:"memory_swap" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_MEMORY_SWAP"` Cpus float64 `json:"cpus" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_CPUS"` - Ulimits map[string]AgentSandboxDockerUlimitValue `json:"ulimits"` + Ulimits map[string]AgentSandboxDockerUlimitValue `json:"ulimits" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_ULIMITS"` SeccompProfile string `json:"seccomp_profile" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_SECCOMP_PROFILE"` ApparmorProfile string `json:"apparmor_profile" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_APPARMOR_PROFILE"` - DNS []string `json:"dns"` - ExtraHosts []string `json:"extra_hosts"` - Binds []string `json:"binds"` + DNS []string `json:"dns" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_DNS"` + ExtraHosts []string `json:"extra_hosts" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_EXTRA_HOSTS"` + Binds []string `json:"binds" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER_BINDS"` } type AgentSandboxConfig struct { @@ -531,17 +531,17 @@ type AgentSandboxConfig struct { Scope string `json:"scope" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_SCOPE"` WorkspaceAccess string `json:"workspace_access" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_WORKSPACE_ACCESS"` WorkspaceRoot string `json:"workspace_root" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_WORKSPACE_ROOT"` - Docker AgentSandboxDockerConfig `json:"docker"` - Prune AgentSandboxPruneConfig `json:"prune"` + Docker AgentSandboxDockerConfig `json:"docker" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_DOCKER"` + Prune AgentSandboxPruneConfig `json:"prune" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX_PRUNE"` } type SandboxToolPolicyConfig struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` + Allow []string `json:"allow" env:"PICOCLAW_TOOLS_SANDBOX_TOOLS_ALLOW"` + Deny []string `json:"deny" env:"PICOCLAW_TOOLS_SANDBOX_TOOLS_DENY"` } type SandboxToolsConfig struct { - Tools SandboxToolPolicyConfig `json:"tools"` + Tools SandboxToolPolicyConfig `json:"tools" env:"PICOCLAW_TOOLS_SANDBOX_TOOLS"` } type ToolsConfig struct { @@ -549,7 +549,7 @@ type ToolsConfig struct { Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` Skills SkillsToolsConfig `json:"skills"` - Sandbox SandboxToolsConfig `json:"sandbox"` + Sandbox SandboxToolsConfig `json:"sandbox" env:"PICOCLAW_TOOLS_SANDBOX"` } type SkillsToolsConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 093940210..d9f01b258 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -23,7 +23,7 @@ func DefaultConfig() *Config { WorkspaceAccess: "none", WorkspaceRoot: "~/.picoclaw/sandboxes", Docker: AgentSandboxDockerConfig{ - Image: "openclaw-sandbox:bookworm-slim", + Image: "picoclaw-sandbox:bookworm-slim", ContainerPrefix: "picoclaw-sbx-", Workdir: "/workspace", ReadOnlyRoot: true,