mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* fix(acp): register configured MCP tools per turn * fix(acp): address MCP review findings
167 lines
6.5 KiB
Go
167 lines
6.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/Gitlawb/zero/internal/acp"
|
|
"github.com/Gitlawb/zero/internal/agent"
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/execution"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/sandbox"
|
|
)
|
|
|
|
const acpUsage = `zero acp — serve the Agent Client Protocol (ACP) over stdio
|
|
|
|
Editors that speak ACP (Zed, JetBrains, Neovim, ...) spawn this command and drive
|
|
ZERO as a backend over JSON-RPC 2.0 on stdin/stdout. ZERO keeps your provider,
|
|
model, and API keys (BYOK); the editor only hosts the conversation thread.
|
|
|
|
Usage:
|
|
zero acp
|
|
|
|
Not meant to be run interactively — point your editor's ACP / external-agent
|
|
setting at "zero acp".`
|
|
|
|
var acpSignalContext = signalContext
|
|
|
|
// runACP serves ACP over stdio so an editor can drive ZERO's agent core. It
|
|
// speaks JSON-RPC 2.0 (newline-delimited JSON) on stdin/stdout; stderr stays free
|
|
// for human-readable diagnostics. The session lifecycle maps onto ZERO's own
|
|
// session store, and provider/model/keys remain owned by ZERO.
|
|
func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
fmt.Fprintln(stdout, acpUsage)
|
|
return exitSuccess
|
|
default:
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown acp flag %q", arg))
|
|
}
|
|
}
|
|
|
|
// The process's own stdin belongs exclusively to this Conn for the life of
|
|
// the command, so cancellation may close it to interrupt an idle read.
|
|
conn := acp.NewOwnedConn(deps.stdin, stdout)
|
|
acp.NewAgent(conn, acp.Deps{
|
|
ResolveConfig: deps.resolveConfig,
|
|
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
|
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
|
},
|
|
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
|
// so ACP is authenticated for apiKeyStored profiles like every other
|
|
// surface — no ACP-specific credential handling needed.
|
|
NewProvider: deps.newProvider,
|
|
RunAgent: agent.Run,
|
|
BuildWorkspace: func(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode) (*acp.Workspace, error) {
|
|
return buildACPWorkspace(ctx, workspaceRoot, resolved, mode, deps)
|
|
},
|
|
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
|
|
Store: deps.newSessionStore(),
|
|
AgentInfo: acp.Implementation{Name: "zero", Version: version},
|
|
})
|
|
|
|
ctx, stop := acpSignalContext()
|
|
defer stop()
|
|
if err := conn.Serve(ctx); err != nil {
|
|
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
// buildACPWorkspace matches exec's registry construction for one ACP turn. MCP
|
|
// servers use the same sandbox-prepared execution runner, project configuration is
|
|
// gated by the validated workspace's trust state, and their runtime is released
|
|
// when acp.Agent completes the turn. ACP deliberately stays at low autonomy: an
|
|
// editor connection never upgrades MCP permissions beyond an interactive prompt.
|
|
func buildACPWorkspace(ctx context.Context, workspaceRoot string, resolved config.ResolvedConfig, mode agent.PermissionMode, deps appDeps) (*acp.Workspace, error) {
|
|
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
registry := newCoreRegistryScoped(workspaceRoot, scope)
|
|
|
|
workspace := &acp.Workspace{
|
|
Registry: registry, Sandbox: engine,
|
|
DeferThreshold: resolved.Tools.DeferThreshold,
|
|
}
|
|
if mode != agent.PermissionModePlan {
|
|
// MCP stdio servers are subprocesses. Passing the engine to their runner
|
|
// keeps them inside the exact sandbox / lifecycle path used by zero exec.
|
|
runtime, trustSkip, err := registerMCPToolsForWorkspaceWithOptions(
|
|
ctx, workspaceRoot, registry, deps, mcp.AutonomyLow, workspaceRoot,
|
|
mcp.RegisterOptions{Execution: execution.NewRunner(engine), AdvertiseInAuto: true},
|
|
)
|
|
if err != nil {
|
|
// RegisterTools may have connected an earlier server before reporting a
|
|
// later failure, so do not orphan a partial runtime on this error path.
|
|
if runtime != nil {
|
|
_ = runtime.Close()
|
|
}
|
|
return nil, err
|
|
}
|
|
workspace.Cleanup = runtime.Close
|
|
workspace.Notices = acpMCPSetupNotices(trustSkip, runtime)
|
|
}
|
|
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
|
// MCP tools are deferred-eligible. Register their loader only after every
|
|
// ACP-visible tool is present, using the same mode the agent receives.
|
|
registerToolSearchIfEligible(registry, resolved.Tools.DeferThreshold, mode, nil, nil)
|
|
return workspace, nil
|
|
}
|
|
|
|
func acpMCPSetupNotices(skip trustSkip, runtime mcpToolRuntime) []string {
|
|
var notices []string
|
|
if skip.excludedProjectConfig {
|
|
if skip.trustCheckErrored {
|
|
notices = append(notices, "The workspace-trust store could not be read; project MCP servers were ignored (fail-closed). Run 'zero trust' to enable them.")
|
|
} else {
|
|
notices = append(notices, "Project MCP servers were ignored in this untrusted workspace. Run 'zero trust' to enable them.")
|
|
}
|
|
}
|
|
if runtime == nil {
|
|
return notices
|
|
}
|
|
for _, skipped := range runtime.Skipped() {
|
|
if skipped.UnconfiguredDefault {
|
|
continue
|
|
}
|
|
message := fmt.Sprintf("MCP server %s unavailable, skipped", skipped.Name)
|
|
if skipped.Err != nil {
|
|
message += ": " + redaction.ErrorMessage(skipped.Err, redaction.Options{})
|
|
}
|
|
notices = append(notices, redaction.RedactString(message, redaction.Options{}))
|
|
}
|
|
return notices
|
|
}
|
|
|
|
// acpWorkspaceRootResolver validates a client-supplied cwd into a confinement
|
|
// root. It reuses exec's resolveWorkspaceRoot (abs+clean, must be an existing
|
|
// dir) and additionally rejects the filesystem root and the home directory — an
|
|
// editor must not be able to point ZERO's file/shell tools at the whole disk.
|
|
func acpWorkspaceRootResolver(deps appDeps) func(string) (string, error) {
|
|
return func(cwd string) (string, error) {
|
|
root, err := resolveWorkspaceRoot(cwd, deps)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if root == filepath.Dir(root) {
|
|
return "", fmt.Errorf("cwd must not be the filesystem root: %s", root)
|
|
}
|
|
if home, herr := os.UserHomeDir(); herr == nil && home != "" && filepath.Clean(home) == root {
|
|
return "", fmt.Errorf("cwd must not be the home directory: %s", root)
|
|
}
|
|
return root, nil
|
|
}
|
|
}
|