auth: delete the legacy keyring store and pre-contexts migration (COR-393) · Entire

Home

Log in

auth: delete the legacy keyring store and pre-contexts migration (COR-393)

37de13a→main·

toothbrush·1mo ago·17 files·+35 added/-1,221 removed

contexts.json + the shared tokenstore are now the only credential store. Login records the context fatally instead of dual-writing a legacy entire-cli/ keyring slot; the context-preferring ContextStore wrapper, CurrentContextToken, LookupCurrentToken, and MigrateLegacyLoginContext (with its callers in the git remote helper and the repo-token path) are gone. Pre-contexts logins now surface the existing "run `entire login`" hints instead of being bridged.

The authfilestore build tag existed only to keep the legacy store off developer keychains in tests; the surviving store honors ENTIRE_TOKEN_STORE=file unconditionally, so the tag, the file backend, and the keyring-timeout shim go too. Integration login tests move to `--server` + the v2 device-authorization path and sandbox via ENTIRE_CONFIG_DIR / ENTIRE_TOKEN_STORE.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Sessions

e1810eaf446fView transcript

?\ Auth Refactor: Eliminate Static FallbacksClaude Code·Fable 5.[1m]·4 steps

Changes

17

2 unmodified lines

3
4
5
6
6
7
8
9
10
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
11
12
13
81 unmodified lines

95
96
97
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157

2 unmodified lines

import (
    "fmt"

"github.com/entireio/auth-go/tokens"
    "github.com/entireio/cli/internal/entireclient/contexts"
    "github.com/entireio/cli/internal/entireclient/tokenstore"
    "github.com/entireio/cli/internal/entireclient/userdirs"
)

// CurrentContextToken returns the login JWT for the active context in
// contexts.json, or ("", false) when there is no current context or it
// has no stored token. This is the contexts.json half of the CLI's
// credential resolution; callers fall back to the legacy keyring entry so
// pre-contexts logins keep working until migrated.
func CurrentContextToken() (string, bool) {
    f, err := contexts.Load(userdirs.Config())
    if err != nil {
        return "", false
    }
    c := f.Find(f.CurrentContext)
    if c == nil {
        return "", false
    }
    tok, err := LoginTokenForContext(c)
    if err != nil || tok == "" {
        return "", false
    }
    return tok, true
}

// RemoveCurrentContext deletes the active context from contexts.json and
// its keyring token, clearing current_context. It is a no-op (returns nil)
// when there is no current context. Used by logout.
81 unmodified lines

}
    return f.Contexts, f.CurrentContext, nil
}

// ContextStore wraps the legacy keyring Store so token *reads* prefer the
// active contexts.json context, falling back to the legacy
// entire-cli/<authBaseURL> entry. Writes are inherited from Store
// unchanged — login dual-writes the context via RecordLoginContext, so the
// write side needs no override here.
//
// This is the single seam that lets the control-plane readers (the
// tokenmanager, LookupCurrentToken, and `auth status`/`list`) honor a
// contexts.json login — including one created by entiredb's CLIs that
// share this file. *ContextStore satisfies both the cli package's
// tokenStore interface and auth-go's tokenstore.Store.
type ContextStore struct {
    *Store
}

// NewContextStore returns a context-preferring view over the legacy store.
func NewContextStore() *ContextStore {
    return &ContextStore{Store: NewStore()}
}

// GetToken prefers the active context's token, falling back to the legacy
// entry keyed by baseURL.
func (s *ContextStore) GetToken(baseURL string) (string, error) {
    if tok, ok := CurrentContextToken(); ok {
        return tok, nil
    }
    return s.Store.GetToken(baseURL)
}

// LoadTokens (the tokenstore.Store method the tokenmanager calls) prefers
// the active context's token, falling back to the legacy profile entry.
func (s *ContextStore) LoadTokens(profile string) (tokens.TokenSet, error) {
    if tok, ok := CurrentContextToken(); ok {
        return tokens.TokenSet{AccessToken: tok}, nil
    }
    return s.Store.LoadTokens(profile)
}

Mcmd/entire/cli/auth/context_store.go-60

7 unmodified lines

8
9
10
11
11
12
13
136 unmodified lines

150
151
152
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
153
154
155

7 unmodified lines

"time"

"github.com/entireio/auth-go/tokens"
    "github.com/entireio/cli/cmd/entire/cli/api"
    "github.com/entireio/cli/internal/entireclient/contexts"
    "github.com/entireio/cli/internal/entireclient/tokenstore"
    "github.com/entireio/cli/internal/entireclient/userdirs"
136 unmodified lines

return strings.TrimRight(a, "/") == strings.TrimRight(b, "/")
}

// MigrateLegacyLoginContext bridges users who logged in before the
// contexts.json dual-write existed: if the legacy entire-cli/<authBaseURL>
// keyring entry holds a usable JWT and no context yet covers its issuer,
// it records an equivalent context (and keychain entry under the shared
// scheme) so the git remote helper can authenticate without a re-login.
//
// Returns (true, nil) when it created a context. No-ops — returning
// (false, nil) — when there's no legacy token, the token is opaque
// (no derivable issuer), or a context for that issuer already exists.
// Idempotent: safe to call on every helper invocation.
func MigrateLegacyLoginContext() (migrated bool, err error) {
    legacy, err := NewStore().GetToken(api.AuthBaseURL())
    if err != nil {
        return false, fmt.Errorf("read legacy login token: %w", err)
    }
    if legacy == "" {
        return false, nil
    }
    claims, parseErr := tokens.ParseClaims(legacy)
    if parseErr != nil || claims.Issuer == "" {
        // Opaque/unsigned legacy token — can't derive a context from it.
        return false, nil //nolint:nilerr // absence of a JWT issuer is not an error here
    }
    handle := claims.Handle
    if handle == "" {
        handle = claims.Subject
    }
    f, err := contexts.Load(userdirs.Config())
    if err != nil {
        return false, fmt.Errorf("load contexts: %w", err)
    }
    // Skip only when this exact identity is already represented. Keying on
    // issuer alone would skip a legacy bob@core just because alice@core (e.g.
    // from another CLI) already exists, leaving Bob without a context.
    for _, c := range f.Contexts {
        if sameIssuer(c.CoreURL, claims.Issuer) && c.Handle == handle {
            return false, nil
        }
    }
    // activate=false: migrating an old login (e.g. on first `git clone`) must
    // not silently switch the user's active context. RecordLoginContext still
    // sets current_context when none exists yet.
    // No refresh token: the legacy entry is access-token-only.
    if _, err := RecordLoginContext(legacy, "", false); err != nil {
        return false, err
    }
    return true, nil
}

// LoginTokenForContext returns the login JWT stored for c, read from the
// OS keyring slot the context points at. The encoded expiry is stripped;
// the server is the authority on validity and the device-flow login holds

Mcmd/entire/cli/auth/contexts.go-50

7 unmodified lines

8
9
10
11
11
12
14
13
14
15
102 unmodified lines

118
119
120
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
121
122
123
21 unmodified lines

145
146
147
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
148
149
150
5 unmodified lines

156
157
158
242
243
159
160
161
162
163
3 unmodified lines

167
168
169
253
254
170
171
172
173
174
192 unmodified lines

367
368
369
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
370
371
372

7 unmodified lines

"testing"
    "time"

"github.com/entireio/cli/cmd/entire/cli/api"
    "github.com/entireio/cli/internal/entireclient/contexts"
    "github.com/entireio/cli/internal/entireclient/tokenstore"
    "github.com/zalando/go-keyring"
)

// makeJWT builds a three-segment JWT-shaped string with a non-"none" alg
102 unmodified lines

}
}

func TestMigrateLegacyLoginContext_SynthesizesContext(t *testing.T) {
    // Mocks the legacy keyring + swaps the new tokenstore + sets the config
    // dir — all process-global, so not parallel.
    keyring.MockInit()
    cfgDir := t.TempDir()
    t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
    restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
    t.Cleanup(restore)

const coreURL = "https://legacy-core.example.com"
    const handle = "bob"
    exp := time.Now().Add(time.Hour).Unix()
    legacy := makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":%q,"exp":%d}`, coreURL, handle, exp))

// Seed only the legacy single-host keyring entry.
    if err := NewStore().SaveToken(api.AuthBaseURL(), legacy); err != nil {
        t.Fatalf("seed legacy token: %v", err)
    }

migrated, err := MigrateLegacyLoginContext()
    if err != nil {
        t.Fatalf("MigrateLegacyLoginContext: %v", err)
    }
    if !migrated {
        t.Fatal("expected migration to synthesize a context")
    }

f, err := contexts.Load(cfgDir)
    if err != nil {
        t.Fatalf("load contexts: %v", err)
    }
    if got := f.ContextsForIssuer(coreURL); len(got) != 1 {
        t.Fatalf("contexts for issuer = %d, want 1", len(got))
    }

// Idempotent: a second call is a no-op now that a context exists.
    migratedAgain, err := MigrateLegacyLoginContext()
    if err != nil {
        t.Fatalf("second MigrateLegacyLoginContext: %v", err)
    }
    if migratedAgain {
        t.Fatal("second migration should be a no-op")
    }
}

func TestLoginTokenForContext(t *testing.T) {
    restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
    t.Cleanup(restore)
21 unmodified lines

}
}

func TestContextStore_PrefersCurrentContextThenLegacy(t *testing.T) {
    keyring.MockInit()
    cfgDir := t.TempDir()
    t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
    restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
    t.Cleanup(restore)

store := NewContextStore()

// No context yet: falls back to the legacy entry.
    if err := store.SaveToken(api.AuthBaseURL(), "legacy-token"); err != nil {
        t.Fatalf("seed legacy: %v", err)
    }
    legacyGot, err := store.GetToken(api.AuthBaseURL())
    if err != nil {
        t.Fatalf("GetToken (legacy): %v", err)
    }
    if legacyGot != "legacy-token" {
        t.Fatalf("with no context, GetToken = %q, want legacy-token", legacyGot)
    }

// Record a context: its token now wins over the legacy entry.
    exp := time.Now().Add(time.Hour).Unix()
    ctxToken := makeJWT(t, fmt.Sprintf(`{"iss":"https://core.example.com","handle":"alice","exp":%d}`, exp))
    if _, err := RecordLoginContext(ctxToken, "", true); err != nil {
        t.Fatalf("RecordLoginContext: %v", err)
    }
    got, err := store.GetToken(api.AuthBaseURL())
    if err != nil {
        t.Fatalf("GetToken: %v", err)
    }
    if got != ctxToken {
        t.Fatal("with a current context, GetToken should return the context token")
    }
}

func TestRemoveCurrentContext(t *testing.T) {
    cfgDir := t.TempDir()
    t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
5 unmodified lines

if _, err := RecordLoginContext(token, "entr_refresh", true); err != nil {
        t.Fatalf("RecordLoginContext: %v", err)
    }
    if _, ok := CurrentContextToken(); !ok {
        t.Fatal("precondition: expected a current context token")
    if _, current, err := Contexts(); err != nil || current == "" {
        t.Fatalf("precondition: expected a current context (current=%q, err=%v)", current, err)
    }
    svc := tokenstore.CoreKeyringService("https://core.example.com")
    if r, _ := tokenstore.Get(tokenstore.RefreshService(svc), "alice"); r != "entr_refresh" { //nolint:errcheck // read-back; only the value matters
3 unmodified lines

if err := RemoveCurrentContext(); err != nil {
        t.Fatalf("RemoveCurrentContext: %v", err)
    }
    if _, ok := CurrentContextToken(); ok {
        t.Fatal("after RemoveCurrentContext, expected no current context token")
    if _, current, err := Contexts(); err != nil || current != "" {
        t.Fatalf("after RemoveCurrentContext, expected no current context (current=%q, err=%v)", current, err)
    }
    // Logout must scrub both slots: the access token and the long-lived
    // refresh token. A leftover refresh token would let any keyring-capable
192 unmodified lines

}
}

func TestMigrateLegacyLoginContext_PreservesCurrentContext(t *testing.T) {
    keyring.MockInit()
    cfgDir := t.TempDir()
    t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
    restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
    t.Cleanup(restore)

exp := time.Now().Add(time.Hour).Unix()

// An existing, active context for one core.
    active, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":"https://active.example.com","handle":"alice","exp":%d}`, exp)), "", true)
    if err != nil {
        t.Fatalf("seed active context: %v", err)
    }

// A legacy keyring login for a *different* core, not yet migrated.
    legacy := makeJWT(t, fmt.Sprintf(`{"iss":"https://legacy.example.com","handle":"alice","exp":%d}`, exp))
    if err := NewStore().SaveToken(api.AuthBaseURL(), legacy); err != nil {
        t.Fatalf("seed legacy token: %v", err)
    }

migrated, err := MigrateLegacyLoginContext()
    if err != nil {
        t.Fatalf("migrate: %v", err)
    }
    if !migrated {
        t.Fatal("expected migration to run")
    }

// Migration recorded the legacy context but must NOT have switched away
    // from the already-active one.
    _, current, err := Contexts()
    if err != nil {
        t.Fatalf("Contexts: %v", err)
    }
    if current != active {
        t.Fatalf("current_context = %q, want unchanged %q after migration", current, active)
    }
}

func TestMigrateLegacyLoginContext_DifferentHandleSameCore(t *testing.T) {
    keyring.MockInit()
    cfgDir := t.TempDir()
    t.Setenv("ENTIRE_CONFIG_DIR", cfgDir)
    restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
    t.Cleanup(restore)

const coreURL = "https://core.example.com"
    exp := time.Now().Add(time.Hour).Unix()

// contexts.json already has alice@core (e.g. from another CLI).
    if _, err := RecordLoginContext(makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"alice","exp":%d}`, coreURL, exp)), "", true); err != nil {
        t.Fatalf("seed alice: %v", err)
    }

// The legacy keyring token is bob on the *same* core — migration must
    // still run (issuer-only dedup would wrongly skip it).
    bob := makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":"bob","exp":%d}`, coreURL, exp))
    if err := NewStore().SaveToken(api.AuthBaseURL(), bob); err != nil {
        t.Fatalf("seed legacy bob: %v", err)
    }

migrated, err := MigrateLegacyLoginContext()
    if err != nil {
        t.Fatalf("migrate: %v", err)
    }
    if !migrated {
        t.Fatal("expected bob to be migrated despite alice@core existing")
    }

f, err := contexts.Load(cfgDir)
    if err != nil {
        t.Fatalf("load: %v", err)
    }
    if got := f.ContextsForIssuer(coreURL); len(got) != 2 {
        t.Fatalf("contexts for issuer = %d, want 2 (alice + bob)", len(got))
    }
}

func TestRecordLoginContext_RejectsTokenWithoutIssuer(t *testing.T) {
    restore := tokenstore.UseFileBackendForTesting(filepath.Join(t.TempDir(), "tokens.json"))
    t.Cleanup(restore)

Mcmd/entire/cli/auth/contexts_test.go+4/-166

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

package auth

import (
    "context"
    "errors"
    "fmt"
    "os"
    "runtime"
    "time"
)

// defaultKeyringTimeout caps how long every OS keyring call may take.
// The underlying keyring API (Secret Service on Linux, Keychain on
// macOS, Credential Manager on Windows) can block indefinitely when no
// provider is reachable — a headless SSH/container/WSL session, a
// suppressed Keychain prompt, a stuck Credential Manager — and that
// freezes the CLI. 5s is comfortably longer than any healthy
// round-trip while still surfacing the hang to the user quickly.
const defaultKeyringTimeout = 5 * time.Second

// keyringTimeoutEnvVar overrides defaultKeyringTimeout. Accepts any
// time.ParseDuration string; invalid or non-positive values fall back
// to the default. Useful on slow keyrings or to extend the wait on a
// system where the secret service is just sluggish.
const keyringTimeoutEnvVar = "ENTIRE_KEYRING_TIMEOUT"

func keyringTimeout() time.Duration {
    v := os.Getenv(keyringTimeoutEnvVar)
    if v == "" {
        return defaultKeyringTimeout
    }
    d, err := time.ParseDuration(v)
    if err != nil || d <= 0 {
        return defaultKeyringTimeout
    }
    return d
}

// newKeyringContext returns a context that cancels after the configured
// keyring timeout. Callers must invoke the returned cancel to release
// the timer regardless of which select branch wins.
func newKeyringContext() (context.Context, context.CancelFunc) {
    return context.WithTimeout(context.Background(), keyringTimeout())
}

// callKeyringWithContext runs fn in a goroutine and returns its result,
// or a descriptive error if ctx is cancelled (e.g. its deadline
// elapses) first. The goroutine continues running — a blocked D-Bus
// syscall can't be cancelled from Go — and its eventual result is
// discarded. The buffered result channel keeps the goroutine from
// leaking forever waiting to publish into a receiver that's already
// gone.
func callKeyringWithContext(ctx context.Context, op string, fn func() (string, error)) (string, error) {
    type result struct {
        val string
        err error
    }
    ch := make(chan result, 1)
    go func() {
        v, err := fn()
        ch <- result{val: v, err: err}
    }()
    select {
    case r := <-ch:
        return r.val, r.err
    case <-ctx.Done():
        if errors.Is(ctx.Err(), context.DeadlineExceeded) {
            return "", fmt.Errorf(
                "%s timed out: OS keyring (%s) appears unavailable; set %s to a longer duration to wait further: %w",
                op, keyringProviderName(), keyringTimeoutEnvVar, ctx.Err(),
            )
        }
        return "", fmt.Errorf("%s cancelled: %w", op, ctx.Err())
    }
}

// keyringProviderName returns the human name of the OS keyring backend
// for the current platform, so the timeout error can point the user at
// the specific service that's likely stuck (Keychain on macOS,
// Credential Manager on Windows, Secret Service on Linux/BSD). The
// fallback for unrecognised GOOS is the generic "OS keyring".
func keyringProviderName() string {
    switch runtime.GOOS {
    case "darwin":
        return "macOS Keychain"
    case "windows":
        return "Windows Credential Manager"
    case "linux", "freebsd", "openbsd", "netbsd", "dragonfly":
        return "Secret Service (D-Bus)"
    default:
        return "OS keyring"
    }
}

Dcmd/entire/cli/auth/keyring_timeout.go-93

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137

package auth

import (
    "context"
    "errors"
    "strings"
    "testing"
    "time"
)

func TestCallKeyringWithContext_ReturnsValueWhenFast(t *testing.T) {
    t.Parallel()

got, err := callKeyringWithContext(context.Background(), "get", func() (string, error) {
        return "token", nil
    })
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if got != "token" {
        t.Fatalf("got = %q, want %q", got, "token")
    }
}

func TestCallKeyringWithContext_PropagatesInnerError(t *testing.T) {
    t.Parallel()

sentinel := errors.New("backend exploded")
    _, err := callKeyringWithContext(context.Background(), "get", func() (string, error) {
        return "", sentinel
    })
    if !errors.Is(err, sentinel) {
        t.Fatalf("got %v, want %v wrapped", err, sentinel)
    }
}

func TestCallKeyringWithContext_DeadlineExceeded(t *testing.T) {
    t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
    defer cancel()

start := time.Now()
    _, err := callKeyringWithContext(ctx, "get", func() (string, error) {
        time.Sleep(5 * time.Second)
        return "should not be returned", nil
    })
    elapsed := time.Since(start)

if err == nil {
        t.Fatal("expected timeout error, got nil")
    }
    if !errors.Is(err, context.DeadlineExceeded) {
        t.Fatalf("want DeadlineExceeded wrapped, got %v", err)
    }
    if elapsed > 2*time.Second {
        t.Fatalf("call did not return promptly after timeout: elapsed=%s", elapsed)
    }

msg := err.Error()
    for _, want := range []string{"get", "OS keyring", keyringTimeoutEnvVar} {
        if !strings.Contains(msg, want) {
            t.Errorf("timeout error %q missing %q", msg, want)
        }
    }
}

func TestCallKeyringWithContext_ExternalCancellation(t *testing.T) {
    t.Parallel()

ctx, cancel := context.WithCancel(context.Background())
    go func() {
        time.Sleep(20 * time.Millisecond)
        cancel()
    }()

_, err := callKeyringWithContext(ctx, "get", func() (string, error) {
        time.Sleep(5 * time.Second)
        return "should not be returned", nil
    })
    if err == nil {
        t.Fatal("expected cancellation error, got nil")
    }
    if !errors.Is(err, context.Canceled) {
        t.Fatalf("want context.Canceled wrapped, got %v", err)
    }
    if errors.Is(err, context.DeadlineExceeded) {
        t.Fatalf("plain cancel should not surface as DeadlineExceeded: %v", err)
    }
}

func TestKeyringTimeout_DefaultWhenUnset(t *testing.T) {
    t.Setenv(keyringTimeoutEnvVar, "")

if got := keyringTimeout(); got != defaultKeyringTimeout {
        t.Fatalf("got %v, want default %v", got, defaultKeyringTimeout)
    }
}

func TestKeyringTimeout_HonoursEnvOverride(t *testing.T) {
    t.Setenv(keyringTimeoutEnvVar, "150ms")

if got := keyringTimeout(); got != 150*time.Millisecond {
        t.Fatalf("got %v, want 150ms", got)
    }
}

func TestKeyringTimeout_IgnoresInvalidEnvValue(t *testing.T) {
    t.Setenv(keyringTimeoutEnvVar, "not-a-duration")

if got := keyringTimeout(); got != defaultKeyringTimeout {
        t.Fatalf("got %v, want default %v", got, defaultKeyringTimeout)
    }
}

func TestKeyringTimeout_IgnoresNonPositiveValue(t *testing.T) {
    t.Setenv(keyringTimeoutEnvVar, "0s")

if got := keyringTimeout(); got != defaultKeyringTimeout {
        t.Fatalf("got %v, want default %v", got, defaultKeyringTimeout)
    }
}

func TestNewKeyringContext_HasDeadline(t *testing.T) {
    t.Setenv(keyringTimeoutEnvVar, "250ms")

ctx, cancel := newKeyringContext()
    defer cancel()

deadline, ok := ctx.Deadline()
    if !ok {
        t.Fatal("expected context with deadline, got none")
    }
    if remaining := time.Until(deadline); remaining <= 0 || remaining > 250*time.Millisecond {
        t.Fatalf("deadline out of expected window: remaining=%s", remaining)
    }
}

Dcmd/entire/cli/auth/keyring_timeout_test.go-137

71 unmodified lines

72
73
74
75
76
77
78
75
76
77

71 unmodified lines

return nil, errors.New("repo-scoped token exchange requires a target cluster host")
    }

// Bridge any pre-contexts.json login so the resolver can match it.
    // Best-effort: a migration failure must not block resolution.
    _, _ = MigrateLegacyLoginContext() //nolint:errcheck // best-effort bridge; resolution proceeds regardless

httpClient := &http.Client{Timeout: repoExchangeTimeout, Transport: repoExchangeTransportForTest}
    clusterCtx, err := resolveContextForCluster(ctx, userdirs.Config(), userdirs.Cache(), clusterHost, httpClient, nil)
    if err != nil {

Mcmd/entire/cli/auth/repo_token.go-4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213

package auth

import (
    "errors"
    "fmt"
    "strings"

"github.com/entireio/auth-go/tokens"
    "github.com/entireio/auth-go/tokenstore"
    "github.com/entireio/cli/cmd/entire/cli/api"
    "github.com/zalando/go-keyring"
)

// keyringService is the OS-keyring service name for this CLI. Renaming
// would orphan every existing user's stored credentials — they'd appear
// logged out until they ran `entire login` again. Don't change this
// without a migration path.
const keyringService = "entire-cli"

// Store manages CLI authentication tokens via a pluggable backend. The
// production binary always resolves to the OS keyring. A file-backed
// backend is available only in builds tagged `authfilestore` (used by
// integration tests to avoid the OS keychain).
//
// Implements tokenstore.Store so it can be passed to tokenmanager.New
// as the persistence layer. The interface methods (SaveTokens /
// LoadTokens / DeleteTokens) delegate to the same backend as the
// legacy SaveToken / GetToken / DeleteToken pair, so production and
// test paths share a single source of truth.
type Store struct {
    service string
    backend tokenBackend
}

// tokenBackend abstracts token persistence. Implementations must treat
// "missing key" as a non-error: get returns ("", nil) and delete is a
// no-op so callers don't have to plumb backend-specific sentinels.
type tokenBackend interface {
    save(service, key, value string) error
    get(service, key string) (string, error)
    delete(service, key string) error
}

// chooseBackend returns the backend used by NewStore and
// NewStoreWithService. The default returns the keyring backend; the
// `authfilestore` build adds an init() that may swap in a file-backed
// backend when the test env var is set.
var chooseBackend = func() tokenBackend { return keyringBackend{} }

// NewStore returns a Store backed by the system keyring (or, in
// `authfilestore` builds, optionally a file-backed test store).
func NewStore() *Store {
    return &Store{service: keyringService, backend: chooseBackend()}
}

// NewStoreWithService returns a Store with a custom keyring service name (for testing).
// Honors the same backend selection as NewStore so tests that opt into the
// file-backed test store via env var see consistent behavior across both
// constructors.
func NewStoreWithService(service string) *Store {
    return &Store{service: service, backend: chooseBackend()}
}

// SaveToken persists an access token for the given base URL. Prefer
// SaveTokens (the tokenstore.Store interface method) for new callers;
// SaveToken is kept for the legacy direct-bearer call sites (login,
// logout, auth status/list/revoke) that don't go through the tokenmanager.
func (s *Store) SaveToken(baseURL, token string) error {
    token = strings.TrimSpace(token)
    if token == "" {
        return errors.New("refusing to save empty token")
    }
    return s.backend.save(s.service, baseURL, token)
}

// GetToken retrieves a stored token for the given base URL. Returns
// an empty string (and no error) if no token is stored, or if the
// stored value is JSON-shaped (defensive: pre-shim entries are
// opaque token strings, never JSON; a JSON blob in the keyring is
// corruption and must not be put on the wire as a bearer).
//
// Prefer LoadTokens (the tokenstore.Store interface method) for new
// callers — it returns the full TokenSet so refresh tokens and expiry
// survive the round trip. GetToken is retained for the direct-bearer
// call sites that only need the access token string.
func (s *Store) GetToken(baseURL string) (string, error) {
    raw, err := s.backend.get(s.service, baseURL)
    if err != nil {
        return "", err
    }
    if looksJSONShaped(raw) {
        return "", nil
    }
    return raw, nil
}

// looksJSONShaped reports whether the keyring value's first
// non-whitespace byte is '{' or '['. Used to reject corrupt /\
// previous-encoding entries before they end up in an\
// Authorization: Bearer header.\
func looksJSONShaped(s string) bool {\
    trimmed := strings.TrimLeft(s, " \t\r\n")\
    if trimmed == "" {\
        return false\
    }\
    return trimmed[0] == '{' || trimmed[0] == '['\
}\
\
// DeleteToken removes a stored token for the given base URL. Returns\
// no error if the token does not exist. Prefer DeleteTokens (the\
// tokenstore.Store interface method); DeleteToken is retained for\
// direct-bearer call sites.\
func (s *Store) DeleteToken(baseURL string) error {\
    return s.backend.delete(s.service, baseURL)\
}\
\
// SaveTokens implements tokenstore.Store. Refresh token, scope, expiry,\
// and token type are intentionally dropped — the entire device-flow\
// surface doesn't issue refresh tokens, and the legacy keyring/file\
// layout stores bare access-token strings. If refresh-token support\
// lands, this method (and the tokenBackend interface) become the\
// migration point.\
func (s *Store) SaveTokens(profile string, t tokens.TokenSet) error {\
    access := strings.TrimSpace(t.AccessToken)\
    if access == "" {\
        return errors.New("refusing to save empty access token")\
    }\
    return s.backend.save(s.service, profile, access)\
}\
\
// LoadTokens implements tokenstore.Store. Reads the bare-string entry\
// and wraps it back into a TokenSet. Returns tokenstore.ErrNotFound\
// when nothing is stored under the profile (or the stored value is\
// JSON-shaped — see GetToken's note about defensive rejection of\
// non-token blobs) so callers can errors.Is against the lib sentinel.\
func (s *Store) LoadTokens(profile string) (tokens.TokenSet, error) {\
    access, err := s.backend.get(s.service, profile)\
    if err != nil {\
        return tokens.TokenSet{}, err\
    }\
    if access == "" || looksJSONShaped(access) {\
        return tokens.TokenSet{}, tokenstore.ErrNotFound\
    }\
    return tokens.TokenSet{AccessToken: access}, nil\
}\
\
// DeleteTokens implements tokenstore.Store.\
func (s *Store) DeleteTokens(profile string) error {\
    return s.backend.delete(s.service, profile)\
}\
\
// LookupCurrentToken retrieves the active login token. It prefers the\
// current contexts.json context (so a login from this or entiredb's CLIs\
// authenticates control-plane commands), falling back to the legacy entry\
// keyed by the auth issuer (api.AuthBaseURL()) for pre-contexts logins.\
func LookupCurrentToken() (string, error) {\
    return NewContextStore().GetToken(api.AuthBaseURL())\
}\
\
// keyringBackend persists tokens in the OS keyring. Every operation is\
// wrapped in callKeyringWithContext because the underlying keyring call\
// can block indefinitely when no provider is reachable — most commonly\
// a headless Linux host with no Secret Service running (gnome-keyring,\
// kwalletd, KeePassXC), but the same hang shape exists on macOS when\
// the Keychain prompt is suppressed or on Windows when Credential\
// Manager misbehaves.\
type keyringBackend struct{}\
\
func (keyringBackend) save(service, key, value string) error {\
    ctx, cancel := newKeyringContext()\
    defer cancel()\
    _, err := callKeyringWithContext(ctx, "save", func() (string, error) {\
        return "", keyring.Set(service, key, value)\
    })\
    if err != nil {\
        return fmt.Errorf("save token to keyring: %w", err)\
    }\
    return nil\
}\
\
func (keyringBackend) get(service, key string) (string, error) {\
    ctx, cancel := newKeyringContext()\
    defer cancel()\
    token, err := callKeyringWithContext(ctx, "get", func() (string, error) {\
        v, err := keyring.Get(service, key)\
        if errors.Is(err, keyring.ErrNotFound) {\
            return "", nil\
        }\
        if err != nil {\
            return "", fmt.Errorf("keyring.Get: %w", err)\
        }\
        return v, nil\
    })\
    if err != nil {\
        return "", fmt.Errorf("get token from keyring: %w", err)\
    }\
    return token, nil\
}\
\
func (keyringBackend) delete(service, key string) error {\
    ctx, cancel := newKeyringContext()\
    defer cancel()\
    _, err := callKeyringWithContext(ctx, "delete", func() (string, error) {\
        if err := keyring.Delete(service, key); err != nil && !errors.Is(err, keyring.ErrNotFound) {\
            return "", fmt.Errorf("keyring.Delete: %w", err)\
        }\
        return "", nil\
    })\
    if err != nil {\
        return fmt.Errorf("delete token from keyring: %w", err)\
    }\
    return nil\
}\
```\
\
Dcmd/entire/cli/auth/store.go-213\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
\
//go:build authfilestore\
\
// File-backed auth store backend. Compiled only under the `authfilestore`\
// build tag, which is enabled by:\
//   - the integration test subprocess build (cmd/entire/cli/integration_test/setup_test.go)\
//   - test:integration / test:ci tasks (mise.toml) for running this package's\
//     tagged tests\
//\
// Production builds (no tag) do not include this file, so the env var below\
// has no effect outside test environments.\
\
package auth\
\
import (\
    "encoding/json"\
    "errors"\
    "fmt"\
    "os"\
    "path/filepath"\
)\
\
// testAuthStoreFileEnv, when set, redirects token storage to a JSON file at\
// the given path instead of the OS keyring. Only honored in `authfilestore`\
// builds.\
const testAuthStoreFileEnv = "ENTIRE_TEST_AUTH_STORE_FILE"\
\
func init() {\
    chooseBackend = func() tokenBackend {\
        if path := os.Getenv(testAuthStoreFileEnv); path != "" {\
            return &fileBackend{path: path}\
        }\
        return keyringBackend{}\
    }\
}\
\
type fileBackend struct {\
    path string\
}\
\
func (b *fileBackend) save(service, key, value string) error {\
    tokens, err := b.read()\
    if err != nil {\
        return err\
    }\
    svc := tokens[service]\
    if svc == nil {\
        svc = make(map[string]string)\
        tokens[service] = svc\
    }\
    svc[key] = value\
    return b.write(tokens)\
}\
\
func (b *fileBackend) get(service, key string) (string, error) {\
    tokens, err := b.read()\
    if err != nil {\
        return "", err\
    }\
    return tokens[service][key], nil\
}\
\
func (b *fileBackend) delete(service, key string) error {\
    tokens, err := b.read()\
    if err != nil {\
        return err\
    }\
    if svc := tokens[service]; svc != nil {\
        delete(svc, key)\
        if len(svc) == 0 {\
            delete(tokens, service)\
        }\
    }\
    return b.write(tokens)\
}\
\
func (b *fileBackend) read() (map[string]map[string]string, error) {\
    data, err := os.ReadFile(b.path)\
    if errors.Is(err, os.ErrNotExist) {\
        return make(map[string]map[string]string), nil\
    }\
    if err != nil {\
        return nil, fmt.Errorf("read test auth store: %w", err)\
    }\
    if len(data) == 0 {\
        return make(map[string]map[string]string), nil\
    }\
\
    var tokens map[string]map[string]string\
    if err := json.Unmarshal(data, &tokens); err != nil {\
        return nil, fmt.Errorf("parse test auth store: %w", err)\
    }\
    if tokens == nil {\
        tokens = make(map[string]map[string]string)\
    }\
    return tokens, nil\
}\
\
func (b *fileBackend) write(tokens map[string]map[string]string) error {\
    if err := os.MkdirAll(filepath.Dir(b.path), 0o700); err != nil {\
        return fmt.Errorf("create test auth store directory: %w", err)\
    }\
    data, err := json.Marshal(tokens)\
    if err != nil {\
        return fmt.Errorf("marshal test auth store: %w", err)\
    }\
    if err := os.WriteFile(b.path, data, 0o600); err != nil {\
        return fmt.Errorf("write test auth store: %w", err)\
    }\
    // os.WriteFile preserves an existing file's permission bits, so an\
    // already-broad file (e.g. 0o644 from an earlier test setup) would\
    // keep those bits. Force 0o600 to make the post-condition unconditional.\
    if err := os.Chmod(b.path, 0o600); err != nil {\
        return fmt.Errorf("restrict test auth store permissions: %w", err)\
    }\
    return nil\
}\
```\
\
Dcmd/entire/cli/auth/store\_filebackend.go-116\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
\
//go:build authfilestore\
\
package auth\
\
import (\
    "os"\
    "path/filepath"\
    "testing"\
)\
\
func TestNewStore_UsesTestStoreFile(t *testing.T) {\
    storeFile := filepath.Join(t.TempDir(), "auth-store.json")\
    t.Setenv(testAuthStoreFileEnv, storeFile)\
\
    store := NewStore()\
    if err := store.SaveToken("http://localhost:8787", "  file-token  "); err != nil {\
        t.Fatalf("SaveToken() error = %v", err)\
    }\
\
    otherProcessStore := NewStore()\
    got, err := otherProcessStore.GetToken("http://localhost:8787")\
    if err != nil {\
        t.Fatalf("GetToken() error = %v", err)\
    }\
    if got != "file-token" {\
        t.Fatalf("GetToken() = %q, want %q", got, "file-token")\
    }\
\
    info, err := os.Stat(storeFile)\
    if err != nil {\
        t.Fatalf("stat store file: %v", err)\
    }\
    if got := info.Mode().Perm(); got != 0o600 {\
        t.Fatalf("store file mode = %v, want 0600", got)\
    }\
}\
\
func TestNewStoreWithService_UsesTestStoreFileAndRestrictsExistingFile(t *testing.T) {\
    storeFile := filepath.Join(t.TempDir(), "auth-store.json")\
    t.Setenv(testAuthStoreFileEnv, storeFile)\
    if err := os.WriteFile(storeFile, []byte(`{}`), 0o644); err != nil {\
        t.Fatalf("precreate store file: %v", err)\
    }\
    if err := os.Chmod(storeFile, 0o644); err != nil {\
        t.Fatalf("set broad store file permissions: %v", err)\
    }\
\
    store := NewStoreWithService("custom-service")\
    if err := store.SaveToken("http://localhost:8787", "service-token"); err != nil {\
        t.Fatalf("SaveToken() error = %v", err)\
    }\
\
    otherProcessStore := NewStoreWithService("custom-service")\
    got, err := otherProcessStore.GetToken("http://localhost:8787")\
    if err != nil {\
        t.Fatalf("GetToken() error = %v", err)\
    }\
    if got != "service-token" {\
        t.Fatalf("GetToken() = %q, want %q", got, "service-token")\
    }\
\
    info, err := os.Stat(storeFile)\
    if err != nil {\
        t.Fatalf("stat store file: %v", err)\
    }\
    if got := info.Mode().Perm(); got != 0o600 {\
        t.Fatalf("store file mode = %v, want 0600", got)\
    }\
}\
```\
\
Dcmd/entire/cli/auth/store\_filebackend\_test.go-69\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
\
package auth\
\
import (\
    "os"\
    "path/filepath"\
    "strings"\
    "testing"\
)\
\
// TestProductionAuthStoreIsKeyringOnly enforces that the file-backed auth\
// store backend stays fully gated behind //go:build authfilestore. The\
// production CLI binary (no build tag) must not contain code that reads\
// the test env var or persists tokens to disk — otherwise an end user\
// can flip the variable in their shell and silently bypass the OS\
// keyring.\
//\
// This runs as part of the regular (untagged) test suite, so any new\
// production-compiled .go file in this package that mentions the\
// forbidden symbols will fail CI immediately. The remediation is to\
// move the offending code into a //go:build authfilestore file\
// alongside store_filebackend.go.\
func TestProductionAuthStoreIsKeyringOnly(t *testing.T) {\
    t.Parallel()\
\
    // Symbols that may only appear in authfilestore-tagged files.\
    // Keep this list tight — these are the load-bearing markers of\
    // "we are persisting auth tokens to a file from production code".\
    forbidden := []string{\
        "ENTIRE_TEST_AUTH_STORE_FILE", // the test env-var hook\
        "os.WriteFile",                // token-on-disk write\
        "os.ReadFile",                 // token-on-disk read\
    }\
\
    entries, err := os.ReadDir(".")\
    if err != nil {\
        t.Fatalf("readdir auth pkg: %v", err)\
    }\
\
    for _, e := range entries {\
        if e.IsDir() {\
            continue\
        }\
        name := e.Name()\
        // _test.go files are never in the production binary, so they\
        // cannot reintroduce the file backend regardless of contents.\
        if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {\
            continue\
        }\
\
        data, err := os.ReadFile(filepath.Join(".", name))\
        if err != nil {\
            t.Fatalf("read %s: %v", name, err)\
        }\
        src := string(data)\
\
        if hasAuthFileStoreBuildTag(src) {\
            continue\
        }\
\
        for _, sym := range forbidden {\
            if strings.Contains(src, sym) {\
                t.Errorf(\
                    "%s references %q outside a //go:build authfilestore file. "+\
                        "File-backed auth storage must stay gated so production "+\
                        "builds cannot opt into it. Move this code into a tagged "+\
                        "file (see store_filebackend.go).",\
                    name, sym,\
                )\
            }\
        }\
    }\
}\
\
// hasAuthFileStoreBuildTag reports whether the file's build constraint\
// requires the authfilestore tag. Build constraints must appear before\
// the package clause, so we only scan up to that point.\
func hasAuthFileStoreBuildTag(src string) bool {\
    for _, line := range strings.Split(src, "\n") {\
        trimmed := strings.TrimSpace(line)\
        if strings.HasPrefix(trimmed, "package ") {\
            return false\
        }\
        if strings.HasPrefix(trimmed, "//go:build ") &&\
            strings.Contains(trimmed, "authfilestore") {\
            return true\
        }\
    }\
    return false\
}\
```\
\
Dcmd/entire/cli/auth/store\_invariants\_test.go-89\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
121\
122\
123\
124\
125\
126\
127\
128\
129\
130\
131\
132\
133\
134\
135\
136\
137\
138\
139\
140\
141\
142\
143\
144\
145\
146\
147\
148\
149\
150\
151\
152\
153\
154\
\
package auth\
\
import (\
    "os"\
    "testing"\
\
    "github.com/entireio/cli/cmd/entire/cli/api"\
    "github.com/zalando/go-keyring"\
)\
\
func TestMain(m *testing.M) {\
    keyring.MockInit()\
    os.Exit(m.Run())\
}\
\
func TestStoreSaveAndGetToken(t *testing.T) {\
    // Not parallel: go-keyring's mock provider uses an unprotected map.\
    store := NewStoreWithService("test-save-get")\
\
    if err := store.SaveToken("https://entire.io", "prod-token"); err != nil {\
        t.Fatalf("SaveToken() error = %v", err)\
    }\
\
    got, err := store.GetToken("https://entire.io")\
    if err != nil {\
        t.Fatalf("GetToken() error = %v", err)\
    }\
    if got != "prod-token" {\
        t.Fatalf("GetToken() = %q, want %q", got, "prod-token")\
    }\
}\
\
func TestStoreGetToken_NotFound(t *testing.T) {\
    // Not parallel: go-keyring's mock provider uses an unprotected map.\
    store := NewStoreWithService("test-not-found")\
\
    got, err := store.GetToken("https://missing.example.com")\
    if err != nil {\
        t.Fatalf("GetToken() error = %v", err)\
    }\
    if got != "" {\
        t.Fatalf("GetToken() = %q, want empty string", got)\
    }\
}\
\
func TestStoreSaveToken_PreservesOtherBaseURLs(t *testing.T) {\
    // Not parallel: go-keyring's mock provider uses an unprotected map.\
    store := NewStoreWithService("test-preserve")\
\
    if err := store.SaveToken("https://entire.io", "prod-token"); err != nil {\
        t.Fatalf("SaveToken(prod) error = %v", err)\
    }\
\
    if err := store.SaveToken("http://localhost:8787", "local-token"); err != nil {\
        t.Fatalf("SaveToken(local) error = %v", err)\
    }\
\
    prod, err := store.GetToken("https://entire.io")\
    if err != nil {\
        t.Fatalf("GetToken(prod) error = %v", err)\
    }\
    if prod != "prod-token" {\
        t.Fatalf("prod token = %q, want %q", prod, "prod-token")\
    }\
\
    local, err := store.GetToken("http://localhost:8787")\
    if err != nil {\
        t.Fatalf("GetToken(local) error = %v", err)\
    }\
    if local != "local-token" {\
        t.Fatalf("local token = %q, want %q", local, "local-token")\
    }\
}\
\
func TestStoreSaveToken_RejectsEmptyToken(t *testing.T) {\
    // Not parallel: go-keyring's mock provider uses an unprotected map.\
    store := NewStoreWithService("test-empty")\
\
    if err := store.SaveToken("https://entire.io", ""); err == nil {\
        t.Fatal("SaveToken() with empty token should fail")\
    }\
\
    if err := store.SaveToken("https://entire.io", "   "); err == nil {\
        t.Fatal("SaveToken() with whitespace token should fail")\
    }\
}\
\
func TestStoreSaveToken_TrimsWhitespace(t *testing.T) {\
    // Not parallel: go-keyring's mock provider uses an unprotected map.\
    store := NewStoreWithService("test-trim")\
\
    if err := store.SaveToken("https://entire.io", "  my-token  "); err != nil {\
        t.Fatalf("SaveToken() error = %v", err)\
    }\
\
    got, err := store.GetToken("https://entire.io")\
    if err != nil {\
        t.Fatalf("GetToken() error = %v", err)\
    }\
    if got != "my-token" {\
        t.Fatalf("GetToken() = %q, want %q (whitespace should be trimmed)", got, "my-token")\
    }\
}\
\
func TestStoreDeleteToken(t *testing.T) {\
    // Not parallel: go-keyring's mock provider uses an unprotected map.\
    store := NewStoreWithService("test-delete")\
\
    if err := store.SaveToken("https://entire.io", "tok"); err != nil {\
        t.Fatalf("SaveToken() error = %v", err)\
    }\
\
    if err := store.DeleteToken("https://entire.io"); err != nil {\
        t.Fatalf("DeleteToken() error = %v", err)\
    }\
\
    got, err := store.GetToken("https://entire.io")\
    if err != nil {\
        t.Fatalf("GetToken() error = %v", err)\
    }\
    if got != "" {\
        t.Fatalf("GetToken() after delete = %q, want empty", got)\
    }\
}\
\
func TestStoreDeleteToken_NotFoundIsNoop(t *testing.T) {\
    // Not parallel: go-keyring's mock provider uses an unprotected map.\
    store := NewStoreWithService("test-delete-noop")\
\
    if err := store.DeleteToken("https://nonexistent.example.com"); err != nil {\
        t.Fatalf("DeleteToken() on missing key error = %v", err)\
    }\
}\
\
func TestLookupCurrentToken(t *testing.T) {\
    // Pin both URLs: tokens are keyed by AuthBaseURL, which defaults to the\
    // production auth host when the env override is unset. Setting only\
    // BaseURL would write to a key the lookup doesn't read.\
    t.Setenv(api.BaseURLEnvVar, "http://localhost:8787")\
    t.Setenv(api.AuthBaseURLEnvVar, "http://localhost:8787")\
\
    store := NewStore()\
    if err := store.SaveToken("http://localhost:8787", "local-token"); err != nil {\
        t.Fatalf("SaveToken() error = %v", err)\
    }\
\
    got, err := LookupCurrentToken()\
    if err != nil {\
        t.Fatalf("LookupCurrentToken() error = %v", err)\
    }\
    if got != "local-token" {\
        t.Fatalf("LookupCurrentToken() = %q, want %q", got, "local-token")\
    }\
}\
```\
\
Dcmd/entire/cli/auth/store\_test.go-154\
\
```\
115 unmodified lines\
\
116\
117\
118\
119\
120\
121\
119\
120\
121\
122\
123\
167 unmodified lines\
\
291\
292\
293\
295\
296\
297\
294\
295\
296\
297\
298\
299\
300\
2 unmodified lines\
\
303\
304\
305\
306\
307\
308\
309\
306\
307\
308\
309\
310\
311\
\
115 unmodified lines\
\
        t.Fatalf("output missing login complete message (token save likely failed):\n%s", output)\
    }\
\
    // A --server login is recorded as a contexts.json context (the legacy\
    // keyring entry is only written for the default login server, whose key\
    // is the only one legacy readers consult).\
    // The login is recorded as a contexts.json context — the only\
    // credential store.\
    contextsPath := filepath.Join(proc.configDir, "contexts.json")\
    data, readErr := os.ReadFile(contextsPath)\
    if readErr != nil {\
167 unmodified lines\
\
    env := NewTestEnv(t)\
    configDir := filepath.Join(env.RepoDir, ".entire-test-config")\
\
    // ENTIRE_AUTH_BASE_URL is retired (commands reject it when set at all);\
    // --server is how a login targets the test server instead of the\
    // production default.\
    // --server pins the login at the in-process test server instead of the\
    // production default. The login lands in contexts.json + the file token\
    // store, both sandboxed below so the test never touches the developer's\
    // real config or OS keychain.\
    args = append(args, "--server", apiBaseURL)\
    cmd := execx.NonInteractive(context.Background(), getTestBinary(), args...)\
    cmd.Dir = env.RepoDir\
2 unmodified lines\
\
        "ENTIRE_TEST_GEMINI_PROJECT_DIR="+env.GeminiProjectDir,\
        "ENTIRE_TEST_OPENCODE_PROJECT_DIR="+env.OpenCodeProjectDir,\
        "ENTIRE_API_BASE_URL="+apiBaseURL,\
        "ENTIRE_TEST_AUTH_STORE_FILE="+filepath.Join(env.RepoDir, ".entire-test-auth-store.json"),\
        // A --server login records its credential in contexts.json and the\
        // token store; point both at the test sandbox so the spawned binary\
        // can't touch the real ~/.config/entire or the OS keychain.\
        // The login records its credential in contexts.json and the token\
        // store; point both at the test sandbox so the spawned binary can't\
        // touch the real ~/.config/entire or the OS keychain.\
        "ENTIRE_CONFIG_DIR="+configDir,\
        "ENTIRE_TOKEN_STORE=file",\
        "ENTIRE_TOKEN_STORE_PATH="+filepath.Join(env.RepoDir, ".entire-test-tokens.json"),\
```\
\
Mcmd/entire/cli/integration\_test/login\_test.go+9/-10\
\
```\
42 unmodified lines\
\
43\
44\
45\
46\
47\
48\
49\
46\
47\
48\
49\
\
42 unmodified lines\
\
    }\
\
    moduleRoot := findModuleRoot()\
    // -tags=authfilestore enables the file-backed auth store backend so the\
    // subprocess can opt into ENTIRE_TEST_AUTH_STORE_FILE instead of touching\
    // the real OS keychain. Production builds omit this tag.\
    buildCmd := exec.Command("go", "build", "-tags=authfilestore", "-o", testBinaryPath, ".")\
    buildCmd := exec.Command("go", "build", "-o", testBinaryPath, ".")\
    buildCmd.Dir = filepath.Join(moduleRoot, "cmd", "entire")\
\
    buildOutput, err := buildCmd.CombinedOutput()\
```\
\
Mcmd/entire/cli/integration\_test/setup\_test.go+1/-4\
\
```\
123 unmodified lines\
\
124\
125\
126\
127\
128\
129\
127\
128\
132\
129\
130\
134\
131\
132\
136\
133\
134\
138\
135\
136\
140\
137\
138\
139\
140\
172 unmodified lines\
\
313\
314\
315\
319\
320\
321\
316\
317\
318\
319\
320\
321\
322\
323\
327\
328\
329\
330\
331\
332\
333\
334\
335\
336\
337\
338\
339\
340\
341\
342\
343\
344\
345\
346\
347\
324\
325\
326\
327\
349\
350\
351\
352\
328\
329\
330\
331\
\
123 unmodified lines\
\
    if err != nil {\
        return "", fmt.Errorf("parse server URL: %w", err)\
    }\
    // Error messages echo u.Redacted(), not raw: the URL may carry\
    // userinfo (that's one of the rejection cases), and stderr often ends\
    // up in CI logs where a password must not appear.\
    switch {\
    case u.Scheme != schemeHTTPS && u.Scheme != schemeHTTP:\
        return "", fmt.Errorf("scheme must be http or https, got %q", u.Redacted())\
        return "", fmt.Errorf("scheme must be http or https, got %q", raw)\
    case u.Host == "":\
        return "", fmt.Errorf("missing host in %q", u.Redacted())\
        return "", fmt.Errorf("missing host in %q", raw)\
    case u.User != nil:\
        return "", fmt.Errorf("userinfo not allowed in %q", u.Redacted())\
        return "", fmt.Errorf("userinfo not allowed in %q", raw)\
    case u.Path != "" && u.Path != "/":\
        return "", fmt.Errorf("path not allowed in %q (use the bare origin)", u.Redacted())\
        return "", fmt.Errorf("path not allowed in %q (use the bare origin)", raw)\
    case u.RawQuery != "" || u.Fragment != "":\
        return "", fmt.Errorf("query/fragment not allowed in %q", u.Redacted())\
        return "", fmt.Errorf("query/fragment not allowed in %q", raw)\
    }\
    return api.NormalizeOriginURL(raw), nil\
}\
172 unmodified lines\
\
    return persistLogin(outW, errW, baseURL, token, refreshToken)\
}\
\
// persistLogin validates the freshly-issued access token, saves it to the\
// keyring, and dual-writes the shared contexts.json credential model.\
// Shared by the device-code and browser flows.\
// persistLogin validates the freshly-issued access token and records it in\
// the shared contexts.json credential model. Shared by the device-code and\
// browser flows.\
func persistLogin(outW, errW io.Writer, baseURL, token, refreshToken string) error {\
    if err := validateReceivedToken(token, baseURL, time.Now()); err != nil {\
        return fmt.Errorf("reject login token: %w", err)\
    }\
\
    // Every legacy-keyring read in the CLI keys by api.AuthBaseURL(),\
    // which is always the default origin now that ENTIRE_AUTH_BASE_URL is\
    // retired. A legacy entry saved under any other --server origin would\
    // be unreadable forever (and undeletable by logout, which deletes the\
    // default key) — so only write it when this login targeted that origin.\
    legacyReadable := baseURL == api.AuthBaseURL()\
    if legacyReadable {\
        // Login deliberately uses the legacy SaveToken (string, string)\
        // surface — we only have an access-token string at this point;\
        // neither flow's client returns a TokenSet here.\
        if err := auth.NewStore().SaveToken(baseURL, token); err != nil {\
            return fmt.Errorf("save auth token: %w", err)\
        }\
    }\
\
    // Dual-write the shared contexts.json credential model so the git\
    // remote helper (and entiredb's CLIs) can authenticate against any\
    // entitled cluster from this login. Best-effort only when the legacy\
    // entry above exists as the control-plane source of truth; for a\
    // non-default --server the context is the sole record of the login,\
    // so failing to write it means the login failed.\
    // Record the login in the shared contexts.json credential model — the\
    // single store every consumer (control plane, data API, git remote\
    // helper, entiredb's CLIs) resolves against.\
    if _, err := auth.RecordLoginContext(token, refreshToken, true); err != nil {\
        if !legacyReadable {\
            return fmt.Errorf("record login context: %w", err)\
        }\
        fmt.Fprintf(errW, "Warning: logged in, but could not record a shareable context (clone via entire:// may need a re-login): %v\n", err)\
        return fmt.Errorf("save login: %w", err)\
    }\
\
    fmt.Fprintln(outW, "✓ Login complete.")\
```\
\
Mcmd/entire/cli/login.go+12/-36\
\
```\
214 unmodified lines\
\
215\
216\
217\
218\
219\
218\
219\
220\
221\
11 unmodified lines\
\
233\
234\
235\
237\
238\
239\
240\
241\
236\
237\
238\
\
214 unmodified lines\
\
//     entirely — the CI / workload-identity path. A non-URL aud is a hard\
//     error, never a silent fallback to context resolution.\
//   - otherwise: resolve the login context for this cluster from contexts.json\
//     (migrating any pre-contexts.json login first) and exchange its stored\
//     login JWT.\
//     and exchange its stored login JWT.\
func resolveCreds(ctx context.Context, parsedURL *url.URL, clusterBaseURL string, skipTLS bool, httpClient *http.Client) (*repocreds.Cache, error) {\
    // Presence of ENTIRE_TOKEN is the signal: if it's set at all (LookupEnv,\
    // not Getenv, so we can tell set-empty from unset), we commit to the\
11 unmodified lines\
\
        return resolveEnvTokenCreds(ctx, envToken, parsedURL.Host, clusterBaseURL, userdirs.Cache(), httpClient)\
    }\
\
    // Bridge any pre-contexts.json login so the resolver can find it.\
    if _, err := auth.MigrateLegacyLoginContext(); err != nil {\
        debuglog.Printf("legacy login migration: %v", err)\
    }\
\
    // Resolve which login context authenticates this cluster: the cluster's\
    // login servers are taken from the cluster_cores.json cache (or a live\
    // /.well-known fetch on miss/expiry), then the account is selected from\
```\
\
Mcmd/git-remote-entire/main.go+1/-7\
\
```\
25 unmodified lines\
\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
29\
30\
31\
32\
33\
34\
39\
35\
36\
37\
\
25 unmodified lines\
\
    testutil.SetRunDir(runDir)\
\
    // Route every spawned entire binary (and the git hooks that invoke it) at\
    // file-backed token stores so e2e never touches the developer's real OS\
    // keychain. These env vars are inherited by child processes:\
    //   - internal/entireclient/tokenstore honors ENTIRE_TOKEN_STORE/_PATH\
    //     unconditionally (always compiled).\
    //   - the auth package's legacy keyring store honors\
    //     ENTIRE_TEST_AUTH_STORE_FILE only in -tags=authfilestore builds, which\
    //     the build:e2e task produces.\
    // the file-backed token store so e2e never touches the developer's real\
    // OS keychain. internal/entireclient/tokenstore honors these env vars\
    // unconditionally, and they are inherited by child processes.\
    // In-process keyring.MockInit() cannot help here: the binary is a subprocess.\
    os.Setenv("ENTIRE_TOKEN_STORE", "file")\
    os.Setenv("ENTIRE_TOKEN_STORE_PATH", filepath.Join(runDir, "e2e-tokenstore.json"))\
    os.Setenv("ENTIRE_TEST_AUTH_STORE_FILE", filepath.Join(runDir, "e2e-auth-tokens.json"))\
\
    // Same for the CLI's config and cache directories: contexts.json,\
    // version_check.json, and the discovery caches must never resolve to the\
```\
\
Me2e/tests/main\_test.go+3/-8\
\
```\
16 unmodified lines\
\
17\
18\
19\
20\
20\
21\
22\
23\
23\
24\
25\
26\
25\
26\
27\
28\
29\
30\
31\
32\
32\
33\
34\
35\
\
16 unmodified lines\
\
[tasks."test:integration"]\
description = "Run integration tests"\
run = "gotestsum --format testname --format-icons text --hide-summary skipped -- -tags=integration,authfilestore ./cmd/entire/cli/integration_test/... ./cmd/entire/cli/auth/..."\
run = "gotestsum --format testname --format-icons text --hide-summary skipped -- -tags=integration ./cmd/entire/cli/integration_test/... ./cmd/entire/cli/auth/..."\
\
[tasks."build:e2e"]\
description = "Build the CLI for e2e tests with the file-backed auth store (-tags=authfilestore), so the spawned binary, the git-remote-entire helper, and git hooks never touch the developer's OS keychain"\
description = "Build the CLI and git-remote-entire helper for e2e tests (the harness routes token storage to a file via ENTIRE_TOKEN_STORE so spawned binaries never touch the developer's OS keychain)"\
run = """\
go build -tags=authfilestore ./cmd/entire\
go build -tags=authfilestore ./cmd/git-remote-entire\
go build ./cmd/entire\
go build ./cmd/git-remote-entire\
"""\
\
[tasks."test:ci"]\
description = "Run all tests (unit + integration + E2E canary) with race detection"\
run = """\
go test -tags=integration,authfilestore -race ./...\
go test -tags=integration -race ./...\
mise run test:e2e:canary\
"""\
```\
\
Mmise.toml+5/-5