Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions pkg/agent/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"path/filepath"
"strings"
"time"

"github.com/sipeed/picoclaw/pkg/fileutil"
)

// MemoryStore manages persistent memory for the agent.
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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.
Expand Down
11 changes: 5 additions & 6 deletions pkg/auth/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"time"

"github.com/sipeed/picoclaw/pkg/fileutil"
)

type AuthCredential struct {
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 4 additions & 7 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 4 additions & 7 deletions pkg/cron/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
119 changes: 119 additions & 0 deletions pkg/fileutil/file.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way you implement "atomicwrite" just using tmpfile for the job, it will corrupt the file if write to tmp file failed too.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're correct. I've fixed the issue。

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
}
3 changes: 2 additions & 1 deletion pkg/heartbeat/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion pkg/skills/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"time"

"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/utils"
)

Expand Down Expand Up @@ -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)
}

Expand Down
27 changes: 8 additions & 19 deletions pkg/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"path/filepath"
"sync"
"time"

"github.com/sipeed/picoclaw/pkg/fileutil"
)

// State represents the persistent state for a workspace.
Expand Down Expand Up @@ -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.
Expand Down
Loading