mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* 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.
836 lines
31 KiB
Go
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
|
|
}
|