Files
HomeAgent/internal/plugins/files/plugin.go
JianFeeeee cb76828e43 fix(cmd/files/webui): shell 语义修复 + 根沙箱误判 + 上传注入走 interrupt + UI 区分附件来源
1. cmd_run 改经 /bin/bash -c 执行完整 shell 语法
   旧实现 shellUnquote 拆词后直接 exec:'pwd; ls /' 变成执行名为
   'pwd;' 的程序(exit -1)、heredoc 被截断、管道/命令替换全部失效——
   agent 多次反馈命令解析奇怪即此。危险命令拦截(kill homed 等)保留。

2. files 沙箱根目录判断修复
   pathWithinSandbox 在 base='/' 时 prefix 变 '//',所有绝对路径误判
   逃逸(生产实锤:files.dir=/ 下 files_read/write/ls 全部报 outside
   sandbox)。根沙箱直接放行。

3. webui 文件上传注入改走 interrupt(system 角色)
   文件元信息不再混入用户消息气泡;用户附言作为正常消息先行注入,
   文件说明紧随其后以 no_memory interrupt 补充——对齐 terminal_watch/
   timer 工具提醒模式,聊天流保持干净。

4. 前端附件卡片按 role 区分来源
   user=右侧+『你发送的』标签+accent 底色;assistant=左侧+『小宅发送的』。
   📌 emoji 按钮换为 SVG 图标,前端 emoji 清零。
2026-08-26 10:24:47 +08:00

582 lines
16 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"
"runtime"
"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 的父目录),空则禁用
}
var isWindowsBuild = runtime.GOOS == "windows"
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 !pathWithinSandbox(abs, base) {
return "", fmt.Errorf("path outside sandbox: %s", userPath)
}
return abs, nil
}
// pathWithinSandbox 判断 abs 是否位于沙箱 base 之内。
// Windows 文件系统大小写不敏感,且卷根目录(如 C:\)应放行全盘路径。
func pathWithinSandbox(abs, base string) bool {
lower := func(s string) string {
if isWindowsBuild {
return strings.ToLower(s)
}
return s
}
abs = filepath.Clean(abs)
base = filepath.Clean(base)
if equalFoldPath(abs, base) {
return true
}
// 根沙箱Linux "/"表示整机可访问Clean("") 会返回 "."
// 而 prefix 变成 "//" 导致所有绝对路径误判逃逸生产实锤files.dir=/ 时
// 全部 files_read/write 报 path outside sandbox。根目录直接放行。
if base == string(filepath.Separator) {
return true
}
// 卷根沙箱C:\、D:\ 等)表示整机可访问
if isWindowsBuild && len(base) == 3 && base[1] == ':' && base[2] == '\\' {
return true
}
prefix := lower(base) + string(filepath.Separator)
return strings.HasPrefix(lower(abs), prefix)
}
func equalFoldPath(a, b string) bool {
if isWindowsBuild {
return strings.EqualFold(a, b)
}
return a == b
}
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
}