Name a refused credential store instead of asking for a login
Problem
A customer runs the CLI from Codex on Windows. Codex's default Windows sandbox (MXC) refuses access to Windows Credential Manager. The CLI's keyring probe gets Access is denied., falls back to an empty credentials.json, and reports the miss as auth_required with the hint Run: basecamp auth login.
That remedy is wrong. The person is already logged in, and the login sits in the vault the sandbox won't open. A new login inside the sandbox would be refused the same way, or stored somewhere the next sandboxed run can't read either. The same wall shows up for gh inside Codex on Windows (openai/codex#21821).
Change
-
Classify a refused store.
storeRefusedmatches any store error that wrapsfs.ErrPermission. That covers a keyring probe refused with a permission error, which credstore carries in the fallback miss, and a credentials file that can't be read for lack of permission.- On Windows,
syscall.ErrnomapsERROR_ACCESS_DENIEDtofs.ErrPermission. - On Unix,
EACCESandEPERMmap to it. - An error already classified as something other than an auth failure keeps its class.
- On Windows,
-
unreadablechecks this first. A refused probe also reads asErrNoCredential, so the order matters. The error staysauth_required, and its message now says "the system credential store refused access" alongside the OS's own error. The hint isCredentialStoreRefusedHint, which lists only remedies any account can use:The system credential store refused this process access, as AI agent sandboxes do. Run the CLI outside the sandbox; or log in outside it with BASECAMP_NO_KEYRING=1 and XDG_CONFIG_HOME set to a directory the sandbox can read and write, and give the agent the same two settings; or connect the agent to Basecamp's hosted MCP server (https://basecamp.com/ai). A token you already have also works in BASECAMP_TOKEN
It doesn't send anyone to create a personal access token. A test pins that.
-
CheckAuthenticatedreturns that error for a refused store. Before, it returnedfalse, nil, so the wizard,people,mcpand completion gates read a refused store as "nothing stored" and offered a login. -
README: a new "AI agent sandboxes" section under Authentication. It covers:
- running outside the sandbox (approval prompt or rule);
- the hosted MCP server, linking https://basecamp.com/ai;
- a file-stored login in a directory the sandbox can write (
BASECAMP_NO_KEYRING=1plusXDG_CONFIG_HOME). It warns that the directory must be writable, because a refresh that can't save its rotated token loses the login, and that it must stay out of git; BASECAMP_TOKENonly for a token the person already has;- egress-proxy notes:
BASECAMP_OAUTH_USE_PROXY=1(exactly1),SSL_CERT_FILE, and #849.
It also links #800, which asks for
BASECAMP_NO_KEYRINGto be documented. -
README, existing "Personal access tokens" section: it presented personal access tokens as available to anyone, which is misleading for customers. The token page isn't open to every account yet, which I checked in bc3's
My::AccessTokensController. I fixed this here because it's a one-sentence change on the same topic: the section now says tokens aren't available on every account and points to the OAuth login otherwise. -
basecamp-doctor skill: one remediation bullet for the refused-store error. It tells an agent to relay the error's hint instead of looping on
auth login, and never to send the user off to create a personal access token.
Deliberately not done: a silent plain-file fallback
One tempting fix is to also keep a plaintext copy of every keyring login, so a sandbox that refuses the keyring can still read it. That isn't in this PR:
- It would put every user's refresh token on disk in plaintext to serve the minority who run inside a sandbox. That undoes the reason the keyring is the default.
- It wouldn't reliably work. A sandbox that refuses the vault often limits filesystem access too, and Codex limits writes to the workspace. A copy it can read but not rewrite breaks at the first refresh, because the refresh token rotates.
- Two stores holding one rotating refresh token can drift apart. One refreshes, and the other's copy becomes a replayed token that Basecamp treats as theft and signs the whole login out (see "Containers, CI and scheduled jobs").
Choosing file storage stays explicit (BASECAMP_NO_KEYRING=1), and the new README section explains when to choose it and what it costs.
Not covered
- A keyring that is unavailable rather than refused, such as a missing D-Bus or a locked keychain with no prompt, keeps the login hint. On a host with no keyring, a login is the right remedy because it falls back to file storage and works. Only a permission refusal changes the remedy.
auth statusanddoctorstill useIsAuthenticated(a bool) and report a refused store as "not logged in". That could be a follow-up.- No Windows CI runs these tests. The Windows classification rests on the stdlib
syscall.Errno.Ismapping ofERROR_ACCESS_DENIED, and on go-keyring/wincred returning thatErrnounwrapped fromCredWrite. I checked both in source (go-keyring v0.2.8, wincred v1.2.3), andGOOS=windows go vetand a test build pass.
Verification
- Wrote the new tests first and watched them fail on
main:TestAccessTokenWhenTheKeyringRefusesAccessNamesTheRefusal: the message lacked "refused access", and the hint wasRun: basecamp auth login.TestCredentialStoreRefusedHintNamesRemediesAnyoneCanTake: failed against this PR's first hint, which pointed to the personal-access-token page.TestCheckAuthenticatedReportsARefusedStore: returnedfalse, nil.TestAccessTokenWithNothingStoredStillSaysLogin, the control, passes both before and after. That pins the classification to refusals only.
- End to end on Linux through the real credstore and go-keyring chain. I pointed
DBUS_SESSION_BUS_ADDRESSat a socket in a mode-000 directory, so the probe getsconnect: permission denied.- v0.13.0:
auth_required, hintRun: basecamp auth login. - This branch:
auth_required, message "…the system credential store refused access: …permission denied…", and the hint above. - File-storage remedy: with the same refused keyring and
XDG_CONFIG_HOMEpointing at a directory holding a file-stored login, the CLI reads the file and sends the request. This worked both with and withoutBASECAMP_NO_KEYRING=1on the agent's side.
- v0.13.0:
bin/cipasses locally.