Skip to content

fix(store): bridge credentials I/O to macOS keychain - #8

Open
maciborka wants to merge 1 commit into
Leuconoe:mainfrom
maciborka:fix/macos-keychain-credentials
Open

fix(store): bridge credentials I/O to macOS keychain#8
maciborka wants to merge 1 commit into
Leuconoe:mainfrom
maciborka:fix/macos-keychain-credentials

Conversation

@maciborka

@maciborka maciborka commented Jul 16, 2026

Copy link
Copy Markdown

Problem

On macOS, Claude Code 2.x stores OAuth credentials in the login keychain (service Claude Code-credentials), not in the legacy ~/.claude/.credentials.json file. The switcher only read/wrote that file, so on a default macOS install:

  • cc-switch / cc-sync-oauth crashed with ENOENT: ... open '~/.claude/.credentials.json'
  • even if a switch "succeeded", it wrote a file Claude Code never reads, so the active account never actually changed.

This is #3.

Root cause

lib/store/io.cjs hardcoded the file path and used fs for all credential I/O (getDefaultCredentialsPath, readJson, writeJson, backupFile). No keychain code path existed.

Fix

Bridge the credential I/O to the keychain via the security CLI when the credentials file is absent on macOS, keeping full backward compatibility for older Claude Code / Linux / Windows (where the file still exists).

lib/store/io.cjs:

  • credentialsUseKeychain(path) — true when process.platform === 'darwin' and the credentials file does not exist
  • readCredentials(path) — keychain read when the keychain case applies, otherwise readJson
  • writeCredentials(path, value, backupDir) — keychain write (with backup) when the keychain case applies, otherwise the existing file write
  • backupKeychainCredentials(backupDir) — dumps the current keychain value to the backup dir before overwriting (the file backupFile step was a no-op when no credentials file existed)
  • writeLiveState now uses writeCredentials, so switching actually updates the keychain entry Claude Code reads

cc-switch.cjs reads credentials via readCredentials instead of readJson.

The stored keychain value already has the shape the tool expects ({ claudeAiOauth, ... }credentials.claudeAiOauth), so only the I/O layer changed; the rest of the switch logic works unchanged.

Verified

On macOS with Claude Code 2.1.204 (credentials only in keychain, no .credentials.json):

Step Before After
cc-switch (list) ENOENT crash lists accounts
cc-sync-oauth ENOENT crash syncs account with claudeAiOauth tokens
keychain no-op write n/a keychain entry stable, backup produced

Closes #3.

Happy to adjust the approach (e.g. prefer keychain unconditionally on darwin, or add a config flag) if you'd prefer different behavior. The approach from the issue — bridging I/O while preserving the file path as a fallback — felt like the least invasive option.

Summary by CodeRabbit

  • New Features
    • macOS users can securely store credentials in Keychain when no credential file exists.
    • Credential backups are created automatically, retaining the three most recent versions.
  • Bug Fixes
    • Improved credential loading errors with clearer messages when Keychain access fails.
    • Existing credential files and non-macOS storage behavior remain supported.

On macOS, Claude Code 2.x stores OAuth credentials in the login
keychain (service "Claude Code-credentials") instead of the legacy
~/.claude/.credentials.json file. The switcher only read/wrote that
file, so on a default macOS install it crashed with ENOENT and switches
had no effect (Claude Code never reads the file it wrote).

Bridge the credential I/O in lib/store/io.cjs:
- readCredentials / writeCredentials detect the keychain case
  (darwin + credentials file absent) and use the "security" CLI,
  otherwise fall back to the existing file path — keeping full
  backward compatibility for older Claude Code / Linux / Windows.
- writeLiveState now uses writeCredentials, so switching actually
  updates the keychain entry Claude Code reads.
- backupKeychainCredentials dumps the current keychain value to the
  backup dir before overwriting (the file backupFile step was a no-op
  when no credentials file existed).

cc-switch.cjs reads credentials via readCredentials instead of readJson.

Verified on macOS with Claude Code 2.1.204: cc-switch, cc-sync-oauth,
list and the no-op write path all work; the keychain entry stays stable
and a backup is produced.

Fixes Leuconoe#3
@Leuconoe

Leuconoe commented Aug 8, 2026

Copy link
Copy Markdown
Owner

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds macOS Keychain-backed credential I/O. Credential reads and writes use the Keychain when the credential file is absent on Darwin. Other platforms and existing credential files continue to use JSON storage.

Changes

Credential storage

Layer / File(s) Summary
Keychain credential handlers
lib/store/io.cjs
The store detects macOS, reads and writes the Claude Code-credentials Keychain item through security, creates timestamped backups, retains the three newest backups, and exports readCredentials and writeCredentials.
Credential loading and live-state writing
cc-switch.cjs, lib/store/io.cjs
cc-switch loads credentials with readCredentials. writeLiveState persists credentials through writeCredentials.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant cc-switch
  participant storeIo
  participant security
  cc-switch->>storeIo: readCredentials(credentialsPath)
  storeIo->>security: read Claude Code-credentials
  security-->>storeIo: credential JSON
  storeIo-->>cc-switch: parsed credentials
  storeIo->>security: write updated credential JSON
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: connecting credential I/O to the macOS Keychain.
Linked Issues check ✅ Passed The implementation reads, writes, and backs up macOS Keychain credentials while preserving file-based behavior and credential structure [#3].
Out of Scope Changes check ✅ Passed All changes support macOS Keychain credential handling or update callers to use the credential-aware I/O functions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch fix/macos-keychain-credentials
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@Leuconoe

Leuconoe commented Aug 8, 2026

Copy link
Copy Markdown
Owner

@maciborka I'm sorry. For some unknown reason, I didn't receive the PR notification. Since there have been changes, please resolve the conflicts and resubmit the PR. Thank you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/store/io.cjs`:
- Around line 76-82: Update backupKeychainCredentials to distinguish a confirmed
missing Keychain item from other read failures: return without writing only for
the established “not found” condition, and propagate malformed JSON,
access-denied, and command errors instead of swallowing them. Ensure
writeCredentials cannot overwrite the existing item when readKeychainCredentials
fails for any reason other than absence.
- Around line 47-54: Update keychainAccount to capture stderr and distinguish an
explicitly missing Keychain item from other failures. Return null only when
security reports that the item is absent; otherwise propagate access,
authorization, command, and account-parsing errors so the caller cannot proceed
with an unsafe fallback account.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a3cde619-7049-478a-aca6-a73120c61f12

📥 Commits

Reviewing files that changed from the base of the PR and between 8c3dc67 and 3e59d8f.

📒 Files selected for processing (2)
  • cc-switch.cjs
  • lib/store/io.cjs

Comment thread lib/store/io.cjs
Comment on lines +47 to +54
function keychainAccount() {
try {
const out = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE], { encoding: 'utf8' });
const match = out.match(/"acct"<blob>="([^"]*)"/);
return match ? match[1] : null;
} catch {
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

if [[ "$(uname -s)" != "Darwin" ]]; then
  echo "Run this check on a macOS host with Claude Code credentials."
  exit 0
fi

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT

security find-generic-password -s 'Claude Code-credentials' \
  >"$tmpdir/stdout" 2>"$tmpdir/stderr"

if grep -q '"acct"<blob>' "$tmpdir/stdout"; then
  echo 'Account metadata is written to stdout.'
elif grep -q '"acct"<blob>' "$tmpdir/stderr"; then
  echo 'Account metadata is written to stderr.'
else
  echo 'Account metadata was not found in either stream.'
  exit 1
fi

Repository: Leuconoe/ClaudeCodeMultiAccounts

Length of output: 231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- lib/store/io.cjs ---'
cat -n lib/store/io.cjs | sed -n '1,140p'

printf '%s\n' '--- related security calls and consumers ---'
rg -n -C 3 "keychainAccount|find-generic-password|add-generic-password|execFileSync|backup" lib

Repository: Leuconoe/ClaudeCodeMultiAccounts

Length of output: 11035


🌐 Web query:

macOS security find-generic-password output acct blob stdout stderr

💡 Result:

To retrieve a password from the macOS keychain using the security command-line tool, you primarily use the find-generic-password command [1][2]. Retrieving the Password To output only the password string to standard output (stdout), use the -w flag [1][2]: security find-generic-password -s "YourServiceName" -a "YourAccountName" -w Handling macOS 26 (Tahoe) Hex Encoding As of macOS 26 (Tahoe), the output format of security find-generic-password -w has changed [3]. If the stored password contains binary data or non-ASCII characters, it is now returned as a HEX-encoded string [3]. If you encounter this hex-encoded output in scripts, you must decode it to retrieve the original data [3]. You can use xxd to perform this conversion [3]: # Decode hex output back to binary security find-generic-password -s "YourServiceName" -w | xxd -r -p Additional Options -g: When used instead of or in addition to -w, this flag requests that the password be displayed [1]. Historically, -g outputs metadata and the password in a structured text format, often triggering a GUI prompt to authorize access [4][5]. -a (account): Specifies the account name associated with the generic password [1]. -s (service): Specifies the service name associated with the generic password [1]. Important Considerations - Access Control: The first time the security tool attempts to access a specific keychain item, you will be prompted to grant it permission [2]. You can grant persistent access via the prompt or by adding -T /usr/bin/security when initially creating the item [2]. - Duplicate Entries: The security command returns the first entry it finds that matches the specified -s (service) and -a (account) attributes [6]. If multiple entries exist for the same service but different accounts, or if duplicates have been created, querying by service alone may lead to unpredictable results [6]. - Security Note: Be cautious when storing retrieved passwords in environment variables, as they may be visible to other processes via tools like ps -E [7].

Citations:


🌐 Web query:

Node.js execFileSync return value stdout stderr documentation

💡 Result:

The execFileSync method in Node.js returns the stdout from the executed command as a Buffer or string [1][2][3]. Key behavioral details include: Return Value: When successful, it returns the stdout of the process [1][2]. The type of the return value depends on the encoding option provided; if an encoding (e.g., 'utf8') is specified, it returns a string, otherwise it returns a Buffer [4][1]. Handling Stderr and Errors: 1. Default Behavior: By default, stderr from the child process is piped to the parent process's stderr and is not captured in the return value of execFileSync [4][5]. 2. Capturing Stderr: To capture stderr, you must configure the stdio option (e.g., set stdio: 'pipe') [4][3]. 3. Non-Zero Exit Codes: If the child process exits with a non-zero exit code or times out, execFileSync throws an Error [4][1]. This error object contains the full result of the process execution, including the stdout and stderr properties [4][3]. You can access these by catching the error: const { execFileSync } = require('node:child_process'); try { const stdout = execFileSync('my-script.sh', { stdio: 'pipe', encoding: 'utf8' }); } catch (err) { // If the process fails, access stdout/stderr from the error object const { stdout, stderr } = err; console.error({ stdout, stderr }); }

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- project references and tests ---'
rg -n -C 4 "Claude Code-credentials|keychain|credentialsUseKeychain|writeCredentials|backupKeychainCredentials|macOS" . \
  -g '!node_modules' -g '!dist' -g '!build'

Repository: Leuconoe/ClaudeCodeMultiAccounts

Length of output: 8063


🌐 Web query:

"acct\"<blob>" "security find-generic-password"

💡 Result:

The string acct"= appears in the output of the macOS security command when querying keychain items, specifically when using the -g (dump) flag [1][2]. It is a formatted representation of the account attribute associated with a generic password item in a keychain [3]. When you run security find-generic-password with the -g (or --dump) option, the tool outputs detailed attributes of the found keychain item, often including internal metadata tags formatted like "acct"= [1][4]. Because this output is not designed for direct machine parsing, many scripts use tools like sed, awk, or grep to isolate the account name or password by targeting this specific string pattern [1][5][6]. For example, a common approach to extract the account name from such output involves: 1. Running security find-generic-password -g -s [service_name] to retrieve the full item details [7]. 2. Piping the output to grep to find the line containing acct"= [1]. 3. Using a stream editor like sed or cut to strip away the surrounding metadata and extract only the account value [1][5][6]. Note that this output format is specific to legacy file-based keychains [8]. The security command-line tool does not have access to entries managed by the modern macOS Passwords app (or items stored in the Data Protection/iCloud Keychain backend) [8]. Additionally, extracting passwords programmatically often triggers system authorization prompts, which may require user intervention unless access has been specifically granted to the binary [3][9].

Citations:


🌐 Web query:

site:github.com "security find-generic-password -s" '"acct"<blob>'

💡 Result:

The command security find-generic-password -s 'acct' is used on macOS to retrieve a generic password item from the Keychain that matches a specific service name [1][2][3]. In this command: - find-generic-password is the subcommand used to search for a generic password item [1][4]. - -s 'acct' specifies the service name to match. The -s flag tells the security tool to filter the search by the "service" attribute associated with the keychain item [1][2][3]. While your example includes <blob>, in a functional command, you would replace 'acct' (and the surrounding syntax) with the actual name of the service registered in your Keychain [5][3]. For example, if you stored a password with the service name "MyService", you would run: security find-generic-password -s "MyService" -w The -w flag is commonly used in this context to output only the password itself to standard output, making it useful for scripts and command-line automation [1][5][3]. If you do not use the -w or -g flags, the tool typically outputs descriptive information about the keychain item rather than the password itself [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- package scripts and repository tests ---'
cat package.json
find . -maxdepth 4 -type f \( -iname '*test*' -o -iname '*spec*' \) -print

Repository: Leuconoe/ClaudeCodeMultiAccounts

Length of output: 1598


🌐 Web query:

macOS security find-generic-password without -g output attributes stdout "attributes:"

💡 Result:

To retrieve only the password from a generic keychain item using the macOS security command line tool without including extra attributes or metadata, use the -w (or --password) flag [1][2][3]. When this flag is used, the command outputs the raw password string directly to standard output (stdout) [1][2]. Example usage: security find-generic-password -s "YourServiceName" -a "YourAccountName" -w If you do not specify the -w flag, the security tool will output verbose information, including attributes, metadata, and (if the -g flag is also used) the password prefixed with text like "password: " [2][4][5]. Key points regarding this command: - The -w flag is the primary mechanism to suppress attribute output and return only the password value [2][3]. - You may still be prompted for your macOS user password (or Keychain password) to authorize access to the stored secret, depending on your system's access control settings [1][6][7]. - If the command fails to find the item, it will typically print an error to standard error (stderr) and return a non-zero exit code [6]. - Because the password is printed to stdout, ensure you handle it securely (e.g., avoid echoing it or including it in shell history files if possible) [1].

Citations:


🌐 Web query:

site:github.com "security find-generic-password" "attributes:" "acct"

💡 Result:

In the context of the macOS security command line utility, acct is a core attribute tag representing the account name associated with a password item [1]. It corresponds to the SecAccountItemAttr constant in the underlying Apple Security framework [1]. When using the security find-generic-password command, you use the -a flag to specify or match this acct attribute [2]. Usage: To find a password for a specific account and service: security find-generic-password -a <account_name> -s <service_name> -w Key points: 1. Attribute Role: The acct attribute identifies the user account (or username) linked to a keychain item [1]. 2. Command-Line Flag: The -a flag is the standard interface for the acct attribute [2][3]. 3. Output Format: When inspecting keychain items, the acct attribute is often displayed in the output as "acct" =="" [4][5]. 4. Application: It is used in generic, Internet, and AppleShare password items to uniquely identify the account [1]. For example, to retrieve only the password (-w) for a specific service and account, use: security find-generic-password -s "my-service" -a "my-username" -w [3][6]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exported store API and callers ---'
cat -n lib/store/io.cjs | sed -n '129,165p'
rg -n -C 5 "writeLiveState|writeCredentials|readCredentials" --glob '*.cjs' .

Repository: Leuconoe/ClaudeCodeMultiAccounts

Length of output: 6149


Distinguish a missing Keychain item from a failed lookup.

execFileSync returns stdout, and security find-generic-password prints "acct"<blob> in that output. Capture stderr for command diagnostics. Do not convert every lookup error into null. Use the fallback account only when the item is explicitly absent. Propagate access, authorization, and parse errors before writing the new value; otherwise the write can update the wrong item without a backup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/store/io.cjs` around lines 47 - 54, Update keychainAccount to capture
stderr and distinguish an explicitly missing Keychain item from other failures.
Return null only when security reports that the item is absent; otherwise
propagate access, authorization, command, and account-parsing errors so the
caller cannot proceed with an unsafe fallback account.

Comment thread lib/store/io.cjs
Comment on lines +76 to +82
function backupKeychainCredentials(backupDir) {
let value;
try {
value = readKeychainCredentials();
} catch {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Stop the write when Keychain backup fails.

Line 80 suppresses malformed JSON, denied Keychain access, and command failures. writeCredentials then overwrites the item at Line 105. This can destroy the only recoverable credential value without a backup.

Skip backup only when the Keychain item is confirmed absent. Propagate every other read failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/store/io.cjs` around lines 76 - 82, Update backupKeychainCredentials to
distinguish a confirmed missing Keychain item from other read failures: return
without writing only for the established “not found” condition, and propagate
malformed JSON, access-denied, and command errors instead of swallowing them.
Ensure writeCredentials cannot overwrite the existing item when
readKeychainCredentials fails for any reason other than absence.

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.

macOS: credentials live in login keychain, not ~/.claude/.credentials.json — switcher fails

2 participants