Name a refused credential store instead of asking for a login

RO
3d ago
+222-1

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. storeRefused matches any store error that wraps fs.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.Errno maps ERROR_ACCESS_DENIED to fs.ErrPermission.
    • On Unix, EACCES and EPERM map to it.
    • An error already classified as something other than an auth failure keeps its class.
  • unreadable checks this first. A refused probe also reads as ErrNoCredential, so the order matters. The error stays auth_required, and its message now says "the system credential store refused access" alongside the OS's own error. The hint is CredentialStoreRefusedHint, 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.

  • CheckAuthenticated returns that error for a refused store. Before, it returned false, nil, so the wizard, people, mcp and 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=1 plus XDG_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_TOKEN only for a token the person already has;
    • egress-proxy notes: BASECAMP_OAUTH_USE_PROXY=1 (exactly 1), SSL_CERT_FILE, and #849.

    It also links #800, which asks for BASECAMP_NO_KEYRING to 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 status and doctor still use IsAuthenticated (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.Is mapping of ERROR_ACCESS_DENIED, and on go-keyring/wincred returning that Errno unwrapped from CredWrite. I checked both in source (go-keyring v0.2.8, wincred v1.2.3), and GOOS=windows go vet and 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 was Run: basecamp auth login.
    • TestCredentialStoreRefusedHintNamesRemediesAnyoneCanTake: failed against this PR's first hint, which pointed to the personal-access-token page.
    • TestCheckAuthenticatedReportsARefusedStore: returned false, 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_ADDRESS at a socket in a mode-000 directory, so the probe gets connect: permission denied.
    • v0.13.0: auth_required, hint Run: 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_HOME pointing at a directory holding a file-stored login, the CLI reads the file and sends the request. This worked both with and without BASECAMP_NO_KEYRING=1 on the agent's side.
  • bin/ci passes locally.