package plugin // loader.go 实现插件加载器. // // 负责从文件系统发现和加载插件. // 插件是包含 plugin.json 清单文件的目录. // // 插件搜索路径(按优先级从低到高): // 1. ~/.flyto/plugins/ - 用户级插件 // 2. /.flyto/plugins/ - 项目级插件 // // 同名插件后加载的覆盖先加载的(项目级覆盖用户级). // // 加载流程: // 1. 遍历搜索路径,查找包含 plugin.json 的子目录 // 2. 解析 plugin.json 清单 // 3. 加载技能文件(skills/ 目录下的 .md 文件) // 4. 加载 hooks 配置 // 5. 解析 MCP 服务器配置 import ( "fmt" "os" "path/filepath" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" ) // pluginStub 是发现阶段的中间结果:已确认插件目录存在,但尚未完整加载. // 携带 Source 信息,以便加载后的 Plugin 记录其来源. type pluginStub struct { // Path 插件目录的绝对路径 Path string // Source 来源级别(用户级 or 项目级) Source Source } // discoverWithSource 遍历搜索路径,发现所有可用的插件目录,并附带来源信息. // // 搜索逻辑: // - 按 searchPaths 顺序扫描(低优先级在前,高优先级在后) // - 同名插件后发现的覆盖先发现的(map 覆盖策略) // - 路径索引 0 → SourceUser,其余 → SourceProject // // 精妙之处(CLEVER): 用 map 而非 slice 存储 stub,自然实现"同名后来居上"的覆盖语义, // 代码比显式的双重循环去重简洁得多. func discoverWithSource(searchPaths []string) []pluginStub { // 精妙之处(CLEVER): 用有序 keys 列表 + map 组合,保留插入顺序(Go map 无序) // 同时实现 O(1) 的同名覆盖判断. stubMap := make(map[string]pluginStub) var stubOrder []string for i, searchPath := range searchPaths { src := sourceForIndex(i) entries, err := os.ReadDir(searchPath) if err != nil { continue // 目录不存在或无权限,跳过 } for _, entry := range entries { if !entry.IsDir() { continue } pluginDir := filepath.Join(searchPath, entry.Name()) manifestPath := filepath.Join(pluginDir, manifestFileName) if _, err := os.Stat(manifestPath); err != nil { continue // 没有 plugin.json,不是插件目录 } // 快速验证清单可解析(不做完整验证,完整验证在 loadPluginWithErrors 中) manifest, err := LoadManifest(pluginDir) if err != nil { continue } stub := pluginStub{Path: pluginDir, Source: src} if _, exists := stubMap[manifest.Name]; !exists { stubOrder = append(stubOrder, manifest.Name) } stubMap[manifest.Name] = stub // 后来覆盖先来 } } result := make([]pluginStub, 0, len(stubOrder)) for _, name := range stubOrder { result = append(result, stubMap[name]) } return result } // sourceForIndex 根据搜索路径的索引推断来源级别. // 约定:索引 0 = 用户级,其余 = 项目级. // 这与 DefaultSearchPaths 的顺序(用户级在前,项目级在后)对应. func sourceForIndex(i int) Source { if i == 0 { return SourceUser } return SourceProject } // loadPluginWithErrors 完整加载单个插件,以结构化错误列表代替 error 返回值. // // 加载步骤: // 1. 解析清单(plugin.json) // 2. 加载技能(skills/ 目录) // 3. 加载 hooks 配置 // 4. 解析 MCP 服务器配置 // 5. 加载 declarative shell tool (每个 PluginToolDef 翻译为 pluginShellTool) // // 步骤 2-5 失败为非致命(记录 warning),不阻断插件整体加载. // 步骤 1 失败为致命,直接返回错误列表,不继续加载. // // executor 透传给步骤 5 的 loadPluginTools, 最终落到每个 pluginShellTool 的 // executor 字段. M1 commit 7b 起必填, 调用链: Host.LoadAll / LoadFromDir → // loadPluginWithErrors → loadPluginTools → NewPluginShellTool. func loadPluginWithErrors(stub pluginStub, executor execenv.Executor) (*Plugin, []PluginError) { // 步骤 1:解析清单(致命) manifest, err := LoadManifest(stub.Path) if err != nil { code := ErrManifestInvalid if os.IsNotExist(err) { code = ErrManifestNotFound } return nil, []PluginError{{ Code: code, Message: fmt.Sprintf("load manifest from %s", stub.Path), Cause: err, }} } p := &Plugin{ Name: manifest.Name, Description: manifest.Description, Version: manifest.Version, Path: stub.Path, Source: stub.Source, Manifest: manifest, Enabled: true, Hooks: make(map[string][]HookDef), } var warnings []PluginError // 步骤 2:加载技能(非致命) skills, err := LoadPluginSkills(manifest.SkillsPath, manifest.Name) if err != nil { warnings = append(warnings, PluginError{ Code: ErrSkillsLoadFailed, PluginName: manifest.Name, Message: "load skills", Cause: err, }) } p.Skills = skills // 步骤 3:加载 hooks(非致命) if manifest.HooksPath != "" { hooks, err := LoadHooks(manifest.HooksPath) if err != nil { warnings = append(warnings, PluginError{ Code: ErrHooksLoadFailed, PluginName: manifest.Name, Message: "load hooks", Cause: err, }) } else if hooks != nil { p.Hooks = hooks } } // 步骤 4:解析 MCP 服务器配置(非致命,单个服务器配置错误跳过) if len(manifest.MCPServers) > 0 { mcpServers := make([]MCPServerDef, 0, len(manifest.MCPServers)) for name, def := range manifest.MCPServers { mcpServers = append(mcpServers, MCPServerDef{ Name: name, PluginName: manifest.Name, MCPDef: def, }) } p.MCPServers = mcpServers } // 步骤 5: 加载 declarative shell tool (非致命). // 每个 PluginToolDef 被翻译成一个实现 tools.Tool interface 的 pluginShellTool, // 非法声明 (空 Name 或空 Command) 被静默跳过. 详见 plugin_tool.go. p.Tools = loadPluginTools(manifest.Tools, stub.Path, manifest.Name, executor) return p, warnings } // LoadPlugin 完整加载一个插件 (公开接口, 向后兼容). // 参数 dir 是插件目录路径 (必须包含 plugin.json). // 错误时返回第一个致命错误 (如有). // // 本函数使用 DefaultVerifier (当前是 NoopVerifier, 不做完整性校验). // 消费层如需启用完整性校验, 使用 LoadPluginWithVerifier 并传入: // - NewSHA256IntegrityVerifier() 宽松模式 (有 plugin.checksum 才校验) // - RejectUnsignedVerifier{Inner: ...} 严格模式 (必须有 plugin.checksum) // // executor 必填 (方案 β 严格 DI), 透传给每个 pluginShellTool. // 详见 integrity.go 和 signature.go. func LoadPlugin(dir string, executor execenv.Executor) (*Plugin, error) { return LoadPluginWithVerifier(dir, DefaultVerifier(), executor) } // LoadPluginWithVerifier 加载插件并执行完整性 / 签名验证. // // 参数: // - dir: 插件目录路径 (必须包含 plugin.json) // - verifier: 完整性验证器 (nil 则用 DefaultVerifier, 当前是 NoopVerifier) // // 执行顺序: // 1. 加载 manifest // 2. 调用 verifier.Verify(dir, manifest) - 策略由 verifier 自主决定 // (宽松 / 严格 / 零校验) // 3. 验证通过才继续走 loadPluginWithErrors 加载 skills/hooks/MCP // 4. 验证失败返回 wrap 后的 error (通常 errors.Is(err, ErrInvalidSignature)) // // 宽松 vs 严格策略**不在 loader 层做判断**, 完全交给 verifier: // - NoopVerifier: 零校验 // - SHA256IntegrityVerifier: plugin.checksum 不存在放行, 存在则校验 // - RejectUnsignedVerifier: plugin.checksum 不存在直接拒绝 // // 这种 "loader 调度 + verifier 策略" 的分离让未来新增 verifier 实现 // (Ed25519 / Sshsig / Sigstore / PKCS#7) 不需要改 loader 一行代码. func LoadPluginWithVerifier(dir string, verifier SignatureVerifier, executor execenv.Executor) (*Plugin, error) { if verifier == nil { verifier = DefaultVerifier() } // 第一步: 加载 manifest manifest, err := LoadManifest(dir) if err != nil { return nil, fmt.Errorf("load manifest: %w", err) } // 第二步: 完整性验证 (verifier 自主决定宽松/严格/零校验) if err := verifier.Verify(dir, manifest); err != nil { return nil, fmt.Errorf("verify plugin %q: %w", manifest.Name, err) } // 第三步: 完整加载 (skills / hooks / MCP servers / declarative tools) p, errs := loadPluginWithErrors(pluginStub{Path: dir, Source: SourceUser}, executor) if p == nil && len(errs) > 0 { return nil, errs[0] } return p, nil } // ValidateManifest 主动验证插件目录的清单,不实际加载插件. // 用于安装/更新时的预检,或 lint 工具. // // 与 LoadManifest 的区别: // - ValidateManifest 返回结构化 ValidationResult(含 Warnings) // - LoadManifest 只返回 error(pass/fail) func ValidateManifest(dir string) ValidationResult { vr := ValidationResult{Valid: true} manifest, err := LoadManifest(dir) if err != nil { code := ErrManifestInvalid if os.IsNotExist(err) { code = ErrManifestNotFound } vr.addError(code, filepath.Base(dir), "manifest validation failed", err) return vr } // 建议字段检查(非致命 Warning) if manifest.Description == "" { vr.addWarning(ErrManifestValidation, manifest.Name, "description is empty (recommended)", nil) } // MCP 服务器健全性检查(已在 validateManifest 中做了致命检查,这里做额外 Warning) for name, def := range manifest.MCPServers { if def.Transport == "stdio" && len(def.Args) == 0 { vr.addWarning(ErrMCPConfigInvalid, manifest.Name, fmt.Sprintf("mcp_server %q: no args provided for stdio transport", name), nil) } } return vr } // DiscoverPlugins 发现所有可用的插件(旧接口兼容). // 返回发现的插件列表(尚未完全加载,仅解析了清单). // executor 透传给每个 LoadPlugin 调用, 本地模式传 execenv.DefaultExecutor{}. func DiscoverPlugins(searchPaths []string, executor execenv.Executor) []*Plugin { stubs := discoverWithSource(searchPaths) result := make([]*Plugin, 0, len(stubs)) for _, stub := range stubs { p, _ := LoadPlugin(stub.Path, executor) if p != nil { result = append(result, p) } } return result } // DefaultSearchPaths 返回默认的插件搜索路径列表. // 搜索路径按优先级从低到高排列(用户级在前,项目级在后). func DefaultSearchPaths(cwd string) []string { var paths []string // 1. 用户级:~/.flyto/plugins/ home, err := os.UserHomeDir() if err == nil { userPluginDir := filepath.Join(home, ".flyto", "plugins") paths = append(paths, userPluginDir) } // 2. 项目级:/.flyto/plugins/ if cwd != "" { projectPluginDir := filepath.Join(cwd, ".flyto", "plugins") paths = append(paths, projectPluginDir) } return paths }