From 60364cfffb639d2af46ed5794db7268166639f29 Mon Sep 17 00:00:00 2001 From: stevef1uk Date: Fri, 17 Apr 2026 18:34:52 +0200 Subject: [PATCH 1/5] Update Dockerfile.rpi --- docker/Dockerfile.rpi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile.rpi b/docker/Dockerfile.rpi index de6b7d7d2..bef147a7c 100644 --- a/docker/Dockerfile.rpi +++ b/docker/Dockerfile.rpi @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binaries # ============================================================ -FROM --platform=linux/arm64 golang:1.25-alpine AS builder +FROM --platform=linux/arm64 golang:1.26-alpine AS builder WORKDIR /app From a8104eed057d3b8a04ee95e88fd47394ce9207a6 Mon Sep 17 00:00:00 2001 From: stevef Date: Fri, 17 Apr 2026 19:30:46 +0200 Subject: [PATCH 2/5] closed gap in skills adding --- pkg/agent/loop.go | 2 ++ pkg/channels/pico/pico.go | 10 +++--- pkg/tools/skills_install.go | 17 +++++++++ pkg/tools/skills_install_test.go | 62 +++++++++++++++++++++++++------- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b91d2db0d..7106a6024 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -193,6 +193,7 @@ func registerSharedTools( ) { allowReadPaths := buildAllowReadPatterns(cfg) denyReadPaths := compilePatterns(cfg.Tools.DenyReadPaths) + denyWritePaths := compilePatterns(cfg.Tools.DenyWritePaths) var ttsProvider tts.TTSProvider if cfg.Tools.IsToolEnabled("send_tts") { ttsProvider = tts.DetectTTS(cfg) @@ -376,6 +377,7 @@ func registerSharedTools( agent.Workspace, cfg.Tools.Skills.Whitelist, cfg.Tools.Skills.WhitelistEnabled, + denyWritePaths, ), ) } diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index d5a71ba77..fcb4cad73 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -437,11 +437,11 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { } logger.WarnCF("pico", "Authentication failed: No valid token provided in request", map[string]any{ - "path": r.URL.Path, - "remote_addr": r.RemoteAddr, - "has_auth_hdr": auth != "", - "has_token_q": r.URL.Query().Get("token") != "", - "has_subproto": r.Header.Get("Sec-WebSocket-Protocol") != "", + "path": r.URL.Path, + "remote_addr": r.RemoteAddr, + "has_auth_hdr": auth != "", + "has_token_q": r.URL.Query().Get("token") != "", + "has_subproto": r.Header.Get("Sec-WebSocket-Protocol") != "", }) return false } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 562809803..6bc9e5eca 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sync" "time" @@ -20,23 +21,27 @@ type InstallSkillTool struct { workspace string whitelist []string whitelistEnabled bool + denyWritePaths []*regexp.Regexp mu sync.Mutex } // NewInstallSkillTool creates a new InstallSkillTool. // registryMgr is the shared registry manager (same instance as FindSkillsTool). // workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/. +// denyWritePaths is a list of regex patterns to check before allowing installation. func NewInstallSkillTool( registryMgr *skills.RegistryManager, workspace string, whitelist []string, whitelistEnabled bool, + denyWritePaths []*regexp.Regexp, ) *InstallSkillTool { return &InstallSkillTool{ registryMgr: registryMgr, workspace: workspace, whitelist: whitelist, whitelistEnabled: whitelistEnabled, + denyWritePaths: denyWritePaths, mu: sync.Mutex{}, } } @@ -109,6 +114,18 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To version, _ := args["version"].(string) force, _ := args["force"].(bool) + // Check deny write paths before proceeding with installation. + // Patterns are expected to match relative paths (e.g., "skills", "skills/foo"), + // so we check against the relative path from workspace. + if len(t.denyWritePaths) > 0 { + relativePath := "skills" + for _, pattern := range t.denyWritePaths { + if pattern.MatchString(relativePath) { + return ErrorResult("access denied: cannot write to skills directory") + } + } + } + // Check if already installed. skillsDir := filepath.Join(t.workspace, "skills") targetDir := filepath.Join(skillsDir, slug) diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go index 5c12f0029..e0dacc3ba 100644 --- a/pkg/tools/skills_install_test.go +++ b/pkg/tools/skills_install_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "regexp" "testing" "github.com/stretchr/testify/assert" @@ -13,19 +14,19 @@ import ( ) func TestInstallSkillToolName(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false, nil) assert.Equal(t, "install_skill", tool.Name()) } func TestInstallSkillToolMissingSlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false, nil) result := tool.Execute(context.Background(), map[string]any{}) assert.True(t, result.IsError) assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") } func TestInstallSkillToolEmptySlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": " ", }) @@ -34,7 +35,7 @@ func TestInstallSkillToolEmptySlug(t *testing.T) { } func TestInstallSkillToolUnsafeSlug(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false, nil) cases := []string{ "../etc/passwd", @@ -56,7 +57,7 @@ func TestInstallSkillToolAlreadyExists(t *testing.T) { skillDir := filepath.Join(workspace, "skills", "existing-skill") require.NoError(t, os.MkdirAll(skillDir, 0o755)) - tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace, nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace, nil, false, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": "existing-skill", "registry": "clawhub", @@ -67,7 +68,7 @@ func TestInstallSkillToolAlreadyExists(t *testing.T) { func TestInstallSkillToolRegistryNotFound(t *testing.T) { workspace := t.TempDir() - tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace, nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace, nil, false, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", "registry": "nonexistent", @@ -78,7 +79,7 @@ func TestInstallSkillToolRegistryNotFound(t *testing.T) { } func TestInstallSkillToolParameters(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false, nil) params := tool.Parameters() props, ok := params["properties"].(map[string]any) @@ -95,7 +96,7 @@ func TestInstallSkillToolParameters(t *testing.T) { } func TestInstallSkillToolMissingRegistry(t *testing.T) { - tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false) + tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir(), nil, false, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", }) @@ -108,7 +109,7 @@ func TestInstallSkillToolWhitelist(t *testing.T) { rm := skills.NewRegistryManager() t.Run("blocked-by-whitelist", func(t *testing.T) { - tool := NewInstallSkillTool(rm, workspace, []string{"allowed-skill"}, true) + tool := NewInstallSkillTool(rm, workspace, []string{"allowed-skill"}, true, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": "blocked-skill", "registry": "clawhub", @@ -119,7 +120,7 @@ func TestInstallSkillToolWhitelist(t *testing.T) { t.Run("allowed-by-whitelist", func(t *testing.T) { // This will still fail because registry is not found, but it should pass the whitelist check - tool := NewInstallSkillTool(rm, workspace, []string{"allowed-skill"}, true) + tool := NewInstallSkillTool(rm, workspace, []string{"allowed-skill"}, true, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": "allowed-skill", "registry": "clawhub", @@ -129,7 +130,7 @@ func TestInstallSkillToolWhitelist(t *testing.T) { }) t.Run("empty-whitelist-allows-all", func(t *testing.T) { - tool := NewInstallSkillTool(rm, workspace, []string{}, false) + tool := NewInstallSkillTool(rm, workspace, []string{}, false, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": "any-skill", "registry": "clawhub", @@ -139,7 +140,7 @@ func TestInstallSkillToolWhitelist(t *testing.T) { }) t.Run("nil-whitelist-allows-all", func(t *testing.T) { - tool := NewInstallSkillTool(rm, workspace, nil, false) + tool := NewInstallSkillTool(rm, workspace, nil, false, nil) result := tool.Execute(context.Background(), map[string]any{ "slug": "any-skill", "registry": "clawhub", @@ -148,3 +149,40 @@ func TestInstallSkillToolWhitelist(t *testing.T) { assert.NotContains(t, result.ForLLM, "not in whitelist") }) } + +func TestInstallSkillToolDenyWritePaths(t *testing.T) { + workspace := t.TempDir() + rm := skills.NewRegistryManager() + + t.Run("blocked-by-deny-write-paths", func(t *testing.T) { + denyPatterns := []*regexp.Regexp{regexp.MustCompile(`^skills(/.*)?$`)} + tool := NewInstallSkillTool(rm, workspace, nil, false, denyPatterns) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "some-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.Contains(t, result.ForLLM, "access denied") + }) + + t.Run("allowed-without-deny-paths", func(t *testing.T) { + tool := NewInstallSkillTool(rm, workspace, nil, false, nil) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "some-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.NotContains(t, result.ForLLM, "access denied") + }) + + t.Run("non-matching-deny-pattern-allows", func(t *testing.T) { + denyPatterns := []*regexp.Regexp{regexp.MustCompile(`^restricted(/.*)?$`)} + tool := NewInstallSkillTool(rm, workspace, nil, false, denyPatterns) + result := tool.Execute(context.Background(), map[string]any{ + "slug": "some-skill", + "registry": "clawhub", + }) + assert.True(t, result.IsError) + assert.NotContains(t, result.ForLLM, "access denied") + }) +} From 5f7c6b32bafe6e420d680bffe53b50dabac01f05 Mon Sep 17 00:00:00 2001 From: stevef Date: Fri, 17 Apr 2026 22:14:49 +0200 Subject: [PATCH 3/5] Harden security: prevent shell bypasses in exec and synchronize Web API guards for skills --- pkg/tools/shell.go | 56 +++++++++++++++++++++++++--- pkg/tools/skills_install.go | 12 +++--- web/backend/api/skills.go | 73 ++++++++++++++++++++++++++++++++----- 3 files changed, 120 insertions(+), 21 deletions(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 96200b9ff..76626f2e9 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -40,6 +40,7 @@ type ExecTool struct { allowPatterns []*regexp.Regexp customAllowPatterns []*regexp.Regexp allowedPathPatterns []*regexp.Regexp + denyWritePaths []*regexp.Regexp restrictToWorkspace bool allowRemote bool sessionManager *SessionManager @@ -120,8 +121,18 @@ func NewExecTool(workingDir string, restrict bool, allowPaths ...[]*regexp.Regex func NewExecToolWithConfig( workingDir string, restrict bool, - config *config.Config, + cfg *config.Config, allowPaths ...[]*regexp.Regexp, +) (*ExecTool, error) { + return NewExecToolWithDenyPaths(workingDir, restrict, allowPaths, nil, cfg) +} + +func NewExecToolWithDenyPaths( + workingDir string, + restrict bool, + allowPaths [][]*regexp.Regexp, + denyWritePaths []*regexp.Regexp, + cfg *config.Config, ) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) customAllowPatterns := make([]*regexp.Regexp, 0) @@ -131,8 +142,8 @@ func NewExecToolWithConfig( allowedPathPatterns = allowPaths[0] } - if config != nil { - execConfig := config.Tools.Exec + if cfg != nil { + execConfig := cfg.Tools.Exec enableDenyPatterns := execConfig.EnableDenyPatterns allowRemote = execConfig.AllowRemote if enableDenyPatterns { @@ -163,8 +174,8 @@ func NewExecToolWithConfig( } var timeout time.Duration - if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { - timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second + if cfg != nil && cfg.Tools.Exec.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.Tools.Exec.TimeoutSeconds) * time.Second } return &ExecTool{ @@ -174,6 +185,7 @@ func NewExecToolWithConfig( allowPatterns: nil, customAllowPatterns: customAllowPatterns, allowedPathPatterns: allowedPathPatterns, + denyWritePaths: denyWritePaths, restrictToWorkspace: restrict, allowRemote: allowRemote, sessionManager: getSessionManager(), @@ -1033,6 +1045,40 @@ func (t *ExecTool) guardCommand(command, cwd string) string { return "Command blocked by safety guard (dangerous pattern detected)" } } + + // Check deny write paths - block commands that reference protected directories or variables + // We perform a broad check on the entire command string to prevent variable bypasses. + if len(t.denyWritePaths) > 0 { + // First check: literal occurrences in the whole command + for _, pattern := range t.denyWritePaths { + if pattern.MatchString(cmd) { + return fmt.Sprintf("Command blocked: reference to restricted path detected") + } + } + + // Second check: check individual words/arguments for deeper validation + words := strings.Fields(cmd) + for _, word := range words { + // Clean whitespace and common shell chars from word to find actual path candidates + cleanWord := strings.Trim(word, " ;&|><\"'$()") + if cleanWord == "" { + continue + } + + for _, pattern := range t.denyWritePaths { + if pattern.MatchString(cleanWord) { + return fmt.Sprintf("Command blocked: cannot access protected path %q", cleanWord) + } + // Also check path components (e.g. "skills" in "mkdir -p skills/foo") + pathParts := strings.Split(cleanWord, "/") + for _, part := range pathParts { + if part != "" && pattern.MatchString(part) { + return fmt.Sprintf("Command blocked: cannot access protected path component %q", part) + } + } + } + } + } } if len(t.allowPatterns) > 0 { diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 6bc9e5eca..74585adb6 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -115,13 +115,13 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To force, _ := args["force"].(bool) // Check deny write paths before proceeding with installation. - // Patterns are expected to match relative paths (e.g., "skills", "skills/foo"), - // so we check against the relative path from workspace. if len(t.denyWritePaths) > 0 { - relativePath := "skills" - for _, pattern := range t.denyWritePaths { - if pattern.MatchString(relativePath) { - return ErrorResult("access denied: cannot write to skills directory") + pathsToCheck := []string{"skills", filepath.Join("skills", slug)} + for _, path := range pathsToCheck { + for _, pattern := range t.denyWritePaths { + if pattern.MatchString(path) { + return ErrorResult(fmt.Sprintf("access denied: cannot write to %q", path)) + } } } } diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go index 329225ce6..4bc9d352e 100644 --- a/web/backend/api/skills.go +++ b/web/backend/api/skills.go @@ -127,9 +127,17 @@ func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) { return } + // Filter based on security policy + filtered := make([]skillSupportItem, 0, len(items)) + for _, item := range items { + if ensureSkillRegistryToolEnabled(cfg, "", item.Name) == nil { + filtered = append(filtered, item) + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(skillSupportResponse{ - Skills: items, + Skills: filtered, }) } @@ -146,6 +154,12 @@ func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { return } name := r.PathValue("name") + + if registryErr := ensureSkillRegistryToolEnabled(cfg, "", name); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } + for _, skillItem := range skillItems { if skillItem.Name != name { continue @@ -174,7 +188,7 @@ func (h *Handler) handleSearchSkills(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError) return } - if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills"); registryErr != nil { + if registryErr := ensureSkillRegistryToolEnabled(cfg, "find_skills", ""); registryErr != nil { http.Error(w, registryErr.Error(), http.StatusBadRequest) return } @@ -278,17 +292,17 @@ func (h *Handler) handleInstallSkill(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Failed to load config: %v", loadErr), http.StatusInternalServerError) return } - if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill"); registryErr != nil { - http.Error(w, registryErr.Error(), http.StatusBadRequest) - return - } - var req installSkillRequest if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", decodeErr), http.StatusBadRequest) return } + if registryErr := ensureSkillRegistryToolEnabled(cfg, "install_skill", req.Slug); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } + req.Slug = strings.TrimSpace(req.Slug) req.Registry = strings.TrimSpace(req.Registry) req.Version = strings.TrimSpace(req.Version) @@ -448,6 +462,11 @@ func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { } defer uploadedFile.Close() + if registryErr := ensureSkillRegistryToolEnabled(cfg, "write_file", fileHeader.Filename); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } + content, err := io.ReadAll(io.LimitReader(uploadedFile, maxImportedSkillSize+1)) if err != nil { http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest) @@ -479,6 +498,11 @@ func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { loader := newSkillsLoader(cfg.WorkspacePath()) name := r.PathValue("name") + + if registryErr := ensureSkillRegistryToolEnabled(cfg, "", name); registryErr != nil { + http.Error(w, registryErr.Error(), http.StatusBadRequest) + return + } workspaceSkillWriteMu.Lock() defer workspaceSkillWriteMu.Unlock() @@ -531,13 +555,42 @@ func newSkillsRegistryManager(cfg *config.Config) *skills.RegistryManager { }) } -func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string) error { +func ensureSkillRegistryToolEnabled(cfg *config.Config, toolName string, skillName string) error { if !cfg.Tools.IsToolEnabled("skills") { return fmt.Errorf("tools.skills is disabled") } - if !cfg.Tools.IsToolEnabled(toolName) { - return fmt.Errorf("%s is disabled", toolName) + if toolName != "" { + if !cfg.Tools.IsToolEnabled(toolName) { + return fmt.Errorf("%s is disabled", toolName) + } } + + // Check whitelist for specific skill if enabled + if cfg.Tools.Skills.WhitelistEnabled && skillName != "" { + allowed := false + for _, s := range cfg.Tools.Skills.Whitelist { + if s == skillName { + allowed = true + break + } + } + if !allowed { + return fmt.Errorf("skill %q is not in the whitelist", skillName) + } + } + + // Check deny paths + if skillName != "" { + // Path would be skills/skillName + pathCandidate := filepath.Join("skills", skillName) + for _, patternStr := range cfg.Tools.DenyWritePaths { + re, err := regexp.Compile(patternStr) + if err == nil && re.MatchString(pathCandidate) { + return fmt.Errorf("access to skill %q is blocked by security policy", skillName) + } + } + } + return nil } From f5ad3a51c02c21c378d14732d42a1b5604ccd7d5 Mon Sep 17 00:00:00 2001 From: stevef Date: Fri, 17 Apr 2026 22:21:55 +0200 Subject: [PATCH 4/5] Add build-raspberry-pi and docker-build-raspberry-pi aliases to Makefile --- Makefile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Makefile b/Makefile index c98537681..20cba880e 100644 --- a/Makefile +++ b/Makefile @@ -211,6 +211,12 @@ build-linux-mipsle: generate build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" +## build-raspberry-pi: Alias for build-pi-zero +build-raspberry-pi: build-pi-zero + +## build-rpi: Alias for build-pi-zero +build-rpi: build-pi-zero + ## build-all: Build picoclaw for all platforms build-all: generate @echo "Building for multiple platforms..." @@ -332,6 +338,9 @@ docker-push-rpi: @echo "Pushing Raspberry Pi Docker image (ARM64)..." docker push $(DOCKER_USER)/picoclaw-rpi:latest +docker-build-raspberry-pi: docker-build-rpi +docker-push-raspberry-pi: docker-push-rpi + ## docker-run-full: Run picoclaw gateway in Docker (full-featured) docker-run-full: docker compose -f docker/docker-compose.full.yml --profile gateway up From cdf1741222672400bd8607fe6240c14011906b69 Mon Sep 17 00:00:00 2001 From: stevef Date: Fri, 17 Apr 2026 22:34:01 +0200 Subject: [PATCH 5/5] Update build-raspberry-pi to include docker build --- Makefile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 20cba880e..beb718361 100644 --- a/Makefile +++ b/Makefile @@ -211,11 +211,12 @@ build-linux-mipsle: generate build-pi-zero: build-linux-arm build-linux-arm64 @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" -## build-raspberry-pi: Alias for build-pi-zero -build-raspberry-pi: build-pi-zero +## build-raspberry-pi: Build binaries and Docker image for Raspberry Pi +build-raspberry-pi: build-pi-zero docker-build-rpi + @echo "Raspberry Pi full build complete (binaries and Docker image)" -## build-rpi: Alias for build-pi-zero -build-rpi: build-pi-zero +## build-rpi: Build binaries and Docker image for Raspberry Pi +build-rpi: build-raspberry-pi ## build-all: Build picoclaw for all platforms build-all: generate