feat: Improve sandbox image fallback, add Podman-compatible workspace binds, and centralize home directory resolution.

This commit is contained in:
0x5487 2026-02-25 21:59:24 +08:00
parent 0186122a87
commit 7cdaf2cfa1
7 changed files with 107 additions and 41 deletions

View file

@ -96,8 +96,8 @@ build-all: generate
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@echo "All builds complete" @echo "All builds complete"
build-docker-images: build-sandbox:
docker build -t picoclaw-sandbox:bookworm-slim . ./scripts/build-sandbox.sh
## install: Install picoclaw to system and copy builtin skills ## install: Install picoclaw to system and copy builtin skills
install: build install: build

View file

@ -13,6 +13,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"github.com/sipeed/picoclaw/internal/infra"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
) )
@ -190,8 +191,7 @@ func printHelp() {
} }
func getConfigPath() string { func getConfigPath() string {
home, _ := os.UserHomeDir() return filepath.Join(infra.ResolveHomeDir(), "config.json")
return filepath.Join(home, ".picoclaw", "config.json")
} }
func loadConfig() (*config.Config, error) { func loadConfig() (*config.Config, error) {

22
internal/infra/homedir.go Normal file
View file

@ -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")
}

View file

@ -24,6 +24,7 @@ import (
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/sipeed/picoclaw/internal/infra"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
@ -68,12 +69,16 @@ type ContainerSandbox struct {
hash string 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. // NewContainerSandbox creates a container sandbox with normalized defaults and precomputed config hash.
func NewContainerSandbox(cfg ContainerSandboxConfig) *ContainerSandbox { func NewContainerSandbox(cfg ContainerSandboxConfig) *ContainerSandbox {
if strings.TrimSpace(cfg.Image) == "" { if strings.TrimSpace(cfg.Image) == "" {
cfg.Image = "picoclaw-sandbox:bookworm-slim" cfg.Image = DefaultSandboxImage
} }
if strings.TrimSpace(cfg.ContainerPrefix) == "" { if strings.TrimSpace(cfg.ContainerPrefix) == "" {
cfg.ContainerPrefix = "picoclaw-sandbox-" 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 { if _, err := c.cli.ImageInspect(ctx, c.cfg.Image); err != nil {
rc, pullErr := c.cli.ImagePull(ctx, c.cfg.Image, image.PullOptions{}) if c.cfg.Image == DefaultSandboxImage {
if pullErr != nil { // If default image is missing, try to pull fallback and tag it
c.startErr = fmt.Errorf("docker image unavailable (%s): %w", c.cfg.Image, pullErr) rc, pullErr := c.cli.ImagePull(ctx, FallbackSandboxImage, image.PullOptions{})
return c.startErr 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 return nil
@ -371,17 +394,29 @@ func (c *ContainerSandbox) createAndStart(ctx context.Context) error {
func (c *ContainerSandbox) binds() []string { func (c *ContainerSandbox) binds() []string {
binds := make([]string, 0, 1+len(c.cfg.Binds)) binds := make([]string, 0, 1+len(c.cfg.Binds))
workspace := strings.TrimSpace(c.cfg.Workspace) workspace := strings.TrimSpace(c.cfg.Workspace)
// Determine the effective host directory to mount
var hostDir string
if workspace != "" { if workspace != "" {
abs, err := filepath.Abs(workspace) if abs, err := filepath.Abs(workspace); err == nil {
if err == nil { hostDir = abs
switch c.cfg.WorkspaceAccess { }
case "ro": }
binds = append(binds, fmt.Sprintf("%s:%s:ro", abs, c.cfg.Workdir))
case "rw": if hostDir != "" {
binds = append(binds, fmt.Sprintf("%s:%s:rw", abs, c.cfg.Workdir)) if c.cfg.WorkspaceAccess == "none" {
default: // Ensure the isolated directory exists on the host so Docker doesn't create it as root
binds = append(binds, fmt.Sprintf("%s:%s", abs, c.cfg.Workdir)) _ = 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 { for _, bind := range c.cfg.Binds {
@ -397,19 +432,7 @@ func (c *ContainerSandbox) registryPath() string {
} }
func (c *ContainerSandbox) sandboxStateDir() string { func (c *ContainerSandbox) sandboxStateDir() string {
return filepath.Join(resolvePicoClawHomeDir(), "sandboxes") return filepath.Join(infra.ResolveHomeDir(), "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")
} }
func (c *ContainerSandbox) stopAndRemoveContainer(ctx context.Context, containerName string) error { func (c *ContainerSandbox) stopAndRemoveContainer(ctx context.Context, containerName string) error {

View file

@ -238,7 +238,7 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) {
Workdir: "/workspace", Workdir: "/workspace",
}) })
roBinds := ro.binds() 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) t.Fatalf("unexpected ro bind: %#v", roBinds)
} }
@ -248,7 +248,7 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) {
Workdir: "/workspace", Workdir: "/workspace",
}) })
rwBinds := rw.binds() 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) t.Fatalf("unexpected rw bind: %#v", rwBinds)
} }
@ -256,10 +256,11 @@ func TestContainerSandbox_Binds_WorkspaceAccessModes(t *testing.T) {
Workspace: filepath.Join(root, "ws-none"), Workspace: filepath.Join(root, "ws-none"),
WorkspaceAccess: "none", WorkspaceAccess: "none",
Workdir: "/workspace", Workdir: "/workspace",
ContainerName: "test-none",
}) })
noneBinds := none.binds() noneBinds := none.binds()
if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace") { if len(noneBinds) == 0 || !strings.HasSuffix(noneBinds[0], ":/workspace:rw,Z") {
t.Fatalf("unexpected none bind: %#v", noneBinds) t.Fatalf("expected none bind to isolated workspace, got: %#v", noneBinds)
} }
} }

View file

@ -13,6 +13,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/internal/infra"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/routing" "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 { if m.pruneIdleHours <= 0 && m.pruneMaxAgeDays <= 0 {
return nil return nil
} }
regPath := filepath.Join(infra.ResolveHomeDir(), "sandbox", defaultSandboxRegistryFile)
regPath := filepath.Join(resolvePicoClawHomeDir(), "sandbox", defaultSandboxRegistryFile)
registryMu.Lock() registryMu.Lock()
data, err := loadRegistry(regPath) data, err := loadRegistry(regPath)
registryMu.Unlock() registryMu.Unlock()

20
scripts/build-sandbox.sh Executable file
View file

@ -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}"