Stop concurrent basecamp processes from losing a credential, and let an agent authenticate
Run several basecamp processes against one profile — a connector beside a
handful of basecamp mcp -P <agent> workers, a shell with xargs -P, a script
running commands in parallel — and credentials go missing. The two shapes it
takes are token refresh failed: and Not authenticated for profile: on a
profile that was logged in a moment ago. Both are read-modify-write races over
the credential store, and nothing kept the processes out of each other's way.
The other half of this: a Basecamp agent has no person behind it, no browser to
sign in with, and is granted no refresh token at all. Neither login flow the CLI
has applies to it, and the refresh path has nothing to work with.
Originally tracked in CLI auth: cross-process refresh lock and client_credentials grant.
Why
The connector will run beside several worker processes sharing one profile, so
the first failure stops being an occasional annoyance and becomes the normal
case. And it authenticates as a Basecamp agent principal, which the CLI cannot
do at all today — the bot-user device-flow profile is a stand-in.
What
The races. The authorization server rotates the refresh token on every
grant, so two processes refreshing at once leave one of them holding a token the
server has already retired — and the credential is gone at the next command.
Separately, the keyring fallback keeps every profile in one credentials.json
and rewrites the whole document on each write, so two processes saving
different profiles at the same time drop one of them outright.
Both are fixed by holding a lock across the whole cycle rather than around the
write, and the two cycles have different extents, so there are two. A per-key
lock spans load → renew → save, network round trip included; a whole-store lock
covers the file backend's document rewrite and nothing else. Every mutation of a
credential now runs under the first: the refresh path, a forced auth refresh,
both interactive logins, an imported token, the identity writeback, logout and
revoke. The renewal path also re-reads and re-checks the expiry under the
lock, so twenty concurrent commands make one token request between them rather
than twenty; a credential comfortably inside its lifetime takes no lock at all.
The lock is an flock(2) on a file in the config directory, not a lock on the
store — which is usually the OS keyring, where a file lock would guard nothing.
What has to be serialized is the CLI's own critical section, and every process
reaches it through the same code whatever backend holds the bytes. The kernel
drops an flock when the holder's descriptor closes, process death included, so
there is no stale lock to reap. Three limits are documented in lock.go, all of
which degrade to the behavior that shipped before rather than to something
worse: filesystems whose flock is local-only or a no-op, a host where the lock
cannot be created at all (warn once and run unsynchronized, because failing
closed would break setups that work today), and processes that disagree about
XDG_CONFIG_HOME while sharing a keyring.
Agents. A new credential kind whose durable credential is the OAuth client
itself: a client id and secret that mint a one-hour self-token, stored together
in the keyring where one is available, because they — not a refresh token — are
what survives an expiry. Renewal dispatches on the kind: an agent mints another
token, everything else refreshes as before. Nothing is written unless the mint
succeeded and nothing is ever deleted, so a server having a bad minute leaves the
client credentials in place for the next command instead of emptying the store
and asking for a login. Logout forgets an agent credential without attempting a
revocation and says why: the same client would mint another, so rotating the
secret in Basecamp is what ends an agent's access.
basecamp auth login --with-client-credentials --client-id <id> -P agent --account 999
is how the credential gets there, with the secret on stdin — never an argument,
so it stays out of shell history and the process table. It is the shape
--with-token already established for machine identities and now shares its
profile handling.
Out of scope
The grant is minted by the connection ceremony bc3 PR 12314 provides, which is
not merged. Nothing here has been exercised against production: it is built
to the documented shape and covered against a local token endpoint. The live
check waits on that PR and on card 04; until then the bot-user device-flow
profile stays the fallback.
Testing
The concurrency proof runs twenty real processes, not goroutines and not
mocks: the test binary re-invokes itself, each child stops at a barrier, and the
parent releases all of them at once only after every one has reported ready —
without that, the scheduler is free to run each to completion before the next
starts and a test of concurrency never becomes concurrent. The mock
authorization server rotates the refresh token and refuses a spent one, so a
lost rotation is counted rather than tolerated: twenty usable tokens, one grant
between them, no stale presentations, and the credential intact afterwards.
A second test does the same for twenty different profiles saving at once, and a
third holds the lock from the parent and shows a real child's save blocked until
it is released. Every one of them was checked by sabotage — with the locks made
no-ops they fail, three runs out of three, with exactly the two errors from the
report.
Around that: the lock's own behavior (held across the network round trip, a dead
holder's lock is free immediately, an unlockable host proceeds, a canceled save
waiting on the lock writes nothing), the agent grant (mints instead of
refreshing, re-mints only on expiry, keeps the credential when a mint fails,
refuses an unsafe stored endpoint, refuses a redirect, classifies a refusal
against a rate limit against a server fault), and the command surface (creates
the profile, JSON envelope, stores nothing when refused, the secret never
echoed, bad stdin, flag exclusivity) — plus e2e coverage for the new flags.
bin/ci is green, and go test -race ./internal/auth/ -count=2 is clean.
Locally that is everything except lint-actions, which needs actionlint and
zizmor; no workflow files are touched.
Review
Reviewed adversarially with Codex over three rounds — lock scope versus the real
read-modify-write window, a process dying or hanging under the lock, NFS and
flock caveats, the keyring path where a file lock guards nothing, expiry races
and clock skew on re-mint, whether a failed mint can wipe a good credential, and
whether the concurrency test proves what it claims. Eight findings applied and
two argued down on the record; the last commit is that work.