diff --git a/.gitignore b/.gitignore index 3084b6f..5396df0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.tar.gz *.bak.* .DS_Store +.private/ +.tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d9324..3b9d534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 70f754e..a30ba27 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d810b81 --- /dev/null +++ b/ROADMAP.md @@ -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). diff --git a/bin/ccswitch b/bin/ccswitch index 2b1875f..8dc937e 100755 --- a/bin/ccswitch +++ b/bin/ccswitch @@ -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" @@ -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 @@ -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) diff --git a/test/fixtures/security b/test/fixtures/security index f8c5739..695a955 100755 --- a/test/fixtures/security +++ b/test/fixtures/security @@ -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; } diff --git a/test/keychain.sh b/test/keychain.sh index d23354d..391eca8 100755 --- a/test/keychain.sh +++ b/test/keychain.sh @@ -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"; } @@ -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 ' @@ -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" @@ -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 ] diff --git a/test/smoke.sh b/test/smoke.sh index 2515230..cd277a6 100755 --- a/test/smoke.sh +++ b/test/smoke.sh @@ -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. @@ -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" @@ -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"