mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* fix(auth): propagate credentials to every provider-build surface and pin children to the live provider Sub-agents, swarm members, ACP sessions, and mid-run escalation switches could build providers without the credentials the parent was running with. This consolidates credential resolution so every surface gets the same auth: - Wrap deps.newProvider once in fillAppDeps to fold the stored API key in centrally; ACP, exec escalation, and buildProvider no longer patch it per call site (an apiKeyStored profile previously went out keyless on those paths and 401'd). - Export ZERO_PROVIDER at startup (buildProvider) and keep exporting on every runtime switch (wizard, onboarding, /model, /provider), so spawned children resolve the parent's live provider instead of whatever another process last persisted to config.json. Injected via appDeps so tests never mutate the process environment. - Share one OAuth login selection end to end: ProviderProfile. OAuthLoginCandidates (name, then catalog-ID fallback; nothing for a profile with its own configured credential) + oauth.FirstStored resolve the login ONCE, and the bound store key is threaded through providers.Options.OAuthLoginKey so the Codex account-id header always reads the same login that issued the bearer token. A renamed profile (name != catalog ID) now finds its login; an explicit API key is never silently overridden by a same-name or catalog-shared OAuth login. - Export ProviderProfile.HasConfiguredCredential as the single "profile is key-authed" predicate (cli/tui/config previously had three copies). - applyProviderWizard commits live state (provider, profile, name, env export) only after BOTH the build and the config persist succeed, so a persist failure no longer leaves the chat running on a provider the status line and children disagree with. Covered by red-green tests for each path: stored-key wrap, escalation switch, catalog-ID fallback (incl. case-variant names), explicit-key protection, same-login account selection, wizard persist-failure rollback, onboarding/startup env export. * test: isolate OAuth/config test storage and fix the flaky MCP response-id test Review + CI fixes, all test-only: - factory/cli OAuth tests: pin ZERO_OAUTH_STORAGE=file next to the temp ZERO_OAUTH_TOKENS_PATH — an inherited "keyring" value sends NewStore to the OS keychain and ignores the temp path entirely, so the tests could read (or clobber) the developer's real logins. newOAuthStoreForTest now owns both pins so a future call site can't forget them. - TestApplyProviderWizardExportsActiveProviderEnv: point m.userConfigPath at a temp config and stub newProvider, so the SUCCESS path exercises the full build+persist+commit sequence in isolation (the default empty path silently skipped persist; a changed default must never reach the real user config). - TestClientRequestWaitsForMatchingResponseID (ubuntu smoke flake): the responses were pre-loaded into a bytes.Buffer, so the client's reader goroutine could consume the matching response — and hit EOF — before request() registered its pending id, failing with "request() error = EOF" on fast runners. The fake server now sits on pipes and responds only after reading the outgoing request, matching production ordering; survives -race -count=50.
98 lines
3.6 KiB
Go
98 lines
3.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"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/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,
|
|
// 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
|
|
}
|
|
}
|