Problem
~/.pad/credentials.json (v2, per TASK-1228) holds exactly one credential per server URL.
That fits one human per machine, but breaks the increasingly common setup of multiple AI
coding agents sharing one machine and one CLI install, where each agent should act as its
own pad user so comments and item changes are attributed correctly.
Concrete case: Claude Code and Cursor both drive pad on the same workstation against one
self-hosted server. Whichever user is logged in "wins" — both agents write as that user.
The only workaround inside the CLI is pad auth logout / pad auth login around every
run, which is unusable when agents run concurrently (the credentials file is a shared
mutable singleton).
The pieces are almost all there already:
- The server accepts long-lived
pad_ API tokens on every endpoint
(internal/server/middleware_auth.go), with user-scoped mint/rotate/revoke endpoints and
a web settings UI — but the CLI has no way to select a token other than the single
stored login.
- The v2 store comment in
internal/cli/credentials.go explicitly anticipates this:
"The v2 schema can be extended later if a use case shows up; the current map shape is
forward compatible." This is that use case.
Workaround today (for anyone hitting this before a fix lands)
Credential entries are keyed by the literal URL string (normalizeServerURL strips only
whitespace and trailing slashes — no hostname canonicalization), so two different URLs
reaching the same server get independent entries. Combined with the PAD_URL env override
(internal/config/config.go), each agent can hold its own login:
- Add a hosts-file/DNS alias for the pad server, e.g.
pad-cursor → 192.168.1.50.
- In the second agent's environment only:
PAD_URL=http://pad-cursor:7777, then
pad auth login -i as the second pad user (-i avoids the browser flow, which would
authenticate as whoever is signed into the web UI).
credentials.json now holds entries for both http://192.168.1.50:7777 and
http://pad-cursor:7777; each agent resolves its own token by URL, concurrently, with
no logout/login cycling.
This works because store reads are side-effect-free and login only Set()s the entry for
the URL being logged into. But it hides identity selection inside infrastructure config
(hosts/DNS), which is the gap this issue proposes to close properly.
Proposed fix (two independent PRs, smallest first)
1. PAD_TOKEN environment override
If PAD_TOKEN is set, the CLI uses it as the bearer token and skips the credential-store
lookup — same convention as gh's GH_TOKEN. Each agent process gets its own API token in
its environment; zero file contention, no schema change.
Touch points:
internal/cli/client.go — check PAD_TOKEN in NewClientFromURL before the
LoadStore() block (~line 64).
cmd/pad/cmd_auth.go — whoami currently short-circuits on the store lookup before
building a client; it should honor PAD_TOKEN and report the effective identity.
login/logout keep managing the store but print a warning when PAD_TOKEN is set
(again matching gh).
2. Named credential profiles + --profile flag
Bump the store to v3: each server key holds a map of named profiles, with default as the
implicit profile so single-user files migrate transparently (same probe-and-migrate pattern
LoadStore() already uses for v1 → v2):
{
"version": 3,
"credentials": {
"https://app.getpad.dev": {
"profiles": {
"default": { "token": "...", "user_id": "..." },
"cursor": { "token": "...", "user_id": "..." }
}
}
}
}
- Selection is stateless, per invocation:
--profile persistent flag >
PAD_PROFILE env > default. No persisted "current profile" and no pad auth switch —
consistent with v2's deliberate rejection of a top-level default pointer
(split-brain avoidance).
pad auth login --profile cursor, pad auth logout --profile cursor, whoami prints
the active profile when non-default.
- Store API: add
GetProfile(url, name); Get(url) delegates to
GetProfile(url, "default") so existing call sites are untouched.
- Reads stay side-effect-free; single-profile users see zero behavior change.
Alternatives considered
- Logout/login per invocation — racy under concurrency (motivating problem).
- URL aliasing (the workaround above) — works today, but requires DNS/hosts control
and hides identity in infrastructure config.
- Flat composite keys (
"<url>#profile") — pollutes the URL keyspace and breaks
Get(url) semantics; nested profile map is cleaner to migrate.
.pad.toml agent_name / X-Pad-Agent — attribution metadata only; doesn't change
the authenticated user.
Happy to open a draft PR for (1) first if the approach looks right, with (2) as a follow-up.
Suggested labels (cannot apply as outside contributor): area:cli, enhancement, effort:M
Problem
~/.pad/credentials.json(v2, per TASK-1228) holds exactly one credential per server URL.That fits one human per machine, but breaks the increasingly common setup of multiple AI
coding agents sharing one machine and one CLI install, where each agent should act as its
own pad user so comments and item changes are attributed correctly.
Concrete case: Claude Code and Cursor both drive
padon the same workstation against oneself-hosted server. Whichever user is logged in "wins" — both agents write as that user.
The only workaround inside the CLI is
pad auth logout/pad auth loginaround everyrun, which is unusable when agents run concurrently (the credentials file is a shared
mutable singleton).
The pieces are almost all there already:
pad_API tokens on every endpoint(
internal/server/middleware_auth.go), with user-scoped mint/rotate/revoke endpoints anda web settings UI — but the CLI has no way to select a token other than the single
stored login.
internal/cli/credentials.goexplicitly anticipates this:"The v2 schema can be extended later if a use case shows up; the current map shape is
forward compatible." This is that use case.
Workaround today (for anyone hitting this before a fix lands)
Credential entries are keyed by the literal URL string (
normalizeServerURLstrips onlywhitespace and trailing slashes — no hostname canonicalization), so two different URLs
reaching the same server get independent entries. Combined with the
PAD_URLenv override(
internal/config/config.go), each agent can hold its own login:pad-cursor→192.168.1.50.PAD_URL=http://pad-cursor:7777, thenpad auth login -ias the second pad user (-iavoids the browser flow, which wouldauthenticate as whoever is signed into the web UI).
credentials.jsonnow holds entries for bothhttp://192.168.1.50:7777andhttp://pad-cursor:7777; each agent resolves its own token by URL, concurrently, withno logout/login cycling.
This works because store reads are side-effect-free and login only
Set()s the entry forthe URL being logged into. But it hides identity selection inside infrastructure config
(hosts/DNS), which is the gap this issue proposes to close properly.
Proposed fix (two independent PRs, smallest first)
1.
PAD_TOKENenvironment overrideIf
PAD_TOKENis set, the CLI uses it as the bearer token and skips the credential-storelookup — same convention as
gh'sGH_TOKEN. Each agent process gets its own API token inits environment; zero file contention, no schema change.
Touch points:
internal/cli/client.go— checkPAD_TOKENinNewClientFromURLbefore theLoadStore()block (~line 64).cmd/pad/cmd_auth.go—whoamicurrently short-circuits on the store lookup beforebuilding a client; it should honor
PAD_TOKENand report the effective identity.login/logoutkeep managing the store but print a warning whenPAD_TOKENis set(again matching
gh).2. Named credential profiles +
--profileflagBump the store to v3: each server key holds a map of named profiles, with
defaultas theimplicit profile so single-user files migrate transparently (same probe-and-migrate pattern
LoadStore()already uses for v1 → v2):{ "version": 3, "credentials": { "https://app.getpad.dev": { "profiles": { "default": { "token": "...", "user_id": "..." }, "cursor": { "token": "...", "user_id": "..." } } } } }--profilepersistent flag >PAD_PROFILEenv >default. No persisted "current profile" and nopad auth switch—consistent with v2's deliberate rejection of a top-level default pointer
(split-brain avoidance).
pad auth login --profile cursor,pad auth logout --profile cursor,whoamiprintsthe active profile when non-default.
GetProfile(url, name);Get(url)delegates toGetProfile(url, "default")so existing call sites are untouched.Alternatives considered
and hides identity in infrastructure config.
"<url>#profile") — pollutes the URL keyspace and breaksGet(url)semantics; nested profile map is cleaner to migrate..pad.tomlagent_name/X-Pad-Agent— attribution metadata only; doesn't changethe authenticated user.
Happy to open a draft PR for (1) first if the approach looks right, with (2) as a follow-up.
Suggested labels (cannot apply as outside contributor):
area:cli,enhancement,effort:M