mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* stage 09: finish plugin wiring (tools/hooks/skills)
Activate resolved plugin manifests so their declared tools, hooks, and
skills take effect at runtime instead of being parsed-but-unconsumed.
- internal/plugins/activate.go: Activate() registers each plugin tool into
the tools.Registry via a command-runner adapter (JSON args on stdin,
stdout/exit -> tools.Result, ${AGENT_PLUGIN_ROOT} expansion + env),
builds hooks.Definition values for plugin hooks, and exposes plugin
skill search roots. Permission maps allow/prompt/deny -> tools.Safety;
allow only survives load-time clamping when manifest auto-approval is
enabled, so mutating plugin tools are not auto-approved by default. A
plugin-aware skill tool merges the default skills dir with plugin roots
(recording duplicate names, never crashing). Malformed plugins/extensions
are skipped with a warning; activation order is deterministic.
- internal/cli: activatePlugins() invokes activation in both bootstrap
paths (interactive TUI in app.go and one-shot exec in exec.go), after
core/specialist/MCP registration. newHookDispatcherWithExtra folds plugin
hooks into the dispatcher's hook set. Fails open: a bad plugin warns and
is skipped, never wedging startup.
- internal/plugins/plugins.go: update the stale 'not yet registered' note.
* stage 09: normalize plugin command path separators for Windows
expandPluginRoot kept the manifest's forward slashes, so a ${AGENT_PLUGIN_ROOT}/bin/x command became a mixed-separator path on Windows (C:\...\001/bin/x), failing the path tests. Added expandPluginRootPath (filepath.FromSlash on the placeholder-expanded executable path only; args keep their slashes since they may be URLs/flags) and used it for the tool and hook command paths.
* stage 09: address plugin wiring review
- surface per-plugin load diagnostics on stderr (not just top-level errors)
- skip plugin tools whose name collides with an already-registered tool
- resolve manifest-relative plugin skill paths against the plugin dir
* stage 09: fall back to plugin path/id when manifest path unknown in load diagnostic
219 lines
7.2 KiB
Go
219 lines
7.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/hooks"
|
|
"github.com/Gitlawb/zero/internal/plugins"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
// fakePluginDeps builds appDeps whose loadPlugins returns the supplied plugins and
|
|
// whose skillsDir points at an empty dir, so activatePlugins can be exercised
|
|
// without touching the real plugin roots.
|
|
func fakePluginDeps(t *testing.T, loaded []plugins.LoadedPlugin) appDeps {
|
|
t.Helper()
|
|
skillsDir := t.TempDir()
|
|
return appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
loadPlugins: func(plugins.LoadOptions) (plugins.LoadResult, error) {
|
|
return plugins.LoadResult{Plugins: loaded}, nil
|
|
},
|
|
skillsDir: func() string { return skillsDir },
|
|
}
|
|
}
|
|
|
|
func TestActivatePluginsRegistersToolAndCollectsHooks(t *testing.T) {
|
|
pluginDir := t.TempDir()
|
|
loaded := []plugins.LoadedPlugin{{
|
|
ID: "zero.demo",
|
|
Name: "Demo",
|
|
Enabled: true,
|
|
Source: plugins.SourceProject,
|
|
PluginDir: pluginDir,
|
|
Tools: []plugins.ToolExtension{{
|
|
Name: "demo_lookup",
|
|
Description: "lookup",
|
|
Command: "true",
|
|
Permission: plugins.PermissionPrompt,
|
|
}},
|
|
Hooks: []plugins.HookExtension{{
|
|
Name: "pre",
|
|
Event: plugins.HookBeforeTool,
|
|
Command: "guard.sh",
|
|
}},
|
|
}}
|
|
|
|
registry := tools.NewRegistry()
|
|
var stderr bytes.Buffer
|
|
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
|
|
|
|
if _, ok := registry.Get("demo_lookup"); !ok {
|
|
t.Fatalf("plugin tool not registered into the bootstrap registry")
|
|
}
|
|
if len(activation.hooks) != 1 || activation.hooks[0].Event != hooks.EventBeforeTool {
|
|
t.Fatalf("expected one beforeTool plugin hook, got %#v", activation.hooks)
|
|
}
|
|
if activation.hooks[0].ID != "zero.demo.pre" {
|
|
t.Fatalf("plugin hook id = %q, want namespaced zero.demo.pre", activation.hooks[0].ID)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("unexpected stderr for a clean activation: %s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestActivatePluginsRegistersPluginSkillTool(t *testing.T) {
|
|
pluginDir := t.TempDir()
|
|
skillDir := filepath.Join(pluginDir, "skills", "demo-skill")
|
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
skillMd := filepath.Join(skillDir, "SKILL.md")
|
|
if err := os.WriteFile(skillMd, []byte("---\nname: demo-skill\n---\nplugin skill body"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
loaded := []plugins.LoadedPlugin{{
|
|
ID: "zero.skills",
|
|
Name: "Skills",
|
|
Enabled: true,
|
|
Source: plugins.SourceProject,
|
|
PluginDir: pluginDir,
|
|
Skills: []plugins.PathExtension{{Name: "demo-skill", Path: skillMd}},
|
|
}}
|
|
|
|
registry := tools.NewRegistry()
|
|
registry.Register(tools.NewSkillTool(t.TempDir())) // core skill tool, like the real bootstrap
|
|
var stderr bytes.Buffer
|
|
activation := activatePlugins(t.TempDir(), registry, fakePluginDeps(t, loaded), &stderr)
|
|
|
|
if len(activation.skillRoots) != 1 {
|
|
t.Fatalf("expected one plugin skill root, got %#v", activation.skillRoots)
|
|
}
|
|
skillTool, ok := registry.Get("skill")
|
|
if !ok {
|
|
t.Fatal("skill tool missing after activation")
|
|
}
|
|
// The re-registered skill tool resolves the plugin skill by name.
|
|
res := skillTool.Run(context.Background(), map[string]any{"name": "demo-skill"})
|
|
if res.Status != tools.StatusOK {
|
|
t.Fatalf("plugin skill not resolvable via the agent skill tool: %q (%s)", res.Status, res.Output)
|
|
}
|
|
}
|
|
|
|
func TestActivatePluginsSurfacesLoadDiagnostics(t *testing.T) {
|
|
// Load succeeds (no top-level error) but reports a per-plugin diagnostic for a
|
|
// plugin it had to skip. activatePlugins must forward that diagnostic to stderr
|
|
// rather than dropping it, so a skipped plugin is never silent.
|
|
deps := appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
loadPlugins: func(plugins.LoadOptions) (plugins.LoadResult, error) {
|
|
return plugins.LoadResult{
|
|
Diagnostics: []plugins.Diagnostic{{
|
|
Kind: plugins.DiagnosticSchema,
|
|
Message: "schemaVersion: Expected schemaVersion 1.",
|
|
}},
|
|
}, nil
|
|
},
|
|
skillsDir: func() string { return t.TempDir() },
|
|
}
|
|
registry := tools.NewRegistry()
|
|
var stderr bytes.Buffer
|
|
activatePlugins(t.TempDir(), registry, deps, &stderr)
|
|
|
|
if stderr.Len() == 0 {
|
|
t.Fatal("a load diagnostic must be surfaced on stderr")
|
|
}
|
|
if !bytes.Contains(stderr.Bytes(), []byte("Expected schemaVersion 1")) {
|
|
t.Fatalf("stderr should carry the diagnostic message, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestActivatePluginsFailsOpenOnLoadError(t *testing.T) {
|
|
deps := appDeps{
|
|
loadPlugins: func(plugins.LoadOptions) (plugins.LoadResult, error) {
|
|
return plugins.LoadResult{}, errors.New("boom")
|
|
},
|
|
skillsDir: func() string { return t.TempDir() },
|
|
}
|
|
registry := tools.NewRegistry()
|
|
var stderr bytes.Buffer
|
|
activation := activatePlugins(t.TempDir(), registry, deps, &stderr)
|
|
|
|
if len(activation.hooks) != 0 || len(activation.skillRoots) != 0 {
|
|
t.Fatalf("a load error must yield an inert activation, got %#v", activation)
|
|
}
|
|
if stderr.Len() == 0 {
|
|
t.Fatalf("a load error should be surfaced as a warning on stderr")
|
|
}
|
|
}
|
|
|
|
func TestActivatePluginsWarnsOnMalformedPluginButKeepsGood(t *testing.T) {
|
|
good := plugins.LoadedPlugin{
|
|
ID: "zero.good",
|
|
Name: "Good",
|
|
Enabled: true,
|
|
Source: plugins.SourceProject,
|
|
PluginDir: t.TempDir(),
|
|
Tools: []plugins.ToolExtension{{Name: "good_tool", Command: "true", Permission: plugins.PermissionPrompt}},
|
|
}
|
|
bad := plugins.LoadedPlugin{
|
|
ID: "zero.bad",
|
|
Name: "Bad",
|
|
Enabled: true,
|
|
Source: plugins.SourceProject,
|
|
PluginDir: t.TempDir(),
|
|
Tools: []plugins.ToolExtension{{Name: "bad_tool", Command: "", Permission: plugins.PermissionPrompt}},
|
|
}
|
|
|
|
registry := tools.NewRegistry()
|
|
var stderr bytes.Buffer
|
|
activatePlugins(t.TempDir(), registry, fakePluginDeps(t, []plugins.LoadedPlugin{bad, good}), &stderr)
|
|
|
|
if _, ok := registry.Get("good_tool"); !ok {
|
|
t.Fatal("the good plugin tool must still register despite a malformed sibling")
|
|
}
|
|
if _, ok := registry.Get("bad_tool"); ok {
|
|
t.Fatal("the malformed plugin tool must not register")
|
|
}
|
|
if stderr.Len() == 0 {
|
|
t.Fatal("a malformed plugin should produce a warning on stderr")
|
|
}
|
|
}
|
|
|
|
func TestNewHookDispatcherWithExtraFoldsPluginHooks(t *testing.T) {
|
|
// Keep the hook audit store inside a temp dir rather than the user's real data
|
|
// directory (the audit path is derived from XDG_DATA_HOME).
|
|
t.Setenv("XDG_DATA_HOME", t.TempDir())
|
|
workspace := t.TempDir()
|
|
extra := []hooks.Definition{{
|
|
ID: "zero.demo.pre",
|
|
Event: hooks.EventBeforeTool,
|
|
Command: "guard.sh",
|
|
Enabled: true,
|
|
}}
|
|
|
|
dispatcher := newHookDispatcherWithExtra(workspace, extra)
|
|
if dispatcher == nil {
|
|
t.Fatal("dispatcher should never be nil for a clean workspace")
|
|
}
|
|
|
|
// Drive a beforeTool dispatch: the plugin hook runs, proving it is part of the
|
|
// active hook set (not merely returned as data).
|
|
outcome := dispatcher.Dispatch(context.Background(), hooks.DispatchInput{
|
|
Event: hooks.EventBeforeTool,
|
|
ToolName: "bash",
|
|
})
|
|
// With no real guard.sh on PATH the command fails to launch, which for a
|
|
// beforeTool hook is advisory (fails OPEN), so the call is recorded as having
|
|
// run exactly one hook.
|
|
if outcome.Ran != 1 {
|
|
t.Fatalf("expected the plugin beforeTool hook to run, Ran=%d", outcome.Ran)
|
|
}
|
|
}
|