# fix(login): point headless users at the file token store, report real backend

`e616b4c`→[main](/content/gh/entireio/cli/commits/main/index.html)·

peyton-alt·3d ago·9 files·+408 added/-11 removed

Closes the residual gap in #1036: the mechanisms (ENTIRE_TOKEN_STORE=file,
ENTIRE_TOKEN) already exist, but a locked/absent OS keyring during
`entire login` surfaced a raw store error with no way forward, and
`entire auth status` claimed "stored in OS keychain" regardless of the
configured backend.

- login: when the credential-store write fails and the file backend is not
already selected, append guidance naming ENTIRE_TOKEN_STORE=file and the
concrete resolved token path. Store failures are tagged with
auth.ErrCredentialStoreWrite at the two tokenstore.Set sites in
RecordLoginContext, so the hint never fires for claim/context failures
the file store would not help with.
- tokenstore: export BackendEnvVar/PathEnvVar, FileBackendSelected (the
single backend-selection predicate), FileBackendPath, and
BackendDescription; `auth status` now reports the resolved backend
(file path, or the per-OS keyring name).
- tokenstore file backend: warn once per store (stderr) when the store
file is group/other-accessible, naming the file and the chmod 0600
remediation. Deliberately a warning, not a refusal: provisioned files
(CI secret mounts, read-only volumes) can carry modes the user cannot
change, and a refusal would also block the login rewrite that restores
0600 and break diagnostic commands. Files written by the store were
already 0600.
- error wording: "store ... token in keyring" -> "in credential store",
since the failing backend may be the file store.

Fault injection in tests uses the existing UseFailingBackendForTesting
deterministic under root), loose-permission fixtures chmod explicitly
(immune to hardened umask), and both store-write sites, both
BackendDescription branches, and the default token path are pinned.

Fixes #1036

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

## Sessions

01KXGTT2YYMN1Y1ZYB26M2NZT4View transcript

## Changes

9

- cmd/entire/cli

- Mauth.go+2/-1

- auth

- Mcontexts.go+16/-2

- Mauth_test.go+25

- Mlogin.go+17/-1

- Alogin_headless_hint_test.go+116

- internal/entireclient/tokenstore

- Mfile.go+29

- Mfile_test.go+161

- Mkeyring_timeout.go+1/-1

- Mtokenstore.go+41/-6

```  
16 unmodified lines
17  
18  
19  
...  
394 unmodified lines
418  
419  
420  
420  
421  
422  
423  
424  
```

```  
"github.com/entireio/cli/cmd/entire/cli/palette"  
"github.com/entireio/cli/internal/coreapi"  
"github.com/entireio/cli/internal/entireclient/contexts"  
"github.com/entireio/cli/internal/entireclient/tokenstore"  
"github.com/spf13/cobra"
  
394 unmodified lines
  
```

```  
if t.activeContext != "" {  
    writeAuthStatusLine(w, "Context:", t.activeContext)  
}  
writeAuthStatusLine(w, "Token:", "stored in OS keychain")  
writeAuthStatusLine(w, "Token:", "stored in "+tokenstore.BackendDescription())

// Active sessions on this core. The token is already known good, so a
// listing failure is non-fatal — note it and carry on.
```

```  
Mcmd/entire/cli/auth.go+2/-1  
```

```  
19 unmodified lines
20  
21  
22  
23  
24  
25  
26  
27  
28  
29  
30...  
```

// so a conservative non-zero value is enough to keep the entry usable.
const defaultContextTokenTTL = time.Hour

// ErrCredentialStoreWrite marks a failure writing tokens to the configured
// credential backend (OS keyring or file store), as opposed to claim
// validation or contexts.json failures. Login UX branches on it via
// errors.Is to decide whether pointing the user at the file token store
// would actually help.
var ErrCredentialStoreWrite = errors.New("credential store write failed")

// credStoreWriteError tags an underlying store error with
// ErrCredentialStoreWrite without changing its message.
type credStoreWriteError struct{ inner error }

func (e *credStoreWriteError) Error() string   { return e.inner.Error() }
func (e *credStoreWriteError) Unwrap() []error { return []error{e.inner, ErrCredentialStoreWrite} }

// RecordLoginContext records a freshly obtained login token in the
// shared contexts.json credential model: it derives the issuer (core
// URL), handle, and expiry from the token's own claims, stores the token

...  
```

```  
refreshSlot := tokenstore.RefreshService(keychainService)
if refreshToken != "" {
    if err := tokenstore.Set(refreshSlot, handle, refreshToken); err != nil {
        return "", fmt.Errorf("store refresh token in keyring: %w", err)
        return "", fmt.Errorf("store refresh token in credential store: %w", &credStoreWriteError{err})
    }
} else {
    _ = tokenstore.Delete(refreshSlot, handle) //nolint:errcheck // best-effort cleanup of a stale refresh token
}
```

```  
encoded := tokenstore.EncodeTokenWithExpiration(rawToken, expiresIn)
if err := tokenstore.Set(keychainService, handle, encoded); err != nil {
    return "", fmt.Errorf("store login token in keyring: %w", err)
    return "", fmt.Errorf("store login token in credential store: %w", &credStoreWriteError{err})
}

var name string
```

```  
// The Token: provenance line must reflect the configured credential backend:
// with ENTIRE_TOKEN_STORE=file the token lives in a JSON file, not the OS
// keychain, and claiming otherwise misleads exactly the headless users the
// file backend exists for (#1036).
func TestRunAuthStatus_FileTokenStoreProvenance(t *testing.T) {
    t.Setenv("ENTIRE_TOKEN_STORE", "file")
    t.Setenv("ENTIRE_TOKEN_STORE_PATH", "/ci/secrets/tokens.json")

target := statusTarget{coreURL: testCoreURL, token: "tok", activeContext: "core"}
    listSessions := func(context.Context, string, string) ([]api.AuthSession, error) { return nil, nil }

var out bytes.Buffer
    if err := runAuthStatus(context.Background(), &out, okProfile, listSessions, target); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    got := out.String()
    if !strings.Contains(got, "stored in file /ci/secrets/tokens.json") {
        t.Fatalf("output = %q, want the file-backend provenance line", got)
    }
    if strings.Contains(got, "OS keychain") {
        t.Fatalf("output = %q, must not claim the OS keychain when the file backend is configured", got)
    }
}
```
