Files
zero/internal/plugins/activate_unix_test.go
KRATOS 6beb0b0908 wedge stage 09: finish plugin wiring (tools/hooks/skills) (#185)
* 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
2026-06-13 10:21:14 +05:30

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)
}
}