Files
zero/internal/cli/acp.go
ddfdf2878e Fix ChatGPT OAuth model discovery and ACP model selection (#724)
* fix: expose authenticated ChatGPT models to ACP

Co-authored-by: Pierre Bruno <[email protected]>

* fix(acp): persist discovered model selections

* fix(acp): normalize model selections

---------

Co-authored-by: Amp <[email protected]>
2026-07-22 08:11:55 +08:00

103 lines
3.8 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/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
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".`
// 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))
}
}
conn := acp.NewConn(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,
// Build the SCOPED registry + sandbox engine per workspace, exactly like the
// exec surface, so ACP shell/file tools are confined — never run unconfined.
BuildWorkspace: func(workspaceRoot string, resolved config.ResolvedConfig) (*tools.Registry, *sandbox.Engine, error) {
scope, err := sandbox.NewScope(workspaceRoot, resolved.Sandbox.AdditionalWriteRoots)
if err != nil {
return nil, nil, err
}
engine, err := buildExecSandboxEngine(workspaceRoot, resolved, deps, scope)
if err != nil {
return nil, nil, err
}
registry := newCoreRegistryScoped(workspaceRoot, scope)
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
return registry, engine, nil
},
ResolveWorkspaceRoot: acpWorkspaceRootResolver(deps),
Store: deps.newSessionStore(),
AgentInfo: acp.Implementation{Name: "zero", Version: version},
})
ctx, stop := signalContext()
defer stop()
if err := conn.Serve(ctx); err != nil && ctx.Err() == nil {
return writeAppError(stderr, "acp: "+err.Error(), exitCrash)
}
return exitSuccess
}
// 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
}
}