mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -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
92 lines
3.0 KiB
Go
92 lines
3.0 KiB
Go
//go:build unix
|
|
|
|
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
// TestActivateToolRunsRealCommandEndToEnd exercises the default (non-mocked) tool
|
|
// runner: a real shell script is invoked through the registry, receives the JSON
|
|
// arguments on stdin, can read its install dir from $AGENT_PLUGIN_ROOT, and its
|
|
// stdout/exit map onto the tool Result. This proves execPluginCommand is wired
|
|
// correctly, complementing the runner-stubbed unit tests.
|
|
func TestActivateToolRunsRealCommandEndToEnd(t *testing.T) {
|
|
pluginDir := t.TempDir()
|
|
script := filepath.Join(pluginDir, "tool.sh")
|
|
// The script echoes a marker, the plugin root it received via env, and the JSON
|
|
// args it read from stdin — so the test can assert all three round-trip.
|
|
body := "#!/bin/sh\n" +
|
|
"printf 'ran in %s\\n' \"$AGENT_PLUGIN_ROOT\"\n" +
|
|
"cat\n"
|
|
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
registry := tools.NewRegistry()
|
|
plugin := LoadedPlugin{
|
|
ID: "zero.demo",
|
|
Name: "Demo",
|
|
Enabled: true,
|
|
Source: SourceProject,
|
|
PluginDir: pluginDir,
|
|
Tools: []ToolExtension{{
|
|
Name: "demo",
|
|
Command: "${AGENT_PLUGIN_ROOT}/tool.sh",
|
|
Permission: PermissionPrompt,
|
|
}},
|
|
}
|
|
|
|
// No runTool override: this uses the real execPluginCommand subprocess runner.
|
|
Activate(registry, []LoadedPlugin{plugin}, ActivateOptions{})
|
|
|
|
res := registry.RunWithOptions(context.Background(), "demo", map[string]any{"q": "hello"}, tools.RunOptions{PermissionGranted: true})
|
|
if res.Status != tools.StatusOK {
|
|
t.Fatalf("status = %q (%s)", res.Status, res.Output)
|
|
}
|
|
if !strings.Contains(res.Output, "ran in "+pluginDir) {
|
|
t.Fatalf("output missing AGENT_PLUGIN_ROOT round-trip: %q", res.Output)
|
|
}
|
|
if !strings.Contains(res.Output, `"q":"hello"`) {
|
|
t.Fatalf("output missing JSON args on stdin: %q", res.Output)
|
|
}
|
|
if res.Meta["exit_code"] != "0" {
|
|
t.Fatalf("exit_code meta = %q, want 0", res.Meta["exit_code"])
|
|
}
|
|
}
|
|
|
|
// TestActivateToolRealCommandMissingBinaryErrors confirms a plugin tool whose
|
|
// command cannot be launched surfaces as a tool error (not a misleading exit
|
|
// code), via the real subprocess runner.
|
|
func TestActivateToolRealCommandMissingBinaryErrors(t *testing.T) {
|
|
registry := tools.NewRegistry()
|
|
plugin := LoadedPlugin{
|
|
ID: "zero.demo",
|
|
Name: "Demo",
|
|
Enabled: true,
|
|
Source: SourceProject,
|
|
PluginDir: t.TempDir(),
|
|
Tools: []ToolExtension{{
|
|
Name: "demo",
|
|
Command: filepath.Join(t.TempDir(), "does-not-exist"),
|
|
Permission: PermissionPrompt,
|
|
}},
|
|
}
|
|
|
|
Activate(registry, []LoadedPlugin{plugin}, ActivateOptions{})
|
|
|
|
res := registry.RunWithOptions(context.Background(), "demo", map[string]any{}, tools.RunOptions{PermissionGranted: true})
|
|
if res.Status != tools.StatusError {
|
|
t.Fatalf("status = %q, want error for unlaunchable command", res.Status)
|
|
}
|
|
if !strings.Contains(res.Output, "Error executing plugin tool demo") {
|
|
t.Fatalf("output = %q, want launch error", res.Output)
|
|
}
|
|
}
|