Skip to content

feat(server): claude.credentials — use the login this machine already has (#691) - #696

Merged
edspencer merged 3 commits into
mainfrom
feat/691-credentials-lever
Aug 5, 2026
Merged

feat(server): claude.credentials — use the login this machine already has (#691)#696
edspencer merged 3 commits into
mainfrom
feat/691-credentials-lever

Conversation

@edspencer

Copy link
Copy Markdown
Owner

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 sets
that variable, a macOS claude /login is invisible. On a Mac with no token in the
environment 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.

claude:
  transcripts: own    # own | host — default own   (step 2)
  credentials: host   # own | host — default host  (this PR)

The default is host, deliberately against the pattern

Everything 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 own recreates #683 for every Mac user who has never exported a token.
The guarantee own everywhere buys — nothing outside the data dir is written — is
untouched by it. That reasoning is in claude-credentials.ts where the default is
declared, not only in the docs.

Mechanism, re-verified against the SDK bundle

sdk.mjs builds the service name as

var _z = "-credentials";
function yz(e = "") {
  let t = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR,
      r = t !== undefined ? !t : !process.env.CLAUDE_CONFIG_DIR,
      n = t !== undefined ? t.normalize("NFC") : Yt(),
      o = r ? "" : `-${sha256(n).digest("hex").substring(0, 8)}`;
  return `Claude Code${OAUTH_FILE_SUFFIX}${e}${o}`;
}

Checked rather than assumed: OAUTH_FILE_SUFFIX is "" in the production OAuth
config (the -custom-oauth / -local-oauth variants are dev paths), and yz has
exactly two occurrences in the bundle — its definition and one darwin-only
caller that runs security find-generic-password -a $USER -w -s <service>. So the
name is exactly Claude Code-credentials, #686's probe was right, and the
variable is inert on Linux rather than merely harmless.

credentials: hostCLAUDE_SECURESTORAGE_CONFIG_DIR=""; own ⇒ unset.

What I changed

  • claude-credentials.ts (new) — the mode, the default and a pure
    applyCredentialsMode(), mirroring transcripts.ts. An operator who set the
    variable themselves to a non-empty value is honoured rather than clobbered
    (herdctl#423's courtesy); an empty value is ours by construction, so own
    clears it.
  • config.tsclaude.credentials, PADDOCK_CLAUDE_CREDENTIALS, env > file

    default.

  • claude-home.ts — applies the mode to the environment before the fleet
    starts; splits .credentials.json out of BRIDGED_ENTRIES; updates the probe's
    "unverified" comment; teaches the boot notice about the lever.
  • instance-config.ts — read-only Settings field, like transcripts.
  • Docs: 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 each
mode, builds the env with the exact herdctl function the SDK runtime uses
(withClaudeConfigDir, whose result replaces the child's environment), and then
spawns a real child process with it:

host → child sees ""    (and CLAUDE_CONFIG_DIR still Paddock's own home)
own  → child sees unset

The child process is the point. "" is not a value most environment plumbing is
careful with — Windows drops empty variables entirely, and had Node done the same
on POSIX the lever would silently degrade to own, failing exactly like #683
again. An assertion against an object in the test process would have skipped that.

Things that contradict #691 / the brief

  1. There is no per-invocation seam, so this is process.env. The brief points
    at withClaudeConfigDir as 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 and toSDKOptions never sets env; the CLI runtime
    passes only {CLAUDE_CONFIG_DIR} to execa. FleetManagerOptions has no env
    field, 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.

  2. 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 Linux
    and in the Docker image the login is ~/.claude/.credentials.json, which
    BRIDGED_ENTRIES symlinked in unconditionally — so credentials: own would
    have claimed isolation while still reading the user's token, on the platform CI
    and this box run. I moved .credentials.json out of BRIDGED_ENTRIES and gated
    it on the same key, and made own withdraw a link a previous host boot
    planted (a real .credentials.json in Paddock's own home is never touched).
    Default host means no existing install changes behaviour. Slightly beyond the
    brief's scope, flagged rather than done quietly.

  3. 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 found here while Claude
    Code would miss it — a service running under a different USER than the
    terminal 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

  • The Keychain lookup is not tested and is not simulated. This box and CI are
    Linux; there is no security binary and no Keychain, and the lookup happens
    inside 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.
  • Local E2E did not run. npm run build fails on the pre-existing
    reapChatSession typecheck error (installed @herdctl/core@5.29.1 against a
    declared ^5.31.0 — environmental, present on main, not mine), and the E2E
    harness serves dist/. CI installs fresh, so E2E runs there; see the checks.
  • mcpServers / instructions / hooks are untouched (steps 4–5), including
    the rest of BRIDGED_ENTRIES. Only the credential entry moved.
  • Whether MCP OAuth tokens follow credentials: hostDesign: independent levers for what Paddock shares with the host's Claude Code (transcripts / credentials / MCP / instructions / hooks) #691 lists this as the
    first thing to test. Not tested: it needs an authenticated MCP server on a Mac.

Verification

  • env -u NODE_ENV npm test1738 server + 941 web, all green (baseline
    1714 + 941; 24 new).
  • npx tsc --noEmit -p packages/server — clean apart from the pre-existing
    reapChatSession error above.
  • E2E: CI only, for the reason above.

Closes nothing on its own; #691 stays open for steps 4–6.

… 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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

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

View logs

HomeLab Agent and others added 2 commits August 5, 2026 16:49
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant