feat(server): claude.credentials — use the login this machine already has (#691) - #696
Merged
Conversation
… has (#691) Step 2 made paddock always own its Claude home and knowingly regressed #683: Claude Code files its secure-storage entry under a service name derived from CLAUDE_CONFIG_DIR, so setting that variable hides a macOS `claude /login`. On a Mac with no token in the environment that is an instance which boots cleanly, reports itself ready, and fails every turn with "Not logged in". This is step 3 of #691's sequence: the login gets shared back without the home moving. - `claude: { credentials: own | host }`, overridable by PADDOCK_CLAUDE_CREDENTIALS (env > file > default, as usual). - **Default `host`, deliberately against the pattern.** Isolation is about writes: reading a keychain entry creates, moves and deletes nothing, while defaulting `own` recreates #683 for everyone who never exported a token. Said in the code where it is implemented, not only in the docs. - `host` sets CLAUDE_SECURESTORAGE_CONFIG_DIR="" in the environment the runtime gets. When that variable is DEFINED it overrides CLAUDE_CONFIG_DIR for secure storage only, and the empty value drops the path-hash suffix — so Claude Code reads the plain `Claude Code-credentials` entry while its config dir stays at paddock's own home. Re-read out of the SDK bundle rather than taken on trust. - On Linux/Docker the same key is the `.credentials.json` bridge, which is why it moved out of BRIDGED_ENTRIES: one config key has to mean the same thing on both platforms, and `own` also WITHDRAWS a link a previous `host` boot planted. A real credentials file in paddock's own home is never touched. - The darwin probe's service name, shipped in #686 flagged as unverified, is now confirmed exact; the boot notice knows about the lever, so a found Keychain login under `host` is reported as the login in use rather than warned about. Process-wide `process.env` is the only seam available: herdctl builds the SDK's options.env itself and the CLI runtime lets execa merge over the inherited environment, and neither takes an env contribution from paddock. Safe here in a way a Claude home would not be — credentials are a property of the instance, so every agent in it has the same answer. NOT verified, and not faked in a test: the Keychain lookup itself. This box and CI are Linux. The tests assert the environment paddock hands the runtime, and carry it through a real spawn to prove the empty string survives as an empty string rather than as absent — the one OS-level fact the mechanism rests on. Co-Authored-By: Claude <noreply@anthropic.com>
Deploying paddock with
|
| Latest commit: |
5b05e35
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://df7a020a.paddock-7u2.pages.dev |
| Branch Preview URL: | https://feat-691-credentials-lever.paddock-7u2.pages.dev |
… an ambient var An operator-set CLAUDE_SECURESTORAGE_CONFIG_DIR wins over the config key, so a dev box that exports one would quietly change which login the E2E instance runs on — the same reason the harness already clears CLAUDE_CONFIG_DIR. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Merged
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 3 of #691. Step 2 (#695) made Paddock always own its Claude home and
knowingly regressed #683 on the way: Claude Code files its secure-storage entry
under a service name derived from
CLAUDE_CONFIG_DIR, so the moment Paddock setsthat variable, a macOS
claude /loginis invisible. On a Mac with no token in theenvironment that is an instance which boots cleanly, reports itself ready, and
then fails every single turn with "Not logged in". This gets the login back
without giving the home back.
The default is
host, deliberately against the patternEverything else in the block defaults
own. Isolation is about writes.Reading a Keychain entry creates, moves and deletes nothing of the user's, while
defaulting
ownrecreates #683 for every Mac user who has never exported a token.The guarantee
owneverywhere buys — nothing outside the data dir is written — isuntouched by it. That reasoning is in
claude-credentials.tswhere the default isdeclared, not only in the docs.
Mechanism, re-verified against the SDK bundle
sdk.mjsbuilds the service name asChecked rather than assumed:
OAUTH_FILE_SUFFIXis""in the production OAuthconfig (the
-custom-oauth/-local-oauthvariants are dev paths), andyzhasexactly two occurrences in the bundle — its definition and one darwin-only
caller that runs
security find-generic-password -a $USER -w -s <service>. So thename is exactly
Claude Code-credentials, #686's probe was right, and thevariable is inert on Linux rather than merely harmless.
credentials: host⇒CLAUDE_SECURESTORAGE_CONFIG_DIR="";own⇒ unset.What I changed
claude-credentials.ts(new) — the mode, the default and a pureapplyCredentialsMode(), mirroringtranscripts.ts. An operator who set thevariable themselves to a non-empty value is honoured rather than clobbered
(herdctl#423's courtesy); an empty value is ours by construction, so
ownclears it.
config.ts—claude.credentials,PADDOCK_CLAUDE_CREDENTIALS, env > fileclaude-home.ts— applies the mode to the environment before the fleetstarts; splits
.credentials.jsonout ofBRIDGED_ENTRIES; updates the probe's"unverified" comment; teaches the boot notice about the lever.
instance-config.ts— read-only Settings field, liketranscripts.environment.md,config-file.md,getting-started.md,NPM-README.md,CLI
USAGE,CLAUDE.md, the architecture env table. Changeset included.The most important test
test/integration/claude-credentials-env.test.ts— boots a real app in eachmode, builds the env with the exact herdctl function the SDK runtime uses
(
withClaudeConfigDir, whose result replaces the child's environment), and thenspawns a real child process with it:
The child process is the point.
""is not a value most environment plumbing iscareful with — Windows drops empty variables entirely, and had Node done the same
on POSIX the lever would silently degrade to
own, failing exactly like #683again. An assertion against an object in the test process would have skipped that.
Things that contradict #691 / the brief
There is no per-invocation seam, so this is
process.env. The brief pointsat
withClaudeConfigDiras the precedent for injecting a var per invocation —but that is herdctl injecting into itself. Paddock cannot contribute to it:
the SDK runtime calls
withClaudeConfigDir(claudeHomePath, sdkOptions.env ?? process.env)internally andtoSDKOptionsnever setsenv; the CLI runtimepasses only
{CLAUDE_CONFIG_DIR}to execa.FleetManagerOptionshas no envfield, and herdctl's config-schema
env:keys are for Docker and MCP servers,not the agent process. So Paddock sets the variable in its own environment at
boot and both runtimes inherit it — which is safe here in a way a Claude home
would not be: herdctl's warning is about per-agent values leaking across
concurrent agents, and credentials are per-instance, one answer for every agent.
Documented at the function.
The lever would have been a no-op on Linux as scoped. The brief scopes this
to
CLAUDE_SECURESTORAGE_CONFIG_DIR, which is darwin-only in effect. On Linuxand in the Docker image the login is
~/.claude/.credentials.json, whichBRIDGED_ENTRIESsymlinked in unconditionally — socredentials: ownwouldhave claimed isolation while still reading the user's token, on the platform CI
and this box run. I moved
.credentials.jsonout ofBRIDGED_ENTRIESand gatedit on the same key, and made
ownwithdraw a link a previoushostbootplanted (a real
.credentials.jsonin Paddock's own home is never touched).Default
hostmeans no existing install changes behaviour. Slightly beyond thebrief's scope, flagged rather than done quietly.
The probe is less precise than Claude Code's own lookup. Claude Code passes
-a(process.env.USER, falling back to the OS username); the probe does not,so an entry filed under a different account reads as
foundhere while ClaudeCode would miss it — a service running under a different
USERthan theterminal that logged in. Left as-is (a false negative is the costlier one: it
downgrades a specific instruction back to the generic warning) and written down
at the function.
What I did not do, and could not
Linux; there is no
securitybinary and no Keychain, and the lookup happensinside Claude Code on darwin. No test in this PR implies otherwise — both test
files say so at the top. What is proven is everything on Paddock's side of that
line: the mode resolves, the variable is set/unset, and a real child sees it.
The end-to-end claim — that a Mac with no token in the environment now
completes a turn — is unverified and needs one run on the maintainer's Mac.
npm run buildfails on the pre-existingreapChatSessiontypecheck error (installed@herdctl/core@5.29.1against adeclared
^5.31.0— environmental, present onmain, not mine), and the E2Eharness serves
dist/. CI installs fresh, so E2E runs there; see the checks.mcpServers/instructions/hooksare untouched (steps 4–5), includingthe rest of
BRIDGED_ENTRIES. Only the credential entry moved.credentials: host— Design: independent levers for what Paddock shares with the host's Claude Code (transcripts / credentials / MCP / instructions / hooks) #691 lists this as thefirst thing to test. Not tested: it needs an authenticated MCP server on a Mac.
Verification
env -u NODE_ENV npm test— 1738 server + 941 web, all green (baseline1714 + 941; 24 new).
npx tsc --noEmit -p packages/server— clean apart from the pre-existingreapChatSessionerror above.Closes nothing on its own; #691 stays open for steps 4–6.