diff --git a/.goreleaser.yaml b/.goreleaser.yaml index af26509e6..49d230506 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -39,7 +39,9 @@ builds: dockers_v2: - id: picoclaw - dockerfile: Dockerfile.goreleaser + dockerfile: docker/Dockerfile.goreleaser + extra_files: + - docker/entrypoint.sh ids: - picoclaw images: diff --git a/README.fr.md b/README.fr.md index f1d4f848e..c452b71ac 100644 --- a/README.fr.md +++ b/README.fr.md @@ -164,39 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe git clone https://github.com/sipeed/picoclaw.git cd picoclaw -# 2. Configurez vos clés API -cp config/config.example.json config/config.json -vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc. +# 2. Premier lancement — génère docker/data/config.json puis s'arrête +docker compose -f docker/docker-compose.yml --profile gateway up +# Le conteneur affiche "First-run setup complete." puis s'arrête. -# 3. Compiler & Démarrer -docker compose --profile gateway up -d +# 3. Configurez vos clés API +vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc. + +# 4. Démarrer +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` > [!TIP] > **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. +```bash +# 5. Voir les logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway -# 4. Voir les logs -docker compose logs -f picoclaw-gateway - -# 5. Arrêter -docker compose --profile gateway down +# 6. Arrêter +docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Mode Agent (exécution unique) ```bash # Poser une question -docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?" +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?" # Mode interactif -docker compose run --rm picoclaw-agent +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` -### Recompiler +### Mettre à jour ```bash -docker compose --profile gateway build --no-cache -docker compose --profile gateway up -d +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Démarrage Rapide diff --git a/README.ja.md b/README.ja.md index 48fb89fe3..6d5d09451 100644 --- a/README.ja.md +++ b/README.ja.md @@ -126,39 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla git clone https://github.com/sipeed/picoclaw.git cd picoclaw -# 2. API キーを設定 -cp config/config.example.json config/config.json -vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定 +# 2. 初回起動 — docker/data/config.json を自動生成して終了 +docker compose -f docker/docker-compose.yml --profile gateway up +# コンテナが "First-run setup complete." を表示して停止します。 -# 3. ビルドと起動 -docker compose --profile gateway up -d +# 3. API キーを設定 +vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定 + +# 4. 起動 +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` > [!TIP] > **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 +```bash +# 5. ログ確認 +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway -# 4. ログ確認 -docker compose logs -f picoclaw-gateway - -# 5. 停止 -docker compose --profile gateway down +# 6. 停止 +docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Agent モード(ワンショット) ```bash # 質問を投げる -docker compose run --rm picoclaw-agent -m "What is 2+2?" +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" # インタラクティブモード -docker compose run --rm picoclaw-agent +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` -### リビルド +### アップデート ```bash -docker compose --profile gateway build --no-cache -docker compose --profile gateway up -d +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 クイックスタート(ネイティブ) diff --git a/README.md b/README.md index 72a933b6f..6f5cce4d0 100644 --- a/README.md +++ b/README.md @@ -167,39 +167,43 @@ You can also run PicoClaw using Docker Compose without installing anything local git clone https://github.com/sipeed/picoclaw.git cd picoclaw -# 2. Set your API keys -cp config/config.example.json config/config.json -vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc. +# 2. First run — auto-generates docker/data/config.json then exits +docker compose -f docker/docker-compose.yml --profile gateway up +# The container prints "First-run setup complete." and stops. -# 3. Build & Start -docker compose --profile gateway up -d +# 3. Set your API keys +vim docker/data/config.json # Set provider API keys, bot tokens, etc. + +# 4. Start +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` > [!TIP] > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. +```bash +# 5. Check logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway -# 4. Check logs -docker compose logs -f picoclaw-gateway - -# 5. Stop -docker compose --profile gateway down +# 6. Stop +docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Agent Mode (One-shot) ```bash # Ask a question -docker compose run --rm picoclaw-agent -m "What is 2+2?" +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?" # Interactive mode -docker compose run --rm picoclaw-agent +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` -### Rebuild +### Update ```bash -docker compose --profile gateway build --no-cache -docker compose --profile gateway up -d +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Quick Start diff --git a/README.pt-br.md b/README.pt-br.md index 1dbee5201..61663e363 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -165,39 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca git clone https://github.com/sipeed/picoclaw.git cd picoclaw -# 2. Configure suas API keys -cp config/config.example.json config/config.json -vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. +# 2. Primeiro uso — gera docker/data/config.json automaticamente e para +docker compose -f docker/docker-compose.yml --profile gateway up +# O contêiner exibe "First-run setup complete." e para. -# 3. Build & Iniciar -docker compose --profile gateway up -d +# 3. Configure suas API keys +vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc. + +# 4. Iniciar +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` > [!TIP] > **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`. +```bash +# 5. Ver logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway -# 4. Ver logs -docker compose logs -f picoclaw-gateway - -# 5. Parar -docker compose --profile gateway down +# 6. Parar +docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Modo Agente (Execução única) ```bash # Fazer uma pergunta -docker compose run --rm picoclaw-agent -m "Quanto e 2+2?" +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?" # Modo interativo -docker compose run --rm picoclaw-agent +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` -### Rebuild +### Atualizar ```bash -docker compose --profile gateway build --no-cache -docker compose --profile gateway up -d +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Início Rápido diff --git a/README.vi.md b/README.vi.md index 0dd4994c2..f8ece7eda 100644 --- a/README.vi.md +++ b/README.vi.md @@ -145,39 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà git clone https://github.com/sipeed/picoclaw.git cd picoclaw -# 2. Thiết lập API Key -cp config/config.example.json config/config.json -vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. +# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại +docker compose -f docker/docker-compose.yml --profile gateway up +# Container hiển thị "First-run setup complete." rồi tự dừng. -# 3. Build & Khởi động -docker compose --profile gateway up -d +# 3. Thiết lập API Key +vim docker/data/config.json # API key của provider, bot token, v.v. + +# 4. Khởi động +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` > [!TIP] > **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`. +```bash +# 5. Xem logs +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway -# 4. Xem logs -docker compose logs -f picoclaw-gateway - -# 5. Dừng -docker compose --profile gateway down +# 6. Dừng +docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Chế độ Agent (chạy một lần) ```bash # Đặt câu hỏi -docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?" +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?" # Chế độ tương tác -docker compose run --rm picoclaw-agent +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` -### Build lại +### Cập nhật ```bash -docker compose --profile gateway build --no-cache -docker compose --profile gateway up -d +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 Bắt đầu nhanh diff --git a/README.zh.md b/README.zh.md index 8ce1ad2ee..7c9351cb4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -166,41 +166,43 @@ make install git clone https://github.com/sipeed/picoclaw.git cd picoclaw -# 2. 设置 API Key -cp config/config.example.json config/config.json -vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等 +# 2. 首次运行 — 自动生成 docker/data/config.json 后退出 +docker compose -f docker/docker-compose.yml --profile gateway up +# 容器打印 "First-run setup complete." 后自动停止 -# 3. 构建并启动 -docker compose --profile gateway up -d +# 3. 填写 API Key 等配置 +vim docker/data/config.json # 设置 provider API key、Bot Token 等 + +# 4. 正式启动 +docker compose -f docker/docker-compose.yml --profile gateway up -d +``` > [!TIP] -**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 +> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。 -# 4. 查看日志 -docker compose logs -f picoclaw-gateway - -# 5. 停止 -docker compose --profile gateway down +```bash +# 5. 查看日志 +docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway +# 6. 停止 +docker compose -f docker/docker-compose.yml --profile gateway down ``` ### Agent 模式 (一次性运行) ```bash # 提问 -docker compose run --rm picoclaw-agent -m "2+2 等于几?" +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?" # 交互模式 -docker compose run --rm picoclaw-agent - +docker compose -f docker/docker-compose.yml run --rm picoclaw-agent ``` -### 重新构建 +### 更新镜像 ```bash -docker compose --profile gateway build --no-cache -docker compose --profile gateway up -d - +docker compose -f docker/docker-compose.yml pull +docker compose -f docker/docker-compose.yml --profile gateway up -d ``` ### 🚀 快速开始 diff --git a/assets/picoclaw_detect_person.mp4 b/assets/picoclaw_detect_person.mp4 deleted file mode 100644 index b56999689..000000000 Binary files a/assets/picoclaw_detect_person.mp4 and /dev/null differ diff --git a/assets/wechat.png b/assets/wechat.png index 776c07885..1900c7556 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/Dockerfile b/docker/Dockerfile similarity index 100% rename from Dockerfile rename to docker/Dockerfile diff --git a/Dockerfile.goreleaser b/docker/Dockerfile.goreleaser similarity index 58% rename from Dockerfile.goreleaser rename to docker/Dockerfile.goreleaser index 0cdc8c6bd..68a02aae8 100644 --- a/Dockerfile.goreleaser +++ b/docker/Dockerfile.goreleaser @@ -5,6 +5,8 @@ ARG TARGETPLATFORM RUN apk add --no-cache ca-certificates tzdata COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw +COPY docker/entrypoint.sh /entrypoint.sh -ENTRYPOINT ["picoclaw"] -CMD ["gateway"] +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 64% rename from docker-compose.yml rename to docker/docker-compose.yml index c268b01cd..9ec71abab 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,12 +1,10 @@ services: # ───────────────────────────────────────────── # PicoClaw Agent (one-shot query) - # docker compose run --rm picoclaw-agent -m "Hello" + # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello" # ───────────────────────────────────────────── picoclaw-agent: - build: - context: . - dockerfile: Dockerfile + image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-agent profiles: - agent @@ -14,33 +12,23 @@ services: #extra_hosts: # - "host.docker.internal:host-gateway" volumes: - - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace + - ./data:/root/.picoclaw entrypoint: ["picoclaw", "agent"] stdin_open: true tty: true # ───────────────────────────────────────────── # PicoClaw Gateway (Long-running Bot) - # docker compose up picoclaw-gateway + # docker compose -f docker/docker-compose.yml up picoclaw-gateway # ───────────────────────────────────────────── picoclaw-gateway: - build: - context: . - dockerfile: Dockerfile + image: docker.io/sipeed/picoclaw:latest container_name: picoclaw-gateway - restart: unless-stopped + restart: on-failure profiles: - gateway # Uncomment to access host network; leave commented unless needed. #extra_hosts: # - "host.docker.internal:host-gateway" volumes: - # Configuration file - - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - # Persistent workspace (sessions, memory, logs) - - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace - command: ["gateway"] - -volumes: - picoclaw-workspace: + - ./data:/root/.picoclaw diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 000000000..b6fc724b5 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -e + +# First-run: neither config nor workspace exists. +# If config.json is already mounted but workspace is missing we skip onboard to +# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container. +if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then + picoclaw onboard + echo "" + echo "First-run setup complete." + echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container." + exit 0 +fi + +exec picoclaw gateway "$@" diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ad2611365..ad36282c8 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -55,6 +55,8 @@ type processOptions struct { NoHistory bool // If true, don't load session history (for heartbeat) } +const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." + func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { registry := NewAgentRegistry(cfg, provider) @@ -315,7 +317,7 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha Channel: channel, ChatID: chatID, UserMessage: content, - DefaultResponse: "I've completed processing but have no response to give.", + DefaultResponse: defaultResponse, EnableSummary: false, SendResponse: false, NoHistory: true, // Don't load session history for heartbeat @@ -381,7 +383,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) Channel: msg.Channel, ChatID: msg.ChatID, UserMessage: msg.Content, - DefaultResponse: "I've completed processing but have no response to give.", + DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, }) 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 dae213bdb..25300301f 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. @@ -640,12 +641,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/config/config_test.go b/pkg/config/config_test.go index bf56b7f34..45cdd8ec8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -210,8 +210,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) { func TestDefaultConfig_Model(t *testing.T) { cfg := DefaultConfig() - if cfg.Agents.Defaults.Model == "" { - t.Error("Model should not be empty") + if cfg.Agents.Defaults.Model != "" { + t.Error("Model should be empty") } } @@ -331,8 +331,8 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.Workspace == "" { t.Error("Workspace should not be empty") } - if cfg.Agents.Defaults.Model == "" { - t.Error("Model should not be empty") + if cfg.Agents.Defaults.Model != "" { + t.Error("Model should be empty") } if cfg.Agents.Defaults.Temperature != nil { t.Error("Temperature should be nil when not provided") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index d19ce1d38..4dc7946bf 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -13,10 +13,10 @@ func DefaultConfig() *Config { Workspace: "~/.picoclaw/workspace", RestrictToWorkspace: true, Provider: "", - Model: "glm-4.7", - MaxTokens: 8192, + Model: "", + MaxTokens: 32768, Temperature: nil, // nil means use provider default - MaxToolIterations: 20, + MaxToolIterations: 50, }, }, Bindings: []AgentBinding{}, 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 ce14ed77c..09c93fc6b 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -17,6 +17,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" @@ -276,7 +277,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/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index b6b3d70aa..9216442bb 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -296,8 +296,8 @@ func TestConvertConfig(t *testing.T) { if len(warnings) != 0 { t.Errorf("expected no warnings, got %v", warnings) } - if cfg.Agents.Defaults.Model != "glm-4.7" { - t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model) + if cfg.Agents.Defaults.Model != "" { + t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model) } }) } diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go index 23f137538..26905159f 100644 --- a/pkg/providers/legacy_provider.go +++ b/pkg/providers/legacy_provider.go @@ -18,9 +18,21 @@ import ( func CreateProvider(cfg *config.Config) (LLMProvider, string, error) { model := cfg.Agents.Defaults.GetModelName() - // Ensure model_list is populated (should be done by LoadConfig, but handle edge cases) - if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { - cfg.ModelList = config.ConvertProvidersToModelList(cfg) + // Ensure model_list is populated from providers config if needed + // This handles two cases: + // 1. ModelList is empty - convert all providers + // 2. ModelList has some entries but not all providers - merge missing ones + if cfg.HasProvidersConfig() { + providerModels := config.ConvertProvidersToModelList(cfg) + existingModelNames := make(map[string]bool) + for _, m := range cfg.ModelList { + existingModelNames[m.ModelName] = true + } + for _, pm := range providerModels { + if !existingModelNames[pm.ModelName] { + cfg.ModelList = append(cfg.ModelList, pm) + } + } } // Must have model_list at this point 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()) - 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) + 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 := 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) }