Files
zero/internal/cli/extensions_test.go
05c1b5e160 feat(plugins): add CLI enable/disable for plugin.json (#1202)
* feat(plugins): add CLI enable/disable for plugin.json

Add zero plugins enable|disable so installed plugins can be toggled
without removing them, matching hooks/MCP parity and EXTENDING docs.

* fix(plugins): serialize toggles with installs and preserve manifest bytes

Address review findings on the enable/disable CLI:

- Take the same cross-process installtxn lock plugins add/remove take on
  the plugin's install root before the final lookup and read, and publish
  plugin.json with installtxn.WriteFileAtomically while holding it.
- Re-resolve the plugin under the lock so a concurrent install can never
  be overwritten with manifest bytes read before it committed, retrying
  when precedence moves to the other root; a plugin removed under the
  lock reports ErrNotInstalled instead of being recreated.
- Edit the enabled value in place (a targeted token walk) so field
  order, formatting, and numeric literals survive; the old
  decode/re-encode dropped precision (9007199254740993 became
  9007199254740992) and sorted the keys.
- Fail closed on manifest stat errors, refresh a clean plugins.lock hash
  after a toggle, and leave pre-existing drift in place instead of
  absorbing it into the lockfile.
- Redact the text-mode output, reject an empty plugin id as a usage
  error, and document project-over-user precedence in the help text.

* fix(plugins): recover interrupted install transactions before toggling

SetEnabledByID takes the plugin root's installtxn lock but went straight
to reading and rewriting the tree. installtxn.Recover requires every
holder of that lock to resolve interrupted transactions first and to
abort on its error, as Install and Remove already do.

Without it, a toggle after an install killed between its tree swap and
its lockfile publish edited the unpublished tree, and the next install
or removal restored the retained backup over it, silently undoing the
enable/disable. A transaction recovery could not resolve also failed to
stop the toggle.

Call installtxn.Recover with the plugins reconciler right after taking
the lock, and add regression tests for every interrupted state that
leaves the plugin discoverable, the unresolved-transaction abort, and a
plugin removed while the toggle waits on the lock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(plugins): say when a plugin toggle takes effect

Zero reads plugin.json "enabled" only when it activates plugins at
startup (runExec and the interactive TUI bootstrap), and nothing reloads
them afterwards. A running interactive session therefore keeps the
tools, hooks, and skills of a plugin disabled from another terminal.

State that in the plugins docs and in the enable/disable help so the
documented activation rule matches what the command does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(plugins): pin toggled manifests, redact toggle errors, honor last enabled key

Address the review findings on the plugin enable/disable toggle.

Pin the plugin directory before editing its manifest. The install lock
only serializes cooperating installers, so swapping the plugin directory
for a link after discovery redirected the read and the atomic publish
through it, overwriting another manifest. The toggle now opens the
directory under its install root with pathjail, refuses a link or
reparse point, checks that the handle is the directory entry itself,
and reads, stats, creates the temporary file, and renames through that
handle.

Redact every stderr message the toggle writes. Parser and not-installed
usage errors echoed the raw argument, so a mistyped credential reached
stderr unredacted while the toggle's other output was redacted.

Validate only the last "enabled" value, as the loader does. The loader
keeps the last duplicate key, but the toggle rejected an earlier,
overwritten non-boolean value, so a plugin discovered as enabled could
not be toggled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(plugins): keep the toggle on the pinned plugin directory throughout

The previous fix pinned the manifest read and publish to a handle on the
plugin directory but still hashed the tree for plugins.lock by path, and
reported success without checking that the path still named the pinned
directory. If the directory was replaced after it was opened, the edit
landed in the moved-away original while the lockfile check and refresh
read the replacement, and the command reported a state the installed
plugin did not have.

Hash the tree before and after the edit through the pinned handle, and
confirm that the plugin's directory entry is still the pinned directory
before refreshing the lockfile or reporting any result, including a
no-op. hashTree now delegates to the same rooted walker, so path and
handle callers hash identical bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(plugins): hash plugin trees through native os.Root calls

20b484f moved hashTree onto an os.Root handle but walked it through
handle.FS(). That adapter accepts only fs.ValidPath names, which must be
UTF-8, so a plugin holding a Linux-valid name such as byte 0xff failed
with "lstat ...: invalid argument". copyTree and the previous walker
accept those names, so Install, the toggle's lock refresh, and Info's
drift check all regressed for such plugins.

Walk the handle with its own Open/Lstat methods instead. The walk stays
pinned to the directory, and the hash bytes match the merge-base
implementation (checked on a fixture with nested and non-UTF-8 names,
an exec bit, a symlink, .git and a FIFO).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Ayush7614 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-07 09:56:16 -08:00

568 lines
19 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Twigpine/zero/internal/config"
"github.com/Twigpine/zero/internal/hooks"
"github.com/Twigpine/zero/internal/mcp"
"github.com/Twigpine/zero/internal/plugins"
"github.com/Twigpine/zero/internal/tools"
)
func TestRunPluginsListsJSONAndText(t *testing.T) {
cwd := t.TempDir()
result := plugins.LoadResult{
Plugins: []plugins.LoadedPlugin{{
SchemaVersion: 1,
ID: "zero.docs",
Name: "Docs",
Version: "1.0.0",
Enabled: true,
Source: plugins.SourceProject,
Prompts: []plugins.PathExtension{{Name: "review", Path: filepath.Join(cwd, "review.md")}},
Tools: []plugins.ToolExtension{},
Skills: []plugins.PathExtension{},
Hooks: []plugins.HookExtension{},
}},
}
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
if options.Cwd != cwd {
t.Fatalf("plugin Cwd = %q, want %q", options.Cwd, cwd)
}
return result, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
var payload struct {
Plugins []struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Source string `json:"source"`
} `json:"plugins"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("plugin JSON failed to decode: %v\n%s", err, stdout.String())
}
if payload.Plugins[0].ID != "zero.docs" || payload.Plugins[0].Name != "Docs" || payload.Plugins[0].Version != "1.0.0" {
t.Fatalf("unexpected plugin JSON: %#v", payload)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "list"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
for _, want := range []string{"Zero Plugins:", "zero.docs", "Docs", "1.0.0", "1 prompts"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("plugin text missing %q: %s", want, stdout.String())
}
}
}
func TestRunHooksListsRedactedJSONAndText(t *testing.T) {
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
result := hooks.LoadResult{
Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{{
ID: "zero.preflight",
Event: hooks.EventBeforeTool,
Matcher: "bash",
Command: "node",
Args: []string{"hooks/preflight.mjs", secret},
Enabled: true,
}},
},
}
deps := appDeps{
getwd: func() (string, error) { return t.TempDir(), nil },
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
return result, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"hooks", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), secret) || !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("hook JSON redaction failed: %s", stdout.String())
}
var payload struct {
Hooks struct {
Hooks []struct {
ID string `json:"id"`
} `json:"hooks"`
} `json:"hooks"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("hook JSON failed to decode: %v\n%s", err, stdout.String())
}
if payload.Hooks.Hooks[0].ID != "zero.preflight" {
t.Fatalf("unexpected hook JSON: %#v", payload)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"hooks", "list"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "Zero Hooks") || !strings.Contains(stdout.String(), "zero.preflight") {
t.Fatalf("unexpected hook text: %s", stdout.String())
}
if strings.Contains(stdout.String(), secret) || !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("hook text redaction failed: %s", stdout.String())
}
}
func TestRunMCPPermissionsListRevokeAndClear(t *testing.T) {
store, err := mcp.NewPermissionStore(mcp.StoreOptions{
FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json"),
Now: func() time.Time { return time.Date(2026, 6, 3, 9, 30, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("NewPermissionStore returned error: %v", err)
}
if _, err := store.GrantServer(mcp.GrantServerInput{ServerName: "docs", ServerIdentity: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", MaxAutonomy: mcp.AutonomyMedium}); err != nil {
t.Fatal(err)
}
if _, err := store.GrantTool(mcp.GrantToolInput{ServerName: "docs", ServerIdentity: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ToolName: "lookup", MaxAutonomy: mcp.AutonomyLow}); err != nil {
t.Fatal(err)
}
deps := appDeps{newMCPStore: func() (*mcp.PermissionStore, error) { return store, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "permissions", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
var listPayload struct {
Permissions []mcp.PermissionGrant `json:"permissions"`
}
if err := json.Unmarshal(stdout.Bytes(), &listPayload); err != nil {
t.Fatalf("permission JSON failed to decode: %v\n%s", err, stdout.String())
}
if len(listPayload.Permissions) != 2 {
t.Fatalf("expected 2 permissions, got %#v", listPayload.Permissions)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"mcp", "permissions", "revoke", "docs", "lookup", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), `"scope": "tool"`) || !strings.Contains(stdout.String(), `"revoked": 1`) {
t.Fatalf("unexpected revoke tool output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"mcp", "permissions", "clear", "--json"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("clear without confirm exitCode = %d", exitCode)
}
if !strings.Contains(stderr.String(), "--confirm") {
t.Fatalf("expected confirm warning, got %q", stderr.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"mcp", "permissions", "clear", "--confirm", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), `"cleared": 1`) {
t.Fatalf("unexpected clear output: %s", stdout.String())
}
}
func TestRunMCPToolsListJSONAndText(t *testing.T) {
cwd := t.TempDir()
closeCalls := 0
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
},
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
registry.Register(cliFakeMCPRegistryTool{})
return closeFunc(func() error {
closeCalls++
return nil
}), nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "tools", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
var payload struct {
Tools []struct {
Name string `json:"name"`
Permission string `json:"permission"`
} `json:"tools"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("MCP tools JSON failed to decode: %v\n%s", err, stdout.String())
}
if len(payload.Tools) != 1 || payload.Tools[0].Name != "mcp_docs_lookup" || payload.Tools[0].Permission != string(tools.PermissionAllow) {
t.Fatalf("unexpected MCP tools JSON: %#v", payload)
}
if closeCalls != 1 {
t.Fatalf("MCP runtime close calls after JSON list = %d, want 1", closeCalls)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"mcp", "tools", "list"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
for _, want := range []string{"MCP Tools:", "mcp_docs_lookup", "Lookup documentation", "allow"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("MCP tools text missing %q: %s", want, stdout.String())
}
}
if closeCalls != 2 {
t.Fatalf("MCP runtime close calls after text list = %d, want 2", closeCalls)
}
}
func TestRunMCPLegacyListAliases(t *testing.T) {
cwd := t.TempDir()
closeCalls := 0
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"docs": {Type: "stdio", Command: "docs-mcp"},
}}, nil
},
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
registry.Register(cliFakeMCPRegistryTool{})
return closeFunc(func() error {
closeCalls++
return nil
}), nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"mcp", "list", "--tools", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), `"tools"`) || !strings.Contains(stdout.String(), "mcp_docs_lookup") {
t.Fatalf("unexpected legacy mcp tools list output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"mcp", "list", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), `"servers"`) || !strings.Contains(stdout.String(), `"docs"`) {
t.Fatalf("unexpected legacy mcp server list output: %s", stdout.String())
}
if closeCalls != 1 {
t.Fatalf("legacy server list should not connect tools, closeCalls = %d", closeCalls)
}
}
func TestRunMCPPermissionsHelpDoesNotOpenStore(t *testing.T) {
deps := appDeps{
newMCPStore: func() (*mcp.PermissionStore, error) {
return nil, errors.New("store should not be opened for help")
},
}
for _, args := range [][]string{
{"mcp", "permissions", "list", "--help"},
{"mcp", "permissions", "revoke", "--help"},
{"mcp", "permissions", "clear", "--help"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
if !strings.Contains(stdout.String(), "zero mcp permissions") {
t.Fatalf("expected help output, got %q", stdout.String())
}
})
}
}
func TestRunPluginsEnableDisable(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
pluginDir := filepath.Join(cwd, ".zero", "plugins", "demo")
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
var disablePayload plugins.SetEnabledResult
if err := json.Unmarshal(stdout.Bytes(), &disablePayload); err != nil {
t.Fatalf("decode disable JSON: %v\n%s", err, stdout.String())
}
if disablePayload.ID != "zero.demo" || disablePayload.Enabled || !disablePayload.Changed {
t.Fatalf("unexpected disable payload: %#v", disablePayload)
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"enabled": false`) {
t.Fatalf("manifest not disabled: %s", raw)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop disable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("enable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now enabled") {
t.Fatalf("enable output: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "enable", "missing-plugin"}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("missing plugin exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
}
// The text-mode toggle output runs through the redaction pipeline, so a
// manifest path that contains something secret-shaped is masked.
func TestRunPluginsToggleTextOutputIsRedacted(t *testing.T) {
cwd := t.TempDir()
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
secretDir := "sk-proj-abcdefghijklmnopqrstuvwxyz"
pluginDir := filepath.Join(cwd, ".zero", "plugins", secretDir)
if err := os.MkdirAll(pluginDir, 0o700); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(pluginDir, "plugin.json")
if err := os.WriteFile(manifestPath, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0"
}
`), 0o600); err != nil {
t.Fatal(err)
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable exit=%d stderr=%s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
if !strings.Contains(stdout.String(), "[REDACTED]") {
t.Fatalf("toggle text was not redacted: %q", stdout.String())
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"plugins", "disable", "zero.demo"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("noop disable exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "was already disabled") {
t.Fatalf("noop toggle output: %q", stdout.String())
}
if strings.Contains(stdout.String(), secretDir) {
t.Fatalf("noop toggle text leaked a secret-shaped manifest path: %q", stdout.String())
}
}
func TestRunPluginsToggleRejectsEmptyID(t *testing.T) {
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
for _, subcommand := range []string{"enable", "disable"} {
t.Run(subcommand, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", subcommand, ""}, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if !strings.Contains(stderr.String(), "usage: zero plugins "+subcommand) {
t.Fatalf("stderr = %q", stderr.String())
}
})
}
}
func TestRunPluginsDisableUserIgnoresProject(t *testing.T) {
home := t.TempDir()
cwd := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
userManifest := filepath.Join(home, "zero", "plugins", "demo", "plugin.json")
projectManifest := filepath.Join(cwd, ".zero", "plugins", "demo", "plugin.json")
for _, path := range []string{userManifest, projectManifest} {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{
"schemaVersion": 1,
"id": "zero.demo",
"name": "Demo",
"version": "1.0.0",
"enabled": true
}
`), 0o600); err != nil {
t.Fatal(err)
}
}
deps := appDeps{getwd: func() (string, error) { return cwd, nil }}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"plugins", "disable", "zero.demo", "--user"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("disable --user exit=%d stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "is now disabled") {
t.Fatalf("disable --user output: %s", stdout.String())
}
userRaw, err := os.ReadFile(userManifest)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(userRaw), `"enabled": false`) {
t.Fatalf("user manifest not disabled: %s", userRaw)
}
projectRaw, err := os.ReadFile(projectManifest)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(projectRaw), `"enabled": false`) {
t.Fatalf("project manifest should stay enabled: %s", projectRaw)
}
}
// The toggle's usage errors echo the caller's arguments, which can be a
// mistyped credential, so they pass through redaction like its other output.
func TestRunPluginsToggleUsageErrorsAreRedacted(t *testing.T) {
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("HOME", home)
deps := appDeps{getwd: func() (string, error) { return t.TempDir(), nil }}
const secret = "sk-proj-abcdefghijklmnopqrstuvwxyz"
cases := []struct {
name string
args []string
want string
}{
{"unknown flag", []string{"zero.demo", "--token=" + secret}, "unknown plugins"},
{"missing plugin", []string{secret}, "plugin is not installed"},
}
for _, subcommand := range []string{"enable", "disable"} {
for _, tc := range cases {
t.Run(subcommand+" "+tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
args := append([]string{"plugins", subcommand}, tc.args...)
exitCode := runWithDeps(args, &stdout, &stderr, deps)
if exitCode != exitUsage {
t.Fatalf("exit=%d want %d stderr=%s", exitCode, exitUsage, stderr.String())
}
if strings.Contains(stderr.String(), secret) {
t.Fatalf("usage error leaked the argument: %q", stderr.String())
}
if !strings.Contains(stderr.String(), tc.want) || !strings.Contains(stderr.String(), "[REDACTED]") {
t.Fatalf("stderr = %q, want a redacted %q error", stderr.String(), tc.want)
}
})
}
}
}