mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-02 16:21:39 +00:00
- Remove standalone mode code: file-based stores, standalone gateway, heartbeat service, SQLite memory, standalone docker-compose - Rename docker-compose.managed.yml → docker-compose.postgres.yml - Clean up ~130 Go comments referencing "managed mode" qualifier - Simplify docker-compose.yml env vars (providers/channels via web UI) - Update .env.example to essential vars only (token + encryption key) - Add setup wizard UI (provider → agent → channel bootstrap flow) - Add logs.tail WebSocket handler for live log streaming - Add cursor-pointer to interactive UI components - Clean up config page (remove standalone-only sections) - Update README and docs for managed-only architecture
230 lines
7.1 KiB
Go
230 lines
7.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// 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
|
|
groupWriterCache *store.GroupWriterCache // nil = no group write restriction
|
|
}
|
|
|
|
// DenyPaths adds path prefixes that edit must reject.
|
|
func (t *EditTool) DenyPaths(prefixes ...string) {
|
|
t.deniedPrefixes = append(t.deniedPrefixes, prefixes...)
|
|
}
|
|
|
|
func (t *EditTool) SetContextFileInterceptor(intc *ContextFileInterceptor) {
|
|
t.contextFileIntc = intc
|
|
}
|
|
|
|
func (t *EditTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
|
|
t.memIntc = intc
|
|
}
|
|
|
|
// SetGroupWriterCache enables group write permission checks.
|
|
func (t *EditTool) SetGroupWriterCache(c *store.GroupWriterCache) {
|
|
t.groupWriterCache = c
|
|
}
|
|
|
|
func NewEditTool(workspace string, restrict bool) *EditTool {
|
|
return &EditTool{workspace: workspace, restrict: restrict}
|
|
}
|
|
|
|
func NewSandboxedEditTool(workspace string, restrict bool, mgr sandbox.Manager) *EditTool {
|
|
return &EditTool{workspace: workspace, restrict: restrict, sandboxMgr: mgr}
|
|
}
|
|
|
|
func (t *EditTool) SetSandboxKey(key string) {}
|
|
|
|
func (t *EditTool) Name() string { return "edit" }
|
|
func (t *EditTool) Description() string {
|
|
return "Edit a file by replacing exact text matches. Use old_string/new_string for precise edits without rewriting the entire file."
|
|
}
|
|
|
|
func (t *EditTool) Parameters() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"type": "object",
|
|
"properties": map[string]interface{}{
|
|
"path": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Path to the file to edit",
|
|
},
|
|
"old_string": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Exact text to find (must match uniquely unless replace_all is true)",
|
|
},
|
|
"new_string": map[string]interface{}{
|
|
"type": "string",
|
|
"description": "Replacement text",
|
|
},
|
|
"replace_all": map[string]interface{}{
|
|
"type": "boolean",
|
|
"description": "Replace all occurrences (default: false, requires unique match)",
|
|
},
|
|
},
|
|
"required": []string{"path", "old_string", "new_string"},
|
|
}
|
|
}
|
|
|
|
func (t *EditTool) Execute(ctx context.Context, args map[string]interface{}) *Result {
|
|
path, _ := args["path"].(string)
|
|
oldStr, _ := args["old_string"].(string)
|
|
newStr, _ := args["new_string"].(string)
|
|
replaceAll, _ := args["replace_all"].(bool)
|
|
|
|
if path == "" {
|
|
return ErrorResult("path is required")
|
|
}
|
|
if oldStr == "" {
|
|
return ErrorResult("old_string is required")
|
|
}
|
|
if oldStr == newStr {
|
|
return ErrorResult("old_string and new_string are identical")
|
|
}
|
|
|
|
// Group write permission check
|
|
if t.groupWriterCache != nil {
|
|
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
}
|
|
|
|
// Virtual FS: context files
|
|
if t.contextFileIntc != nil {
|
|
if content, handled, err := t.contextFileIntc.ReadFile(ctx, path); handled {
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to read context file: %v", err))
|
|
}
|
|
if content == "" {
|
|
return ErrorResult(fmt.Sprintf("context file not found: %s", path))
|
|
}
|
|
newContent, result := applyEdit(content, oldStr, newStr, replaceAll)
|
|
if result != nil {
|
|
return result
|
|
}
|
|
if _, err := t.contextFileIntc.WriteFile(ctx, path, newContent); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write context file: %v", err))
|
|
}
|
|
return SilentResult(fmt.Sprintf("Context file edited: %s", path))
|
|
}
|
|
}
|
|
|
|
// Virtual FS: memory files
|
|
if t.memIntc != nil {
|
|
if content, handled, err := t.memIntc.ReadFile(ctx, path); handled {
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to read memory file: %v", err))
|
|
}
|
|
if content == "" {
|
|
return ErrorResult(fmt.Sprintf("memory file not found: %s", path))
|
|
}
|
|
newContent, result := applyEdit(content, oldStr, newStr, replaceAll)
|
|
if result != nil {
|
|
return result
|
|
}
|
|
if _, err := t.memIntc.WriteFile(ctx, path, newContent); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write memory file: %v", err))
|
|
}
|
|
return SilentResult(fmt.Sprintf("Memory file edited: %s", path))
|
|
}
|
|
}
|
|
|
|
// Sandbox routing
|
|
sandboxKey := ToolSandboxKeyFromCtx(ctx)
|
|
if t.sandboxMgr != nil && sandboxKey != "" {
|
|
return t.executeInSandbox(ctx, path, oldStr, newStr, replaceAll, sandboxKey)
|
|
}
|
|
|
|
// Host execution — use per-user workspace from context if available
|
|
workspace := ToolWorkspaceFromCtx(ctx)
|
|
if workspace == "" {
|
|
workspace = t.workspace
|
|
}
|
|
resolved, err := resolvePath(path, workspace, t.restrict)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
if err := checkDeniedPath(resolved, t.workspace, t.deniedPrefixes); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
data, err := os.ReadFile(resolved)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
|
|
}
|
|
|
|
content := string(data)
|
|
newContent, result := applyEdit(content, oldStr, newStr, replaceAll)
|
|
if result != nil {
|
|
return result
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(resolved), 0755); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
|
|
}
|
|
|
|
if err := os.WriteFile(resolved, []byte(newContent), 0644); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
count := strings.Count(content, oldStr)
|
|
return SilentResult(fmt.Sprintf("File edited: %s (%d replacement(s))", path, count))
|
|
}
|
|
|
|
func (t *EditTool) executeInSandbox(ctx context.Context, path, oldStr, newStr string, replaceAll bool, sandboxKey string) *Result {
|
|
sb, err := t.sandboxMgr.Get(ctx, sandboxKey, t.workspace)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
|
|
}
|
|
|
|
bridge := sandbox.NewFsBridge(sb.ID(), "/workspace")
|
|
content, err := bridge.ReadFile(ctx, path)
|
|
if err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
|
|
}
|
|
|
|
newContent, result := applyEdit(content, oldStr, newStr, replaceAll)
|
|
if result != nil {
|
|
return result
|
|
}
|
|
|
|
if err := bridge.WriteFile(ctx, path, newContent); err != nil {
|
|
return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
|
|
}
|
|
|
|
count := strings.Count(content, oldStr)
|
|
return SilentResult(fmt.Sprintf("File edited: %s (%d replacement(s))", path, count))
|
|
}
|
|
|
|
// applyEdit performs the search-and-replace. Returns (newContent, nil) on success
|
|
// or ("", errorResult) on failure.
|
|
func applyEdit(content, oldStr, newStr string, replaceAll bool) (string, *Result) {
|
|
count := strings.Count(content, oldStr)
|
|
if count == 0 {
|
|
return "", ErrorResult("old_string not found in file")
|
|
}
|
|
if !replaceAll && count > 1 {
|
|
return "", ErrorResult(fmt.Sprintf("old_string found %d times — use replace_all=true or provide a more specific match", count))
|
|
}
|
|
|
|
if replaceAll {
|
|
return strings.ReplaceAll(content, oldStr, newStr), nil
|
|
}
|
|
return strings.Replace(content, oldStr, newStr, 1), nil
|
|
}
|