Files
zero/internal/cli/app.go
KRATOS bb7de29c80 feat(oauth): reusable provider OAuth engine + zero auth CLI (#210)
* feat(oauth): reusable provider OAuth engine + `zero auth` CLI

Add internal/oauth: a transport/identity-agnostic OAuth 2.0 engine that powers
provider login alongside the MCP-server OAuth zero already has. It is additive
and provider-neutral — NO providers, endpoints, or client identities are baked
into the binary; every provider is configured entirely via ZERO_OAUTH_<NAME>_*
env vars, so the engine works with any OAuth 2.0 / OIDC server.

Engine (internal/oauth):
- PKCE (S256 mandatory; "plain" refused) + per-flow CSRF state.
- Authorization-code flow: BuildAuthorizationURL / ExchangeCode / Refresh, with
  an https-only token-endpoint guard (loopback exempt) and error bodies that
  carry only error/error_description (never token material).
- Loopback callback server: 127.0.0.1-only, OS-assigned port, single use, state
  verified, then closed.
- Device-authorization grant (RFC 8628): RequestDeviceCode + PollDeviceToken
  honoring authorization_pending / slow_down / interval / expiry (headless/SSH).
- RFC 8414 + OIDC discovery (issuer-driven endpoint resolution).
- Namespaced token store ("provider:<name>" / "mcp:<name>"), 0600, file-locked
  (ownership-aware), atomic writes, malformed-fails-closed; GetFresh (on-demand
  refresh) + Handle401 (forced refresh).
- Opt-in proactive RefreshScheduler.

CLI (zero auth):
- login <provider> [--device] [--scope ...], logout, status [provider],
  refresh <provider> [--watch]. Status/output never print token material.
- Registered in app.go dispatch + top-level help.

MCP OAuth (internal/mcp) is left byte-for-byte unchanged (zero regression);
sharing the engine with MCP and encrypted-at-rest/keyring storage are documented
follow-ups. Tests cover every path (PKCE, auth URL, https-guard + redaction,
exchange/refresh, loopback capture + state-mismatch + timeout, device pending/
slow_down/expired, store namespacing/0600/locking, provider env resolution,
manager login (loopback+device)/GetFresh/Handle401, scheduler). Gates: gofmt /
vet / build (host+linux+windows) / test -race / staticcheck (no new) /
govulncheck (0) / deadcode (no new) all pass.

* oauth: fix Windows smoke — use OS-appropriate path in store-path test

TestResolveStorePathHonorsOverride hardcoded a unix "/tmp/custom/tok.json"
literal, which is not absolute on Windows (no drive), so ResolveStorePath
correctly resolves it against the current drive and the verbatim comparison
failed (D:\tmp\custom\tok.json). The production code is correct; the test now
builds the override with filepath.Join(t.TempDir(), ...) so it is absolute and
clean on every OS.

* oauth: address CodeRabbit review (11 findings)

Security / correctness:
- flow: caller-supplied ExtraAuthParams/extraParams can no longer override the
  reserved OAuth/PKCE fields (state, redirect_uri, code_challenge[_method],
  response_type, client_id) — e.g. forcing method=plain.
- device: a device-authorization response without expires_in now gets a bounded
  default lifetime (fail closed), and the poll loop re-checks expiry AFTER the
  interval sleep so it never polls past the deadline (e.g. after slow_down).
- lock: handle WriteString/Close errors on the token lock file — a partial write
  no longer strands an undeletable lock.
- store: a non-nil env map is now authoritative (hermetic) — a controlled map
  never falls back to ambient ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME.
- scheduler: a transient loadForKey error backs off and retries (bounded) instead
  of permanently stopping proactive refresh.
- cli auth: reject flags a subcommand does not accept (e.g. login --watch,
  status --device) and reject empty --scope values — fail fast.

Lint blockers:
- manager: restructured the discovery fallback so the non-nil-err branch no longer
  returns nil (nilerr).
- providers: dropped the ineffectual flow initializer (ineffassign).

Tests: assert Save/Load errors in the scheduler test; the slow_down device test
no longer depends on post-expiry polling. Added tests for reserved-param
stripping, hermetic env, device default-expiry, and CLI flag validation. The
store-path test was already made OS-portable in the Windows-smoke fix.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.

* swarm: de-flake TestMailboxConcurrentSends on Windows CI

The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)

* oauth: address CodeRabbit re-review (4 findings)

- loopback (security): NewLoopbackListener refuses an empty CSRF state — an empty
  state would match a callback carrying no state at all, defeating the check.
- device (correctness): RequestDeviceCode now sends client_secret when set, so a
  confidential client authenticates on the device-authorization endpoint too
  (consistent with the token poll).
- cli auth: document --watch in the help Flags section.
- scheduler test: assert the seed Save error instead of ignoring it.

Also scrubbed a stray third-party reference from a loopback.go comment.
Tests added: empty-state rejection, device client_secret is sent. Gates:
gofmt/vet/build/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new)
all pass.

* mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) (#212)

OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine
instead of carrying its own copy of the transport/identity-agnostic primitives.
internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState,
discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh,
the token-response parser, and joinWellKnown now delegate to internal/oauth.

Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types,
LoginOptions/Login orchestration, loopback handling, registerClient (dynamic
client registration — not part of the shared engine), CLI output, and on-disk
token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp
oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited
from the shared engine: the token endpoint must be https (loopback exempt) —
a credential is no longer sent to a plaintext non-loopback endpoint.

Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+
linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new
vs base) all pass; `zero mcp oauth status` output is identical.
2026-06-15 18:47:43 +05:30

836 lines
31 KiB
Go

package cli
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/selfverify"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/swarm"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zerogit"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
var version = "dev"
type appDeps struct {
getwd func() (string, error)
stdin io.Reader
userConfigPath func() (string, error)
resolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
resolveMCPConfig func(workspaceRoot string) (config.MCPConfig, error)
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
now func() time.Time
}
type mcpToolRuntime interface {
Close() error
}
type noopMCPRuntime struct{}
func (noopMCPRuntime) Close() error {
return nil
}
// Run executes the minimal Go CLI surface. It returns an exit code so tests can
// exercise command behavior without terminating the test process.
func Run(args []string, stdout io.Writer, stderr io.Writer) int {
return runWithDeps(args, stdout, stderr, defaultAppDeps())
}
func defaultAppDeps() appDeps {
return appDeps{
getwd: os.Getwd,
stdin: os.Stdin,
userConfigPath: config.DefaultUserConfigPath,
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return config.ResolvedConfig{}, err
}
options.Overrides = overrides
return config.Resolve(options)
},
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return config.MCPConfig{}, err
}
return config.ResolveMCP(options)
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return providers.New(profile, providers.Options{UserAgent: userAgent()})
},
probeProviderHealth: providerhealth.Probe,
newSessionStore: func() *sessions.Store {
return sessions.NewStore(sessions.StoreOptions{})
},
loadPlugins: plugins.Load,
loadHooks: hooks.LoadConfig,
skillsDir: func() string {
return skills.DefaultDir(nil)
},
pluginsDir: defaultUserPluginsDir,
// Scaffolded tools are plugins, so the toolbox dir is the user plugins root:
// after activation a `tools make` skeleton is discovered like any plugin.
toolsDir: defaultUserPluginsDir,
newMCPStore: func() (*mcp.PermissionStore, error) {
return mcp.NewPermissionStore(mcp.StoreOptions{})
},
newMCPTokenStore: func() (*mcp.TokenStore, error) {
return mcp.NewTokenStore(mcp.TokenStoreOptions{})
},
newSandboxStore: func() (*sandbox.GrantStore, error) {
return sandbox.NewGrantStore(sandbox.StoreOptions{})
},
selectSandboxBackend: sandbox.SelectBackend,
registerMCPTools: func(ctx context.Context, registry *tools.Registry, cfg config.MCPConfig, options mcp.RegisterOptions) (mcpToolRuntime, error) {
return mcp.RegisterTools(ctx, registry, cfg, options)
},
prepareWorktree: worktrees.Prepare,
detectVerifyPlan: verify.DetectPlan,
runVerify: verify.Run,
runSelfVerify: selfverify.Run,
runAgentEval: defaultRunAgentEval,
inspectChanges: zerogit.Inspect,
commitChanges: zerogit.Commit,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
now: time.Now,
}
}
func userAgent() string {
return "zero/" + version
}
// defaultUserPluginsDir resolves the user-scoped plugins root
// ($XDG_CONFIG_HOME/zero/plugins) used as the install target for `plugin add`
// and the toolbox for `tools make`. It is the SourceUser root from
// plugins.ResolveRoots; an empty string is returned only if it cannot be
// resolved, which the command layer surfaces as an error.
func defaultUserPluginsDir() string {
roots, err := plugins.ResolveRoots(plugins.ResolveRootOptions{})
if err != nil {
return ""
}
for _, root := range roots {
if root.Source == plugins.SourceUser {
return root.Path
}
}
return ""
}
func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) (exitCode int) {
// Convert an unexpected panic anywhere in the CLI into a saved crash report and
// a brief notice, rather than a raw stack trace dumped at the user.
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
deps = fillAppDeps(deps)
addDirs, args, err := splitLeadingAddDirFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
if len(args) == 0 {
return runInteractiveTUI(stderr, deps, agent.PermissionModeAsk, addDirs)
}
// --add-dir grants an extra write root, and only the interactive TUI and
// exec dispatch paths consume one. Fail loud everywhere else rather than
// silently discarding an explicit grant — including help/version, which
// run no agent and could only ignore it. The allowlist names exactly the
// cases below that forward addDirs; a future subcommand is rejected by
// default until it opts in here.
if len(addDirs) > 0 {
switch args[0] {
case "--skip-permissions-unsafe", "-p", "--prompt", "exec":
// Forwarded by the matching case below.
default:
return writeAppError(stderr, "--add-dir is only supported for the interactive TUI and exec", 1)
}
}
switch args[0] {
case "--skip-permissions-unsafe":
// Launch the interactive TUI directly in unsafe mode. Without this, the
// flag fell through to the unknown-command path, so a user could never
// reach unsafe mode in the shell — and the "!" shell escape (which is
// gated behind unsafe) was therefore unreachable.
//
// --add-dir may legally appear on either side of the flag, so re-split
// the remaining args and merge with the dirs already collected. Any
// trailing non-flag args were ignored on this path before --add-dir
// existed and still are — but an --add-dir hidden BEHIND one would be
// silently dropped with them, so reject that misplacement loudly.
moreDirs, rest, err := splitLeadingAddDirFlags(args[1:])
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
for _, arg := range rest {
if arg == "--add-dir" || strings.HasPrefix(arg, "--add-dir=") {
return writeAppError(stderr, "--add-dir must come before any other arguments (it may precede or follow --skip-permissions-unsafe)", 1)
}
}
return runInteractiveTUI(stderr, deps, agent.PermissionModeUnsafe, append(append([]string{}, addDirs...), moreDirs...))
case "-h", "--help", "help":
if err := writeHelp(stdout); err != nil {
return 1
}
return 0
case "-v", "--version", "version":
if _, err := fmt.Fprintf(stdout, "zero %s\n", version); err != nil {
return 1
}
return 0
case "-p", "--prompt":
if len(args) < 2 {
return writePromptRequired(stderr)
}
// Forward leading --add-dir occurrences so exec's own parser collects them.
execArgs := append(addDirFlagArgs(addDirs), "--prompt", args[1])
execArgs = append(execArgs, args[2:]...)
return runExec(execArgs, stdout, stderr, deps)
case "exec":
// Forward leading --add-dir occurrences so exec's own parser collects them.
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
case "daemon":
return runDaemon(args[1:], stdout, stderr, deps)
case "config":
return runConfig(args[1:], stdout, stderr, deps)
case "models":
return runModels(args[1:], stdout, stderr)
case "providers":
return runProviders(args[1:], stdout, stderr, deps)
case "doctor":
return runDoctor(args[1:], stdout, stderr, deps)
case "setup":
return runSetup(args[1:], stdout, stderr, deps)
case "context":
return runContext(args[1:], stdout, stderr, deps)
case "repo-map", "repomap":
return runRepoMap(args[1:], stdout, stderr, deps)
case "search", "find":
return runSearch(args[1:], stdout, stderr, deps)
case "sessions", "session":
return runSessions(args[1:], stdout, stderr, deps)
case "spec":
return runSpec(args[1:], stdout, stderr, deps)
case "specialists", "specialist":
return runSpecialists(args[1:], stdout, stderr, deps)
case "plugins", "plugin":
return runPlugins(args[1:], stdout, stderr, deps)
case "backends", "backend":
return runBackends(args[1:], stdout, stderr, deps)
case "skills", "skill":
return runSkills(args[1:], stdout, stderr, deps)
case "tools", "tool":
return runTools(args[1:], stdout, stderr, deps)
case "hooks":
return runHooks(args[1:], stdout, stderr, deps)
case "mcp":
return runMCP(args[1:], stdout, stderr, deps)
case "auth":
return runAuth(args[1:], stdout, stderr, deps)
case "sandbox":
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "worktrees", "worktree":
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
return runVerifyCommand(args[1:], stdout, stderr, deps)
case "eval":
return runAgentEvalCommand(args[1:], stdout, stderr, deps)
case "changes", "change":
return runChanges(args[1:], stdout, stderr, deps)
case "usage":
return runUsage(args[1:], stdout, stderr, deps)
case "cron":
return runCron(args[1:], stdout, stderr, deps)
case "repo-info", "repoinfo":
return runRepoInfo(args[1:], stdout, stderr, deps)
case "serve":
return runServe(args[1:], stdout, stderr, deps)
default:
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
return 1
}
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
return 1
}
return 2
}
}
func fillAppDeps(deps appDeps) appDeps {
defaults := defaultAppDeps()
if deps.getwd == nil {
deps.getwd = defaults.getwd
}
if deps.stdin == nil {
deps.stdin = defaults.stdin
}
if deps.userConfigPath == nil {
deps.userConfigPath = defaults.userConfigPath
}
if deps.resolveConfig == nil {
deps.resolveConfig = defaults.resolveConfig
}
if deps.resolveMCPConfig == nil {
deps.resolveMCPConfig = defaults.resolveMCPConfig
}
if deps.newProvider == nil {
deps.newProvider = defaults.newProvider
}
if deps.probeProviderHealth == nil {
deps.probeProviderHealth = defaults.probeProviderHealth
}
if deps.newSessionStore == nil {
deps.newSessionStore = defaults.newSessionStore
}
if deps.loadPlugins == nil {
deps.loadPlugins = defaults.loadPlugins
}
if deps.loadHooks == nil {
deps.loadHooks = defaults.loadHooks
}
if deps.skillsDir == nil {
deps.skillsDir = defaults.skillsDir
}
if deps.pluginsDir == nil {
deps.pluginsDir = defaults.pluginsDir
}
if deps.toolsDir == nil {
deps.toolsDir = defaults.toolsDir
}
if deps.newMCPStore == nil {
deps.newMCPStore = defaults.newMCPStore
}
if deps.newMCPTokenStore == nil {
deps.newMCPTokenStore = defaults.newMCPTokenStore
}
if deps.newSandboxStore == nil {
deps.newSandboxStore = defaults.newSandboxStore
}
if deps.selectSandboxBackend == nil {
deps.selectSandboxBackend = defaults.selectSandboxBackend
}
if deps.registerMCPTools == nil {
deps.registerMCPTools = defaults.registerMCPTools
}
if deps.prepareWorktree == nil {
deps.prepareWorktree = defaults.prepareWorktree
}
if deps.detectVerifyPlan == nil {
deps.detectVerifyPlan = defaults.detectVerifyPlan
}
if deps.runVerify == nil {
deps.runVerify = defaults.runVerify
}
if deps.runSelfVerify == nil {
deps.runSelfVerify = defaults.runSelfVerify
}
if deps.runAgentEval == nil {
deps.runAgentEval = defaults.runAgentEval
}
if deps.inspectChanges == nil {
deps.inspectChanges = defaults.inspectChanges
}
if deps.commitChanges == nil {
deps.commitChanges = defaults.commitChanges
}
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
if deps.runEditor == nil {
deps.runEditor = defaults.runEditor
}
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.now == nil {
deps.now = defaults.now
}
return deps
}
func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string) int {
return runInteractiveTUIWithSetup(stderr, deps, permissionMode, addDirs, false)
}
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, forceSetup bool) int {
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
}
doctorUserConfigPath := ""
projectConfigPath := ""
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
doctorUserConfigPath = resolveOptions.UserConfigPath
projectConfigPath = resolveOptions.ProjectConfigPath
}
needsSetup := setupRequired(resolved)
setupVisible := forceSetup || needsSetup
configPath := ""
if setupVisible {
configPath, err = deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
}
scope, err := sandbox.NewScope(workspaceRoot, append(append([]string{}, resolved.Sandbox.AdditionalWriteRoots...), addDirs...))
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
provider, err := buildProvider(resolved, deps)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
specialistRuntime, err := registerSpecialistTools(registry, workspaceRoot)
if err != nil {
return writeAppError(stderr, "failed to initialize specialist tools: "+err.Error(), 1)
}
defer closeSpecialistRuntime(stderr, specialistRuntime)
mcpConfig, err := deps.resolveMCPConfig(workspaceRoot)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
}
mcpPermissionStore, err := deps.newMCPStore()
if err != nil {
return writeAppError(stderr, "failed to initialize MCP permissions: "+err.Error(), 1)
}
mcpTokenStore, err := deps.newMCPTokenStore()
if err != nil {
_, _ = fmt.Fprintf(stderr, "[zero] warning: failed to initialize MCP OAuth tokens: %s\n", err)
mcpTokenStore = nil
err = nil
}
mcpRuntime := mcpToolRuntime(noopMCPRuntime{})
if len(mcpConfig.Servers) > 0 {
mcpRuntime, err = deps.registerMCPTools(context.Background(), registry, mcpConfig, mcp.RegisterOptions{
PermissionStore: mcpPermissionStore,
Autonomy: mcp.AutonomyLow,
})
}
if err != nil {
closeMCPRuntime(stderr, mcpRuntime)
return writeAppError(stderr, err.Error(), 1)
}
defer closeMCPRuntime(stderr, mcpRuntime)
// Make local plugins live: register their declared tools into the registry and
// collect their hooks + skill roots for the dispatcher and skill tool below.
// Done after specialist + MCP registration so plugin tools are part of the
// deferral count, and it fails OPEN — a malformed plugin is warned and skipped.
pluginActivation := activatePlugins(workspaceRoot, registry, deps, stderr)
// Ask (not Auto) is the interactive default: in Auto, ToolAdvertised exposes
// only PermissionAllow tools, so prompt-gated tools (write_file/edit_file/bash/
// apply_patch) would never be offered to the model — the TUI could neither edit
// files nor run shell. Ask advertises them and routes each through the existing
// OnPermissionRequest flow; shift+tab lets the user switch modes live. An
// explicit --skip-permissions-unsafe launch overrides this to unsafe (the only
// way to reach unsafe, since shift+tab deliberately cycles auto↔ask only).
//
// Resolve the effective mode BEFORE the deferral gate below so the registration
// count uses the SAME permission mode the agent loop's partition will use; an
// empty mode here would mis-gate prompt-advertised deferred tools.
if permissionMode == "" {
permissionMode = agent.PermissionModeAsk
}
// Activate deferred MCP-tool loading for the interactive run only when the
// VISIBLE deferred-eligible count meets the resolved threshold, matching exec.
// The registry is complete (core + specialist + MCP + plugins) here, so the
// count is accurate; below threshold this is a no-op and the surface is
// unchanged. The interactive surface applies no operator tool filters, so
// enabled/disabled are nil — matching the AgentOptions below.
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, permissionMode, nil, nil)
sandboxStore, err := deps.newSandboxStore()
if err != nil {
return writeAppError(stderr, "failed to initialize sandbox grants: "+err.Error(), 1)
}
sandboxEngine := sandbox.NewEngine(sandbox.EngineOptions{
WorkspaceRoot: workspaceRoot,
Policy: applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), resolved.Sandbox),
Store: sandboxStore,
Backend: deps.selectSandboxBackend(sandbox.BackendOptions{}),
Scope: scope,
})
lastKnownMCPConfig := mcpConfig
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
ProviderName: resolved.Provider.Name,
ModelName: resolved.Provider.Model,
ProviderProfile: resolved.Provider,
FavoriteModels: resolved.Preferences.FavoriteModels,
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
if ctx == nil {
ctx = context.Background()
}
var stdout, stderr bytes.Buffer
exitCode := runMCPWithContext(ctx, args, &stdout, &stderr, deps)
nextConfig := lastKnownMCPConfig
if refreshed, err := deps.resolveMCPConfig(workspaceRoot); err == nil {
lastKnownMCPConfig = refreshed
nextConfig = refreshed
}
return tui.MCPCommandResult{
Config: nextConfig,
Output: strings.TrimSpace(stdout.String()),
Error: strings.TrimSpace(stderr.String()),
ExitCode: exitCode,
}
},
AgentOptions: agent.Options{
MaxTurns: resolved.MaxTurns,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: string(sandbox.AutonomyLow),
Sandbox: sandboxEngine,
FileTracker: tools.NewFileTracker(),
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
},
PermissionMode: permissionMode,
Notify: resolved.Notify,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
ConfigPath: configPath,
Providers: setupProviderOptions(),
Save: func(selection tui.SetupSelection) (tui.SetupResult, error) {
return saveSetupProvider(deps, selection, setupSaveOptions{})
},
},
})
}
func buildProvider(resolved config.ResolvedConfig, deps appDeps) (zeroruntime.Provider, error) {
if !config.HasProviderProfile(resolved.Provider) {
return nil, nil
}
return deps.newProvider(resolved.Provider)
}
func newCoreRegistry(workspaceRoot string) *tools.Registry {
return newCoreRegistryScoped(workspaceRoot, nil)
}
func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.Registry {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(workspaceRoot, scope) {
registry.Register(tool)
}
return registry
}
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
// both, closeSpecialistRuntime tears down both.
type agentToolRuntime struct {
specialist *specialist.Runtime
swarm *swarm.Swarm
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*agentToolRuntime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
executor := specialist.Executor{Paths: paths}
runtime, err := specialist.RegisterTools(registry, executor)
if err != nil {
return nil, err
}
// The swarm reuses the same specialist executor to launch each member, so
// every member runs under the orchestrator's sandbox + policy. Mailbox state
// lives under the workspace so its files fall within the sandbox write rules.
sw, err := swarm.New(swarm.Options{
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
Launcher: swarm.NewSpecialistLauncher(executor),
})
if err != nil {
runtime.Close()
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{specialist: runtime, swarm: sw}, nil
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
if options.useSpec {
return false
}
if strings.EqualFold(strings.TrimSpace(options.tag), specialist.SessionTagSpecialist) {
return false
}
return options.skipPermissionsUnsafe || strings.EqualFold(strings.TrimSpace(options.autonomy), "high")
}
func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
if runtime == nil {
return
}
if err := runtime.Close(); err != nil {
_, _ = fmt.Fprintf(stderr, "[zero] mcp_close_error: %s\n", err)
}
}
func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
if runtime == nil {
return
}
if runtime.swarm != nil {
runtime.swarm.Close()
}
if runtime.specialist != nil {
if err := runtime.specialist.Close(); err != nil {
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
}
}
}
func writeAppError(stderr io.Writer, message string, exitCode int) int {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return 1
}
return exitCode
}
func writeUsageError(stderr io.Writer, message string) int {
return writeExecUsageError(stderr, message)
}
func writeHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `ZERO terminal coding agent
Usage:
zero [command]
Commands:
exec Run a one-shot prompt through the Go agent runtime
daemon Manage the local background worker daemon (start/stop/status/run/attach)
setup Guide first-run provider setup
config Inspect resolved Go configuration without leaking secrets
models List Zero model registry entries
providers Inspect resolved provider profiles
doctor Run backend health checks for config and provider setup
context Report workspace context budget usage
repo-map Build a deterministic repository map for agent context
search Search persisted local Zero session events
find Alias for search
sessions Inspect local Zero session lineage
spec Review and approve saved spec-mode drafts
specialist Manage local Zero specialist profiles
plugins Inspect, install, and remove local Zero plugins
backends Inspect MCP, hook, and plugin backend lifecycle state
skills Inspect, install, and remove local Zero skills
tools Scaffold and list local Zero plugin-tools
hooks Inspect Zero hook configuration
mcp Manage MCP backend settings
auth Log in to model providers via OAuth
sandbox Inspect sandbox policy and persistent grants
update Check for Zero CLI updates
worktrees Prepare isolated git worktrees
verify Detect and run local verification checks
eval Validate offline agent eval suites
changes Inspect and commit local git changes
usage Summarize token usage and estimated cost
cron Schedule agent jobs (foreground, file-backed)
repo-info Characterize the current repository (local git only)
serve Run Zero protocol servers
help Show this help
version Print version
Flags:
-h, --help Show this help
-v, --version Print version
-p, --prompt Run a one-shot prompt
--add-dir <path> Allow writes in an extra directory (repeatable)
--skip-permissions-unsafe Launch the interactive shell in unsafe mode (enables the ! shell escape)
`)
return err
}
// addDirFlagArgs rebuilds "--add-dir <dir>" flag pairs so dirs collected by
// splitLeadingAddDirFlags can be forwarded into exec's own argument parser
// (which accepts --add-dir anywhere) instead of being silently dropped.
func addDirFlagArgs(addDirs []string) []string {
flags := make([]string, 0, 2*len(addDirs))
for _, dir := range addDirs {
flags = append(flags, "--add-dir", dir)
}
return flags
}
// splitLeadingAddDirFlags strips leading --add-dir flags from the root
// argument list (zero --add-dir <path> [--add-dir <path>] [subcommand …]).
// Subcommands like exec parse their own --add-dir occurrences.
func splitLeadingAddDirFlags(args []string) ([]string, []string, error) {
addDirs := []string{}
for len(args) > 0 {
switch {
case args[0] == "--add-dir":
if len(args) < 2 {
return nil, nil, errors.New("--add-dir requires a directory path")
}
value := strings.TrimSpace(args[1])
if value == "" {
return nil, nil, errors.New("--add-dir requires a directory path")
}
if strings.HasPrefix(value, "-") {
return nil, nil, errors.New("--add-dir requires a directory path")
}
addDirs = append(addDirs, value)
args = args[2:]
case strings.HasPrefix(args[0], "--add-dir="):
value := strings.TrimSpace(strings.TrimPrefix(args[0], "--add-dir="))
if value == "" {
return nil, nil, errors.New("--add-dir requires a directory path")
}
// Match the space form: a flag-like value is almost certainly a
// mistyped option, not a directory. A directory literally named
// "-foo" is still reachable as --add-dir=./-foo.
if strings.HasPrefix(value, "-") {
return nil, nil, errors.New("--add-dir requires a directory path")
}
addDirs = append(addDirs, value)
args = args[1:]
default:
return addDirs, args, nil
}
}
return addDirs, args, nil
}
func writePromptRequired(stderr io.Writer) int {
if _, err := fmt.Fprintln(stderr, "[zero] Prompt required. Use `zero exec \"prompt\"` or `zero exec --file prompt.txt`."); err != nil {
return 1
}
return 2
}
func writeExecHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero exec [flags] [prompt]
Runs a one-shot prompt through the Go agent runtime.
Flags:
-f, --file <path> Read prompt text from a file
--image <path> Attach a local image (repeatable; vision models only)
--add-dir <path> Allow writes in an extra directory (repeatable)
--mode <name> Apply a preset (smart, deep, fast, large, precise); explicit flags override it
-m, --model <model> Select the model for provider setup
--use-spec Draft a spec first and stop for review
--spec-model <model> Override the draft model when --use-spec is set
--spec-reasoning-effort <effort>
Override draft reasoning effort when --use-spec is set
--max-turns <number> Override the maximum agent loop turns
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
--enabled-tools <tools> Only expose these comma or space separated tools
--disabled-tools <tools> Hide these comma or space separated tools
--list-tools List model-visible tools and exit
--profile <profile> Accept legacy model profile selection
-r, --reasoning-effort <effort> Accept legacy reasoning effort selection
-C, --cwd <path> Set the workspace directory
-w, --worktree [name] Run from an isolated git worktree
--worktree-dir <path> Base directory for created worktrees
-i, --input-format text|stream-json
Select prompt input format
-o, --output-format text|json|stream-json
Select text, JSON, or schema-versioned JSONL output
("debug" is accepted as a stream-json alias)
--prompt <prompt> Provide prompt text as a flag
--resume [id] Resume a session; omit id to use the latest
--fork <id> Fork an existing session into a new session
--calling-session-id <id> Parent session id for specialist child runs
--calling-tool-use-id <id> Parent tool-call id for specialist child runs
--tag <tag> Attach runtime tag metadata to the exec run
--depth <number> Set specialist nesting depth metadata
--session-title <text> Set the created session title
--init-session-id <id> Create a new exec session with this id
--skip-permissions-unsafe Allow prompt-gated tools without approval
--allow-escalation Let the agent escalate to a stronger model mid-run via escalate_model
--self-correct Run the post-edit verify-and-correct loop (auto-fix needs --auto medium or high)
--notify <off|bell|notify|both>
Override notification mode for this run
--no-notify Disable notifications for this run
`)
return err
}