Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
*.tar.gz
*.bak.*
.DS_Store
.private/
.tmp/
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Keychain writes now use compact ASCII JSON without a trailing newline.
`security -w` hex-encodes values containing non-printable bytes, which made
Claude Code report signed out after switching. The test fixture now models
this behavior; Unicode data is preserved through JSON escapes.
- macOS now defaults to Keychain even when a stale `.credentials.json` exists,
so account switching updates the credential Claude Code actually reads.
Plaintext-only setups can still select `CCSWITCH_BACKEND=file` explicitly.

## [0.9.0] - 2026-10-18

### Added
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,12 @@ This path has been exercised against a `security(1)` stand-in but not yet agains

`ccswitch doctor` prints what it resolved to. The first switch may raise a keychain prompt — macOS asks before letting a new binary read an item it did not create. Choose **Always Allow** if you would rather not be asked again.

The keychain is used only when there is no `~/.claude/.credentials.json`; a file left by an older Claude Code still wins. Force it either way with `CCSWITCH_BACKEND=keychain` or `CCSWITCH_BACKEND=file`.
The keychain is the default on macOS, even if `~/.claude/.credentials.json` exists. Claude Code prefers Keychain, so letting a stale plaintext file select the backend can make ccswitch switch a different credential from the one Claude reads. The file is left untouched. If Claude Code is deliberately using plaintext storage because Keychain is unavailable, use `CCSWITCH_BACKEND=file`. You can also explicitly select `CCSWITCH_BACKEND=keychain`.

Keychain values are written as compact ASCII JSON without a trailing newline.
This matters because `security -w` returns hex instead of JSON when a stored
value contains non-printable bytes. See Apple's
[password output implementation](https://github.com/apple-oss-distributions/Security/blob/main/SecurityTool/macOS/keychain_find.c#L387).

## Contributing

Expand Down
45 changes: 45 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Current status

## In progress

- Real-device feedback showed that backend selection alone did not resolve
switching: newline-containing Keychain JSON is returned as hex by
`security -w`, which Claude cannot parse. The backend now serializes compact
ASCII JSON; the security fixture models the actual output behavior. The user
applied this follow-up to the Work checkout and confirmed switching works.
- Upstream review: [PR #5](https://github.com/2hmad/ccswitch/pull/5) is open.
CI currently reports `action_required`; automated CI validation is pending.
- Full smoke validation on this host is incomplete: 88 checks passed, while
session detection and the open-session warning could not pass because the
host denies `ps` and `pgrep`, including outside the requested sandbox.
These checks still need an environment permitting process inspection.

## Recent external operations

- 2026-10-21 13:32 (Beijing): created the personal fork, published the verified
fix branch, and opened [upstream PR #5](https://github.com/2hmad/ccswitch/pull/5).

## Validation scope

- 2026-10-21 13:25 (Beijing): the user confirmed the macOS account switching
fix works after applying the patch. The open-Claude-process guard also
blocked switching as intended. This is user-reported real-device validation.

- 2026-10-21 11:54 (Beijing): the corrected security fixture reproduced the
pre-fix unreadable credential and signed-out warning. After normalizing
writes, all 26 Keychain/integration checks passed with local Claude 2.1.305,
including both accounts' auth status, Unicode/escaped newline data, isolated
accounts, and refusal to overwrite live credentials with invalid JSON or
non-finite JSON numbers. ShellCheck 0.11 and all six Bash syntax checks passed.

- 2026-10-21 11:38 (Beijing): local Claude 2.1.305 `auth status` recognized
both synthetic accounts after switching, with the plaintext fallback cleared;
all 20 Keychain/integration checks passed. ShellCheck 0.11, Bash syntax
checks and version consistency checks passed. This is branch validation,
not a full main-branch revalidation.

- Keychain tests use a `security(1)` stand-in. Optional
`CCSWITCH_TEST_CLAUDE=/absolute/path/to/claude bash test/keychain.sh` also
checks the real Claude CLI's `auth status` with synthetic credentials.
This does not validate real Keychain access or server-side OAuth validity.
- Release history: [CHANGELOG.md](CHANGELOG.md).
15 changes: 9 additions & 6 deletions bin/ccswitch
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ CRED="$CC_DIR/.credentials.json"
# unset or has characters outside [a-zA-Z0-9._-].
if [ -n "${CCSWITCH_BACKEND:-}" ]; then
BACKEND="$CCSWITCH_BACKEND"
elif [ "$(uname -s)" = "Darwin" ] && [ ! -f "$CRED" ]; then
elif [ "$(uname -s)" = "Darwin" ]; then
# Claude Code prefers Keychain on macOS even when a stale plaintext
# fallback exists. Selecting by file presence splits the two tools' state.
BACKEND="keychain"
else
BACKEND="file"
Expand Down Expand Up @@ -82,10 +84,11 @@ cred_read() {
# Install $1 (a file of credential JSON) as the live credential.
cred_write() { # $1=source file
if [ "$BACKEND" = "keychain" ]; then
# -X takes hex, so JSON quoting never reaches the shell; -U updates in
# place instead of failing with errSecDuplicateItem. This mirrors how
# Claude Code itself writes the item.
local hex; hex="$(python3 -c 'import sys;sys.stdout.write(open(sys.argv[1],"rb").read().hex())' "$1")"
# security -w returns hex if the stored bytes include newlines or other
# non-printable bytes. Claude expects JSON, so store compact ASCII JSON
# without a trailing newline. -X is only the write transport encoding.
local hex; hex="$(python3 -c 'import json,sys;sys.stdout.write(json.dumps(json.load(open(sys.argv[1])),ensure_ascii=True,allow_nan=False,separators=(",",":")).encode("ascii").hex())' "$1")" \
|| die "invalid credential JSON - refusing to overwrite the login keychain"
security add-generic-password -U -a "$(kc_account)" -s "$(kc_service)" -X "$hex" \
|| die "could not write to the login keychain (is it unlocked?)"
else
Expand Down Expand Up @@ -1411,7 +1414,7 @@ ${B}EXAMPLE${RST}
${B}ENVIRONMENT${RST}
CCSWITCH_HOME vault location (default \$XDG_CONFIG_HOME/ccswitch)
CCSWITCH_BACKEND credential store: 'file' or 'keychain' (default: keychain
on macOS when no credentials file exists, else file)
on macOS, file elsewhere)
CCSWITCH_REFRESH_WINDOW_DAYS
how close to the refresh token's expiry 'refresh' renews it
(default 7)
Expand Down
13 changes: 12 additions & 1 deletion test/fixtures/security
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,18 @@ case "$cmd" in
;;
find-generic-password)
[ -f "$key" ] || { echo "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain." >&2; exit 44; }
if [ "$want_pw" = 1 ]; then cat "$key"; fi
if [ "$want_pw" = 1 ]; then
# Match security(1): non-printable bytes (including JSON newlines)
# make -w return hex, not the original text.
python3 - "$key" <<'PY'
import pathlib, sys
data = pathlib.Path(sys.argv[1]).read_bytes()
if any(b < 0x20 or b > 0x7e for b in data):
print(data.hex())
else:
sys.stdout.buffer.write(data + b"\n")
PY
fi
;;
delete-generic-password)
[ -f "$key" ] || { echo "security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain." >&2; exit 44; }
Expand Down
95 changes: 88 additions & 7 deletions test/keychain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

export HOME="$TMP"
export PATH="$ROOT/test/fixtures:$ROOT/bin:$PATH"
mkdir -p "$TMP/bin"
printf '#!/usr/bin/env bash\nprintf "Darwin\\n"\n' > "$TMP/bin/uname"
chmod +x "$TMP/bin/uname"
export PATH="$TMP/bin:$ROOT/test/fixtures:$ROOT/bin:$PATH"
export NO_COLOR=1
# Pin the vault inside TMP. VAULT falls back to $XDG_CONFIG_HOME, which
# escapes $HOME - without this the test would operate on a real vault.
export CCSWITCH_HOME="$TMP/vault"
unset XDG_CONFIG_HOME
export CCSWITCH_BACKEND=keychain
unset CCSWITCH_BACKEND
export FAKE_KEYCHAIN="$TMP/keychain"
export USER=testuser
unset CLAUDE_CONFIG_DIR
unset CLAUDE_SECURESTORAGE_CONFIG_DIR

pass=0; fail=0
ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; }
Expand All @@ -46,6 +50,7 @@ d["userID"] = uid
d["oauthAccount"] = {"emailAddress": email}
json.dump(d, open(p, "w"), indent=2)
cred = json.dumps({"claudeAiOauth": {"accessToken": tok, "refreshToken": "r-" + tok,
"scopes": ["user:inference", "user:profile"],
"expiresAt": int((time.time() + 9 * 3600) * 1000),
"refreshTokenExpiresAt": login_end(email)}})
os.system('security add-generic-password -U -a "$USER" -s "Claude Code-credentials" -X '
Expand All @@ -72,22 +77,33 @@ check "account falls back when \$USER is unportable" \
seed_login UID-A a@example.com TOK-A
ccswitch add alpha >/dev/null
check "add reads from the keychain" "$(ccswitch current)" "alpha"
check "no credentials file created" \
"$(test -e "$HOME/.claude/.credentials.json" && echo yes || echo no)" "no"

seed_login UID-B b@example.net TOK-B
ccswitch add beta >/dev/null
# A plaintext fallback left by an older/failed Keychain write must not take
# precedence over the live Keychain login. Exercise automatic selection.
cp "$CCSWITCH_HOME/accounts/alpha/credentials.json" "$HOME/.claude/.credentials.json"
if ! ccswitch add beta >/dev/null; then
bad "add beta reads the live keychain despite a stale credentials file"
exit 1
fi
check "add prefers the live keychain over a stale file" \
"$(j /vault/accounts/beta/credentials.json "['claudeAiOauth']['accessToken']")" "TOK-B"
check "two accounts listed" "$(ccswitch list | grep -c example)" "2"

ccswitch alpha --force >/dev/null
check "switch writes the keychain" "$(tok)" "TOK-A"
check "switch carries identity" "$(j /.claude.json "['userID']")" "UID-A"

# the whole point of the backend: no credentials file is ever created
check "no credentials file written" "$(test -e "$HOME/.claude/.credentials.json" && echo yes || echo no)" "no"
# Switching must not overwrite the stale plaintext fallback either.
check "stale credentials file is untouched" \
"$(j /.claude/.credentials.json "['claudeAiOauth']['accessToken']")" "TOK-A"

# a token rewritten in the keychain mid-session must survive a round trip
python3 -c "
import json,os,time
cred=json.dumps({'claudeAiOauth':{'accessToken':'TOK-A2','refreshToken':'r2','expiresAt':int((time.time()+12*3600)*1000)}})
cred=json.dumps({'claudeAiOauth':{'accessToken':'TOK-A2','refreshToken':'r2','scopes':['user:inference','user:profile'],'expiresAt':int((time.time()+12*3600)*1000)}})
os.system('security add-generic-password -U -a \"\$USER\" -s \"Claude Code-credentials\" -X '+cred.encode().hex())"
ccswitch beta --force >/dev/null; ccswitch alpha --force >/dev/null
check "keychain refresh persisted" "$(tok)" "TOK-A2"
Expand All @@ -96,20 +112,85 @@ check "keychain refresh persisted" "$(tok)" "TOK-A2"
python3 -c "
import json,os,subprocess
cur=json.loads(subprocess.check_output(['security','find-generic-password','-a',os.environ['USER'],'-s','Claude Code-credentials','-w']))
cur['mcpOAuth']={'figma|k':{'serverName':'figma','accessToken':'FIG-KC'}}
cur['mcpOAuth']={'figma|k':{'serverName':'figma','accessToken':'FIG-KC','note':'设计\\nreview'}}
os.system('security add-generic-password -U -a \"\$USER\" -s \"Claude Code-credentials\" -X '+json.dumps(cur).encode().hex())"
ccswitch beta --force >/dev/null
check "keychain: mcp token follows the switch" \
"$(kc_get "Claude Code-credentials" | python3 -c 'import json,sys;print(json.load(sys.stdin)["mcpOAuth"]["figma|k"]["accessToken"])' 2>/dev/null || echo MISSING)" "FIG-KC"
check "keychain: claude token still swapped" "$(tok)" "TOK-B"
check "keychain: Unicode and escaped newlines survive" \
"$(kc_get "Claude Code-credentials" | python3 -c 'import json,sys; print(json.load(sys.stdin)["mcpOAuth"]["figma|k"]["note"] == "设计\nreview")')" "True"
ccswitch alpha --force >/dev/null

# Isolated accounts bypass MCP merging and pass the stored (multi-line) JSON
# straight to cred_write, so normalization must live in the backend itself.
ccswitch mcp isolate alpha >/dev/null
ccswitch beta --force >/dev/null
ccswitch alpha --force >/dev/null
check "isolated account remains readable through security -w" "$(tok)" "TOK-A2"
ccswitch mcp share alpha >/dev/null

# Invalid JSON must fail before replacing a still-usable Keychain item.
cp "$CCSWITCH_HOME/accounts/beta/credentials.json" "$TMP/beta-backup.json"
printf '{invalid JSON\n' > "$CCSWITCH_HOME/accounts/beta/credentials.json"
if ccswitch beta --force > "$TMP/invalid-output" 2>&1; then
bad "invalid credential JSON is rejected"
else
ok "invalid credential JSON is rejected"
fi
check "invalid JSON leaves the live credential intact" "$(tok)" "TOK-A2"
cp "$TMP/beta-backup.json" "$CCSWITCH_HOME/accounts/beta/credentials.json"

# Python accepts non-finite numbers by default, but Claude's JSON parser does
# not. These must also be rejected before replacing the live credential.
python3 - "$CCSWITCH_HOME/accounts/beta/credentials.json" <<'PY'
import json, sys
path = sys.argv[1]
with open(path) as f:
data = json.load(f)
data["invalidNumber"] = float("nan")
with open(path, "w") as f:
json.dump(data, f)
PY
if ccswitch beta --force > "$TMP/nonfinite-output" 2>&1; then
bad "non-finite JSON number is rejected"
else
ok "non-finite JSON number is rejected"
fi
check "non-finite JSON leaves the live credential intact" "$(tok)" "TOK-A2"
cp "$TMP/beta-backup.json" "$CCSWITCH_HOME/accounts/beta/credentials.json"

# every account still holds a valid token, so this must skip without needing
# 'claude' and must leave the live item alone
ccswitch refresh --all >/dev/null
check "refresh skips, keychain untouched" "$(tok)" "TOK-A2"
check "refresh left current alone" "$(ccswitch current)" "alpha"

# Explicit plaintext mode remains available for Macs without Keychain access.
CCSWITCH_BACKEND=file CCSWITCH_HOME="$TMP/file-vault" ccswitch add fallback >/dev/null
check "explicit file backend reads the file" \
"$(j /file-vault/accounts/fallback/credentials.json "['claudeAiOauth']['accessToken']")" "TOK-A"

# Optional integration check with an installed Claude binary. security(1) is
# still the fixture, and env -i excludes the real user's auth/config settings.
if [ -n "${CCSWITCH_TEST_CLAUDE:-}" ]; then
printf '{"claudeAiOauth":{}}\n' > "$HOME/.claude/.credentials.json"
for name in beta alpha; do
ccswitch "$name" --force >/dev/null
if status="$(env -i HOME="$HOME" USER="$USER" PATH="$PATH" \
FAKE_KEYCHAIN="$FAKE_KEYCHAIN" CLAUDE_SECURESTORAGE_CONFIG_DIR='' \
"$CCSWITCH_TEST_CLAUDE" auth status)"; then
check "claude auth status after switching to $name" \
"$(printf '%s' "$status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["loggedIn"])')" "True"
case "$name" in beta) email=b@example.net ;; alpha) email=a@example.com ;; esac
check "claude auth status identifies $name" \
"$(printf '%s' "$status" | python3 -c 'import json,sys; print(json.load(sys.stdin)["email"])')" "$email"
else
bad "claude auth status failed after switching to $name"
fi
done
fi

echo
echo "$pass passed, $fail failed"
[ "$fail" -eq 0 ]
7 changes: 5 additions & 2 deletions test/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ trap 'rm -rf "$TMP"' EXIT
export HOME="$TMP"
export PATH="$ROOT/bin:$PATH"
export NO_COLOR=1
export CCSWITCH_BACKEND=file
unset CLAUDE_CONFIG_DIR
# Pin the vault inside TMP. VAULT falls back to $XDG_CONFIG_HOME, which
# escapes $HOME - without this the test would operate on a real vault.
Expand Down Expand Up @@ -268,8 +269,9 @@ fake_pid=$!
( PATH="$TMP/fakebin:$PATH" exec "$TMP/fakebin/claude" ) &
real_pid=$!
sleep 1
sed -n '/^claude_session_pids()/,/^}/p' "$ROOT/bin/ccswitch" > "$TMP/session-functions.sh"
# shellcheck source=/dev/null
source <(sed -n '/^claude_session_pids()/,/^}/p' "$ROOT/bin/ccswitch")
source "$TMP/session-functions.sh"
seen="$(PATH="$TMP/fakebin:$PATH" claude_session_pids)"
check "chrome native host is not a session" "$(printf '%s\n' "$seen" | grep -c "^$fake_pid$")" "0"
check "a real session is still detected" "$(printf '%s\n' "$seen" | grep -c "^$real_pid$")" "1"
Expand Down Expand Up @@ -310,8 +312,9 @@ check "targeted save stores into the named slot" \

# Version comparison must be numeric, not lexical: "0.10.0" is newer than
# "0.9.0" but sorts before it as a string.
sed -n '/^version_gt()/,/^}/p' "$ROOT/bin/ccswitch" > "$TMP/version-functions.sh"
# shellcheck source=/dev/null
source <(sed -n '/^version_gt()/,/^}/p' "$ROOT/bin/ccswitch")
source "$TMP/version-functions.sh"
vg() { if version_gt "$1" "$2"; then echo yes; else echo no; fi; }
check "version_gt 0.10.0 > 0.9.0" "$(vg 0.10.0 0.9.0)" "yes"
check "version_gt 0.9.0 !> 0.10.0" "$(vg 0.9.0 0.10.0)" "no"
Expand Down