Files
HomeAgent/internal/plugins/files/plugin.go
root bc9ef15eb0 三层回退恢复机制(L0写前留档/L1恢复梯子/L2离线回滚)+ guard 父守护
- L0: files 插件写受保护系统路径(/etc 等)前自动留档,AbstractBeforeWrite 到 data/file_baseline
- L1: failback 受限 worker 执行恢复梯子 probe→还原DNS/proxy→还原LLM配置+ReloadFromConfig→probe,N轮有界
- L2: tracker changeset 持久化原文 blob,guard 离线 RollbackFromDisk 回滚 agentfs;SystemSnapshot 支撑
- guard 父守护: 心跳 IPC(PING/ACK unix socket, 文件心跳回退)、失败计数、退出码协议(42/43/44)、最后手段
- 发行版路径适配: system.protected_paths/network_paths 可注入,默认面向主流 Linux
- Windows 兼容: guard.go/failback.go 加 //go:build linux, guard_windows.go 提供 no-op 桩
- 修复: guard.yaml last_resort 键冲突、changeset Content 不落盘导致离线回滚丢原文

Build 全绿, vet 干净, system/recovery/ipc/tracker 单元测试全过
2026-08-05 16:00:08 +08:00

544 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package files
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/system"
)
func init() {
plugin.RegisterPluginMeta("files", "文件系统", "Files")
plugin.RegisterFactory("files", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
filesDir string
baseDir string // L0 写前留档根目录(<data>/file_baseline 的父目录),空则禁用
}
func New(name string) *Plugin {
return &Plugin{name: name}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "dir",
Default: "/",
Type: "string",
DisplayName: "文件系统根目录",
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
Category: "files",
})
dir := getSetting[string](s.Settings(), "dir", "/")
if strings.HasPrefix(dir, "~/") {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, dir[2:])
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("resolve files.dir: %w", err)
}
p.filesDir = abs
// L0 写前留档:主 agent 写 /etc 等受保护路径前自动存档原文homed 注入 <data>
if cfg := s.Config().Get(); cfg != nil {
if dd := cfg.Daemon.DataDir; dd != "" {
p.baseDir = dd
}
}
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "baseline_dir",
Default: p.baseDir,
Type: "string",
DisplayName: "写前留档目录",
Description: "写受保护系统路径前自动存档原文的目录(空禁用)",
Category: "files",
})
if v, err := s.Settings().Get("baseline_dir"); err == nil && v != nil {
if s, ok := v.(string); ok && s != "" {
p.baseDir = s
}
}
if p.baseDir != "" {
log.Printf("[%s] write-ahead baseline dir: %s", p.name, system.FileBaselineDir(p.baseDir))
}
tp := p.name + "_"
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
Description: fmt.Sprintf("读取文件内容。支持 offset/limit 分段读取大文件。沙箱路径: %s", p.filesDir),
NoMemory: false,
Cleaner: func(output string) string {
var r struct{ Content string }
if err := json.Unmarshal([]byte(output), &r); err != nil {
return output
}
return r.Content
},
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件路径(绝对路径或相对于沙箱的路径)"},
"offset": map[string]interface{}{"type": "integer", "description": "起始行号从1开始可选默认1"},
"limit": map[string]interface{}{"type": "integer", "description": "最多返回的行数(可选,默认全部)"},
},
"required": []string{"path"},
},
}, p.handleRead)
s.RegisterTool(tp+"write", sdk.ToolDef{
Name: tp + "write",
Description: fmt.Sprintf("写入文件。自动创建父目录。沙箱路径: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
"mode": map[string]interface{}{"type": "string", "description": "写入模式: overwrite覆盖默认| append追加到末尾| insert插入到指定行| create创建新文件已存在则报错"},
"line": map[string]interface{}{"type": "integer", "description": "插入模式时的目标行号从1开始内容将插入到该行之前"},
},
"required": []string{"path", "content"},
},
}, p.handleWrite)
s.RegisterTool(tp+"edit", sdk.ToolDef{
Name: tp + "edit",
Description: fmt.Sprintf("对文件执行精确字符串替换。每个 old 必须在原文中唯一匹配。沙箱路径: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
"edits": map[string]interface{}{
"type": "array",
"description": "一个或多个替换操作。每个 old 必须在原文中恰好出现一次。不要包含重叠的 edit。",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"old": map[string]interface{}{"type": "string", "description": "要查找的原文(必须在文件中唯一)"},
"new": map[string]interface{}{"type": "string", "description": "替换后的文本"},
},
"required": []string{"old", "new"},
},
},
},
"required": []string{"path", "edits"},
},
}, p.handleEdit)
s.RegisterTool(tp+"ls", sdk.ToolDef{
Name: tp + "ls",
Description: fmt.Sprintf("列出目录内容。目录以 / 后缀标记。沙箱路径: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "目录路径(可选,默认为沙箱根目录)"},
"limit": map[string]interface{}{"type": "integer", "description": "最多返回条目数可选默认500"},
},
},
}, p.handleLs)
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
return nil
}
func (p *Plugin) Stop() error {
log.Printf("[%s] stopped", p.name)
return nil
}
func (p *Plugin) resolvePath(userPath string) (string, error) {
if userPath == "" {
userPath = "."
}
if !filepath.IsAbs(userPath) {
userPath = filepath.Join(p.filesDir, userPath)
}
abs, err := filepath.Abs(userPath)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
return "", fmt.Errorf("path outside sandbox: %s", userPath)
}
return abs, nil
}
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if info.IsDir() {
return errorResult("is a directory, use ls instead: " + path), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
return errorResult("read error: " + err.Error()), nil
}
text := string(data)
lines := strings.Split(text, "\n")
totalLines := len(lines)
offset := 0
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = int(v) - 1
}
if offset >= totalLines {
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
}
limit := totalLines - offset
if v, ok := args["limit"].(float64); ok && v > 0 {
if int(v) < limit {
limit = int(v)
}
}
end := offset + limit
if end > totalLines {
end = totalLines
}
selected := lines[offset:end]
output := strings.Join(selected, "\n")
var sb strings.Builder
sb.WriteString(output)
if end < totalLines {
nextOffset := end + 1
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
} else if offset > 0 {
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
}
return map[string]interface{}{
"content": sb.String(),
"size": len(data),
"lines": totalLines,
}, nil
}
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
content, _ := args["content"].(string)
mode, _ := args["mode"].(string)
if mode == "" {
mode = "overwrite"
}
line := 0
if v, ok := args["line"].(float64); ok && v > 0 {
line = int(v)
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
// L0 写前留档:覆盖已有受保护文件前,原文存档供 guard 还原。
if mode != "create" {
if archived, aerr := p.archiveBeforeWrite(absPath); aerr != nil {
log.Printf("[%s] write-ahead archive %s: %v", p.name, absPath, aerr)
} else if archived {
log.Printf("[%s] write-ahead archived %s", p.name, absPath)
}
}
switch mode {
case "create":
if _, err := os.Stat(absPath); err == nil {
return errorResult("file already exists: " + path), nil
}
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
}, nil
case "append":
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return errorResult("open error: " + err.Error()), nil
}
defer f.Close()
if _, err := f.WriteString(content); err != nil {
return errorResult("append error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
}, nil
case "insert":
if line < 1 {
return errorResult("line must be >= 1 for insert mode"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
lines := strings.Split(string(data), "\n")
if line > len(lines)+1 {
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
}
idx := line - 1
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:idx]...)
newLines = append(newLines, content)
newLines = append(newLines, lines[idx:]...)
result := strings.Join(newLines, "\n")
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
}, nil
default: // overwrite
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
}, nil
}
}
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
// L0 写前留档edit 可能覆盖已存在文件)
if archived, aerr := p.archiveBeforeWrite(absPath); aerr != nil {
log.Printf("[%s] write-ahead archive %s: %v", p.name, absPath, aerr)
} else if archived {
log.Printf("[%s] write-ahead archived %s", p.name, absPath)
}
rawEdits, ok := args["edits"].([]interface{})
if !ok || len(rawEdits) == 0 {
return errorResult("edits must be a non-empty array"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
original := string(data)
content := original
applied := 0
var errors []string
for i, raw := range rawEdits {
edit, ok := raw.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
continue
}
oldText, _ := edit["old"].(string)
newText, _ := edit["new"].(string)
if oldText == "" {
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
continue
}
count := strings.Count(content, oldText)
if count == 0 {
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
continue
}
if count > 1 {
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
continue
}
content = strings.Replace(content, oldText, newText, 1)
applied++
}
if applied == 0 {
msg := "no edits applied"
if len(errors) > 0 {
msg += ": " + strings.Join(errors, "; ")
}
return errorResult(msg), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
if len(errors) > 0 {
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
}
return map[string]interface{}{
"content": msg,
}, nil
}
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
path = "."
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("path not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if !info.IsDir() {
return errorResult("not a directory: " + path), nil
}
entries, err := os.ReadDir(absPath)
if err != nil {
return errorResult("readdir error: " + err.Error()), nil
}
limit := 500
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
var lines []string
entryLimitReached := false
for i, entry := range entries {
if i >= limit {
entryLimitReached = true
break
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
info, err := entry.Info()
if err == nil {
name = fmt.Sprintf("%-40s %8d", name, info.Size())
}
lines = append(lines, name)
}
if len(lines) == 0 {
return map[string]interface{}{
"content": "(empty directory)",
}, nil
}
output := strings.Join(lines, "\n")
if entryLimitReached {
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
}
return map[string]interface{}{
"content": output,
}, nil
}
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
// archiveBeforeWrite L0 写前留档:若目标为受保护系统路径且已存在,则存档原文。
func (p *Plugin) archiveBeforeWrite(absPath string) (bool, error) {
if p.baseDir == "" {
return false, nil
}
return system.ArchiveBeforeWrite(p.baseDir, absPath)
}
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
val, ok := v.(T)
if !ok {
return def
}
return val
}