diff --git a/internal/sandbox/fsbridge.go b/internal/sandbox/fsbridge.go index 951f1378..6188f78b 100644 --- a/internal/sandbox/fsbridge.go +++ b/internal/sandbox/fsbridge.go @@ -50,8 +50,9 @@ func (b *FsBridge) ReadFile(ctx context.Context, path string) (string, error) { } // WriteFile writes content to a file inside the container, creating directories as needed. +// When append is true, content is appended (shell >>); otherwise the file is overwritten (shell >). // Matching TS FsBridge.writeFile(). -func (b *FsBridge) WriteFile(ctx context.Context, path, content string) error { +func (b *FsBridge) WriteFile(ctx context.Context, path, content string, appendMode bool) error { resolved := b.resolvePath(path) // Create parent directory @@ -60,8 +61,12 @@ func (b *FsBridge) WriteFile(ctx context.Context, path, content string) error { _, _, _, _ = b.dockerExec(ctx, nil, "mkdir", "-p", dir) } + redir := ">" + if appendMode { + redir = ">>" + } // Write content via stdin pipe - _, stderr, exitCode, err := b.dockerExec(ctx, []byte(content), "sh", "-c", fmt.Sprintf("cat > %q", resolved)) + _, stderr, exitCode, err := b.dockerExec(ctx, []byte(content), "sh", "-c", fmt.Sprintf("cat %s %q", redir, resolved)) if err != nil { return fmt.Errorf("fsbridge write: %w", err) } diff --git a/internal/tools/edit.go b/internal/tools/edit.go index f7afeb26..7ad59266 100644 --- a/internal/tools/edit.go +++ b/internal/tools/edit.go @@ -14,13 +14,13 @@ import ( // EditTool performs search-and-replace edits on files. // Supports context file interceptor and sandbox routing. type EditTool struct { - workspace string - restrict bool - deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw) - sandboxMgr sandbox.Manager - contextFileIntc *ContextFileInterceptor - memIntc *MemoryInterceptor - permStore store.ConfigPermissionStore // nil = no group write restriction + workspace string + restrict bool + deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw) + sandboxMgr sandbox.Manager + contextFileIntc *ContextFileInterceptor + memIntc *MemoryInterceptor + permStore store.ConfigPermissionStore // nil = no group write restriction } // DenyPaths adds path prefixes that edit must reject. @@ -215,7 +215,7 @@ func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr st return result } - if err := bridge.WriteFile(ctx, containerPath, newContent); err != nil { + if err := bridge.WriteFile(ctx, containerPath, newContent, false); err != nil { return ErrorResult(fmt.Sprintf("failed to write file: %v", err) + MaybeFsBridgeHint(err)) } diff --git a/internal/tools/filesystem_write.go b/internal/tools/filesystem_write.go index 15becbcb..c9efba34 100644 --- a/internal/tools/filesystem_write.go +++ b/internal/tools/filesystem_write.go @@ -13,14 +13,14 @@ import ( // 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 + 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. @@ -148,7 +148,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul // Sandbox routing (sandboxKey from ctx — thread-safe) sandboxKey := ToolSandboxKeyFromCtx(ctx) if t.sandboxMgr != nil && sandboxKey != "" { - return t.executeInSandbox(ctx, path, content, sandboxKey, deliver) + return t.executeInSandbox(ctx, path, content, sandboxKey, deliver, appendMode) } // Host execution — use per-user workspace from context if available @@ -222,7 +222,7 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul return result } -func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, sandboxKey string, deliver bool) *Result { +func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, sandboxKey string, deliver, appendMode bool) *Result { bridge, err := t.getFsBridge(ctx, sandboxKey) if err != nil { return ErrorResult(fmt.Sprintf("sandbox error: %v", err)) @@ -234,11 +234,19 @@ func (t *WriteFileTool) executeInSandbox(ctx context.Context, path, content, san } containerPath := ResolveSandboxPath(path, containerCwd) - if err := bridge.WriteFile(ctx, containerPath, content); err != nil { - return ErrorResult(fmt.Sprintf("failed to write file: %v", err) + MaybeFsBridgeHint(err)) + if err := bridge.WriteFile(ctx, containerPath, content, appendMode); err != nil { + verb := "write" + if appendMode { + verb = "append to" + } + return ErrorResult(fmt.Sprintf("failed to %s file: %v", verb, err) + MaybeFsBridgeHint(err)) } - msg := fmt.Sprintf("File written: %s (%d bytes)", path, len(content)) + 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." }