From 7cdaf2cfa1ec46704500197196fedc7d03bb0abd Mon Sep 17 00:00:00 2001 From: 0x5487 Date: Wed, 25 Feb 2026 21:59:24 +0800 Subject: [PATCH] feat: Improve sandbox image fallback, add Podman-compatible workspace binds, and centralize home directory resolution. --- Makefile | 4 +- cmd/picoclaw/main.go | 4 +- internal/infra/homedir.go | 22 ++++++++ pkg/agent/sandbox/container.go | 85 ++++++++++++++++++----------- pkg/agent/sandbox/container_test.go | 9 +-- pkg/agent/sandbox/manager.go | 4 +- scripts/build-sandbox.sh | 20 +++++++ 7 files changed, 107 insertions(+), 41 deletions(-) create mode 100644 internal/infra/homedir.go create mode 100755 scripts/build-sandbox.sh diff --git a/Makefile b/Makefile index 5cf36dcc7..0e3bbe520 100644 --- a/Makefile +++ b/Makefile @@ -96,8 +96,8 @@ 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 . +build-sandbox: + ./scripts/build-sandbox.sh ## install: Install picoclaw to system and copy builtin skills install: build diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 1e4b393f8..6798498cc 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -13,6 +13,7 @@ import ( "path/filepath" "runtime" + "github.com/sipeed/picoclaw/internal/infra" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/skills" ) @@ -190,8 +191,7 @@ func printHelp() { } func getConfigPath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".picoclaw", "config.json") + return filepath.Join(infra.ResolveHomeDir(), "config.json") } func loadConfig() (*config.Config, error) { diff --git a/internal/infra/homedir.go b/internal/infra/homedir.go new file mode 100644 index 000000000..97ff32e7b --- /dev/null +++ b/internal/infra/homedir.go @@ -0,0 +1,22 @@ +package infra + +import ( + "os" + "path/filepath" + "strings" +) + +// ResolveHomeDir returns the effective home directory for PicoClaw. +// It checks the PICOCLAW_HOME environment variable first, +// falls back to ~/.picoclaw if not set or empty. +func ResolveHomeDir() string { + if envHome := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); envHome != "" { + return envHome + } + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + // Extreme fallback + return filepath.Join(os.TempDir(), ".picoclaw") + } + return filepath.Join(home, ".picoclaw") +} diff --git a/pkg/agent/sandbox/container.go b/pkg/agent/sandbox/container.go index 0e1ec4ab1..0ad88cafc 100644 --- a/pkg/agent/sandbox/container.go +++ b/pkg/agent/sandbox/container.go @@ -24,6 +24,7 @@ import ( "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-units" + "github.com/sipeed/picoclaw/internal/infra" "github.com/sipeed/picoclaw/pkg/config" ) @@ -68,12 +69,16 @@ type ContainerSandbox struct { hash string } -const defaultSandboxRegistryFile = "containers.json" +const ( + defaultSandboxRegistryFile = "containers.json" + DefaultSandboxImage = "picoclaw-sandbox:bookworm-slim" + FallbackSandboxImage = "debian:bookworm-slim" +) // NewContainerSandbox creates a container sandbox with normalized defaults and precomputed config hash. func NewContainerSandbox(cfg ContainerSandboxConfig) *ContainerSandbox { if strings.TrimSpace(cfg.Image) == "" { - cfg.Image = "picoclaw-sandbox:bookworm-slim" + cfg.Image = DefaultSandboxImage } if strings.TrimSpace(cfg.ContainerPrefix) == "" { cfg.ContainerPrefix = "picoclaw-sandbox-" @@ -148,13 +153,31 @@ func (c *ContainerSandbox) Start(ctx context.Context) error { } if _, err := c.cli.ImageInspect(ctx, c.cfg.Image); err != nil { - rc, pullErr := c.cli.ImagePull(ctx, c.cfg.Image, image.PullOptions{}) - if pullErr != nil { - c.startErr = fmt.Errorf("docker image unavailable (%s): %w", c.cfg.Image, pullErr) - return c.startErr + if c.cfg.Image == DefaultSandboxImage { + // If default image is missing, try to pull fallback and tag it + rc, pullErr := c.cli.ImagePull(ctx, FallbackSandboxImage, image.PullOptions{}) + if pullErr != nil { + c.startErr = fmt.Errorf("docker fallback image unavailable (%s): %w", FallbackSandboxImage, pullErr) + return c.startErr + } + defer rc.Close() + _, _ = io.Copy(io.Discard, rc) + + // Tag debian:bookworm-slim as picoclaw-sandbox:bookworm-slim + if err := c.cli.ImageTag(ctx, FallbackSandboxImage, DefaultSandboxImage); err != nil { + c.startErr = fmt.Errorf("failed to tag fallback image: %w", err) + return c.startErr + } + } else { + // For non-default images, just try to pull directly + rc, pullErr := c.cli.ImagePull(ctx, c.cfg.Image, image.PullOptions{}) + if pullErr != nil { + c.startErr = fmt.Errorf("docker image unavailable (%s): %w", c.cfg.Image, pullErr) + return c.startErr + } + defer rc.Close() + _, _ = io.Copy(io.Discard, rc) } - defer rc.Close() - _, _ = io.Copy(io.Discard, rc) } return nil @@ -371,17 +394,29 @@ func (c *ContainerSandbox) createAndStart(ctx context.Context) error { func (c *ContainerSandbox) binds() []string { binds := make([]string, 0, 1+len(c.cfg.Binds)) workspace := strings.TrimSpace(c.cfg.Workspace) + + // Determine the effective host directory to mount + var hostDir string if workspace != "" { - abs, err := filepath.Abs(workspace) - if err == nil { - switch c.cfg.WorkspaceAccess { - case "ro": - binds = append(binds, fmt.Sprintf("%s:%s:ro", abs, c.cfg.Workdir)) - case "rw": - binds = append(binds, fmt.Sprintf("%s:%s:rw", abs, c.cfg.Workdir)) - default: - binds = append(binds, fmt.Sprintf("%s:%s", abs, c.cfg.Workdir)) - } + if abs, err := filepath.Abs(workspace); err == nil { + hostDir = abs + } + } + + if hostDir != "" { + if c.cfg.WorkspaceAccess == "none" { + // Ensure the isolated directory exists on the host so Docker doesn't create it as root + _ = os.MkdirAll(hostDir, 0755) + } + // Add :Z flag for SELinux (Podman) to label the content with a private unshared label. + // This fixes errors like: "crun: getcwd: Operation not permitted: OCI permission denied" + switch c.cfg.WorkspaceAccess { + case "ro": + binds = append(binds, fmt.Sprintf("%s:%s:ro,Z", hostDir, c.cfg.Workdir)) + case "rw", "none": + binds = append(binds, fmt.Sprintf("%s:%s:rw,Z", hostDir, c.cfg.Workdir)) + default: + // Default to no mount for unknown access types } } for _, bind := range c.cfg.Binds { @@ -397,19 +432,7 @@ func (c *ContainerSandbox) registryPath() string { } func (c *ContainerSandbox) sandboxStateDir() string { - return filepath.Join(resolvePicoClawHomeDir(), "sandboxes") -} - -func resolvePicoClawHomeDir() string { - if envHome := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); envHome != "" { - if abs := resolveAbsPath(expandHomePath(envHome)); strings.TrimSpace(abs) != "" { - return abs - } - } - if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { - return filepath.Join(home, ".picoclaw") - } - return filepath.Join(osTempDir(), ".picoclaw") + return filepath.Join(infra.ResolveHomeDir(), "sandboxes") } func (c *ContainerSandbox) stopAndRemoveContainer(ctx context.Context, containerName string) error { diff --git a/pkg/agent/sandbox/container_test.go b/pkg/agent/sandbox/container_test.go index fd867bbb1..affc78909 100644 --- a/pkg/agent/sandbox/container_test.go +++ b/pkg/agent/sandbox/container_test.go @@ -238,7 +238,7 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) { Workdir: "/workspace", }) roBinds := ro.binds() - if len(roBinds) == 0 || !strings.HasSuffix(roBinds[0], ":/workspace:ro") { + if len(roBinds) == 0 || !strings.HasSuffix(roBinds[0], ":/workspace:ro,Z") { t.Fatalf("unexpected ro bind: %#v", roBinds) } @@ -248,7 +248,7 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) { Workdir: "/workspace", }) rwBinds := rw.binds() - if len(rwBinds) == 0 || !strings.HasSuffix(rwBinds[0], ":/workspace:rw") { + if len(rwBinds) == 0 || !strings.HasSuffix(rwBinds[0], ":/workspace:rw,Z") { t.Fatalf("unexpected rw bind: %#v", rwBinds) } @@ -256,10 +256,11 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) { Workspace: filepath.Join(root, "ws-none"), WorkspaceAccess: "none", Workdir: "/workspace", + ContainerName: "test-none", }) noneBinds := none.binds() - if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace") { - t.Fatalf("unexpected none bind: %#v", noneBinds) + if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace:rw,Z") { + t.Fatalf("expected none bind to isolated workspace, got: %#v", noneBinds) } } diff --git a/pkg/agent/sandbox/manager.go b/pkg/agent/sandbox/manager.go index 1eb95aae4..3f2f83e0f 100644 --- a/pkg/agent/sandbox/manager.go +++ b/pkg/agent/sandbox/manager.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/sipeed/picoclaw/internal/infra" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/routing" ) @@ -275,8 +276,7 @@ func (m *scopedSandboxManager) pruneOnce(ctx context.Context) error { if m.pruneIdleHours <= 0 && m.pruneMaxAgeDays <= 0 { return nil } - - regPath := filepath.Join(resolvePicoClawHomeDir(), "sandbox", defaultSandboxRegistryFile) + regPath := filepath.Join(infra.ResolveHomeDir(), "sandbox", defaultSandboxRegistryFile) registryMu.Lock() data, err := loadRegistry(regPath) registryMu.Unlock() diff --git a/scripts/build-sandbox.sh b/scripts/build-sandbox.sh new file mode 100755 index 000000000..266f3d5d7 --- /dev/null +++ b/scripts/build-sandbox.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# PicoClaw Sandbox Build Script + +set -e + +# Base directory +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DOCKERFILE="${REPO_ROOT}/Dockerfile.sandbox" +IMAGE_NAME="picoclaw-sandbox:bookworm-slim" + +echo "Building PicoClaw sandbox image: ${IMAGE_NAME}..." + +if [ ! -f "${DOCKERFILE}" ]; then + echo "Error: Dockerfile.sandbox not found at ${DOCKERFILE}" + exit 1 +fi + +docker build -t "${IMAGE_NAME}" -f "${DOCKERFILE}" "${REPO_ROOT}" + +echo "Successfully built ${IMAGE_NAME}"