diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index dd5f4441c..87a687479 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -12,6 +12,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" ) // MemoryStore manages persistent memory for the agent. @@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string { // WriteLongTerm writes content to the long-term memory file (MEMORY.md). func (ms *MemoryStore) WriteLongTerm(content string) error { - return os.WriteFile(ms.memoryFile, []byte(content), 0o644) + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) } // ReadToday reads today's daily note. @@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error { // Ensure month directory exists monthDir := filepath.Dir(todayFile) - os.MkdirAll(monthDir, 0o755) + if err := os.MkdirAll(monthDir, 0o755); err != nil { + return err + } var existingContent string if data, err := os.ReadFile(todayFile); err == nil { @@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error { newContent = existingContent + "\n" + content } - return os.WriteFile(todayFile, []byte(newContent), 0o644) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) } // GetRecentDailyNotes returns daily notes from the last N days. diff --git a/pkg/auth/store.go b/pkg/auth/store.go index 64708421b..283dc6977 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" ) type AuthCredential struct { @@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) { func SaveStore(store *AuthStore) error { path := authFilePath() - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(store, "", " ") if err != nil { return err } - return os.WriteFile(path, data, 0o600) + + // Use unified atomic write utility with explicit sync for flash storage reliability. + return fileutil.WriteFileAtomic(path, data, 0o600) } func GetCredential(provider string) (*AuthCredential, error) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 16559a2df..ca5803c35 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,10 +4,11 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sync/atomic" "github.com/caarlos0/env/v11" + + "github.com/sipeed/picoclaw/pkg/fileutil" ) // rrCounter is a global counter for round-robin load balancing across models. @@ -555,12 +556,8 @@ func SaveConfig(path string, cfg *Config) error { return err } - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - - return os.WriteFile(path, data, 0o600) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return fileutil.WriteFileAtomic(path, data, 0o600) } func (c *Config) WorkspacePath() string { diff --git a/pkg/cron/service.go b/pkg/cron/service.go index e699a44b5..6962041c1 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -7,11 +7,12 @@ import ( "fmt" "log" "os" - "path/filepath" "sync" "time" "github.com/adhocore/gronx" + + "github.com/sipeed/picoclaw/pkg/fileutil" ) type CronSchedule struct { @@ -330,17 +331,13 @@ func (cs *CronService) loadStore() error { } func (cs *CronService) saveStoreUnsafe() error { - dir := filepath.Dir(cs.storePath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(cs.store, "", " ") if err != nil { return err } - return os.WriteFile(cs.storePath, data, 0o600) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return fileutil.WriteFileAtomic(cs.storePath, data, 0o600) } func (cs *CronService) AddJob( diff --git a/pkg/fileutil/file.go b/pkg/fileutil/file.go new file mode 100644 index 000000000..7ca872374 --- /dev/null +++ b/pkg/fileutil/file.go @@ -0,0 +1,119 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package fileutil provides file manipulation utilities. +package fileutil + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern. +// +// This guarantees that the target file is either: +// - Completely written with the new data +// - Unchanged (if any step fails before rename) +// +// The function: +// 1. Creates a temp file in the same directory (original untouched) +// 2. Writes data to temp file +// 3. Syncs data to disk (critical for SD cards/flash storage) +// 4. Sets file permissions +// 5. Syncs directory metadata (ensures rename is durable) +// 6. Atomically renames temp file to target path +// +// Safety guarantees: +// - Original file is NEVER modified until successful rename +// - Temp file is always cleaned up on error +// - Data is flushed to physical storage before rename +// - Directory entry is synced to prevent orphaned inodes +// +// Parameters: +// - path: Target file path +// - data: Data to write +// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable) +// +// Returns: +// - Error if any step fails, nil on success +// +// Example: +// +// // Secure config file (owner read/write only) +// err := utils.WriteFileAtomic("config.json", data, 0o600) +// +// // Public readable file +// err := utils.WriteFileAtomic("public.txt", data, 0o644) +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Create temp file in the same directory (ensures atomic rename works) + // Using a hidden prefix (.tmp-) to avoid issues with some tools + tmpFile, err := os.OpenFile( + filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())), + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + perm, + ) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + tmpPath := tmpFile.Name() + cleanup := true + + defer func() { + if cleanup { + tmpFile.Close() + _ = os.Remove(tmpPath) + } + }() + + // Write data to temp file + // Note: Original file is untouched at this point + if _, err := tmpFile.Write(data); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + // CRITICAL: Force sync to storage medium before any other operations. + // This ensures data is physically written to disk, not just cached. + // Essential for SD cards, eMMC, and other flash storage on edge devices. + if err := tmpFile.Sync(); err != nil { + return fmt.Errorf("failed to sync temp file: %w", err) + } + + // Set file permissions before closing + if err := tmpFile.Chmod(perm); err != nil { + return fmt.Errorf("failed to set permissions: %w", err) + } + + // Close file before rename (required on Windows) + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // Atomic rename: temp file becomes the target + // On POSIX: rename() is atomic + // On Windows: Rename() is atomic for files + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("failed to rename temp file: %w", err) + } + + // Sync directory to ensure rename is durable + // This prevents the renamed file from disappearing after a crash + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + dirFile.Close() + } + + // Success: skip cleanup (file was renamed, no temp to remove) + cleanup = false + return nil +} diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index e05a9fdbf..58462c120 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" @@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically. Add your heartbeat tasks below this line: ` - if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil { + if err := fileutil.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil { hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err) } else { hs.logInfof("Created default HEARTBEAT.md template") diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index f9b5705f1..20f6a49d9 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -10,6 +10,7 @@ import ( "path/filepath" "time" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -66,7 +67,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er } skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, body, 0o644); err != nil { + + // Use unified atomic write utility with explicit sync for flash storage reliability. + if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil { return fmt.Errorf("failed to write skill file: %w", err) } diff --git a/pkg/state/state.go b/pkg/state/state.go index 1a92f82ed..1663faa4c 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -8,6 +8,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" ) // State represents the persistent state for a workspace. @@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time { // saveAtomic performs an atomic save using temp file + rename. // This ensures that the state file is never corrupted: // 1. Write to a temp file -// 2. Rename temp file to target (atomic on POSIX systems) -// 3. If rename fails, cleanup the temp file +// 2. Sync to disk (critical for SD cards/flash storage) +// 3. Rename temp file to target (atomic on POSIX systems) +// 4. If rename fails, cleanup the temp file // // Must be called with the lock held. func (sm *Manager) saveAtomic() error { - // Create temp file in the same directory as the target - tempFile := sm.stateFile + ".tmp" - - // Marshal state to JSON + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. data, err := json.MarshalIndent(sm.state, "", " ") if err != nil { return fmt.Errorf("failed to marshal state: %w", err) } - // Write to temp file - if err := os.WriteFile(tempFile, data, 0o644); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - - // Atomic rename from temp to target - if err := os.Rename(tempFile, sm.stateFile); err != nil { - // Cleanup temp file if rename fails - os.Remove(tempFile) - return fmt.Errorf("failed to rename temp file: %w", err) - } - - return nil + return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600) } // load loads the state from disk. diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 37db8b4ae..03d461dcc 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" ) // validatePath ensures the given path is within the workspace if restrict is true. @@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { } func (h *hostFs) WriteFile(path string, data []byte) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("failed to create parent directories: %w", err) - } - - // We use a "write-then-rename" pattern here to ensure an atomic write. - // This prevents the target file from being left in a truncated or partial state - // if the operation is interrupted, as the rename operation is atomic on Linux. - tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano()) - if err := os.WriteFile(tmpPath, data, 0o644); err != nil { - os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file - return fmt.Errorf("failed to write temp file: %w", err) - } - - if err := os.Rename(tmpPath, path); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("failed to replace original file: %w", err) - } - return nil + // Use unified atomic write utility with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + return fileutil.WriteFileAtomic(path, data, 0o600) } // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. @@ -351,20 +337,46 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { } } - // We use a "write-then-rename" pattern here to ensure an atomic write. - // This prevents the target file from being left in a truncated or partial state - // if the operation is interrupted, as the rename operation is atomic on Linux. - tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano()) + // Use atomic write pattern with explicit sync for flash storage reliability. + // Using 0o600 (owner read/write only) for secure default permissions. + tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) + + tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to open temp file: %w", err) + } + + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to write temp file: %w", err) + } + + // CRITICAL: Force sync to storage medium before rename. + // This ensures data is physically written to disk, not just cached. + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + root.Remove(tmpRelPath) + return fmt.Errorf("failed to sync temp file: %w", err) + } - if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil { - root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file - return fmt.Errorf("failed to write to temp file: %w", err) + if err := tmpFile.Close(); err != nil { + root.Remove(tmpRelPath) + return fmt.Errorf("failed to close temp file: %w", err) } if err := root.Rename(tmpRelPath, relPath); err != nil { root.Remove(tmpRelPath) return fmt.Errorf("failed to rename temp file over target: %w", err) } + + // Sync directory to ensure rename is durable + if dirFile, err := root.Open("."); err == nil { + _ = dirFile.Sync() + dirFile.Close() + } + return nil }) } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 55c0b678d..71bfe730b 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/utils" @@ -197,5 +198,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error { return err } - return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644) + // Use unified atomic write utility with explicit sync for flash storage reliability. + return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) }