Files
goclaw/internal/tools/filesystem_write.go
T
2c1efa29ec fix(memory): prevent silent data loss on memory file overwrites (#279)
* fix(memory): auto-backup + overwrite warning for memory files

When an agent writes to a memory file that already has content:
1. Old content is backed up to memory/.prev/{filename} (best-effort)
2. Write succeeds normally (no blocking)
3. Tool result includes warning with previous content (truncated to
   4000 chars) so the agent can notice and merge if needed

Backup files are hidden from list_files and not indexed/embedded.
No database migration needed - uses existing PutDocument.

* fix(memory): support append mode + fix UTF-8 truncation in overwrite warning

- Add appendMode parameter to MemoryInterceptor.WriteFile() so
  write_file(append=true) actually appends to memory files instead
  of silently replacing content. Uses "\n\n---\n\n" separator.
- Fix UTF-8 truncation in overwrite warning: use []rune instead of
  byte slicing to avoid cutting multi-byte characters.
- Fix char count in warning message: use len([]rune()) not len().
- Remove .prev/ backup mechanism (prevBackupPath, isPrevPath, backup
  writes, ListFiles filter) in favor of simpler approach: append
  mode prevents data loss, warning enables self-correction.

---------

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
Co-authored-by: viettranx <viettranx@gmail.com>
2026-03-19 17:42:03 +07:00

256 lines
8.7 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// WriteFileTool writes content to a file, optionally through a sandbox container.
type WriteFileTool struct {
workspace string
restrict bool
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
memIntc *MemoryInterceptor // nil = no memory routing
permStore store.ConfigPermissionStore // nil = no group write restriction
workspaceIntc *WorkspaceInterceptor // nil = no team workspace validation
}
// DenyPaths adds path prefixes that write_file must reject.
func (t *WriteFileTool) DenyPaths(prefixes ...string) {
t.deniedPrefixes = append(t.deniedPrefixes, prefixes...)
}
// SetContextFileInterceptor enables virtual FS routing for context files.
func (t *WriteFileTool) SetContextFileInterceptor(intc *ContextFileInterceptor) {
t.contextFileIntc = intc
}
// SetMemoryInterceptor enables virtual FS routing for memory files.
func (t *WriteFileTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
t.memIntc = intc
}
// SetConfigPermStore enables group write permission checks.
func (t *WriteFileTool) SetConfigPermStore(s store.ConfigPermissionStore) {
t.permStore = s
}
// SetWorkspaceInterceptor enables team workspace validation and event broadcasting.
func (t *WriteFileTool) SetWorkspaceInterceptor(intc *WorkspaceInterceptor) {
t.workspaceIntc = intc
}
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
return &WriteFileTool{workspace: workspace, restrict: restrict}
}
func NewSandboxedWriteFileTool(workspace string, restrict bool, mgr sandbox.Manager) *WriteFileTool {
return &WriteFileTool{workspace: workspace, restrict: restrict, sandboxMgr: mgr}
}
// SetSandboxKey is a no-op; sandbox key is now read from ctx (thread-safe).
func (t *WriteFileTool) SetSandboxKey(key string) {}
func (t *WriteFileTool) Name() string { return "write_file" }
func (t *WriteFileTool) Description() string {
return "Write content to a file, creating directories as needed. " +
"IMPORTANT: content longer than ~12000 characters may be truncated by the API. " +
"For large files, use the edit tool to build the file in sections, or split into multiple write_file calls with append=true."
}
func (t *WriteFileTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "File path (relative to workspace, or absolute)",
},
"content": map[string]any{
"type": "string",
"description": "Content to write",
},
"append": map[string]any{
"type": "boolean",
"description": "Append content to the file instead of overwriting. Use this to build large files in chunks.",
},
"deliver": map[string]any{
"type": "boolean",
"description": "Deliver this file to the user as an attachment. Defaults to true. Set to false for intermediate/temporary files (e.g. config, cache, temp scripts).",
},
},
"required": []string{"path", "content"},
}
}
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Result {
path, _ := args["path"].(string)
content, _ := args["content"].(string)
appendMode, _ := args["append"].(bool)
deliver := true
if v, ok := args["deliver"].(bool); ok {
deliver = v
}
if path == "" {
return ErrorResult("path is required")
}
// Group write permission check
if t.permStore != nil {
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
return ErrorResult(err.Error())
}
}
// Virtual FS: route context files to DB
if t.contextFileIntc != nil {
if handled, err := t.contextFileIntc.WriteFile(ctx, path, content); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to write context file: %v", err))
}
return SilentResult(fmt.Sprintf("Context file written: %s (%d bytes)", path, len(content)))
}
}
// Virtual FS: route memory files to DB
if t.memIntc != nil {
if mwr, err := t.memIntc.WriteFile(ctx, path, content, appendMode); mwr.Handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to write memory file: %v", err))
}
msg := fmt.Sprintf("Memory file written: %s (%d bytes)", path, len(content))
if mwr.KGTriggered {
msg += "\n\n[Knowledge graph extraction triggered in background. The knowledge system may take a moment to fully update with new entities and relationships.]"
}
if mwr.PreviousContent != "" {
prev := mwr.PreviousContent
prevRunes := []rune(prev)
if len(prevRunes) > 4000 {
prev = string(prevRunes[:4000]) + "\n... (truncated)"
}
msg += fmt.Sprintf("\n\n⚠️ WARNING: This file had existing content (%d chars) that was replaced. "+
"If the old content below contains information not present in your new version, "+
"please re-write the file to merge both.\n\n"+
"--- PREVIOUS CONTENT ---\n%s\n--- END PREVIOUS CONTENT ---",
len([]rune(mwr.PreviousContent)), prev)
}
return SilentResult(msg)
}
}
// Sandbox routing (sandboxKey from ctx — thread-safe)
sandboxKey := ToolSandboxKeyFromCtx(ctx)
if t.sandboxMgr != nil && sandboxKey != "" {
return t.executeInSandbox(ctx, path, content, sandboxKey, deliver)
}
// Host execution — use per-user workspace from context if available
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
workspace = t.workspace
}
allowed := allowedWithTeamWorkspace(ctx, nil)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed)
if err != nil {
return ErrorResult(err.Error())
}
if err := checkDeniedPath(resolved, t.workspace, t.deniedPrefixes); err != nil {
return ErrorResult(err.Error())
}
// Team workspace validation + delete-on-empty.
if t.workspaceIntc != nil {
isDelete, intcErr := t.workspaceIntc.HandleWrite(ctx, resolved, content)
if intcErr != nil {
return ErrorResult(intcErr.Error())
}
if isDelete {
if err := os.Remove(resolved); err != nil && !os.IsNotExist(err) {
return ErrorResult(fmt.Sprintf("failed to delete file: %v", err))
}
t.workspaceIntc.AfterWrite(ctx, resolved, "delete")
return SilentResult(fmt.Sprintf("File deleted: %s", path))
}
}
if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
}
if appendMode {
f, err := os.OpenFile(resolved, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to open file for append: %v", err))
}
_, err = f.WriteString(content)
f.Close()
if err != nil {
return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
}
} else if err := os.WriteFile(resolved, []byte(content), 0644); err != nil {
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
}
if t.workspaceIntc != nil {
t.workspaceIntc.AfterWrite(ctx, resolved, "write")
}
verb := "written"
if appendMode {
verb = "appended"
}
msg := fmt.Sprintf("File %s: %s (%d bytes)", verb, path, len(content))
if deliver {
msg += ". File will be automatically delivered to the user — do NOT send it again via message tool."
}
result := SilentResult(msg)
result.Deliverable = content
if deliver {
result.Media = []bus.MediaFile{{Path: resolved}}
}
return result
}
func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, sandboxKey string, deliver bool) *Result {
bridge, err := t.getFsBridge(ctx, sandboxKey)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
if err := bridge.WriteFile(ctx, path, content); err != nil {
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
}
msg := fmt.Sprintf("File written: %s (%d bytes)", path, len(content))
if deliver {
msg += ". File will be automatically delivered to the user — do NOT send it again via message tool."
}
result := SilentResult(msg)
result.Deliverable = content
if deliver {
// Sandbox workspace is bind-mounted — resolve to host path for delivery
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
workspace = t.workspace
}
hostPath := filepath.Join(workspace, path)
result.Media = []bus.MediaFile{{Path: hostPath}}
}
return result
}
func (t *WriteFileTool) getFsBridge(ctx context.Context, sandboxKey string) (*sandbox.FsBridge, error) {
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace, SandboxConfigFromCtx(ctx))
if err != nil {
return nil, err
}
return sandbox.NewFsBridge(sb.ID(), "/workspace"), nil
}