Files
Cyrene_v2/backend/plugins/file/plugin.go
T
Aska 9d040fde0b feat(plugins): implement web-search, web-fetch, http and file tools
- web-search: real SearXNG /search?format=json query, top-5 formatted
  results (was a 'not configured' stub)
- web-fetch: fetch page, strip scripts/styles/tags, truncate to 4000
  runes for the LLM context
- http: generic request tool (GET/POST/PUT/DELETE/PATCH) with scheme
  and method validation, status + truncated body
- file: sandboxed read/write/list inside the data directory with path
  escape rejection; split into file_read/file_write/file_list
- all tool schemas converted to proper JSON Schema; httptest/tempdir
  tests for each plugin
2026-08-21 12:57:19 +08:00

133 lines
3.6 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 file
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
)
// Plugin provides sandboxed file read/write inside the configured data directory.
type Plugin struct {
dataDir string
}
func New() *Plugin { return &Plugin{} }
func (p *Plugin) Name() string { return "file" }
func (p *Plugin) Version() string { return "1.0.0" }
func (p *Plugin) Description() string { return "文件操作" }
func (p *Plugin) Close() error { return nil }
func (p *Plugin) Enabled() bool { return p.dataDir != "" }
func (p *Plugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
p.dataDir = cfg.DataDir
if p.dataDir == "" {
p.dataDir = "data/files"
}
return os.MkdirAll(p.dataDir, 0o755)
}
func (p *Plugin) Tools() []plugin.ToolDefinition {
return []plugin.ToolDefinition{
{
Name: "file_read",
Description: "Read a text file from the data directory.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]string{"type": "string", "description": "Relative path inside the data directory"},
},
"required": []string{"path"},
},
},
{
Name: "file_write",
Description: "Write text to a file in the data directory (overwrites).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]string{"type": "string", "description": "Relative path inside the data directory"},
"content": map[string]string{"type": "string", "description": "Text content to write"},
},
"required": []string{"path", "content"},
},
},
{
Name: "file_list",
Description: "List files in the data directory (or a subdirectory).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]string{"type": "string", "description": "Relative directory path (default: root)"},
},
},
},
}
}
func (p *Plugin) Execute(_ context.Context, toolName string, params map[string]any) (string, error) {
rel, _ := params["path"].(string)
path, err := p.resolve(rel)
if err != nil {
return "", err
}
switch toolName {
case "file_read":
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
text := string(data)
if runes := []rune(text); len(runes) > 4000 {
text = string(runes[:4000]) + "\n…(文件过长已截断)"
}
return text, nil
case "file_write":
content, _ := params["content"].(string)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return "", fmt.Errorf("write: %w", err)
}
return fmt.Sprintf("已写入 %s(%d 字节)", rel, len(content)), nil
case "file_list":
entries, err := os.ReadDir(path)
if err != nil {
return "", fmt.Errorf("list: %w", err)
}
if len(entries) == 0 {
return "(空目录)", nil
}
var sb strings.Builder
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
sb.WriteString(name + "\n")
}
return sb.String(), nil
}
return "", fmt.Errorf("unknown tool %q", toolName)
}
// resolve maps a relative path into the sandbox and rejects escapes.
func (p *Plugin) resolve(rel string) (string, error) {
clean := filepath.Clean("/" + rel) // force relative interpretation
full := filepath.Join(p.dataDir, clean)
root, _ := filepath.Abs(p.dataDir)
abs, _ := filepath.Abs(full)
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes data directory")
}
return full, nil
}