Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return runAuthStatus(args[1:], stdout, stderr, deps)
case "refresh":
return runAuthRefresh(args[1:], stdout, stderr, deps)
case "reset":
return runAuthReset(args[1:], stdout, stderr, deps)
case "openrouter":
return runAuthOpenRouter(args[1:], stdout, stderr, deps)
case "chatgpt":
Expand Down Expand Up @@ -321,6 +323,7 @@ func validateAuthFlags(sub string, a authArgs) error {
"logout": {"json": true},
"status": {"json": true},
"refresh": {"watch": true},
"reset": {"json": true},
}[sub]
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
if a.json && !allowed["json"] {
Expand Down Expand Up @@ -569,6 +572,40 @@ func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io
return exitSuccess
}

func runAuthReset(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
parsed, err := parseAuthArgs("reset", args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if parsed.help {
_ = writeAuthHelp(stdout)
return exitSuccess
}
if len(parsed.positional) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth reset takes no arguments (got %q)", parsed.positional[0]))
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := manager.Reset(); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if parsed.json {
payload := struct {
Reset bool `json:"reset"`
}{Reset: true}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, "Reset OAuth token store."); err != nil {
return exitCrash
}
return exitSuccess
}

func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
want := oauth.ProviderKey(provider)
filtered := make([]oauth.Status, 0, 1)
Expand All @@ -589,6 +626,7 @@ Commands:
logout <provider> Delete a provider's stored login
status [provider] Show login presence/expiry (never the token)
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
reset Reset the OAuth token store (clears corrupted entries and tokens)
openrouter Log in to OpenRouter in the browser; mints an API key
chatgpt Log in to ChatGPT in the browser (Codex backend, ChatGPT Plus/Pro)

Expand All @@ -615,7 +653,7 @@ Flags:
--device Use the device-code flow (headless/SSH; no browser)
--scope Add an OAuth scope (repeatable)
--watch Keep the token fresh in the foreground (refresh only)
--json Print result as JSON (status/logout)
--json Print result as JSON (status/logout/reset)
-h, --help Show this help
`)
return err
Expand Down
31 changes: 30 additions & 1 deletion internal/cli/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,42 @@ func TestRunAuthHelp(t *testing.T) {
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d", code)
}
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "reset", "--device"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("help missing %q:\n%s", want, stdout.String())
}
}
}

func TestRunAuthReset(t *testing.T) {
path := withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{AccessToken: "secret"}); err != nil {
t.Fatalf("Save: %v", err)
}

var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "reset"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Reset OAuth token store") {
t.Fatalf("stdout = %q", stdout.String())
}

// Verify store is now empty.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("status exit = %d", code)
}
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
t.Fatalf("expected empty store after reset, got: %q", stdout.String())
}
}

// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
// authorize params), not the generic manager path. The generic login accepts
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/completions.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ var completionRoot = completionNode{
{names: []string{"tools"}, children: leafNodes("list")},
{names: []string{"oauth"}, children: leafNodes("login", "logout", "status")},
}},
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh")},
{names: []string{"auth"}, children: leafNodes("openrouter", "chatgpt", "login", "logout", "status", "refresh", "reset")},
{names: []string{"sandbox"}, children: []completionNode{
{names: []string{"policy"}}, {names: []string{"setup"}}, {names: []string{"check"}},
{names: []string{"grants"}, children: leafNodes("list", "allow", "deny", "revoke", "clear")},
Expand Down
1 change: 1 addition & 0 deletions internal/cli/completions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ func TestCompletionTreeCoversAliasesNestingAndCommonFlags(t *testing.T) {
assertCandidates(t, byPath["worktree"], "prepare", "release")
assertCandidates(t, byPath["daemon"], "start", "stop", "status", "run", "attach")
assertCandidates(t, byPath["mcp oauth"], "login", "logout", "status")
assertCandidates(t, byPath["auth"], "openrouter", "chatgpt", "login", "logout", "status", "refresh", "reset")
assertCandidates(t, byPath["sandbox grants"], "list", "allow", "deny", "revoke", "clear")
assertCandidates(t, byPath["completions"], "bash", "zsh", "fish", "powershell", "elvish")
assertCandidates(t, byPath["plugins"], "list", "add", "info", "remove", "rm")
Expand Down
37 changes: 35 additions & 2 deletions internal/keyring/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
// through `security -i` (interactive mode), whose command parser is line-based
// with a fixed 4096-byte line buffer, so Set rejects secrets containing
// newlines or exceeding that budget rather than silently corrupting them;
// Linux's secret-tool has no such restriction.
// Linux's secret-tool has no such restriction. MaxSecretLen reports that budget
// so a caller holding a larger value can split it across entries instead of
// being turned away.
package keyring

import (
Expand Down Expand Up @@ -82,7 +84,7 @@ func (k *Keyring) Set(service, account, secret string) error {
}
}
// -U updates the item if it already exists rather than failing.
line := "add-generic-password -U -s " + securityQuote(service) + " -a " + securityQuote(account) + " -w " + securityQuote(secret) + "\n"
line := securityAddLine(service, account, secret)
if len(line) > securityMaxLine {
return wrap("set", fmt.Errorf("secret too large for the macOS security tool's %d-byte command line; use the file backend instead", securityMaxLine))
}
Expand Down Expand Up @@ -162,11 +164,42 @@ func (k *Keyring) Delete(service, account string) (bool, error) {
}
}

// MaxSecretLen reports the largest secret, in bytes, that Set accepts under
// (service, account) on this platform. ok is false when the backend imposes no
// practical limit, which is the case for Linux's secret-tool: it reads the
// secret from stdin rather than a command line. On macOS the secret shares the
// `security -i` command line with the service and account, so the budget
// depends on both and a caller that splits an oversized value must size each
// part against the exact account name it will be stored under.
//
// The budget assumes the secret needs no quote escaping. securityQuote only
// expands `\` and `"`, neither of which appears in base64 or hex, so a caller
// storing encoded data gets the exact figure.
func (k *Keyring) MaxSecretLen(service, account string) (int, bool) {
if k.goos != "darwin" {
return 0, false
}
// Measuring the real command with an empty secret counts the surrounding
// quotes too, so a secret of exactly this length lands on securityMaxLine.
budget := securityMaxLine - len(securityAddLine(service, account, ""))
if budget < 0 {
budget = 0
}
return budget, true
}

// securityMaxLine is the most `security -i` can read as one command: its line
// buffer is MAX_LINE_LEN (4096) bytes in Apple's SecurityTool, one of which the
// terminating NUL consumes.
const securityMaxLine = 4095

// securityAddLine renders the `security -i` command that stores secret under
// (service, account). Set writes it and MaxSecretLen measures it, so the two
// cannot disagree about how much of the line the overhead consumes.
func securityAddLine(service, account, secret string) string {
return "add-generic-password -U -s " + securityQuote(service) + " -a " + securityQuote(account) + " -w " + securityQuote(secret) + "\n"
}

// securityQuote wraps s for one argument of a `security -i` command line. The
// tool's parser (split_line in Apple's SecurityTool) treats a backslash inside
// double quotes as escaping the next character and the matching quote as the
Expand Down
43 changes: 43 additions & 0 deletions internal/keyring/keyring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,46 @@ func TestAvailable(t *testing.T) {
t.Fatal("linux should be available")
}
}

// TestMaxSecretLenMatchesTheDarwinSetBoundary pins the budget to the boundary
// Set actually enforces. A caller that splits an oversized value depends on the
// two agreeing exactly: one byte of drift either wastes an entry or produces a
// chunk security silently splits into two garbage commands.
func TestMaxSecretLenMatchesTheDarwinSetBoundary(t *testing.T) {
k := newFake("darwin").keyring()
budget, bounded := k.MaxSecretLen("zero", "oauth-tokens")
if !bounded {
t.Fatal("MaxSecretLen reported darwin as unbounded")
}
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens", strings.Repeat("a", budget)); err != nil {
t.Errorf("Set(budget=%d bytes): %v, want acceptance at the boundary", budget, err)
}
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens", strings.Repeat("a", budget+1)); err == nil {
t.Errorf("Set(%d bytes) succeeded, want rejection one byte past the budget", budget+1)
}
}

// TestMaxSecretLenShrinksWithTheAccountName covers why the budget takes the
// account: on macOS the account and the secret share one command line, so a
// longer account name leaves less room for the secret.
func TestMaxSecretLenShrinksWithTheAccountName(t *testing.T) {
k := newFake("darwin").keyring()
short, _ := k.MaxSecretLen("zero", "oauth-tokens")
long, _ := k.MaxSecretLen("zero", "oauth-tokens.a.63")
if want := short - len(".a.63"); long != want {
t.Fatalf("MaxSecretLen for the longer account = %d, want %d", long, want)
}
if err := newFake("darwin").keyring().Set("zero", "oauth-tokens.a.63", strings.Repeat("a", long+1)); err == nil {
t.Error("Set one byte past the longer account's budget succeeded")
}
}

// TestMaxSecretLenUnboundedOffDarwin keeps the limit platform-specific:
// secret-tool reads the secret from stdin, so there is no command line to fill.
func TestMaxSecretLenUnboundedOffDarwin(t *testing.T) {
for _, goos := range []string{"linux", "windows"} {
if n, bounded := newFake(goos).keyring().MaxSecretLen("zero", "oauth-tokens"); bounded || n != 0 {
t.Errorf("MaxSecretLen on %s = (%d, %v), want (0, false)", goos, n, bounded)
}
}
}
8 changes: 8 additions & 0 deletions internal/oauth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,14 @@ func (m *Manager) Logout(name string) (bool, error) {
return m.store.Delete(ProviderKey(name))
}

// Reset clears all persistent state in the underlying store.
func (m *Manager) Reset() error {
if m == nil || m.store == nil {
return nil
}
return m.store.Reset()
}

// StatusAll returns the status of every provider login.
func (m *Manager) StatusAll() ([]Status, error) {
return m.store.Status(KeyPrefixProvider)
Expand Down
Loading
Loading