claude-multiacc 2.0.3 → 2.0.5

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.
@@ -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,73 @@ 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
+ # Its shebang names an interpreter that exists ONLY beside it, mirroring the real
2455
+ # npm's `#!/usr/bin/env node`: resolving npm by absolute path is not enough if the
2456
+ # agent's PATH cannot find npm's own interpreter, which is how the update failed
2457
+ # with an empty version probe and no explanation.
2458
+ cat > "$PFX/bin/multiacc-fake-node" <<'NODEEOF'
2459
+ #!/usr/bin/env bash
2460
+ exec /bin/bash "$@"
2461
+ NODEEOF
2462
+ chmod +x "$PFX/bin/multiacc-fake-node"
2463
+ cat > "$PFX/bin/npm" <<NPMEOF
2464
+ #!/usr/bin/env multiacc-fake-node
2465
+ echo "npm \$*" >> "$WORK/npm-calls-$_cli.log"
2466
+ case "\${1:-}" in
2467
+ view) echo 9.9.9 ;;
2468
+ install) python3 - "$NPMROOT/package.json" <<'PY'
2469
+ import json, sys
2470
+ p = sys.argv[1]
2471
+ doc = json.load(open(p)); doc['version'] = '9.9.9'
2472
+ json.dump(doc, open(p, 'w'))
2473
+ PY
2474
+ ;;
2475
+ esac
2476
+ exit 0
2477
+ NPMEOF
2478
+ chmod +x "$PFX/bin/npm"
2479
+ # A DIFFERENT npm earlier on PATH must not win: the running copy's prefix owns it.
2480
+ mkdir -p "$WORK/wrongbin"
2481
+ printf '#!/usr/bin/env bash\necho "WRONG-NPM \$*" >> "%s"\nexit 0\n' "$WORK/npm-calls-$_cli.log" > "$WORK/wrongbin/npm"
2482
+ chmod +x "$WORK/wrongbin/npm"
2483
+ : > "$WORK/npm-calls-$_cli.log"
2484
+ out="$(env -i HOME="$HOME" PATH="$WORK/wrongbin:/usr/bin:/bin:/usr/sbin:/sbin" \
2485
+ CLAUDE_ACCOUNTS_DIR="$ACC" CODEX_ACCOUNTS_DIR="$WORK/codex-accounts" \
2486
+ "$NPMROOT/bin/$_cli" self-update 2>&1)"
2487
+ rc=$?
2488
+ [ "$rc" = "0" ] && t_ok "$_cli: self-update exits 0 with no npm on PATH" \
2489
+ || t_fail "$_cli self-update rc" "rc=$rc: $(printf '%s' "$out" | head -c 200)"
2490
+ printf '%s' "$out" | grep -q "npm not found" \
2491
+ && t_fail "$_cli self-update npm lookup" "still reports 'npm not found' with an npm in its own prefix" \
2492
+ || t_ok "$_cli: npm resolved without PATH"
2493
+ grep -q "^npm install " "$WORK/npm-calls-$_cli.log" \
2494
+ && t_ok "$_cli: the prefix's own npm performed the install" \
2495
+ || t_fail "$_cli npm install" "calls: $(cat "$WORK/npm-calls-$_cli.log" | head -3)"
2496
+ grep -q "WRONG-NPM" "$WORK/npm-calls-$_cli.log" \
2497
+ && t_fail "$_cli npm choice" "used the npm from PATH instead of the running prefix's" \
2498
+ || t_ok "$_cli: a stray npm on PATH never wins over the running prefix's"
2499
+ check "$_cli: self-update verifies the tree it wrote" "npm update ok (9.9.9)" "$out"
2500
+ grep -q "^npm view " "$WORK/npm-calls-$_cli.log" \
2501
+ && t_ok "$_cli: npm ran despite its interpreter being off PATH" \
2502
+ || t_fail "$_cli npm interpreter" "npm never executed — its shebang interpreter was not found"
2503
+ done
2504
+ # The agents install.sh writes must carry a PATH for the same reason (a future tool
2505
+ # that is not resolved by absolute path would hit exactly this again).
2506
+ grep -q '<key>PATH</key>' "$REPO_DIR/install.sh" \
2507
+ && t_ok "install.sh gives its launchd agents a PATH" \
2508
+ || t_fail "agent PATH" "plist_env_block writes no PATH — launchd agents get /usr/bin:/bin only"
2369
2509
  # postinstall must SKIP for a non-global install and never fail
2370
2510
  out="$(node "$REPO_DIR/scripts/postinstall.mjs" 2>&1)"
2371
2511
  rc=$?
@@ -4072,6 +4212,156 @@ for _bin in claude-accounts codex-accounts; do
4072
4212
  || t_fail "$_bin unknown verb --help" "exited 0 — the probe would accept a verb that does not exist"
4073
4213
  done
4074
4214
 
4215
+ # ---- 17. macOS Keychain-held logins --------------------------------------------------
4216
+ # Claude Code on macOS moves a per-dir OAuth login into the login Keychain (service
4217
+ # "Claude Code-credentials-<sha256(dir)[:8]>") from any keychain-capable session and
4218
+ # deletes .credentials.json — the 2026-08-28 incident read five working accounts as
4219
+ # "missing". The fake `security` above serves items from $FAKE_KEYCHAIN_DIR so the real
4220
+ # lookup code runs on Linux too. Own pool root: the main pool's manifest has been
4221
+ # through a dozen mutations by this point in the suite.
4222
+ KCP="$WORK/kcpool"
4223
+ export FAKE_KEYCHAIN_DIR="$WORK/keychain"
4224
+ mkdir -p "$KCP/tmp" "$KCP/acct-01" "$KCP/acct-02" "$FAKE_KEYCHAIN_DIR"
4225
+ : > "$KCP/.limits-kick"
4226
+ cat > "$KCP/accounts.json" <<EOF
4227
+ { "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
4228
+ "server_repo": "/root/claude-multiacc", "threshold": 90,
4229
+ "accounts": [
4230
+ {"id": "acct-01", "email": "kfile@test", "home": "mac", "added_at": "2026-08-28T00:00:00Z"},
4231
+ {"id": "acct-02", "email": "kchain@test", "home": "mac", "added_at": "2026-08-28T00:00:00Z"}
4232
+ ] }
4233
+ EOF
4234
+ printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kfile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$KCP/acct-01/.credentials.json"
4235
+ kc_hash() { printf '%s' "$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8; }
4236
+ kc_svc_file() { printf '%s/Claude Code-credentials-%s' "$FAKE_KEYCHAIN_DIR" "$(kc_hash "$1")"; }
4237
+ kc_put() { # kc_put <acct dir> <credential json> — store it the way the client would
4238
+ security add-generic-password -U -a tester -s "Claude Code-credentials-$(kc_hash "$1")" \
4239
+ -X "$(printf '%s' "$2" | python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
4240
+ }
4241
+ kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kchain","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
4242
+ export CLAUDE_MULTIACC_KEYCHAIN=1
4243
+
4244
+ # 17a. the keychain login reads as a working machine-local OAuth login
4245
+ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
4246
+ python3 - "$out" <<'EOF'
4247
+ import json, sys
4248
+ doc = json.loads(sys.argv[1])
4249
+ rows = {a['id']: a for a in doc['accounts']}
4250
+ a2 = rows['acct-02']
4251
+ assert a2['status'] == 'active', a2['status']
4252
+ assert a2['credential_class'] == 'machine-local', a2['credential_class']
4253
+ assert a2['credentials']['oauth_store'] == 'keychain', a2['credentials']
4254
+ assert a2['credentials']['keychain'] == 'readable', a2['credentials']
4255
+ assert a2['selectable'] is True, a2
4256
+ assert rows['acct-01']['credentials']['oauth_store'] == 'file', rows['acct-01']['credentials']
4257
+ assert doc['summary']['selectable'] == 2, doc['summary']
4258
+ EOF
4259
+ [ $? -eq 0 ] && t_ok "keychain login is active machine-local in list --json" \
4260
+ || t_fail "keychain list --json" "see assertions above"
4261
+ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list 2>&1)"
4262
+ check "keychain login shown in the plain list" "auth=keychain" "$out"
4263
+
4264
+ # 17b. the shim runs under a keychain-only account (file account parked by a limit)
4265
+ printf '%s\nbucket=weekly_scoped:Fable percent=95 reason=limits\n' "$((now+3600))" > "$KCP/acct-01/.limited"
4266
+ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
4267
+ check "shim selects the keychain-only account" "CFG=acct-02" "$out"
4268
+
4269
+ # 17c. locked keychain (ssh session): 'locked', never 'missing'; excluded HERE only
4270
+ out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts list --json 2>&1)"
4271
+ python3 - "$out" <<'EOF'
4272
+ import json, sys
4273
+ doc = json.loads(sys.argv[1])
4274
+ a2 = {a['id']: a for a in doc['accounts']}['acct-02']
4275
+ assert a2['status'] == 'locked', a2['status']
4276
+ assert a2['credential_class'] == 'machine-local', a2['credential_class']
4277
+ assert a2['credentials']['keychain'] == 'locked', a2['credentials']
4278
+ assert a2['selectable'] is False, a2
4279
+ assert a2['needs_login'] is False, 'locked must not join the re-login worklist'
4280
+ EOF
4281
+ [ $? -eq 0 ] && t_ok "locked keychain reads as 'locked' machine-local, not missing" \
4282
+ || t_fail "keychain locked state" "see assertions above"
4283
+ out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts expired --quiet 2>&1)"; rc=$?
4284
+ [ "$rc" = "0" ] && t_ok "a locked keychain login is not a relogin worklist item" \
4285
+ || t_fail "locked vs expired" "rc=$rc out=$out"
4286
+ out="$(FAKE_KEYCHAIN_LOCKED=1 CLAUDE_ACCOUNTS_DIR="$KCP" claude 2>&1)"
4287
+ check "shim never selects a keychain login it cannot read" "CFG=acct-01" "$out"
4288
+ rm -f "$KCP/acct-01/.limited"
4289
+
4290
+ # 17d. limits: the probe takes its bearer from the keychain
4291
+ rm -f "$KCP/acct-02/limits.json"
4292
+ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits 2>&1)"
4293
+ check "limits fetches with a keychain bearer" "acct-02: ok" "$out"
4294
+ python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("source")=="oauth", d' \
4295
+ "$KCP/acct-02/limits.json" 2>/dev/null \
4296
+ && t_ok "keychain-backed telemetry records source=oauth" \
4297
+ || t_fail "keychain limits source" "limits.json missing or wrong source"
4298
+
4299
+ # 17e. refresh: an expired keychain credential refreshes via the grant and is written
4300
+ # BACK to the keychain — never copied out into a .credentials.json
4301
+ kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kold","refreshToken":"sk-ant-ort01-kc","expiresAt":1000000,"refreshTokenExpiresAt":9999999999999}}'
4302
+ rm -f "$KCP/acct-02/limits.json" "$KCP/acct-02/.oauth-refresh.json"
4303
+ 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)"
4304
+ check "keychain credential refreshes via the grant" "acct-02: oauth access token refreshed" "$out"
4305
+ grep -q 'sk-ant-oat01-refreshednew' "$(kc_svc_file "$KCP/acct-02")" \
4306
+ && t_ok "rotated credential written back to the keychain" \
4307
+ || t_fail "keychain write-back" "item not updated"
4308
+ [ ! -f "$KCP/acct-02/.credentials.json" ] \
4309
+ && t_ok "refresh never copies the keychain credential to a file" \
4310
+ || t_fail "keychain leak to file" ".credentials.json appeared beside a keychain login"
4311
+
4312
+ # 17f. .expired self-heals on a keychain login written after the marker
4313
+ printf '%s\nreason=auth-error marked_at=t detail=test\n' "$now" > "$KCP/acct-02/.expired"
4314
+ touch -t 202001010000 "$KCP/acct-02/.expired"
4315
+ kc_put "$KCP/acct-02" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-knew","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
4316
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude >/dev/null 2>&1
4317
+ [ ! -f "$KCP/acct-02/.expired" ] \
4318
+ && t_ok ".expired self-heals on a newer keychain credential" \
4319
+ || t_fail "keychain .expired self-heal" "marker survived a newer keychain login"
4320
+
4321
+ # 17g. a login ceremony that lands in the keychain registers instead of dying
4322
+ 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)"
4323
+ check "add registers a keychain-backed sign-in" "Registered acct-03 for kc3@test" "$out"
4324
+ [ ! -f "$KCP/acct-03/.credentials.json" ] \
4325
+ && t_ok "keychain add leaves no plaintext credential" \
4326
+ || t_fail "keychain add" "unexpected .credentials.json"
4327
+ [ -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "the sign-in landed in the keychain" \
4328
+ || t_fail "keychain add item" "no keychain item for acct-03"
4329
+
4330
+ # 17h. export refuses a keychain-held machine-local credential, naming the store
4331
+ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts export-credential acct-03 --out "$WORK/kc-export.json" 2>&1)"; rc=$?
4332
+ [ "$rc" = "3" ] && t_ok "export-credential refuses a keychain login (exit 3)" \
4333
+ || t_fail "keychain export rc" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
4334
+ check "export names the keychain as the machine-local store" "macOS Keychain" "$out"
4335
+
4336
+ # 17i. remove deletes the keychain item with the account
4337
+ CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts remove acct-03 --yes >/dev/null 2>&1
4338
+ [ ! -f "$(kc_svc_file "$KCP/acct-03")" ] && t_ok "remove drops the keychain item" \
4339
+ || t_fail "keychain remove" "the item survived removal"
4340
+
4341
+ # 17j. dedupe keeps the duplicate that actually holds the (keychain) login. The
4342
+ # keychain holder gets the HIGHER id on purpose: with keychain auth invisible, the
4343
+ # lowest-id tiebreak would keep credential-less acct-04 and throw the grant away.
4344
+ python3 - "$KCP/accounts.json" <<'EOF'
4345
+ import json, sys
4346
+ doc = json.load(open(sys.argv[1]))
4347
+ doc['accounts'] += [
4348
+ {'id': 'acct-04', 'email': 'kdup@test', 'home': 'mac', 'added_at': '2026-08-28T00:00:00Z'},
4349
+ {'id': 'acct-05', 'email': 'kdup@test', 'home': 'mac', 'added_at': '2026-08-28T00:00:00Z'}]
4350
+ json.dump(doc, open(sys.argv[1], 'w'))
4351
+ EOF
4352
+ mkdir -p "$KCP/acct-04" "$KCP/acct-05"
4353
+ kc_put "$KCP/acct-05" '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kdup","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}'
4354
+ out="$(CLAUDE_ACCOUNTS_DIR="$KCP" claude-accounts dedupe --yes 2>&1)"
4355
+ check "dedupe removes the credential-less duplicate, not the keychain one" "will remove acct-04 (kdup@test)" "$out"
4356
+ [ -f "$(kc_svc_file "$KCP/acct-05")" ] && t_ok "the keychain-authed duplicate survives dedupe" \
4357
+ || t_fail "dedupe keychain keep" "acct-05's keychain item is gone"
4358
+ grep -q '"acct-04"' "$KCP/accounts.json" \
4359
+ && t_fail "dedupe manifest" "acct-04 still registered" \
4360
+ || t_ok "the credential-less duplicate left the manifest"
4361
+
4362
+ export CLAUDE_MULTIACC_KEYCHAIN=0
4363
+ unset FAKE_KEYCHAIN_DIR
4364
+
4075
4365
  # The reset-credit contract is easier to prove against a stateful local HTTP server
4076
4366
  # than file:// fixtures: it pins thresholding, credit ordering and idempotent POST retry.
4077
4367
  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()