claude-multiacc 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE_ACCS_TASK.md +7 -0
- package/README.md +21 -6
- package/bin/claude +83 -10
- package/bin/claude-accounts +184 -59
- package/bin/codex-accounts +24 -7
- package/docs/ACCOUNT_OPERATIONS.md +6 -2
- package/install.sh +24 -8
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/audit.py +87 -21
- package/lib/common.sh +62 -5
- package/lib/credential.py +9 -1
- package/lib/keychain.py +210 -0
- package/lib/report.py +33 -8
- package/package.json +1 -1
- package/tests/run-tests.sh +278 -0
- package/tests/test_keychain.py +204 -0
package/tests/run-tests.sh
CHANGED
|
@@ -56,6 +56,16 @@ if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then
|
|
|
56
56
|
fi
|
|
57
57
|
if [ "${1:-}" = "auth" ] && [ "${2:-}" = "login" ]; then
|
|
58
58
|
[ -n "${FAKE_LOGIN_FAIL:-}" ] && { echo "login aborted" >&2; exit 1; }
|
|
59
|
+
if [ -n "${FAKE_LOGIN_KEYCHAIN:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
60
|
+
# simulate the macOS client in a keychain-capable session: the login lands in the
|
|
61
|
+
# keychain (via the fake `security` on PATH) and NO .credentials.json is written
|
|
62
|
+
h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
|
|
63
|
+
hex="$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' \
|
|
64
|
+
| python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
65
|
+
security add-generic-password -U -a tester -s "Claude Code-credentials-$h" -X "$hex"
|
|
66
|
+
echo "Logged in."
|
|
67
|
+
exit 0
|
|
68
|
+
fi
|
|
59
69
|
# simulate a completed full-scope login: write auto-refreshing creds to the config dir
|
|
60
70
|
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-login","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999,"scopes":["user:inference"],"subscriptionType":"max"}}' > "${CLAUDE_CONFIG_DIR:-/dev/null}/.credentials.json"
|
|
61
71
|
echo "Logged in."
|
|
@@ -134,6 +144,65 @@ echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none} ARGS=$*"
|
|
|
134
144
|
EOF
|
|
135
145
|
chmod +x "$FAKEBIN/claude"
|
|
136
146
|
|
|
147
|
+
# Fake macOS `security` (generic-password verbs only): items live as files under
|
|
148
|
+
# $FAKE_KEYCHAIN_DIR/<service> (content = the secret) + <service>.acct (account name).
|
|
149
|
+
# FAKE_KEYCHAIN_LOCKED=1 simulates a session that cannot open the keychain — secret
|
|
150
|
+
# reads and every write exit 36 while ATTRIBUTE reads still answer, exactly the split
|
|
151
|
+
# the real tool exhibits over ssh. Shadows any real /usr/bin/security via PATH, so a
|
|
152
|
+
# macOS dev run of this suite can never touch the developer's actual keychain.
|
|
153
|
+
cat > "$FAKEBIN/security" <<'EOF'
|
|
154
|
+
#!/usr/bin/env bash
|
|
155
|
+
KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
|
|
156
|
+
cmd="${1:-}"; shift || true
|
|
157
|
+
svc=""; acct=""; want_pw=0; hexdata=""
|
|
158
|
+
while [ $# -gt 0 ]; do
|
|
159
|
+
case "$1" in
|
|
160
|
+
-s) svc="$2"; shift 2 ;;
|
|
161
|
+
-a) acct="$2"; shift 2 ;;
|
|
162
|
+
-w) want_pw=1; shift ;;
|
|
163
|
+
-X) hexdata="$2"; shift 2 ;;
|
|
164
|
+
*) shift ;;
|
|
165
|
+
esac
|
|
166
|
+
done
|
|
167
|
+
# The modification stamp is RECORDED AT WRITE TIME in a sidecar rather than derived
|
|
168
|
+
# from the item file's mtime: `stat`/`date` flags differ between BSD and GNU (on Linux
|
|
169
|
+
# `stat -f %m` even "succeeds" with garbage), and this fake must behave identically on
|
|
170
|
+
# a developer's Mac and on Linux CI.
|
|
171
|
+
item_mdat() { cat "$KC/$svc.mdat" 2>/dev/null || echo 00000000000000; }
|
|
172
|
+
case "$cmd" in
|
|
173
|
+
show-keychain-info)
|
|
174
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
175
|
+
exit 0 ;;
|
|
176
|
+
find-generic-password)
|
|
177
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
178
|
+
if [ "$want_pw" = "1" ]; then
|
|
179
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
180
|
+
cat "$KC/$svc"
|
|
181
|
+
exit 0
|
|
182
|
+
fi
|
|
183
|
+
a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
|
|
184
|
+
printf 'keychain: "login.keychain-db"\nclass: "genp"\nattributes:\n'
|
|
185
|
+
printf ' "acct"<blob>="%s"\n' "$a"
|
|
186
|
+
printf ' "mdat"<timedate>=0x00 "%sZ\\000"\n' "$(item_mdat)"
|
|
187
|
+
printf ' "svce"<blob>="%s"\n' "$svc"
|
|
188
|
+
exit 0 ;;
|
|
189
|
+
add-generic-password)
|
|
190
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
191
|
+
mkdir -p "$KC"
|
|
192
|
+
printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
|
|
193
|
+
printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
|
|
194
|
+
date -u +%Y%m%d%H%M%S > "$KC/$svc.mdat"
|
|
195
|
+
exit 0 ;;
|
|
196
|
+
delete-generic-password)
|
|
197
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
198
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
199
|
+
rm -f "$KC/$svc" "$KC/$svc.acct" "$KC/$svc.mdat"
|
|
200
|
+
exit 0 ;;
|
|
201
|
+
*) exit 1 ;;
|
|
202
|
+
esac
|
|
203
|
+
EOF
|
|
204
|
+
chmod +x "$FAKEBIN/security"
|
|
205
|
+
|
|
137
206
|
export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
|
|
138
207
|
export FAKE_CTL="$WORK/ctl"
|
|
139
208
|
# Neutralize any ambient state from the invoking environment.
|
|
@@ -155,6 +224,10 @@ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
|
155
224
|
# throttle keeps those background kicks from racing explicit limits runs.
|
|
156
225
|
export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
157
226
|
: > "$ACC/.limits-kick"
|
|
227
|
+
# Keychain lookups are OFF for the legacy sections (their pools are file-based and the
|
|
228
|
+
# extra `security` process per account would only slow them down); section 17 turns
|
|
229
|
+
# them on explicitly against the fake `security` above.
|
|
230
|
+
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
158
231
|
|
|
159
232
|
now="$(date +%s)"
|
|
160
233
|
|
|
@@ -2366,6 +2439,61 @@ if command -v node >/dev/null 2>&1; then
|
|
|
2366
2439
|
esac
|
|
2367
2440
|
case "$out" in *"update manually"*|*"already latest"*|*"git pull"*) t_ok "self-update reports its path" ;;
|
|
2368
2441
|
*) t_fail "self-update message" "unexpected: $out" ;; esac
|
|
2442
|
+
|
|
2443
|
+
# ---- 19a. an npm install updates itself with NO npm on PATH -----------------------
|
|
2444
|
+
# launchd hands an agent PATH=/usr/bin:/bin:/usr/sbin:/sbin, so `command -v npm` finds
|
|
2445
|
+
# nothing and the nightly self-update logged "npm not found; skipping" for days while
|
|
2446
|
+
# every Mac's copy silently froze. npm must be resolved by path — and specifically the
|
|
2447
|
+
# one inside the prefix that owns the RUNNING copy, never whatever a stray PATH offers.
|
|
2448
|
+
for _cli in claude-accounts codex-accounts; do
|
|
2449
|
+
PFX="$WORK/pfx-$_cli"
|
|
2450
|
+
NPMROOT="$PFX/lib/node_modules/claude-multiacc"
|
|
2451
|
+
mkdir -p "$NPMROOT" "$PFX/bin"
|
|
2452
|
+
cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$REPO_DIR/package.json" "$NPMROOT/"
|
|
2453
|
+
# A fake npm that records how it was called and "installs" by bumping package.json.
|
|
2454
|
+
cat > "$PFX/bin/npm" <<NPMEOF
|
|
2455
|
+
#!/usr/bin/env bash
|
|
2456
|
+
echo "npm \$*" >> "$WORK/npm-calls-$_cli.log"
|
|
2457
|
+
case "\${1:-}" in
|
|
2458
|
+
view) echo 9.9.9 ;;
|
|
2459
|
+
install) python3 - "$NPMROOT/package.json" <<'PY'
|
|
2460
|
+
import json, sys
|
|
2461
|
+
p = sys.argv[1]
|
|
2462
|
+
doc = json.load(open(p)); doc['version'] = '9.9.9'
|
|
2463
|
+
json.dump(doc, open(p, 'w'))
|
|
2464
|
+
PY
|
|
2465
|
+
;;
|
|
2466
|
+
esac
|
|
2467
|
+
exit 0
|
|
2468
|
+
NPMEOF
|
|
2469
|
+
chmod +x "$PFX/bin/npm"
|
|
2470
|
+
# A DIFFERENT npm earlier on PATH must not win: the running copy's prefix owns it.
|
|
2471
|
+
mkdir -p "$WORK/wrongbin"
|
|
2472
|
+
printf '#!/usr/bin/env bash\necho "WRONG-NPM \$*" >> "%s"\nexit 0\n' "$WORK/npm-calls-$_cli.log" > "$WORK/wrongbin/npm"
|
|
2473
|
+
chmod +x "$WORK/wrongbin/npm"
|
|
2474
|
+
: > "$WORK/npm-calls-$_cli.log"
|
|
2475
|
+
out="$(env -i HOME="$HOME" PATH="$WORK/wrongbin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
|
2476
|
+
CLAUDE_ACCOUNTS_DIR="$ACC" CODEX_ACCOUNTS_DIR="$WORK/codex-accounts" \
|
|
2477
|
+
"$NPMROOT/bin/$_cli" self-update 2>&1)"
|
|
2478
|
+
rc=$?
|
|
2479
|
+
[ "$rc" = "0" ] && t_ok "$_cli: self-update exits 0 with no npm on PATH" \
|
|
2480
|
+
|| t_fail "$_cli self-update rc" "rc=$rc: $(printf '%s' "$out" | head -c 200)"
|
|
2481
|
+
printf '%s' "$out" | grep -q "npm not found" \
|
|
2482
|
+
&& t_fail "$_cli self-update npm lookup" "still reports 'npm not found' with an npm in its own prefix" \
|
|
2483
|
+
|| t_ok "$_cli: npm resolved without PATH"
|
|
2484
|
+
grep -q "^npm install " "$WORK/npm-calls-$_cli.log" \
|
|
2485
|
+
&& t_ok "$_cli: the prefix's own npm performed the install" \
|
|
2486
|
+
|| t_fail "$_cli npm install" "calls: $(cat "$WORK/npm-calls-$_cli.log" | head -3)"
|
|
2487
|
+
grep -q "WRONG-NPM" "$WORK/npm-calls-$_cli.log" \
|
|
2488
|
+
&& t_fail "$_cli npm choice" "used the npm from PATH instead of the running prefix's" \
|
|
2489
|
+
|| t_ok "$_cli: a stray npm on PATH never wins over the running prefix's"
|
|
2490
|
+
check "$_cli: self-update verifies the tree it wrote" "npm update ok (9.9.9)" "$out"
|
|
2491
|
+
done
|
|
2492
|
+
# The agents install.sh writes must carry a PATH for the same reason (a future tool
|
|
2493
|
+
# that is not resolved by absolute path would hit exactly this again).
|
|
2494
|
+
grep -q '<key>PATH</key>' "$REPO_DIR/install.sh" \
|
|
2495
|
+
&& t_ok "install.sh gives its launchd agents a PATH" \
|
|
2496
|
+
|| t_fail "agent PATH" "plist_env_block writes no PATH — launchd agents get /usr/bin:/bin only"
|
|
2369
2497
|
# postinstall must SKIP for a non-global install and never fail
|
|
2370
2498
|
out="$(node "$REPO_DIR/scripts/postinstall.mjs" 2>&1)"
|
|
2371
2499
|
rc=$?
|
|
@@ -4072,6 +4200,156 @@ for _bin in claude-accounts codex-accounts; do
|
|
|
4072
4200
|
|| t_fail "$_bin unknown verb --help" "exited 0 — the probe would accept a verb that does not exist"
|
|
4073
4201
|
done
|
|
4074
4202
|
|
|
4203
|
+
# ---- 17. macOS Keychain-held logins --------------------------------------------------
|
|
4204
|
+
# Claude Code on macOS moves a per-dir OAuth login into the login Keychain (service
|
|
4205
|
+
# "Claude Code-credentials-<sha256(dir)[:8]>") from any keychain-capable session and
|
|
4206
|
+
# deletes .credentials.json — the 2026-08-28 incident read five working accounts as
|
|
4207
|
+
# "missing". The fake `security` above serves items from $FAKE_KEYCHAIN_DIR so the real
|
|
4208
|
+
# lookup code runs on Linux too. Own pool root: the main pool's manifest has been
|
|
4209
|
+
# through a dozen mutations by this point in the suite.
|
|
4210
|
+
KCP="$WORK/kcpool"
|
|
4211
|
+
export FAKE_KEYCHAIN_DIR="$WORK/keychain"
|
|
4212
|
+
mkdir -p "$KCP/tmp" "$KCP/acct-01" "$KCP/acct-02" "$FAKE_KEYCHAIN_DIR"
|
|
4213
|
+
: > "$KCP/.limits-kick"
|
|
4214
|
+
cat > "$KCP/accounts.json" <<EOF
|
|
4215
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
4216
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
4217
|
+
"accounts": [
|
|
4218
|
+
{"id": "acct-01", "email": "kfile@test", "home": "mac", "added_at": "2026-08-28T00:00:00Z"},
|
|
4219
|
+
{"id": "acct-02", "email": "kchain@test", "home": "mac", "added_at": "2026-08-28T00:00:00Z"}
|
|
4220
|
+
] }
|
|
4221
|
+
EOF
|
|
4222
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KCP/acct-01/.credentials.json"
|
|
4223
|
+
kc_hash() { printf '%s' "$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8; }
|
|
4224
|
+
kc_svc_file() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
|
|
4225
|
+
kc_put() { # kc_put <acct dir> <credential json> — store it the way the client would
|
|
4226
|
+
security add-generic-password -U -a tester -s "Claude Code-credentials-$(kc_hash "$1")" \
|
|
4227
|
+
-X "$(printf '%s' "$2" | python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
4228
|
+
}
|
|
4229
|
+
kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kchain","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4230
|
+
export CLAUDE_MULTIACC_KEYCHAIN=1
|
|
4231
|
+
|
|
4232
|
+
# 17a. the keychain login reads as a working machine-local OAuth login
|
|
4233
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
|
|
4234
|
+
python3 - "$out" <<'EOF'
|
|
4235
|
+
import json, sys
|
|
4236
|
+
doc = json.loads(sys.argv[1])
|
|
4237
|
+
rows = {a['id']: a for a in doc['accounts']}
|
|
4238
|
+
a2 = rows['acct-02']
|
|
4239
|
+
assert a2['status'] == 'active', a2['status']
|
|
4240
|
+
assert a2['credential_class'] == 'machine-local', a2['credential_class']
|
|
4241
|
+
assert a2['credentials']['oauth_store'] == 'keychain', a2['credentials']
|
|
4242
|
+
assert a2['credentials']['keychain'] == 'readable', a2['credentials']
|
|
4243
|
+
assert a2['selectable'] is True, a2
|
|
4244
|
+
assert rows['acct-01']['credentials']['oauth_store'] == 'file', rows['acct-01']['credentials']
|
|
4245
|
+
assert doc['summary']['selectable'] == 2, doc['summary']
|
|
4246
|
+
EOF
|
|
4247
|
+
[ $? -eq 0 ] && t_ok "keychain login is active machine-local in list --json" \
|
|
4248
|
+
|| t_fail "keychain list --json" "see assertions above"
|
|
4249
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list 2>&1)"
|
|
4250
|
+
check "keychain login shown in the plain list" "auth=keychain" "$out"
|
|
4251
|
+
|
|
4252
|
+
# 17b. the shim runs under a keychain-only account (file account parked by a limit)
|
|
4253
|
+
printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$KCP/acct-01/.limited"
|
|
4254
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
|
|
4255
|
+
check "shim selects the keychain-only account" "CFG=acct-02" "$out"
|
|
4256
|
+
|
|
4257
|
+
# 17c. locked keychain (ssh session): 'locked', never 'missing'; excluded HERE only
|
|
4258
|
+
out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
|
|
4259
|
+
python3 - "$out" <<'EOF'
|
|
4260
|
+
import json, sys
|
|
4261
|
+
doc = json.loads(sys.argv[1])
|
|
4262
|
+
a2 = {a['id']: a for a in doc['accounts']}['acct-02']
|
|
4263
|
+
assert a2['status'] == 'locked', a2['status']
|
|
4264
|
+
assert a2['credential_class'] == 'machine-local', a2['credential_class']
|
|
4265
|
+
assert a2['credentials']['keychain'] == 'locked', a2['credentials']
|
|
4266
|
+
assert a2['selectable'] is False, a2
|
|
4267
|
+
assert a2['needs_login'] is False, 'locked must not join the re-login worklist'
|
|
4268
|
+
EOF
|
|
4269
|
+
[ $? -eq 0 ] && t_ok "locked keychain reads as 'locked' machine-local, not missing" \
|
|
4270
|
+
|| t_fail "keychain locked state" "see assertions above"
|
|
4271
|
+
out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts expired --quiet 2>&1)"; rc=$?
|
|
4272
|
+
[ "$rc" = "0" ] && t_ok "a locked keychain login is not a relogin worklist item" \
|
|
4273
|
+
|| t_fail "locked vs expired" "rc=$rc out=$out"
|
|
4274
|
+
out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
|
|
4275
|
+
check "shim never selects a keychain login it cannot read" "CFG=acct-01" "$out"
|
|
4276
|
+
rm -f "$KCP/acct-01/.limited"
|
|
4277
|
+
|
|
4278
|
+
# 17d. limits: the probe takes its bearer from the keychain
|
|
4279
|
+
rm -f "$KCP/acct-02/limits.json"
|
|
4280
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
|
|
4281
|
+
check "limits fetches with a keychain bearer" "acct-02: ok" "$out"
|
|
4282
|
+
python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("source")=="oauth", d' \
|
|
4283
|
+
"$KCP/acct-02/limits.json" 2>/dev/null \
|
|
4284
|
+
&& t_ok "keychain-backed telemetry records source=oauth" \
|
|
4285
|
+
|| t_fail "keychain limits source" "limits.json missing or wrong source"
|
|
4286
|
+
|
|
4287
|
+
# 17e. refresh: an expired keychain credential refreshes via the grant and is written
|
|
4288
|
+
# BACK to the keychain — never copied out into a .credentials.json
|
|
4289
|
+
kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
|
|
4290
|
+
rm -f "$KCP/acct-02/limits.json" "$KCP/acct-02/.oauth-refresh.json"
|
|
4291
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
4292
|
+
check "keychain credential refreshes via the grant" "acct-02: oauth access token refreshed" "$out"
|
|
4293
|
+
grep -q 'sk-ant-oat01-refreshednew' "$(kc_svc_file "$KCP/acct-02")" \
|
|
4294
|
+
&& t_ok "rotated credential written back to the keychain" \
|
|
4295
|
+
|| t_fail "keychain write-back" "item not updated"
|
|
4296
|
+
[ ! -f "$KCP/acct-02/.credentials.json" ] \
|
|
4297
|
+
&& t_ok "refresh never copies the keychain credential to a file" \
|
|
4298
|
+
|| t_fail "keychain leak to file" ".credentials.json appeared beside a keychain login"
|
|
4299
|
+
|
|
4300
|
+
# 17f. .expired self-heals on a keychain login written after the marker
|
|
4301
|
+
printf '%s\nreason=auth-error marked_at=t detail=test\n' "$now" > "$KCP/acct-02/.expired"
|
|
4302
|
+
touch -t 202001010000 "$KCP/acct-02/.expired"
|
|
4303
|
+
kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-knew","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4304
|
+
CLAUDE_ACCOUNTS_DIR="$KCP" claude >/dev/null 2>&1
|
|
4305
|
+
[ ! -f "$KCP/acct-02/.expired" ] \
|
|
4306
|
+
&& t_ok ".expired self-heals on a newer keychain credential" \
|
|
4307
|
+
|| t_fail "keychain .expired self-heal" "marker survived a newer keychain login"
|
|
4308
|
+
|
|
4309
|
+
# 17g. a login ceremony that lands in the keychain registers instead of dying
|
|
4310
|
+
out="$(CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_LOGIN_KEYCHAIN=1 FAKE_EMAIL=kc3@test CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts add kc3@test 2>&1)"
|
|
4311
|
+
check "add registers a keychain-backed sign-in" "Registered acct-03 for kc3@test" "$out"
|
|
4312
|
+
[ ! -f "$KCP/acct-03/.credentials.json" ] \
|
|
4313
|
+
&& t_ok "keychain add leaves no plaintext credential" \
|
|
4314
|
+
|| t_fail "keychain add" "unexpected .credentials.json"
|
|
4315
|
+
[ -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "the sign-in landed in the keychain" \
|
|
4316
|
+
|| t_fail "keychain add item" "no keychain item for acct-03"
|
|
4317
|
+
|
|
4318
|
+
# 17h. export refuses a keychain-held machine-local credential, naming the store
|
|
4319
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts export-credential acct-03 --out "$WORK/kc-export.json" 2>&1)"; rc=$?
|
|
4320
|
+
[ "$rc" = "3" ] && t_ok "export-credential refuses a keychain login (exit 3)" \
|
|
4321
|
+
|| t_fail "keychain export rc" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
4322
|
+
check "export names the keychain as the machine-local store" "macOS Keychain" "$out"
|
|
4323
|
+
|
|
4324
|
+
# 17i. remove deletes the keychain item with the account
|
|
4325
|
+
CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-03 --yes >/dev/null 2>&1
|
|
4326
|
+
[ ! -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
|
|
4327
|
+
|| t_fail "keychain remove" "the item survived removal"
|
|
4328
|
+
|
|
4329
|
+
# 17j. dedupe keeps the duplicate that actually holds the (keychain) login. The
|
|
4330
|
+
# keychain holder gets the HIGHER id on purpose: with keychain auth invisible, the
|
|
4331
|
+
# lowest-id tiebreak would keep credential-less acct-04 and throw the grant away.
|
|
4332
|
+
python3 - "$KCP/accounts.json" <<'EOF'
|
|
4333
|
+
import json, sys
|
|
4334
|
+
doc = json.load(open(sys.argv[1]))
|
|
4335
|
+
doc['accounts'] += [
|
|
4336
|
+
{'id': 'acct-04', 'email': 'kdup@test', 'home': 'mac', 'added_at': '2026-08-28T00:00:00Z'},
|
|
4337
|
+
{'id': 'acct-05', 'email': 'kdup@test', 'home': 'mac', 'added_at': '2026-08-28T00:00:00Z'}]
|
|
4338
|
+
json.dump(doc, open(sys.argv[1], 'w'))
|
|
4339
|
+
EOF
|
|
4340
|
+
mkdir -p "$KCP/acct-04" "$KCP/acct-05"
|
|
4341
|
+
kc_put "$KCP/acct-05" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdup","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
|
|
4342
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts dedupe --yes 2>&1)"
|
|
4343
|
+
check "dedupe removes the credential-less duplicate, not the keychain one" "will remove acct-04 (kdup@test)" "$out"
|
|
4344
|
+
[ -f "$(kc_svc_file "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
|
|
4345
|
+
|| t_fail "dedupe keychain keep" "acct-05's keychain item is gone"
|
|
4346
|
+
grep -q '"acct-04"' "$KCP/accounts.json" \
|
|
4347
|
+
&& t_fail "dedupe manifest" "acct-04 still registered" \
|
|
4348
|
+
|| t_ok "the credential-less duplicate left the manifest"
|
|
4349
|
+
|
|
4350
|
+
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
4351
|
+
unset FAKE_KEYCHAIN_DIR
|
|
4352
|
+
|
|
4075
4353
|
# The reset-credit contract is easier to prove against a stateful local HTTP server
|
|
4076
4354
|
# than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
|
|
4077
4355
|
if CODEX_MULTIACC_AUTO_RESET=1 python3 "$REPO_DIR/tests/test_codex_reset.py"; then
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unit tests for lib/keychain.py and its audit/report integration.
|
|
3
|
+
|
|
4
|
+
Runs everywhere: a fake `security` on PATH serves generic-password items from a
|
|
5
|
+
temp dir, and CLAUDE_MULTIACC_KEYCHAIN=1 forces the lookup on off-macOS. Nothing
|
|
6
|
+
here can ever touch a real keychain — the fake shadows /usr/bin/security.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import unittest
|
|
18
|
+
|
|
19
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
20
|
+
sys.path.insert(0, str(REPO / 'lib'))
|
|
21
|
+
|
|
22
|
+
FAKE_SECURITY = r'''#!/usr/bin/env bash
|
|
23
|
+
KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
|
|
24
|
+
cmd="${1:-}"; shift || true
|
|
25
|
+
svc=""; acct=""; want_pw=0; hexdata=""
|
|
26
|
+
while [ $# -gt 0 ]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
-s) svc="$2"; shift 2 ;;
|
|
29
|
+
-a) acct="$2"; shift 2 ;;
|
|
30
|
+
-w) want_pw=1; shift ;;
|
|
31
|
+
-X) hexdata="$2"; shift 2 ;;
|
|
32
|
+
*) shift ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
case "$cmd" in
|
|
36
|
+
find-generic-password)
|
|
37
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
38
|
+
if [ "$want_pw" = "1" ]; then
|
|
39
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
40
|
+
cat "$KC/$svc"; exit 0
|
|
41
|
+
fi
|
|
42
|
+
a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
|
|
43
|
+
printf 'attributes:\n "acct"<blob>="%s"\n' "$a"
|
|
44
|
+
printf ' "mdat"<timedate>=0x00 "20260828043137Z\\000"\n'
|
|
45
|
+
printf ' "svce"<blob>="%s"\n' "$svc"
|
|
46
|
+
exit 0 ;;
|
|
47
|
+
add-generic-password)
|
|
48
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
49
|
+
mkdir -p "$KC"
|
|
50
|
+
printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
|
|
51
|
+
printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
|
|
52
|
+
exit 0 ;;
|
|
53
|
+
delete-generic-password)
|
|
54
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
55
|
+
rm -f "$KC/$svc" "$KC/$svc.acct"; exit 0 ;;
|
|
56
|
+
*) exit 1 ;;
|
|
57
|
+
esac
|
|
58
|
+
'''
|
|
59
|
+
|
|
60
|
+
DOC = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-x', 'refreshToken': 'r',
|
|
61
|
+
'expiresAt': 9999999999999,
|
|
62
|
+
'refreshTokenExpiresAt': 9999999999999}}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class KeychainTests(unittest.TestCase):
|
|
66
|
+
def setUp(self):
|
|
67
|
+
self.work = tempfile.TemporaryDirectory()
|
|
68
|
+
work = Path(self.work.name)
|
|
69
|
+
fakebin = work / 'bin'
|
|
70
|
+
fakebin.mkdir()
|
|
71
|
+
tool = fakebin / 'security'
|
|
72
|
+
tool.write_text(FAKE_SECURITY)
|
|
73
|
+
tool.chmod(0o755)
|
|
74
|
+
self.kcdir = work / 'keychain'
|
|
75
|
+
self.saved = {k: os.environ.get(k) for k in
|
|
76
|
+
('PATH', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR',
|
|
77
|
+
'FAKE_KEYCHAIN_LOCKED')}
|
|
78
|
+
os.environ['PATH'] = f"{fakebin}:{os.environ['PATH']}"
|
|
79
|
+
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
|
|
80
|
+
os.environ['FAKE_KEYCHAIN_DIR'] = str(self.kcdir)
|
|
81
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
|
|
82
|
+
# (re)load with the fake in place
|
|
83
|
+
import importlib
|
|
84
|
+
import keychain
|
|
85
|
+
self.keychain = importlib.reload(keychain)
|
|
86
|
+
self.root = work / 'accounts'
|
|
87
|
+
(self.root / 'acct-01').mkdir(parents=True)
|
|
88
|
+
(self.root / 'acct-01' / 'tmp').mkdir(exist_ok=True)
|
|
89
|
+
|
|
90
|
+
def tearDown(self):
|
|
91
|
+
for key, value in self.saved.items():
|
|
92
|
+
if value is None:
|
|
93
|
+
os.environ.pop(key, None)
|
|
94
|
+
else:
|
|
95
|
+
os.environ[key] = value
|
|
96
|
+
self.work.cleanup()
|
|
97
|
+
|
|
98
|
+
# -- the layout facts this whole feature stands on ------------------------------
|
|
99
|
+
def test_service_name_matches_the_client(self):
|
|
100
|
+
# Observed live 2026-08-28 (Claude Code 2.1.250, my-mini): this config dir's
|
|
101
|
+
# login sat under exactly this service. The hash input is the PATH STRING as
|
|
102
|
+
# the client received it — no realpath, no trailing slash.
|
|
103
|
+
self.assertEqual(
|
|
104
|
+
self.keychain.service_name('/Users/gas/.claude-accounts/acct-16'),
|
|
105
|
+
'Claude Code-credentials-4219e2b1')
|
|
106
|
+
|
|
107
|
+
def test_absent_then_present_roundtrip(self):
|
|
108
|
+
d = str(self.root / 'acct-01')
|
|
109
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
110
|
+
self.assertTrue(self.keychain.write(d, DOC, account='gas'))
|
|
111
|
+
res = self.keychain.probe(d)
|
|
112
|
+
self.assertEqual(res['state'], 'present')
|
|
113
|
+
self.assertEqual(res['doc'], DOC)
|
|
114
|
+
# -U update path keeps a single item and the account name it was made with
|
|
115
|
+
doc2 = {'claudeAiOauth': dict(DOC['claudeAiOauth'], accessToken='sk-ant-oat01-y')}
|
|
116
|
+
self.assertTrue(self.keychain.write(d, doc2))
|
|
117
|
+
again = self.keychain.probe(d)
|
|
118
|
+
self.assertEqual(again['doc']['claudeAiOauth']['accessToken'], 'sk-ant-oat01-y')
|
|
119
|
+
self.assertTrue(self.keychain.delete(d))
|
|
120
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
121
|
+
self.assertTrue(self.keychain.delete(d)) # deleting a missing item is fine
|
|
122
|
+
|
|
123
|
+
def test_locked_is_distinguished_from_absent(self):
|
|
124
|
+
d = str(self.root / 'acct-01')
|
|
125
|
+
self.keychain.write(d, DOC, account='tester')
|
|
126
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
127
|
+
res = self.keychain.probe(d)
|
|
128
|
+
self.assertEqual(res['state'], 'locked')
|
|
129
|
+
self.assertIsNone(res['doc'])
|
|
130
|
+
self.assertEqual(res['account'], 'tester')
|
|
131
|
+
self.assertGreater(self.keychain.item_mtime(d), 0)
|
|
132
|
+
# an id with NO item is still absent, not locked
|
|
133
|
+
self.assertEqual(self.keychain.probe(d + '-other')['state'], 'absent')
|
|
134
|
+
|
|
135
|
+
def test_corrupt_item(self):
|
|
136
|
+
d = str(self.root / 'acct-01')
|
|
137
|
+
svc = self.keychain.service_name(d)
|
|
138
|
+
self.kcdir.mkdir(exist_ok=True)
|
|
139
|
+
(self.kcdir / svc).write_text('not json at all')
|
|
140
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'corrupt')
|
|
141
|
+
|
|
142
|
+
def test_kill_switch(self):
|
|
143
|
+
d = str(self.root / 'acct-01')
|
|
144
|
+
self.keychain.write(d, DOC)
|
|
145
|
+
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '0'
|
|
146
|
+
self.assertFalse(self.keychain.enabled())
|
|
147
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
148
|
+
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
|
|
149
|
+
|
|
150
|
+
# -- audit + report integration --------------------------------------------------
|
|
151
|
+
def _manifest(self):
|
|
152
|
+
(self.root / 'accounts.json').write_text(json.dumps({
|
|
153
|
+
'version': 1, 'server': 'root@203.0.113.1',
|
|
154
|
+
'server_root': '/root/.claude-accounts',
|
|
155
|
+
'server_repo': '/root/claude-multiacc', 'threshold': 90,
|
|
156
|
+
'accounts': [{'id': 'acct-01', 'email': 'kc@test', 'home': 'mac',
|
|
157
|
+
'added_at': '2026-08-28T00:00:00Z'}]}))
|
|
158
|
+
|
|
159
|
+
def test_audit_sees_a_keychain_login(self):
|
|
160
|
+
import audit
|
|
161
|
+
self._manifest()
|
|
162
|
+
d = str(self.root / 'acct-01')
|
|
163
|
+
self.assertEqual(
|
|
164
|
+
audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
|
|
165
|
+
'home': 'mac'}, machine='mac')['state'],
|
|
166
|
+
'missing')
|
|
167
|
+
self.keychain.write(d, DOC)
|
|
168
|
+
row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
|
|
169
|
+
'home': 'mac'}, machine='mac')
|
|
170
|
+
self.assertEqual((row['state'], row['store']), ('ok', 'keychain'))
|
|
171
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
172
|
+
row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
|
|
173
|
+
'home': 'mac'}, machine='mac')
|
|
174
|
+
self.assertEqual(row['state'], 'locked')
|
|
175
|
+
self.assertEqual(row['label'], 'KEYCHAIN LOCKED')
|
|
176
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
|
|
177
|
+
|
|
178
|
+
def test_report_class_and_status(self):
|
|
179
|
+
self._manifest()
|
|
180
|
+
d = str(self.root / 'acct-01')
|
|
181
|
+
self.keychain.write(d, DOC)
|
|
182
|
+
out = subprocess.run(
|
|
183
|
+
[sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
|
|
184
|
+
'claude', 'mac', 'list'],
|
|
185
|
+
capture_output=True, text=True, check=True, env=os.environ.copy())
|
|
186
|
+
row = json.loads(out.stdout)['accounts'][0]
|
|
187
|
+
self.assertEqual(row['status'], 'active')
|
|
188
|
+
self.assertEqual(row['credential_class'], 'machine-local')
|
|
189
|
+
self.assertEqual(row['credentials']['oauth_store'], 'keychain')
|
|
190
|
+
self.assertEqual(row['credentials']['keychain'], 'readable')
|
|
191
|
+
env = dict(os.environ, FAKE_KEYCHAIN_LOCKED='1')
|
|
192
|
+
out = subprocess.run(
|
|
193
|
+
[sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
|
|
194
|
+
'claude', 'mac', 'list'],
|
|
195
|
+
capture_output=True, text=True, check=True, env=env)
|
|
196
|
+
row = json.loads(out.stdout)['accounts'][0]
|
|
197
|
+
self.assertEqual(row['status'], 'locked')
|
|
198
|
+
self.assertEqual(row['credential_class'], 'machine-local')
|
|
199
|
+
self.assertFalse(row['selectable'])
|
|
200
|
+
self.assertFalse(row['needs_login'])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == '__main__':
|
|
204
|
+
unittest.main()
|