fix(store): bridge credentials I/O to macOS keychain - #8
Conversation
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
|
@coderabbitai review |
✅ Action performedReview finished.
|
📝 WalkthroughWalkthroughThe 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. ChangesCredential storage
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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
Comment |
|
@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. |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
cc-switch.cjslib/store/io.cjs
| 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; | ||
| } |
There was a problem hiding this comment.
🎯 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
fiRepository: 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" libRepository: 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:
- 1: https://keith.github.io/xcode-man-pages/security.1.html
- 2: https://scriptingosx.com/2021/04/get-password-from-keychain-in-shell-scripts/
- 3: https://zenn.dev/omori432/articles/macos-tahoe-keychain-hex-encoding?locale=en
- 4: https://stackoverflow.com/questions/22370552/keychain-services-secure-notes
- 5: https://stackoverflow.com/questions/10574381/how-to-use-a-password-from-the-keychain-within-an-applescript-running-within-mai
- 6: https://brandonwie.dev/posts/macos-keychain-multi-account
- 7: https://ss64.com/mac/security-password-mgt.html
🌐 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:
- 1: https://nodejs.org/api/child_process.md
- 2: https://beta.docs.nodejs.org/child_process.html
- 3: https://github.com/nodejs/node/blob/d90d9d55/doc/api/child_process.md
- 4: https://nodejs.org/api/child_process.html
- 5: https://nodejs.org/docs/latest-v24.x/api/child_process.html
🏁 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:
- 1: https://github.com/rapid7/metasploit-framework/blob/e749733eb6118a4b089e288fc81050f76c8db5ed/modules/post/osx/gather/enum_keychain.rb
- 2: https://github.com/erran/keyjacker/blob/1650354b3620f4cf92a793e8709c318008f67c91/keyjacker.rb
- 3: https://scriptingosx.com/2021/04/get-password-from-keychain-in-shell-scripts/
- 4: https://ss64.com/mac/security-password-mgt.html
- 5: http://blog.gnu-designs.com/howto-fully-automated-zwift-login-on-mac-os-x/
- 6: https://gist.github.com/e11s/75e48e1d2982b6c8d97f9121b4eb9605
- 7: https://gist.github.com/spham/6f04640dbb14e0b58cf812f8bebba1e9
- 8: https://apple.stackexchange.com/questions/476377/how-can-i-use-security-to-search-for-entries-in-passwords-app
- 9: Import connections from Sequel Pro? Sequel-Ace/Sequel-Ace#155
🌐 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:
- 1: https://gist.github.com/tamakiii/9c3eadc493597ed819b9ff96cbcf61d4
- 2: https://gist.github.com/StevenChoo/e21037de7a007b61990815e5777e6903
- 3: https://gist.github.com/jmjohnson/3cb9bf0c4540eeed74b8d59a0dbeaf26
- 4: https://github.com/ohmyzsh/ohmyzsh/blob/51e98fad/plugins/macos/_security
- 5: https://github.com/dineshbhagat/mac-configurations/blob/master/Keychain-password.md
🏁 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*' \) -printRepository: 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:
- 1: https://noboxdev.com/blog/macos-keychain-for-developers
- 2: https://jpmens.net/2021/04/18/storing-passwords-in-macos-keychain/
- 3: https://stackoverflow.com/questions/10574381/how-to-use-a-password-from-the-keychain-within-an-applescript-running-within-mai
- 4: https://herongyang.com/Mac/Keychain-Access-Management-Commands.html
- 5: https://apple.stackexchange.com/questions/115507/visualizing-keychain-item-details
- 6: https://blog.koehntopp.info/2017/01/26/command-line-access-to-the-mac-keychain/
- 7: https://developer.apple.com/documentation/security/seckeychainfindgenericpassword(_:_:_:_:_:_:_:_:)
🌐 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:
- 1: https://gist.github.com/aadsm/50b907be5b01bac54fbb43c0f61af4f5
- 2: https://gist.github.com/tamakiii/9c3eadc493597ed819b9ff96cbcf61d4
- 3: https://gist.github.com/ChristopherA/d39eaae5e62903b9e5f66edf5dd402bd
- 4: https://github.com/yasyf/cc-pool/blob/main/internal/keychain/discover.go
- 5: https://github.com/usemantle/dugout
- 6: https://github.com/agentgazer/agentgazer/blob/main/packages/cli/src/secret-store.ts
🏁 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.
| function backupKeychainCredentials(backupDir) { | ||
| let value; | ||
| try { | ||
| value = readKeychainCredentials(); | ||
| } catch { | ||
| return; | ||
| } |
There was a problem hiding this comment.
🗄️ 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.
Problem
On macOS, Claude Code 2.x stores OAuth credentials in the login keychain (service
Claude Code-credentials), not in the legacy~/.claude/.credentials.jsonfile. The switcher only read/wrote that file, so on a default macOS install:cc-switch/cc-sync-oauthcrashed withENOENT: ... open '~/.claude/.credentials.json'This is #3.
Root cause
lib/store/io.cjshardcoded the file path and usedfsfor all credential I/O (getDefaultCredentialsPath,readJson,writeJson,backupFile). No keychain code path existed.Fix
Bridge the credential I/O to the keychain via the
securityCLI 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 whenprocess.platform === 'darwin'and the credentials file does not existreadCredentials(path)— keychain read when the keychain case applies, otherwisereadJsonwriteCredentials(path, value, backupDir)— keychain write (with backup) when the keychain case applies, otherwise the existing file writebackupKeychainCredentials(backupDir)— dumps the current keychain value to the backup dir before overwriting (the filebackupFilestep was a no-op when no credentials file existed)writeLiveStatenow useswriteCredentials, so switching actually updates the keychain entry Claude Code readscc-switch.cjsreads credentials viareadCredentialsinstead ofreadJson.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):cc-switch(list)cc-sync-oauthclaudeAiOauthtokensCloses #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