mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* fix(update): authenticate GitHub release checks
Rebuild PatrickNoFilter's final change from Twigpine/zero#504 on current
upstream main. Prefer ZERO_GITHUB_TOKEN, fall back to GITHUB_TOKEN, and
limit credentials to HTTPS GitHub API requests with downgrade protection.
Retain the original tests and update documentation.
Original implementation, tests and documentation by PatrickNoFilter.
Source-PR: https://github.com/Twigpine/zero/pull/504
Source-Head: 295b9d2a91
Integration: two equivalent strings.Contains substitutions satisfy current
advisory lint in the copied help test.
Fixes #507
* fix(update): normalize credentials and address review findings
* fix(update): enforce exact-host credentials on redirects
---------
Co-authored-by: PatrickNoFilter <[email protected]>
2083 lines
69 KiB
Go
2083 lines
69 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Twigpine/zero/internal/agent"
|
|
"github.com/Twigpine/zero/internal/config"
|
|
"github.com/Twigpine/zero/internal/mcp"
|
|
"github.com/Twigpine/zero/internal/redaction"
|
|
"github.com/Twigpine/zero/internal/tools"
|
|
"github.com/Twigpine/zero/internal/tui"
|
|
"github.com/Twigpine/zero/internal/update"
|
|
"github.com/Twigpine/zero/internal/workspacetrust"
|
|
"github.com/Twigpine/zero/internal/zeroruntime"
|
|
)
|
|
|
|
var errWriteFailed = errors.New("write failed")
|
|
|
|
type failingWriter struct{}
|
|
|
|
func (failingWriter) Write([]byte) (int, error) {
|
|
return 0, errWriteFailed
|
|
}
|
|
|
|
func TestRunPrintsVersion(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"--version"}, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
// Non-terminal stdout keeps the machine-readable contract: exactly one
|
|
// "zero <version>" record, no wordmark banner, no ANSI.
|
|
if got := stdout.String(); got != "zero dev\n" {
|
|
t.Fatalf("expected version output, got %q", got)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// TestRunVersionRedirectedFile covers the `zero --version > file` path: stdout
|
|
// is a real *os.File but not a terminal, so the single-line contract must hold.
|
|
func TestRunVersionRedirectedFile(t *testing.T) {
|
|
stdout, err := os.CreateTemp(t.TempDir(), "version-stdout")
|
|
if err != nil {
|
|
t.Fatalf("create temp stdout: %v", err)
|
|
}
|
|
defer stdout.Close()
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"--version"}, stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
content, err := os.ReadFile(stdout.Name())
|
|
if err != nil {
|
|
t.Fatalf("read temp stdout: %v", err)
|
|
}
|
|
if got := string(content); got != "zero dev\n" {
|
|
t.Fatalf("expected version output, got %q", got)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunPrintsHelp(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"--help"},
|
|
{"-h"},
|
|
{"help"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
assertHelpOutput(t, args)
|
|
})
|
|
}
|
|
}
|
|
|
|
func setCLIUserConfigRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
root := t.TempDir()
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
t.Setenv("APPDATA", root)
|
|
case "darwin":
|
|
t.Setenv("HOME", root)
|
|
default:
|
|
t.Setenv("XDG_CONFIG_HOME", root)
|
|
}
|
|
|
|
configRoot, err := os.UserConfigDir()
|
|
if err != nil {
|
|
t.Fatalf("UserConfigDir() error = %v", err)
|
|
}
|
|
return configRoot
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
projectConfigPath := filepath.Join(cwd, ".zero", "config.json")
|
|
if err := os.MkdirAll(filepath.Dir(projectConfigPath), 0o700); err != nil {
|
|
t.Fatalf("create project config parent: %v", err)
|
|
}
|
|
if err := os.WriteFile(projectConfigPath, []byte("{}"), 0o600); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.ResolvedConfig{MaxTurns: 12}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
t.Fatal("newProvider should not be called without a resolved provider")
|
|
return nil, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 7
|
|
},
|
|
})
|
|
|
|
if exitCode != 7 {
|
|
t.Fatalf("expected TUI exit code 7, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
if launchedOptions.Cwd != cwd {
|
|
t.Fatalf("Cwd = %q, want %q", launchedOptions.Cwd, cwd)
|
|
}
|
|
if launchedOptions.UserConfigPath != userConfigPath {
|
|
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
|
|
}
|
|
if launchedOptions.DoctorUserConfigPath != "" {
|
|
t.Fatalf("DoctorUserConfigPath = %q, want empty for missing user config", launchedOptions.DoctorUserConfigPath)
|
|
}
|
|
if launchedOptions.ProjectConfigPath != projectConfigPath {
|
|
t.Fatalf("ProjectConfigPath = %q, want %q", launchedOptions.ProjectConfigPath, projectConfigPath)
|
|
}
|
|
if launchedOptions.Provider != nil {
|
|
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
|
|
}
|
|
if launchedOptions.ProviderName != "" || launchedOptions.ModelName != "" {
|
|
t.Fatalf("provider metadata = %q/%q, want empty", launchedOptions.ProviderName, launchedOptions.ModelName)
|
|
}
|
|
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
|
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
|
}
|
|
if launchedOptions.Setup.ConfigPath != userConfigPath {
|
|
t.Fatalf("Setup.ConfigPath = %q, want %q", launchedOptions.Setup.ConfigPath, userConfigPath)
|
|
}
|
|
assertCoreRegistry(t, launchedOptions.Registry)
|
|
assertAgentOptions(t, launchedOptions, 12, agent.PermissionModeAsk)
|
|
}
|
|
|
|
type fakeMCPRuntimeWithSkips struct {
|
|
skipped []mcp.SkippedServer
|
|
}
|
|
|
|
func (r fakeMCPRuntimeWithSkips) Close() error { return nil }
|
|
|
|
func (r fakeMCPRuntimeWithSkips) Skipped() []mcp.SkippedServer { return r.skipped }
|
|
|
|
func TestTUIStartupSuppressesWarningForUnconfiguredDefaultServer(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"exa": config.DefaultMCPServers()["exa"],
|
|
}}, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
|
|
{Name: "exa", Err: errors.New("returned HTTP 401"), UnconfiguredDefault: true},
|
|
}}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if strings.Contains(stderr.String(), "exa") {
|
|
t.Fatalf("stderr = %q, want no warning for an unconfigured default server", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestTUIStartupWarnsForUserConfiguredServerSkip(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, excludeProject bool) (config.MCPConfig, error) {
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"custom": {Type: "stdio", Command: "custom-mcp"},
|
|
}}, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return fakeMCPRuntimeWithSkips{skipped: []mcp.SkippedServer{
|
|
{Name: "custom", Err: errors.New("connect failed"), UnconfiguredDefault: false},
|
|
}}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "warning: MCP server custom unavailable") {
|
|
t.Fatalf("stderr = %q, want a warning for a user-configured server that failed", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
var launchedOptions tui.Options
|
|
launched := false
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
t.Fatal("newProvider should not be called without a resolved provider")
|
|
return nil, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("TUI was not launched; expected fallback into setup instead of a fatal error")
|
|
}
|
|
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
|
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
|
}
|
|
if launchedOptions.Provider != nil {
|
|
t.Fatalf("Provider = %#v, want nil for no-provider fallback", launchedOptions.Provider)
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
var launchedOptions tui.Options
|
|
launched := false
|
|
var providerProfile config.ProviderProfile
|
|
fake := &cliFakeProvider{}
|
|
|
|
usable := config.ProviderProfile{
|
|
Name: "work",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
BaseURL: config.OpenAIBaseURL,
|
|
APIKey: "sk-test",
|
|
Model: "gpt-test",
|
|
}
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
// config.json has providers configured but none marked active (e.g. a
|
|
// blank/stale activeProvider field) — Resolve returns the successfully
|
|
// normalized providers list alongside ErrNoActiveProvider.
|
|
return config.ResolvedConfig{
|
|
Providers: []config.ProviderProfile{usable},
|
|
ModelRPM: map[string]int{"gpt-test": 1},
|
|
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerProfile = profile
|
|
return fake, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("TUI was not launched")
|
|
}
|
|
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
|
|
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
|
|
}
|
|
if providerProfile.Name != "work" {
|
|
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
|
|
|
}
|
|
if launchedOptions.ModelRPM == nil {
|
|
t.Fatal("ModelRPM = nil, want limiter preserved through provider fallback")
|
|
}
|
|
}
|
|
|
|
// Maintainer regression (PR #1001): a stored explicit notify opt-out must
|
|
// survive the provider-recovery startup path. With a stale activeProvider and
|
|
// another usable saved provider, Resolve returns the partial config alongside
|
|
// ErrNoActiveProvider and the recovery branch forwards it — dropping the
|
|
// notify policy there would surface an empty Options.Notify, and the TUI's
|
|
// unconfigured default (both) would resurrect alerts the user explicitly
|
|
// turned off. The same recovery branch that clears the resolved config for
|
|
// the setup wizard carries the stored notify block through.
|
|
func TestRunNoArgsPreservesNotifyOptOutThroughProviderRecovery(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
var launchedOptions tui.Options
|
|
launched := false
|
|
|
|
usable := config.ProviderProfile{
|
|
Name: "work",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
BaseURL: config.OpenAIBaseURL,
|
|
APIKey: "sk-test",
|
|
Model: "gpt-test",
|
|
}
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
// Stale activeProvider + usable saved provider, and the user's
|
|
// config.json explicitly opts out of notifications. The fixed
|
|
// resolver carries the parsed notify block through the error path.
|
|
return config.ResolvedConfig{
|
|
Providers: []config.ProviderProfile{usable},
|
|
Notify: config.NotifyConfig{Mode: "off", FocusMode: "always"},
|
|
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "ghost")
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return &cliFakeProvider{}, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("TUI was not launched")
|
|
}
|
|
// The explicit opt-out reaches the TUI intact — NOT the both/unfocused
|
|
// unconfigured default.
|
|
if launchedOptions.Notify.Mode != "off" || launchedOptions.Notify.FocusMode != "always" {
|
|
t.Fatalf("Options.Notify = %+v, want the stored off/always opt-out preserved through recovery", launchedOptions.Notify)
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, fmt.Errorf("invalid config JSON")
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
t.Fatal("TUI must not launch on a non-provider resolve error")
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode == 0 {
|
|
t.Fatal("exit code = 0, want non-zero for fatal config error")
|
|
}
|
|
if !strings.Contains(stderr.String(), "invalid config JSON") {
|
|
t.Fatalf("stderr = %q, want the underlying config error", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
mcpConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}
|
|
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewPermissionStore() error = %v", err)
|
|
}
|
|
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewTokenStore() error = %v", err)
|
|
}
|
|
var launchedOptions tui.Options
|
|
var registeredConfig config.MCPConfig
|
|
var registeredStore *mcp.PermissionStore
|
|
runtimeClosed := false
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return mcpConfig, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return permissionStore, nil
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return tokenStore, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
registeredConfig = cfg
|
|
registeredStore = options.PermissionStore
|
|
return closeFunc(func() error {
|
|
runtimeClosed = true
|
|
return nil
|
|
}), nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if _, ok := registeredConfig.Servers["docs"]; !ok {
|
|
t.Fatalf("registered MCP config = %#v, want docs server", registeredConfig.Servers)
|
|
}
|
|
if registeredStore != permissionStore {
|
|
t.Fatalf("registered PermissionStore = %#v, want launched store %#v", registeredStore, permissionStore)
|
|
}
|
|
if _, ok := launchedOptions.MCPConfig.Servers["docs"]; !ok {
|
|
t.Fatalf("launched MCP config = %#v, want docs server", launchedOptions.MCPConfig.Servers)
|
|
}
|
|
if launchedOptions.MCPPermissionStore != permissionStore {
|
|
t.Fatalf("launched MCPPermissionStore = %#v, want %#v", launchedOptions.MCPPermissionStore, permissionStore)
|
|
}
|
|
if launchedOptions.MCPTokenStore != tokenStore {
|
|
t.Fatalf("launched MCPTokenStore = %#v, want %#v", launchedOptions.MCPTokenStore, tokenStore)
|
|
}
|
|
if launchedOptions.MCPCommand == nil {
|
|
t.Fatal("launched MCPCommand runner is nil")
|
|
}
|
|
if !runtimeClosed {
|
|
t.Fatal("MCP runtime was not closed after TUI exits")
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsPaintsBeforeOptionalDefaultMCPIsReady(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewPermissionStore() error = %v", err)
|
|
}
|
|
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewTokenStore() error = %v", err)
|
|
}
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
configResult := make(chan error, 1)
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
userConfigPath: func() (string, error) { return userConfigPath, nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) {
|
|
return config.MCPConfig{Servers: config.DefaultMCPServers()}, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) { return permissionStore, nil },
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) { return tokenStore, nil },
|
|
registerMCPTools: func(_ context.Context, registry *tools.Registry, cfg config.MCPConfig, _ mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
var configErr error
|
|
if _, ok := cfg.Servers["exa"]; !ok || len(cfg.Servers) != 1 {
|
|
configErr = fmt.Errorf("optional MCP config = %#v, want only exa", cfg.Servers)
|
|
}
|
|
configResult <- configErr
|
|
close(started)
|
|
<-release
|
|
registry.Register(cliFakeMCPRegistryTool{})
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("optional MCP initialization did not start")
|
|
}
|
|
if options.AwaitToolReadiness == nil {
|
|
t.Fatal("TUI did not receive optional tool readiness barrier")
|
|
}
|
|
if _, ok := options.Registry.Get("mcp_docs_lookup"); ok {
|
|
t.Fatal("optional MCP tool was published before registration completed")
|
|
}
|
|
close(release)
|
|
options.AwaitToolReadiness(ctx)
|
|
if _, ok := options.Registry.Get("mcp_docs_lookup"); !ok {
|
|
t.Fatal("optional MCP tool was not visible after readiness completed")
|
|
}
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if configErr := <-configResult; configErr != nil {
|
|
t.Fatal(configErr)
|
|
}
|
|
}
|
|
|
|
func TestTUIMCPCommandUsesLastGoodConfigOnRefreshError(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
startupConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}
|
|
refreshedConfig := config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"github": {Type: "http", URL: "https://mcp.github.example"},
|
|
}}
|
|
resolveCalls := 0
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
|
resolveCalls++
|
|
switch resolveCalls {
|
|
case 1:
|
|
return startupConfig, nil
|
|
case 2, 3:
|
|
return refreshedConfig, nil
|
|
default:
|
|
return config.MCPConfig{}, errors.New("config temporarily unavailable")
|
|
}
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return closeFunc(func() error { return nil }), nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
first := options.MCPCommand(ctx, []string{"list"})
|
|
if _, ok := first.Config.Servers["github"]; !ok {
|
|
t.Fatalf("first MCP result config = %#v, want refreshed github server", first.Config.Servers)
|
|
}
|
|
second := options.MCPCommand(ctx, []string{"list"})
|
|
if _, ok := second.Config.Servers["github"]; !ok {
|
|
t.Fatalf("second MCP result config = %#v, want refreshed github server", second.Config.Servers)
|
|
}
|
|
third := options.MCPCommand(ctx, []string{"list"})
|
|
if _, ok := third.Config.Servers["github"]; !ok {
|
|
t.Fatalf("third MCP result config = %#v, want last known github server after refresh error", third.Config.Servers)
|
|
}
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsClosesPartialMCPRuntimeWhenRegistrationFails(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewPermissionStore() error = %v", err)
|
|
}
|
|
tokenStore, err := mcp.NewTokenStore(mcp.TokenStoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-oauth.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewTokenStore() error = %v", err)
|
|
}
|
|
runtimeClosed := false
|
|
secret := "sk-proj-" + strings.Repeat("a", 23) + "0"
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
|
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
|
|
"docs": {Type: "stdio", Command: "docs-mcp"},
|
|
}}, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return permissionStore, nil
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return tokenStore, nil
|
|
},
|
|
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return closeFunc(func() error {
|
|
runtimeClosed = true
|
|
return nil
|
|
}), fmt.Errorf("register mcp tools failed: %s", secret)
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
t.Fatal("TUI should not launch after MCP registration fails")
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode == 0 {
|
|
t.Fatalf("exitCode = %d, want failure", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), "register mcp tools failed") {
|
|
t.Fatalf("stderr missing registration error: %s", stderr.String())
|
|
}
|
|
if strings.Contains(stderr.String(), secret) {
|
|
t.Fatalf("stderr leaked MCP registration secret: %s", stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), redaction.RedactedSecret) {
|
|
t.Fatalf("stderr missing redaction marker: %s", stderr.String())
|
|
}
|
|
if !runtimeClosed {
|
|
t.Fatal("partial MCP runtime was not closed after registration error")
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsSoftFailsMCPTokenStoreInit(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
permissionStore, err := mcp.NewPermissionStore(mcp.StoreOptions{FilePath: filepath.Join(t.TempDir(), "mcp-permissions.json")})
|
|
if err != nil {
|
|
t.Fatalf("NewPermissionStore() error = %v", err)
|
|
}
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 8}, nil
|
|
},
|
|
resolveMCPConfig: func(workspaceRoot string, _ bool) (config.MCPConfig, error) {
|
|
return config.MCPConfig{}, nil
|
|
},
|
|
newMCPStore: func() (*mcp.PermissionStore, error) {
|
|
return permissionStore, nil
|
|
},
|
|
newMCPTokenStore: func() (*mcp.TokenStore, error) {
|
|
return nil, errors.New("token store unreadable")
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
|
|
}
|
|
if launchedOptions.MCPTokenStore != nil {
|
|
t.Fatalf("MCPTokenStore = %#v, want nil after soft failure", launchedOptions.MCPTokenStore)
|
|
}
|
|
if !strings.Contains(stderr.String(), "warning: failed to initialize MCP OAuth tokens") {
|
|
t.Fatalf("stderr missing soft-failure warning: %s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesTUIWithResolvedProviderMetadata(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
|
|
fake := &cliFakeProvider{}
|
|
var launchedOptions tui.Options
|
|
var providerProfile config.ProviderProfile
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
if workspaceRoot != cwd {
|
|
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
|
|
}
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "work",
|
|
Provider: config.ProviderProfile{
|
|
Name: "work",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
BaseURL: config.OpenAIBaseURL,
|
|
APIKey: "sk-test",
|
|
Model: "gpt-test",
|
|
},
|
|
Preferences: config.PreferencesConfig{FavoriteModels: []string{"qwen3-coder:480b"}},
|
|
MaxTurns: 5,
|
|
}, nil
|
|
},
|
|
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
providerProfile = profile
|
|
return fake, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return userConfigPath, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
if providerProfile.Name != "work" || providerProfile.Model != "gpt-test" {
|
|
t.Fatalf("providerProfile = %#v, want resolved provider", providerProfile)
|
|
}
|
|
if launchedOptions.Provider != fake {
|
|
t.Fatalf("Provider = %#v, want fake provider", launchedOptions.Provider)
|
|
}
|
|
if launchedOptions.UserConfigPath != userConfigPath {
|
|
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
|
|
}
|
|
if launchedOptions.ProviderName != "work" {
|
|
t.Fatalf("ProviderName = %q, want work", launchedOptions.ProviderName)
|
|
}
|
|
if launchedOptions.ModelName != "gpt-test" {
|
|
t.Fatalf("ModelName = %q, want gpt-test", launchedOptions.ModelName)
|
|
}
|
|
if len(launchedOptions.FavoriteModels) != 1 || launchedOptions.FavoriteModels[0] != "qwen3-coder:480b" {
|
|
t.Fatalf("FavoriteModels = %#v, want qwen3-coder:480b", launchedOptions.FavoriteModels)
|
|
}
|
|
if launchedOptions.Setup.Visible {
|
|
t.Fatalf("Setup.Visible = true, want false for credentialed provider")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
assertCoreRegistry(t, launchedOptions.Registry)
|
|
assertAgentOptions(t, launchedOptions, 5, agent.PermissionModeAsk)
|
|
}
|
|
|
|
func TestRunNoArgsLaunchesTUIInAskPermissionMode(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
// Auto only advertises PermissionAllow tools, so write_file/edit_file/bash/
|
|
// apply_patch (PermissionPrompt) would never be offered to the model. Ask
|
|
// advertises them and gates each through the permission flow.
|
|
if launchedOptions.PermissionMode != agent.PermissionModeAsk {
|
|
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeAsk)
|
|
}
|
|
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeAsk {
|
|
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeAsk)
|
|
}
|
|
}
|
|
|
|
// TestRunInteractiveSurfacesMCPTrustNotice executes the interactive (TUI) path's MCP
|
|
// trust notice (app.go), the third notice site alongside exec and spec-draft. An
|
|
// untrusted repo whose only project config is MCP must print the notice before runTUI;
|
|
// trusting it silences it. runTUI is stubbed so nothing renders; resolveMCPConfig
|
|
// returns no servers so nothing spawns -- the notice depends only on the trust verdict
|
|
// and the real ./.zero/config.json.
|
|
func TestRunInteractiveSurfacesMCPTrustNotice(t *testing.T) {
|
|
setTrustConfigRoot(t)
|
|
repo := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(repo, ".zero"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := `{"mcp":{"servers":{"proj":{"type":"stdio","command":"proj-cmd"}}}}`
|
|
if err := os.WriteFile(filepath.Join(repo, ".zero", "config.json"), []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
run := func() string {
|
|
var stdout, stderr bytes.Buffer
|
|
code := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return repo, nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
resolveMCPConfig: func(string, bool) (config.MCPConfig, error) { return config.MCPConfig{}, nil },
|
|
runTUI: func(context.Context, tui.Options) int { return 0 },
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("interactive launch exit = %d, stderr=%q", code, stderr.String())
|
|
}
|
|
return stderr.String()
|
|
}
|
|
|
|
untrusted := run()
|
|
if !strings.Contains(untrusted, "MCP servers") || !strings.Contains(untrusted, "zero trust") {
|
|
t.Fatalf("untrusted interactive launch must surface the MCP trust notice, stderr=%q", untrusted)
|
|
}
|
|
|
|
if err := workspacetrust.Trust(repo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if trusted := run(); strings.Contains(trusted, "ignoring project") {
|
|
t.Fatalf("trusted interactive launch must not emit a trust notice, stderr=%q", trusted)
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeLaunchesTUIInUnsafeMode(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
launched := false
|
|
var launchedOptions tui.Options
|
|
|
|
// "zero --skip-permissions-unsafe" must launch the interactive TUI in unsafe
|
|
// mode (not fall through to "unknown command"). This is the only way to reach
|
|
// unsafe mode in the shell, which the "!" escape is gated behind.
|
|
exitCode := runWithDeps([]string{"--skip-permissions-unsafe"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("expected the interactive TUI to launch, but runTUI was never called")
|
|
}
|
|
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
|
|
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
|
|
}
|
|
if launchedOptions.AgentOptions.PermissionMode != agent.PermissionModeUnsafe {
|
|
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", launchedOptions.AgentOptions.PermissionMode, agent.PermissionModeUnsafe)
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeRejectsTrailingPrompt(t *testing.T) {
|
|
// AUDIT-L3: a trailing prompt must be rejected loudly, not silently dropped
|
|
// while the interactive TUI launches.
|
|
var stdout, stderr bytes.Buffer
|
|
launched := false
|
|
exitCode := runWithDeps([]string{"--skip-permissions-unsafe", "fix the bug"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(context.Context, tui.Options) int { launched = true; return 0 },
|
|
})
|
|
if exitCode == 0 {
|
|
t.Fatal("a trailing prompt after --skip-permissions-unsafe must be rejected, not silently dropped")
|
|
}
|
|
if launched {
|
|
t.Fatal("the TUI must not launch when trailing args are rejected")
|
|
}
|
|
if !strings.Contains(stderr.String(), "takes no prompt") {
|
|
t.Fatalf("error should explain the no-prompt contract, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunAddDirWiresExtraWriteRootIntoTUISandboxScope(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
extra := t.TempDir()
|
|
launched := false
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{"--add-dir", extra}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("expected the interactive TUI to launch, but runTUI was never called")
|
|
}
|
|
if launchedOptions.AgentOptions.Sandbox == nil {
|
|
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
|
}
|
|
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
|
// The scope stores symlink-resolved roots (e.g. macOS /var -> /private/var),
|
|
// so compare against the resolved extra dir.
|
|
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q): %v", extra, err)
|
|
}
|
|
found := false
|
|
for _, root := range roots {
|
|
if root == resolvedExtra {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("scope roots = %v, want to contain %q", roots, resolvedExtra)
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeMergesAddDirGrants(t *testing.T) {
|
|
cwd := t.TempDir()
|
|
extra := t.TempDir()
|
|
resolvedExtra, err := filepath.EvalSymlinks(extra)
|
|
if err != nil {
|
|
t.Fatalf("EvalSymlinks(%q): %v", extra, err)
|
|
}
|
|
|
|
// --add-dir is accepted on either side of --skip-permissions-unsafe; both
|
|
// orders must reach the unsafe TUI with the extra root in scope.
|
|
for _, args := range [][]string{
|
|
{"--skip-permissions-unsafe", "--add-dir", extra},
|
|
{"--add-dir", extra, "--skip-permissions-unsafe"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d: %s", exitCode, stderr.String())
|
|
}
|
|
if launchedOptions.PermissionMode != agent.PermissionModeUnsafe {
|
|
t.Fatalf("PermissionMode = %q, want %q", launchedOptions.PermissionMode, agent.PermissionModeUnsafe)
|
|
}
|
|
if launchedOptions.AgentOptions.Sandbox == nil {
|
|
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
|
}
|
|
roots := launchedOptions.AgentOptions.Sandbox.Scope().Roots()
|
|
found := false
|
|
for _, root := range roots {
|
|
if root == resolvedExtra {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("scope roots = %v, want extra root %q", roots, resolvedExtra)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSkipPermissionsUnsafeRejectsAddDirHiddenBehindStrayArg(t *testing.T) {
|
|
// splitLeadingAddDirFlags stops at the first non-flag token, so a grant
|
|
// placed after a stray arg would otherwise be discarded with the ignored
|
|
// trailing args. An explicit grant must never be silently dropped.
|
|
for _, args := range [][]string{
|
|
{"--skip-permissions-unsafe", "stray", "--add-dir", "/tmp"},
|
|
{"--skip-permissions-unsafe", "stray", "--add-dir=/tmp"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
runTUI: func(context.Context, tui.Options) int {
|
|
t.Fatal("TUI launcher should not be called when a misplaced --add-dir is rejected")
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--add-dir must come before any other arguments") {
|
|
t.Fatalf("expected misplaced --add-dir rejection on stderr, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunAddDirRejectedForNonAgentCommands(t *testing.T) {
|
|
// --add-dir grants a write root that only the TUI and exec consume; every
|
|
// other subcommand — including help/version, which run no agent — must
|
|
// fail loud rather than silently drop the grant.
|
|
for _, args := range [][]string{
|
|
{"--add-dir", "/tmp", "config"},
|
|
{"--add-dir=/tmp", "doctor"},
|
|
{"--add-dir", "/tmp", "models"},
|
|
{"--add-dir", "/tmp", "sandbox"},
|
|
{"--add-dir", "/tmp", "help"},
|
|
{"--add-dir", "/tmp", "--help"},
|
|
{"--add-dir", "/tmp", "-h"},
|
|
{"--add-dir", "/tmp", "version"},
|
|
{"--add-dir", "/tmp", "--version"},
|
|
{"--add-dir", "/tmp", "-v"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
runTUI: func(context.Context, tui.Options) int {
|
|
t.Fatal("TUI launcher should not be called when --add-dir is rejected")
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--add-dir is only supported for the interactive TUI and exec") {
|
|
t.Fatalf("expected --add-dir rejection on stderr, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunNoArgsReportsConfigErrorsWithoutLaunchingTUI(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
launchCalled := false
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return t.TempDir(), nil
|
|
},
|
|
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{}, errors.New("bad config")
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchCalled = true
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
if launchCalled {
|
|
t.Fatal("TUI launcher should not be called when config fails")
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "bad config") {
|
|
t.Fatalf("expected config error on stderr, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunCommandsDoNotLaunchTUI(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"--help"},
|
|
{"-h"},
|
|
{"help"},
|
|
{"--version"},
|
|
{"version"},
|
|
{"wat"},
|
|
{"exec"},
|
|
{"setup", "--help"},
|
|
{"config"},
|
|
{"models"},
|
|
{"providers"},
|
|
{"doctor"},
|
|
{"search"},
|
|
{"find"},
|
|
{"sessions"},
|
|
{"session"},
|
|
{"plugins"},
|
|
{"plugin"},
|
|
{"skills"},
|
|
{"skill"},
|
|
{"hooks"},
|
|
{"mcp"},
|
|
{"sandbox"},
|
|
{"update"},
|
|
{"worktrees"},
|
|
{"worktree"},
|
|
{"verify"},
|
|
{"serve"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
launchCalled := false
|
|
|
|
_ = runWithDeps(args, &stdout, &stderr, appDeps{
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchCalled = true
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if launchCalled {
|
|
t.Fatalf("TUI launcher should not be called for args %#v", args)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSetupNoArgsForcesSetupTUI(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{"setup"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) {
|
|
return cwd, nil
|
|
},
|
|
userConfigPath: func() (string, error) {
|
|
return configPath, nil
|
|
},
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{
|
|
ActiveProvider: "work",
|
|
Provider: config.ProviderProfile{
|
|
Name: "work",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
BaseURL: config.OpenAIBaseURL,
|
|
APIKey: "sk-test",
|
|
Model: "gpt-test",
|
|
},
|
|
MaxTurns: 3,
|
|
}, nil
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
return &cliFakeProvider{}, nil
|
|
},
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(ctx context.Context, options tui.Options) int {
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !launchedOptions.Setup.Visible {
|
|
t.Fatalf("Setup.Visible = false, want forced setup")
|
|
}
|
|
if launchedOptions.Setup.Required {
|
|
t.Fatalf("Setup.Required = true, want false for credentialed provider")
|
|
}
|
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
|
t.Fatalf("expected no output, stdout=%q stderr=%q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSetupProviderWritesActiveConfig(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
|
|
exitCode := runWithDeps([]string{"setup", "ollama"}, &stdout, &stderr, appDeps{
|
|
userConfigPath: func() (string, error) {
|
|
return configPath, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("read config: %v", err)
|
|
}
|
|
var cfg config.FileConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
t.Fatalf("decode config: %v\n%s", err, string(data))
|
|
}
|
|
if cfg.ActiveProvider != "ollama" {
|
|
t.Fatalf("ActiveProvider = %q, want ollama", cfg.ActiveProvider)
|
|
}
|
|
if len(cfg.Providers) != 1 || cfg.Providers[0].CatalogID != "ollama" || cfg.Providers[0].Model == "" {
|
|
t.Fatalf("Providers = %#v, want ollama provider with model", cfg.Providers)
|
|
}
|
|
if !strings.Contains(stdout.String(), "Zero setup complete") || !strings.Contains(stdout.String(), "next: zero") {
|
|
t.Fatalf("unexpected setup output: %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateCheckTextAndJSON(t *testing.T) {
|
|
result := update.Result{
|
|
CurrentVersion: "dev",
|
|
LatestVersion: "0.2.0",
|
|
ReleaseURL: "https://github.com/Twigpine/zero/releases/tag/v0.2.0",
|
|
TagName: "v0.2.0",
|
|
ReleaseAsset: update.AssetCheck{
|
|
Platform: "linux",
|
|
Arch: "x64",
|
|
ArchiveName: "zero-v0.2.0-linux-x64.tar.gz",
|
|
ArchiveURL: "https://example.test/zero-v0.2.0-linux-x64.tar.gz",
|
|
ChecksumName: "zero-v0.2.0-linux-x64.tar.gz.sha256",
|
|
ChecksumURL: "https://example.test/zero-v0.2.0-linux-x64.tar.gz.sha256",
|
|
ArchiveFound: true,
|
|
ChecksumFound: true,
|
|
Verified: true,
|
|
},
|
|
UpdateAvailable: true,
|
|
}
|
|
deps := appDeps{
|
|
checkUpdate: func(ctx context.Context, options update.Options) (update.Result, error) {
|
|
result.CurrentVersion = options.CurrentVersion
|
|
return result, nil
|
|
},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Update available: dev -> 0.2.0") {
|
|
t.Fatalf("unexpected update text: %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Release asset: zero-v0.2.0-linux-x64.tar.gz") || !strings.Contains(stdout.String(), "Checksum asset: zero-v0.2.0-linux-x64.tar.gz.sha256") {
|
|
t.Fatalf("unexpected update asset text: %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"update", "--check", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
var payload struct {
|
|
CurrentVersion string `json:"currentVersion"`
|
|
LatestVersion string `json:"latestVersion"`
|
|
ReleaseURL string `json:"releaseUrl"`
|
|
TagName string `json:"tagName"`
|
|
ReleaseAsset struct {
|
|
Platform string `json:"platform"`
|
|
Arch string `json:"arch"`
|
|
ArchiveName string `json:"archiveName"`
|
|
ChecksumName string `json:"checksumName"`
|
|
ArchiveFound bool `json:"archiveFound"`
|
|
ChecksumFound bool `json:"checksumFound"`
|
|
Verified bool `json:"verified"`
|
|
} `json:"releaseAsset"`
|
|
UpdateAvailable bool `json:"updateAvailable"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("update JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if payload.CurrentVersion != "dev" ||
|
|
payload.LatestVersion != result.LatestVersion ||
|
|
payload.ReleaseURL != result.ReleaseURL ||
|
|
payload.TagName != result.TagName ||
|
|
payload.ReleaseAsset.ArchiveName != result.ReleaseAsset.ArchiveName ||
|
|
payload.ReleaseAsset.ChecksumName != result.ReleaseAsset.ChecksumName ||
|
|
!payload.ReleaseAsset.Verified ||
|
|
payload.UpdateAvailable != result.UpdateAvailable {
|
|
t.Fatalf("unexpected update JSON: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdatePassesCheckOptions(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
var got update.Options
|
|
exitCode := runWithDeps([]string{"update", "--check", "--repo=Gitlawb/fork", "--endpoint", "https://example.test/releases/latest", "--timeout", "750ms", "--target", "windows-x64", "--json"}, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(_ context.Context, options update.Options) (update.Result, error) {
|
|
got = options
|
|
return update.Result{
|
|
CurrentVersion: options.CurrentVersion,
|
|
LatestVersion: "0.2.0",
|
|
ReleaseURL: "https://example.test/releases/tag/v0.2.0",
|
|
TagName: "v0.2.0",
|
|
UpdateAvailable: true,
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if got.CurrentVersion != "dev" || got.Repository != "Gitlawb/fork" || got.Endpoint != "https://example.test/releases/latest" || got.Timeout != 750*time.Millisecond || got.GOOS != "windows" || got.GOARCH != "amd64" {
|
|
t.Fatalf("unexpected update options: %#v", got)
|
|
}
|
|
if stdout.Len() == 0 {
|
|
t.Fatalf("expected JSON output")
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRejectsInvalidTarget(t *testing.T) {
|
|
tests := []struct {
|
|
args []string
|
|
errorText string
|
|
}{
|
|
{args: []string{"update", "--check", "--target", "solaris-sparc"}, errorText: "unsupported update target"},
|
|
{args: []string{"update", "--check", "--target="}, errorText: "--target requires a non-empty value"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(strings.Join(tt.args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(tt.args, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
t.Fatal("checkUpdate should not run for invalid target")
|
|
return update.Result{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, tt.errorText) {
|
|
t.Fatalf("expected target usage error %q, got %q", tt.errorText, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRequiresCheckFlag(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update"}, &stdout, &stderr, appDeps{})
|
|
|
|
if exitCode == exitSuccess {
|
|
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--check") {
|
|
t.Fatalf("expected --check usage error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRejectsInvalidTimeout(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"update", "--check", "--timeout", "fast"},
|
|
{"update", "--check", "--timeout", "0s"},
|
|
{"update", "--check", "--timeout=-1s"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
t.Fatal("checkUpdate should not run for invalid timeout")
|
|
return update.Result{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "invalid update timeout") {
|
|
t.Fatalf("expected timeout usage error, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateReportsCheckError(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
return update.Result{}, errors.New("network failure")
|
|
},
|
|
})
|
|
|
|
if exitCode == exitSuccess {
|
|
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "network failure") {
|
|
t.Fatalf("expected update error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateHelpDocumentsCheckFlag(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--help"}, &stdout, &stderr, appDeps{})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
for _, want := range []string{"--check", "--repo", "--endpoint", "--timeout", "--target", "ZERO_GITHUB_TOKEN", "ZERO_UPDATE_RELEASE_URL"} {
|
|
if !strings.Contains(stdout.String(), want) {
|
|
t.Fatalf("expected update help to document %s, got %q", want, stdout.String())
|
|
}
|
|
}
|
|
// The fallback entry must have its own line, apart from the precedence prose.
|
|
if !strings.Contains(stdout.String(), "\n GITHUB_TOKEN ") {
|
|
t.Fatalf("expected update help to document a standalone GITHUB_TOKEN entry, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestTopLevelHelpDocumentsRequiredUpdateMode(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"--help"}, &stdout, &stderr, appDeps{})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, "update Check or apply Zero CLI updates (requires --check or --apply)") {
|
|
t.Fatalf("expected top-level help to document required update mode, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateReportsUpToDate(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--check"}, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
return update.Result{
|
|
CurrentVersion: "dev",
|
|
LatestVersion: "dev",
|
|
ReleaseURL: "https://github.com/Twigpine/zero/releases/tag/dev",
|
|
TagName: "dev",
|
|
UpdateAvailable: false,
|
|
}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "up to date") {
|
|
t.Fatalf("expected up-to-date output, got %q", stdout.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
|
|
applyResult := update.ApplyResult{
|
|
Result: update.Result{
|
|
CurrentVersion: "dev",
|
|
LatestVersion: "0.2.0",
|
|
UpdateAvailable: true,
|
|
},
|
|
Applied: true,
|
|
InstallMethod: update.InstallMethodStandalone,
|
|
BinaryPath: "/usr/local/bin/zero",
|
|
Message: "updated to 0.2.0",
|
|
}
|
|
var gotOptions update.Options
|
|
deps := appDeps{
|
|
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
|
|
gotOptions = options
|
|
return applyResult, nil
|
|
},
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
t.Fatal("checkUpdate should not run for --apply")
|
|
return update.Result{}, nil
|
|
},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
|
|
t.Fatalf("unexpected apply text: %q", stdout.String())
|
|
}
|
|
if gotOptions.CurrentVersion != "dev" {
|
|
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
var payload struct {
|
|
Applied bool `json:"applied"`
|
|
InstallMethod string `json:"installMethod"`
|
|
BinaryPath string `json:"binaryPath"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
|
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
|
|
}
|
|
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
|
|
t.Fatalf("unexpected apply JSON: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestRunUpgradeDefaultsToApply(t *testing.T) {
|
|
var applyCalled bool
|
|
deps := appDeps{
|
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
|
applyCalled = true
|
|
return update.ApplyResult{Message: "already up to date"}, nil
|
|
},
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
t.Fatal("checkUpdate should not run for `zero upgrade`")
|
|
return update.Result{}, nil
|
|
},
|
|
}
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
|
|
}
|
|
if !applyCalled {
|
|
t.Fatal("expected `zero upgrade` to call applyUpdate")
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
|
|
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
|
|
t.Fatal("checkUpdate should not run")
|
|
return update.Result{}, nil
|
|
},
|
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
|
t.Fatal("applyUpdate should not run")
|
|
return update.ApplyResult{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
|
|
t.Fatalf("expected --check/--apply usage error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"update", "--apply", "--target", "linux-arm64"},
|
|
{"upgrade", "--target", "linux-arm64"},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
|
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
|
t.Fatal("applyUpdate should not run when --target is combined with --apply")
|
|
return update.ApplyResult{}, nil
|
|
},
|
|
})
|
|
|
|
if exitCode != exitUsage {
|
|
t.Fatalf("expected usage exit code, got %d", exitCode)
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
|
|
t.Fatalf("expected --target/--apply usage error, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunUpdateReportsApplyError(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
|
|
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
|
|
return update.ApplyResult{}, errors.New("download failed")
|
|
},
|
|
})
|
|
|
|
if exitCode == exitSuccess {
|
|
t.Fatalf("expected non-success exit code, got %d", exitCode)
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, "download failed") {
|
|
t.Fatalf("expected apply error, got %q", got)
|
|
}
|
|
}
|
|
|
|
func assertHelpOutput(t *testing.T, args []string) {
|
|
t.Helper()
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run(args, &stdout, &stderr)
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("expected exit code 0, got %d", exitCode)
|
|
}
|
|
|
|
output := stdout.String()
|
|
for _, want := range []string{
|
|
"ZERO terminal coding agent",
|
|
"Usage:",
|
|
"zero [command]",
|
|
"exec",
|
|
"config",
|
|
"models",
|
|
"providers",
|
|
"doctor",
|
|
"context",
|
|
"search",
|
|
"plugins",
|
|
"hooks",
|
|
"mcp",
|
|
"sandbox",
|
|
"update",
|
|
"upgrade",
|
|
"worktrees",
|
|
"verify",
|
|
"serve",
|
|
"usage",
|
|
"--version",
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("expected help output to contain %q, got %q", want, output)
|
|
}
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("expected empty stderr, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestRunExecRequiresPrompt(t *testing.T) {
|
|
for _, args := range [][]string{
|
|
{"exec"},
|
|
{"exec", ""},
|
|
{"exec", " "},
|
|
} {
|
|
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run(args, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("expected exit code 2, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "Prompt required") {
|
|
t.Fatalf("expected prompt error, got %q", stderr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsFailureWhenStdoutWriteFails(t *testing.T) {
|
|
exitCode := Run([]string{"--version"}, failingWriter{}, io.Discard)
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunReturnsFailureWhenStderrWriteFails(t *testing.T) {
|
|
exitCode := Run([]string{"wat"}, io.Discard, failingWriter{})
|
|
|
|
if exitCode != 1 {
|
|
t.Fatalf("expected exit code 1, got %d", exitCode)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsUnknownCommand(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
exitCode := Run([]string{"wat"}, &stdout, &stderr)
|
|
|
|
if exitCode != 2 {
|
|
t.Fatalf("expected exit code 2, got %d", exitCode)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("expected empty stdout, got %q", stdout.String())
|
|
}
|
|
if got := stderr.String(); !strings.Contains(got, `unknown command "wat"`) {
|
|
t.Fatalf("expected unknown command error, got %q", got)
|
|
}
|
|
}
|
|
|
|
type cliFakeProvider struct{}
|
|
|
|
func (cliFakeProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
|
ch := make(chan zeroruntime.StreamEvent)
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func assertCoreRegistry(t *testing.T, registry *tools.Registry) {
|
|
t.Helper()
|
|
|
|
if registry == nil {
|
|
t.Fatal("Registry = nil, want core tool registry")
|
|
}
|
|
|
|
for _, name := range []string{
|
|
"Task",
|
|
"read_file",
|
|
"list_directory",
|
|
"glob",
|
|
"grep",
|
|
"write_file",
|
|
"edit_file",
|
|
"apply_patch",
|
|
"update_plan",
|
|
"bash",
|
|
"web_fetch",
|
|
} {
|
|
if _, ok := registry.Get(name); !ok {
|
|
t.Fatalf("expected registry to include core tool %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func assertAgentOptions(t *testing.T, options tui.Options, maxTurns int, permissionMode agent.PermissionMode) {
|
|
t.Helper()
|
|
|
|
if options.AgentOptions.MaxTurns != maxTurns {
|
|
t.Fatalf("AgentOptions.MaxTurns = %d, want %d", options.AgentOptions.MaxTurns, maxTurns)
|
|
}
|
|
if options.AgentOptions.PermissionMode != permissionMode {
|
|
t.Fatalf("AgentOptions.PermissionMode = %q, want %q", options.AgentOptions.PermissionMode, permissionMode)
|
|
}
|
|
if options.AgentOptions.Autonomy != "low" {
|
|
t.Fatalf("AgentOptions.Autonomy = %q, want %q", options.AgentOptions.Autonomy, "low")
|
|
}
|
|
if options.AgentOptions.Sandbox == nil {
|
|
t.Fatal("AgentOptions.Sandbox = nil, want sandbox engine")
|
|
}
|
|
if options.SandboxStore == nil {
|
|
t.Fatal("SandboxStore = nil, want sandbox grant store")
|
|
}
|
|
if options.PermissionMode != permissionMode {
|
|
t.Fatalf("PermissionMode = %q, want %q", options.PermissionMode, permissionMode)
|
|
}
|
|
}
|
|
|
|
func TestRunThemeFlagPopulatesTUIOptions(t *testing.T) {
|
|
// The --theme flag must reach tui.Options.Theme (resolveThemeMode prefers it
|
|
// over ZERO_THEME). Previously Options.Theme was read but never set by the CLI.
|
|
for _, tc := range []struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
{[]string{"--theme", "light"}, "light"},
|
|
{[]string{"--theme=dark"}, "dark"},
|
|
{[]string{"--theme", "auto"}, "auto"},
|
|
} {
|
|
var stdout, stderr bytes.Buffer
|
|
var got tui.Options
|
|
launched := false
|
|
exit := runWithDeps(tc.args, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(_ context.Context, o tui.Options) int { launched = true; got = o; return 0 },
|
|
})
|
|
if exit != 0 || !launched {
|
|
t.Fatalf("%v: exit=%d launched=%v stderr=%s", tc.args, exit, launched, stderr.String())
|
|
}
|
|
if got.Theme != tc.want {
|
|
t.Fatalf("%v: Options.Theme = %q, want %q", tc.args, got.Theme, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunThemeFlagRejectsBadValue(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
launched := false
|
|
exit := runWithDeps([]string{"--theme", "solarized"}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return t.TempDir(), nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.ResolvedConfig{MaxTurns: 3}, nil
|
|
},
|
|
runTUI: func(context.Context, tui.Options) int { launched = true; return 0 },
|
|
})
|
|
if exit == 0 {
|
|
t.Fatal("an invalid --theme value must be rejected, not silently ignored")
|
|
}
|
|
if launched {
|
|
t.Fatal("the TUI must not launch on a bad --theme value")
|
|
}
|
|
if !strings.Contains(stderr.String(), "--theme must be auto or a registered theme name") {
|
|
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
// A custom endpoint saved without a model previously bricked bare `zero` and
|
|
// `zero setup` — the exact commands that could have fixed it (the resolve
|
|
// error escaped before the wizard could open). The interactive TUI now treats
|
|
// the requires-model failure as "needs onboarding", same as a missing active
|
|
// provider. The error comes from a REAL Resolve over a real config file, so
|
|
// this exercises the production sentinel wrapping, not a hand-built error.
|
|
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cwd := t.TempDir()
|
|
setCLIUserConfigRoot(t)
|
|
brokenConfig := filepath.Join(t.TempDir(), "config.json")
|
|
if err := os.WriteFile(brokenConfig, []byte(`{
|
|
"activeProvider": "gw",
|
|
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
|
|
}`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
launched := false
|
|
var launchedOptions tui.Options
|
|
|
|
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
|
|
getwd: func() (string, error) { return cwd, nil },
|
|
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
|
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
|
|
},
|
|
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
|
t.Fatal("newProvider must not be called without a resolved provider")
|
|
return nil, nil
|
|
},
|
|
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
|
|
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
|
return noopMCPRuntime{}, nil
|
|
},
|
|
runTUI: func(_ context.Context, options tui.Options) int {
|
|
launched = true
|
|
launchedOptions = options
|
|
return 0
|
|
},
|
|
})
|
|
|
|
if exitCode != 0 {
|
|
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
|
|
}
|
|
if !launched {
|
|
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
|
|
}
|
|
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
|
|
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
|
|
}
|
|
}
|
|
|
|
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
|
|
// users try them (reported in the wild), so the unknown-command error points at
|
|
// the real command.
|
|
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
|
|
if exitCode != 2 {
|
|
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
|
|
}
|
|
if !strings.Contains(stderr.String(), `"zero auth login"`) {
|
|
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
|
|
}
|
|
// An unrelated unknown command gets no misleading suggestion.
|
|
stderr.Reset()
|
|
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
|
|
if strings.Contains(stderr.String(), "did you mean") {
|
|
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
|
|
}
|
|
}
|