claude-multiacc 2.0.58 → 2.0.59
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/README.md +111 -3
- package/bin/claude +323 -84
- package/bin/claude-accounts +273 -42
- package/bin/codex +15 -0
- package/bin/codex-accounts +31 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +28 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.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/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +81 -9
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +131 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +109 -0
- package/tests/run-tests.sh +1028 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +14 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_packaged_commands.py +41 -0
package/tests/run-tests.sh
CHANGED
|
@@ -186,6 +186,10 @@ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
186
186
|
# exits 143, and a crash (SIGKILL) leaves the registry behind. The scripted error only
|
|
187
187
|
# fires on a fresh launch; a `--resume <sid>` launch appends what the real client does
|
|
188
188
|
# after the auto-resume prompt — a normal reply — unless FAKE_TUI_QUIET is set.
|
|
189
|
+
# FAKE_TMUX_SCREEN is the pane's screen (the fake tmux's capture-pane prints it): every
|
|
190
|
+
# launch draws its own, and FAKE_TUI=memory puts the client's critical-memory banner in
|
|
191
|
+
# its footer. FAKE_TUI_IGNORE_TERM=1 is a client that SIGTERM does not stop (only KILL).
|
|
192
|
+
[ -n "${FAKE_TMUX_SCREEN:-}" ] && : > "$FAKE_TMUX_SCREEN"
|
|
189
193
|
acct="$(basename "$CLAUDE_CONFIG_DIR")"
|
|
190
194
|
sid=""; prev=""
|
|
191
195
|
for a in "$@"; do
|
|
@@ -212,7 +216,11 @@ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
212
216
|
reg="$CLAUDE_CONFIG_DIR/sessions/$$.json"
|
|
213
217
|
printf '{"pid":%s,"sessionId":"%s","cwd":"%s","kind":"interactive","status":"busy","startedAt":%s000}\n' \
|
|
214
218
|
"$$" "$sid" "$PWD" "$(date +%s)" > "$reg"
|
|
215
|
-
|
|
219
|
+
if [ -n "${FAKE_TUI_IGNORE_TERM:-}" ]; then
|
|
220
|
+
trap '' TERM
|
|
221
|
+
else
|
|
222
|
+
trap 'rm -f "$reg"; exit 143' TERM
|
|
223
|
+
fi
|
|
216
224
|
trap 'rm -f "$reg"; exit 129' HUP
|
|
217
225
|
if [ "$resumed" = 0 ]; then
|
|
218
226
|
sleep "${FAKE_TUI_DELAY:-0.3}"
|
|
@@ -221,6 +229,14 @@ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
221
229
|
auth) apierr authentication_failed ;;
|
|
222
230
|
invalid) apierr invalid_request ;;
|
|
223
231
|
crash) sleep 0.3; kill -9 $$ ;;
|
|
232
|
+
memory)
|
|
233
|
+
printf '{"type":"assistant","timestamp":"%s","isSidechain":false,"sessionId":"%s","message":{"model":"claude-opus-5-5","content":[{"type":"text","text":"Started the workflows."}]}}\n' \
|
|
234
|
+
"$(ts)" "$sid" >> "$tr"
|
|
235
|
+
# the prompt's row as 2.1.304 draws it: the mode indicator, then a no-break space
|
|
236
|
+
rule="────────────────────────────────────────────────────────────"
|
|
237
|
+
printf '%s\n' "⏺ Started the workflows." "" "$rule" "❯$(printf '\302\240')" "$rule" \
|
|
238
|
+
" ⏵⏵ bypass permissions on Critical memory usage (8.3GB) — restart and resume with claude --continue, or run /compact" \
|
|
239
|
+
> "${FAKE_TMUX_SCREEN:-/dev/null}" ;;
|
|
224
240
|
esac
|
|
225
241
|
fi
|
|
226
242
|
# Bounded, in short sleeps so a TERM is handled within a tenth of a second.
|
|
@@ -254,11 +270,14 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
|
|
|
254
270
|
esac
|
|
255
271
|
if [ -n "${FAKE_KEYCHAIN_CLIENT:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] \
|
|
256
272
|
&& [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
|
257
|
-
# The real macOS client's login lookup: the
|
|
258
|
-
# ($USER; Bun's "unknown" without it)
|
|
273
|
+
# The real macOS client's login lookup (2.1.303): the document in the Keychain item
|
|
274
|
+
# under its FA() name ONLY ($USER; Bun's "unknown" without it) when it can read one,
|
|
275
|
+
# else .credentials.json — nothing else — and that document is a login only when it
|
|
276
|
+
# carries claudeAiOauth: MCP OAuth state alone (mcpOAuth) is "Not logged in".
|
|
259
277
|
h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
|
|
260
|
-
|
|
261
|
-
|
|
278
|
+
doc="$(security find-generic-password -a "${USER:-unknown}" -s "Claude Code-credentials-$h" -w 2>/dev/null)" \
|
|
279
|
+
|| doc="$(cat "$CLAUDE_CONFIG_DIR/.credentials.json" 2>/dev/null)"
|
|
280
|
+
if ! printf '%s' "$doc" | grep -Eq '"claudeAiOauth"[[:space:]]*:[[:space:]]*[{]'; then
|
|
262
281
|
echo "Not logged in · Please run /login" >&2
|
|
263
282
|
exit 1
|
|
264
283
|
fi
|
|
@@ -341,7 +360,15 @@ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
341
360
|
"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$FAKE_LIMIT_RESET" "$FAKE_SESSION_ID" \
|
|
342
361
|
>> "$CLAUDE_CONFIG_DIR/projects/-proj/$FAKE_SESSION_ID.jsonl"
|
|
343
362
|
fi
|
|
344
|
-
|
|
363
|
+
if [ -n "${FAKE_SESSION_UNTIL:-}" ]; then
|
|
364
|
+
# held until the test says so (a loaded machine runs the steps slower than any timer)
|
|
365
|
+
fake_n=0
|
|
366
|
+
while [ ! -e "$FAKE_SESSION_UNTIL" ] && [ "$fake_n" -lt $(( ${FAKE_SESSION_HOLD:-4} * 10 )) ]; do
|
|
367
|
+
sleep 0.1; fake_n=$((fake_n + 1))
|
|
368
|
+
done
|
|
369
|
+
else
|
|
370
|
+
sleep "${FAKE_SESSION_HOLD:-4}"
|
|
371
|
+
fi
|
|
345
372
|
rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
|
|
346
373
|
fi
|
|
347
374
|
# what an interactive client would inherit from the shim (auto-resume must leak nothing)
|
|
@@ -363,8 +390,12 @@ chmod +x "$FAKEBIN/security"
|
|
|
363
390
|
|
|
364
391
|
export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
|
|
365
392
|
export FAKE_CTL="$WORK/ctl"
|
|
366
|
-
# Neutralize any ambient state from the invoking environment.
|
|
367
|
-
|
|
393
|
+
# Neutralize any ambient state from the invoking environment. A pooled session exports
|
|
394
|
+
# its pool roots and config dir to every child: run from inside one, the suite's shim
|
|
395
|
+
# calls resolved the REAL pools (2026-10-14: a sandbox test printed a live OAuth token),
|
|
396
|
+
# so the roots go too — every test that needs one sets it explicitly.
|
|
397
|
+
unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN CLAUDE_ACCOUNT CLAUDE_SHIM_ACTIVE \
|
|
398
|
+
CLAUDE_ACCOUNTS_ROOT CODEX_ACCOUNTS_ROOT CODEX_HOME CODEX_ACCOUNT CODEX_SHIM_ACTIVE 2>/dev/null || true
|
|
368
399
|
export CLAUDE_MULTIACC_NO_SYNC=1
|
|
369
400
|
# Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
|
|
370
401
|
# is off by default here; the throttle test re-enables it explicitly.
|
|
@@ -379,12 +410,14 @@ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
|
379
410
|
# Same for the usage endpoint: the SHIM's opportunistic background `limits --quiet`
|
|
380
411
|
# kick inherits this default, so it can never reach a real endpoint from tests
|
|
381
412
|
# (every explicit limits test overrides the URL inline). The pre-armed .limits-kick
|
|
382
|
-
# throttle keeps those background kicks from racing explicit limits runs
|
|
413
|
+
# throttle keeps those background kicks from racing explicit limits runs: armed in the
|
|
414
|
+
# FUTURE, since a throttle armed "now" lapses after 10 minutes, and on a loaded machine a pool
|
|
415
|
+
# outlives that (a kicked pass then held the lock and left a backoff behind).
|
|
383
416
|
export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
384
417
|
# Organization checks must never send synthetic tokens to a real provider. The
|
|
385
418
|
# dedicated identity suite overrides this with its loopback HTTP contract server.
|
|
386
419
|
export CLAUDE_MULTIACC_TOKEN_IDENTITY_URL="file://$WORK/identity-endpoint-missing.json"
|
|
387
|
-
|
|
420
|
+
touch -t 203001010000 "$ACC/.limits-kick"
|
|
388
421
|
# Keychain lookups are OFF for the legacy sections (their pools are file-based and the
|
|
389
422
|
# extra `security` process per account would only slow them down); section 17 turns
|
|
390
423
|
# them on explicitly against the fake `security` above.
|
|
@@ -399,6 +432,12 @@ export CLAUDE_MULTIACC_PATH_PROBE=0
|
|
|
399
432
|
# tmux — a watcher here would otherwise type into the developer's REAL pane.
|
|
400
433
|
export CLAUDE_MULTIACC_AUTORESUME=0 CODEX_MULTIACC_AUTORESUME=0
|
|
401
434
|
unset TMUX TMUX_PANE 2>/dev/null || true
|
|
435
|
+
# The model catalog refresh (lib/model_catalog.py) writes <pool>/model-catalog.json: every
|
|
436
|
+
# limits pass for its own pool, the verb and self-update for BOTH. Until the codex section the
|
|
437
|
+
# suite has no codex root, so the claude side's "other pool" is the operator's real one. Off
|
|
438
|
+
# everywhere; section 19a turns it on against its own roots (and the limits-pass backstop is
|
|
439
|
+
# exercised with the switch on in tests/test_autoresume_catalog_cli.py).
|
|
440
|
+
export CLAUDE_MULTIACC_MODEL_CATALOG=0 CODEX_MULTIACC_MODEL_CATALOG=0
|
|
402
441
|
|
|
403
442
|
now="$(date +%s)"
|
|
404
443
|
|
|
@@ -1188,6 +1227,451 @@ check "a model 404 reaches the caller as it is" "not available on your firstPart
|
|
|
1188
1227
|
|| t_fail "model 404 park" "$(tail -1 "$ACC/acct-01/.expired")"
|
|
1189
1228
|
rm -f "$FAKE_CTL" "$ACC"/acct-*/limits.json "$ACC"/acct-*/.limited "$ACC"/acct-*/.expired
|
|
1190
1229
|
|
|
1230
|
+
# ---- 9g. a credentials file holding only MCP state is NO login --------------------------
|
|
1231
|
+
# Claude Code 2.1.303 keeps the OAuth state of the MCP servers an account signed into
|
|
1232
|
+
# (mcpOAuth, every entry with its own expiresAt and refreshToken) in the same document as
|
|
1233
|
+
# the login, and from a session that cannot open the Keychain (ssh/tmux) it writes a
|
|
1234
|
+
# .credentials.json holding ONLY that. Counted as the login, such accounts were picked
|
|
1235
|
+
# with no token exported, so every run said "Not logged in · Run /login" (2026-10-19):
|
|
1236
|
+
# the greedy expiry scrape read the MCP entry's future expiresAt as the login's. Own pool
|
|
1237
|
+
# root; FAKE_KEYCHAIN_CLIENT makes the fake client refuse a run with no login at all.
|
|
1238
|
+
MOP="$WORK/mcponly"
|
|
1239
|
+
MOP_HOME=mac; [ "$(uname -s)" = "Darwin" ] || MOP_HOME=server
|
|
1240
|
+
mkdir -p "$MOP/tmp" "$MOP/acct-01" "$MOP/acct-02"
|
|
1241
|
+
: > "$MOP/.limits-kick"
|
|
1242
|
+
cat > "$MOP/accounts.json" <<EOF
|
|
1243
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
1244
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
1245
|
+
"accounts": [
|
|
1246
|
+
{"id": "acct-01", "email": "mtok@test", "home": "$MOP_HOME", "added_at": "2026-10-19T00:00:00Z"},
|
|
1247
|
+
{"id": "acct-02", "email": "mnone@test", "home": "$MOP_HOME", "added_at": "2026-10-19T00:00:00Z"}
|
|
1248
|
+
] }
|
|
1249
|
+
EOF
|
|
1250
|
+
MCP_KEY='higgsfield|0123456789abcdef'
|
|
1251
|
+
MCP_ENTRY='"higgsfield|0123456789abcdef":{"serverName":"higgsfield","serverUrl":"https://mcp.higgsfield.ai/mcp","accessToken":"mcp-access-higgsfield","refreshToken":"mcp-refresh-higgsfield","expiresAt":9999999999999,"scope":"openid","issuer":"https://clerk.higgsfield.ai","clientId":"https://claude.ai/oauth/claude-code-client-metadata","redirectUri":"http://localhost:1/callback","discoveryState":{"authorizationServerUrl":"https://clerk.higgsfield.ai","oauthMetadataFound":true}}'
|
|
1252
|
+
MCP_ONLY="{\"mcpOAuth\":{$MCP_ENTRY}}"
|
|
1253
|
+
MOP_TOK='sk-ant-oat01-MCPONLYTOKENMCPONLYTOKENMCPONLYTOKENMCPONLY01'
|
|
1254
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1255
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-02/.credentials.json"
|
|
1256
|
+
printf '%s' "$MOP_TOK" > "$MOP/acct-01/server.token"
|
|
1257
|
+
ok1=1
|
|
1258
|
+
for _ in $(seq 1 10); do
|
|
1259
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude 2>&1)"
|
|
1260
|
+
case "$out" in *"CFG=acct-01 TOK=$MOP_TOK"*) ;; *) ok1=0; break ;; esac
|
|
1261
|
+
done
|
|
1262
|
+
[ "$ok1" = 1 ] && t_ok "an MCP-only credentials file leaves the account to its token, which is exported" \
|
|
1263
|
+
|| t_fail "MCP-only file + token" "got: $(printf '%s' "$out" | head -c 160)"
|
|
1264
|
+
mv "$MOP/acct-01/server.token" "$WORK/mcponly-token.aside"
|
|
1265
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1266
|
+
check "with no token and no Keychain login, an MCP-only account is not selectable" "CFG=none" "$out"
|
|
1267
|
+
mv "$WORK/mcponly-token.aside" "$MOP/acct-01/server.token"
|
|
1268
|
+
# A DEAD login beside a live MCP entry is dead, in either key order: the scrape reads the
|
|
1269
|
+
# login object alone, never the entry's expiresAt or refreshToken.
|
|
1270
|
+
for order in login-first mcp-first; do
|
|
1271
|
+
if [ "$order" = login-first ]; then
|
|
1272
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-expired","expiresAt":1000000000000,"scopes":["user:inference"]},"mcpOAuth":{%s}}' \
|
|
1273
|
+
"$MCP_ENTRY" > "$MOP/acct-01/.credentials.json"
|
|
1274
|
+
else
|
|
1275
|
+
printf '{"mcpOAuth":{%s},"claudeAiOauth":{"accessToken":"sk-ant-oat01-expired","expiresAt":1000000000000}}' \
|
|
1276
|
+
"$MCP_ENTRY" > "$MOP/acct-01/.credentials.json"
|
|
1277
|
+
fi
|
|
1278
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1279
|
+
check "a dead login beside a live MCP entry is dead ($order): the token is exported" \
|
|
1280
|
+
"CFG=acct-01 TOK=$MOP_TOK" "$out"
|
|
1281
|
+
done
|
|
1282
|
+
mv "$MOP/acct-01/server.token" "$WORK/mcponly-token.aside"
|
|
1283
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1284
|
+
check "...and with no token it is not selected" "CFG=none" "$out"
|
|
1285
|
+
grep -q "skipped-expired: acct-01" "$MOP/selection.log" 2>/dev/null \
|
|
1286
|
+
&& t_ok "...but listed as a dead login (skipped-expired)" \
|
|
1287
|
+
|| t_fail "dead login beside MCP state" "no skipped-expired line for acct-01"
|
|
1288
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list 2>&1)"
|
|
1289
|
+
check "list flags the dead login beside MCP state" "EXPIRED-LOGIN" "$(printf '%s\n' "$out" | grep '^acct-01')"
|
|
1290
|
+
mv "$WORK/mcponly-token.aside" "$MOP/acct-01/server.token"
|
|
1291
|
+
# A credential park heals on a newer LOGIN; an MCP-only rewrite (the client saves one on
|
|
1292
|
+
# every MCP token refresh) is no such evidence.
|
|
1293
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1294
|
+
printf '%s\nreason=auth-error marked_at=t detail=test\n' "$now" > "$MOP/acct-01/.expired"
|
|
1295
|
+
touch -t 202001010101 "$MOP/acct-01/server.token" # the token is OLDER than the park
|
|
1296
|
+
touch -t 202001010202 "$MOP/acct-01/.expired"
|
|
1297
|
+
touch "$MOP/acct-01/.credentials.json" # MCP state rewritten after it
|
|
1298
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1299
|
+
check "an MCP-only rewrite does not heal an auth park" "CFG=none" "$out"
|
|
1300
|
+
[ -f "$MOP/acct-01/.expired" ] && t_ok "...the park stays in force" \
|
|
1301
|
+
|| t_fail "MCP-only rewrite healed a park" ".expired was removed"
|
|
1302
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list 2>&1)"
|
|
1303
|
+
check "...for the audit too" "EXPIRED-LOGIN" "$(printf '%s\n' "$out" | grep '^acct-01')"
|
|
1304
|
+
printf '{"mcpOAuth":{%s},"claudeAiOauth":{"accessToken":"sk-ant-oat01-relogged","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' \
|
|
1305
|
+
"$MCP_ENTRY" > "$MOP/acct-01/.credentials.json"
|
|
1306
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude 2>&1)"
|
|
1307
|
+
check "a login written after the park heals it, and runs on the login" "CFG=acct-01 TOK=none" "$out"
|
|
1308
|
+
[ ! -f "$MOP/acct-01/.expired" ] && t_ok "...and the park is gone" \
|
|
1309
|
+
|| t_fail "login write did not heal" ".expired survived a newer login"
|
|
1310
|
+
# list / list --json / verify: an MCP-only file is no oauth login anywhere.
|
|
1311
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1312
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list 2>&1)"
|
|
1313
|
+
line="$(printf '%s\n' "$out" | grep '^acct-01')"
|
|
1314
|
+
case "$line" in
|
|
1315
|
+
*"auth=token "*) t_ok "list shows auth=token, not oauth+token, for an MCP-only file" ;;
|
|
1316
|
+
*) t_fail "list auth column for an MCP-only file" "$line" ;;
|
|
1317
|
+
esac
|
|
1318
|
+
check "list calls the token-less MCP-only account NO-LOGIN" "auth=NONE" "$(printf '%s\n' "$out" | grep '^acct-02')"
|
|
1319
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list --json 2>&1)"
|
|
1320
|
+
python3 - "$out" <<'EOF'
|
|
1321
|
+
import json, sys
|
|
1322
|
+
rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
|
|
1323
|
+
a1, a2 = rows['acct-01'], rows['acct-02']
|
|
1324
|
+
assert a1['credentials']['oauth'] is False and a1['credentials']['oauth_store'] is None, a1['credentials']
|
|
1325
|
+
assert a1['credential_class'] == 'portable' and a1['selectable'] is True, a1
|
|
1326
|
+
assert a2['credentials']['oauth'] is False and a2['credential_class'] == 'none', a2
|
|
1327
|
+
assert a2['status'] == 'missing' and a2['needs_login'] is True, a2
|
|
1328
|
+
assert 'no Claude login' in a2['reason'], a2['reason']
|
|
1329
|
+
EOF
|
|
1330
|
+
[ $? -eq 0 ] && t_ok "list --json: an MCP-only file is no oauth login (token-backed: portable; else missing)" \
|
|
1331
|
+
|| t_fail "MCP-only list --json" "see assertions above"
|
|
1332
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude-accounts verify 2>&1)"
|
|
1333
|
+
check "verify runs an MCP-only account on its token" "acct-01 mtok@test: PASS" "$out"
|
|
1334
|
+
check "verify skips an MCP-only account with nothing to authenticate by" \
|
|
1335
|
+
"acct-02 mnone@test: SKIP (no auth on this machine)" "$out"
|
|
1336
|
+
[ ! -f "$MOP/acct-02/.expired" ] && t_ok "verify parks nothing over an MCP-only file" \
|
|
1337
|
+
|| t_fail "verify parked an MCP-only account" "$(tail -1 "$MOP/acct-02/.expired")"
|
|
1338
|
+
# limits: an MCP-only file is no login to refresh — never a dead grant to park — and the
|
|
1339
|
+
# pass leaves it byte-for-byte alone.
|
|
1340
|
+
cp "$MOP/acct-02/.credentials.json" "$WORK/mcponly-before.json"
|
|
1341
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts limits --force >/dev/null 2>&1
|
|
1342
|
+
[ ! -f "$MOP/acct-02/.expired" ] && t_ok "limits never parks an MCP-only file as a dead grant" \
|
|
1343
|
+
|| t_fail "limits parked an MCP-only account" "$(tail -1 "$MOP/acct-02/.expired")"
|
|
1344
|
+
cmp -s "$WORK/mcponly-before.json" "$MOP/acct-02/.credentials.json" \
|
|
1345
|
+
&& t_ok "limits leaves the MCP state exactly as it was" \
|
|
1346
|
+
|| t_fail "limits rewrote an MCP-only file" "$(head -c 160 "$MOP/acct-02/.credentials.json")"
|
|
1347
|
+
# A refresh writes the login INTO the file as it is when it lands: the MCP token the client
|
|
1348
|
+
# saved while the grant was in flight survives (the endpoint stub saves one mid-flight).
|
|
1349
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-stale","refreshToken":"sk-ant-ort01-mcpkeep","expiresAt":1000,"refreshTokenExpiresAt":9999999999999},"mcpOAuth":{%s},"mcpOAuthClientConfig":{"%s":{"clientId":"cid-kept"}}}' \
|
|
1350
|
+
"$MCP_ENTRY" "$MCP_KEY" > "$MOP/acct-02/.credentials.json"
|
|
1351
|
+
cat > "$WORK/mcp-token-server.py" <<'EOF'
|
|
1352
|
+
import http.server, json, os, sys
|
|
1353
|
+
target = sys.argv[1]
|
|
1354
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
1355
|
+
def log_message(self, *a): pass
|
|
1356
|
+
def do_POST(self):
|
|
1357
|
+
try:
|
|
1358
|
+
self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
|
1359
|
+
except Exception:
|
|
1360
|
+
pass
|
|
1361
|
+
doc = json.load(open(target))
|
|
1362
|
+
doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] = 'mcp-refresh-SAVED-MIDFLIGHT'
|
|
1363
|
+
with open(target + '.mid', 'w') as f:
|
|
1364
|
+
json.dump(doc, f)
|
|
1365
|
+
os.replace(target + '.mid', target)
|
|
1366
|
+
raw = json.dumps({'access_token': 'sk-ant-oat01-mcprefreshed', 'refresh_token': 'sk-ant-ort01-mcprotated',
|
|
1367
|
+
'expires_in': 28800, 'refresh_token_expires_in': 2592000}).encode()
|
|
1368
|
+
self.send_response(200)
|
|
1369
|
+
self.send_header('Content-Type', 'application/json')
|
|
1370
|
+
self.send_header('Content-Length', str(len(raw)))
|
|
1371
|
+
self.end_headers()
|
|
1372
|
+
self.wfile.write(raw)
|
|
1373
|
+
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
|
|
1374
|
+
print(srv.server_address[1], flush=True)
|
|
1375
|
+
srv.serve_forever()
|
|
1376
|
+
EOF
|
|
1377
|
+
mport=""
|
|
1378
|
+
python3 "$WORK/mcp-token-server.py" "$MOP/acct-02/.credentials.json" > "$WORK/mcp-token-port" 2>/dev/null &
|
|
1379
|
+
msrv_pid=$!
|
|
1380
|
+
STUB_PIDS="$STUB_PIDS $msrv_pid"
|
|
1381
|
+
for _ in $(seq 1 20); do
|
|
1382
|
+
mport="$(head -1 "$WORK/mcp-token-port" 2>/dev/null)"
|
|
1383
|
+
case "$mport" in ''|*[!0-9]*) mport=""; sleep 0.2 ;; *) break ;; esac
|
|
1384
|
+
done
|
|
1385
|
+
if [ -z "$mport" ]; then
|
|
1386
|
+
t_ok "mid-flight MCP save test skipped (cannot bind a loopback port here)"
|
|
1387
|
+
else
|
|
1388
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$mport/token" \
|
|
1389
|
+
claude-accounts limits --force 2>&1)"
|
|
1390
|
+
check "a login beside MCP state refreshes" "acct-02: oauth access token refreshed" "$out"
|
|
1391
|
+
python3 - "$MOP/acct-02/.credentials.json" <<'EOF'
|
|
1392
|
+
import json, os, stat, sys
|
|
1393
|
+
doc = json.load(open(sys.argv[1]))
|
|
1394
|
+
o = doc['claudeAiOauth']
|
|
1395
|
+
assert o['accessToken'] == 'sk-ant-oat01-mcprefreshed' and o['refreshToken'] == 'sk-ant-ort01-mcprotated', 'login not rotated'
|
|
1396
|
+
entry = doc['mcpOAuth']['higgsfield|0123456789abcdef']
|
|
1397
|
+
assert entry['refreshToken'] == 'mcp-refresh-SAVED-MIDFLIGHT', 'the MCP token saved mid-flight was rolled back'
|
|
1398
|
+
assert entry['accessToken'] == 'mcp-access-higgsfield', entry
|
|
1399
|
+
assert doc['mcpOAuthClientConfig'] == {'higgsfield|0123456789abcdef': {'clientId': 'cid-kept'}}, doc
|
|
1400
|
+
assert stat.S_IMODE(os.stat(sys.argv[1]).st_mode) == 0o600
|
|
1401
|
+
EOF
|
|
1402
|
+
[ $? -eq 0 ] && t_ok "the refresh writes the login INTO the file: MCP state saved mid-flight survives" \
|
|
1403
|
+
|| t_fail "refresh clobbered MCP state" "see assertions above"
|
|
1404
|
+
fi
|
|
1405
|
+
kill "$msrv_pid" 2>/dev/null; wait "$msrv_pid" 2>/dev/null || true
|
|
1406
|
+
# import --creds: the login lands INTO the account's file, and a file without one is refused.
|
|
1407
|
+
printf '%s' "$MCP_ONLY" > "$WORK/mcponly-import.json"
|
|
1408
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts import mimp@test --id acct-03 --creds "$WORK/mcponly-import.json" --no-sync 2>&1)"; rc=$?
|
|
1409
|
+
[ "$rc" != 0 ] && [ ! -d "$MOP/acct-03" ] && t_ok "import refuses a credentials file holding no login" \
|
|
1410
|
+
|| t_fail "import of an MCP-only file" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
1411
|
+
check "...and says why" "holds no Claude login" "$out"
|
|
1412
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1413
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-imported","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$WORK/login-import.json"
|
|
1414
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts import mtok@test --id acct-01 --creds "$WORK/login-import.json" --no-sync 2>&1)"
|
|
1415
|
+
check "import updates an account whose file holds MCP state" "Imported acct-01" "$out"
|
|
1416
|
+
python3 - "$MOP/acct-01/.credentials.json" <<'EOF'
|
|
1417
|
+
import json, sys
|
|
1418
|
+
doc = json.load(open(sys.argv[1]))
|
|
1419
|
+
assert doc['claudeAiOauth']['accessToken'] == 'sk-ant-oat01-imported', doc
|
|
1420
|
+
assert doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] == 'mcp-refresh-higgsfield', doc
|
|
1421
|
+
EOF
|
|
1422
|
+
[ $? -eq 0 ] && t_ok "import writes the login INTO the file, keeping its MCP state" \
|
|
1423
|
+
|| t_fail "import clobbered MCP state" "see assertions above"
|
|
1424
|
+
# A transcript's auth failure is charged to the credential THAT run carried, never to the
|
|
1425
|
+
# one the account would get now. The pre-fix shim ran these accounts with no token at all
|
|
1426
|
+
# ("Not logged in"), so blaming their token for it parked a working token durably — and a
|
|
1427
|
+
# verify PASS could not stick: the very next run re-read the same record and re-parked it.
|
|
1428
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1429
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-02/.credentials.json" # acct-01 is the only candidate
|
|
1430
|
+
rm -f "$MOP"/acct-0*/.expired "$MOP"/acct-0*/.sessions-index "$MOP"/acct-0*/.client-org-cleared
|
|
1431
|
+
printf '%s\n' "$(printf '%s' "$MOP_TOK" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)" \
|
|
1432
|
+
> "$MOP/acct-01/.server-token-verified"
|
|
1433
|
+
MSID="cafecafe-1111-4222-8333-444455556666"
|
|
1434
|
+
MSID2="cafecafe-7777-4888-8999-aaaabbbbcccc"
|
|
1435
|
+
mkdir -p "$MOP/acct-01/sessions" "$MOP/acct-01/projects/-proj"
|
|
1436
|
+
mop_iso() { date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
|
1437
|
+
mop_auth() { # $1 session id, $2 epoch: the client's own authentication_failed record
|
|
1438
|
+
printf '{"type":"assistant","isApiErrorMessage":true,"isSidechain":false,"error":"authentication_failed","timestamp":"%s","sessionId":"%s","message":{"content":[{"type":"text","text":"Not logged in · Please run /login"}]}}\n' \
|
|
1439
|
+
"$(mop_iso "$2")" "$1" >> "$MOP/acct-01/projects/-proj/$1.jsonl"
|
|
1440
|
+
}
|
|
1441
|
+
mnow="$(date +%s)"
|
|
1442
|
+
printf '%s %s\n' "$MSID" "$(mop_iso $((mnow - 600)))" > "$MOP/acct-01/.sessions-index"
|
|
1443
|
+
mop_auth "$MSID" $((mnow - 300))
|
|
1444
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1445
|
+
check "an untagged run's auth failure takes the account out of the pool" "CFG=none" "$out"
|
|
1446
|
+
grep -q 'reason=auth-error' "$MOP/acct-01/.expired" 2>/dev/null \
|
|
1447
|
+
&& t_ok "...as the login's failure (auth-error), never the token's" \
|
|
1448
|
+
|| t_fail "untagged auth failure blamed the token" "$(tail -1 "$MOP/acct-01/.expired" 2>/dev/null)"
|
|
1449
|
+
[ -f "$MOP/acct-01/.server-token-verified" ] && t_ok "...and the token's proof survives it" \
|
|
1450
|
+
|| t_fail "untagged auth failure" "the token's .server-token-verified was dropped"
|
|
1451
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude-accounts verify 2>&1)"
|
|
1452
|
+
check "verify passes the parked MCP-only account on its token" "acct-01 mtok@test: PASS" "$out"
|
|
1453
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1454
|
+
check "the PASS sticks: the record before it re-parks nothing, the token is exported" \
|
|
1455
|
+
"CFG=acct-01 TOK=$MOP_TOK" "$out"
|
|
1456
|
+
[ ! -f "$MOP/acct-01/.expired" ] && t_ok "...and no park is left behind" \
|
|
1457
|
+
|| t_fail "verify PASS re-parked" "$(tail -1 "$MOP/acct-01/.expired")"
|
|
1458
|
+
# A run the shim launched on the token is tagged in the index, and ITS failure retires it.
|
|
1459
|
+
rm -f "$MOP/acct-01/.sessions-index"
|
|
1460
|
+
FAKE_SESSION_ID="$MSID2" FAKE_SESSION_HOLD=2 CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1461
|
+
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
1462
|
+
grep -q -- "^$MSID2 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null && break
|
|
1463
|
+
sleep 0.2
|
|
1464
|
+
done
|
|
1465
|
+
grep -q -- "^$MSID2 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1466
|
+
&& t_ok "the shim tags the session of a run it launched on the portable token" \
|
|
1467
|
+
|| t_fail "token-run tag" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1468
|
+
sleep 1 # a real failure postdates verify's watermark
|
|
1469
|
+
mop_auth "$MSID2" "$(date +%s)"
|
|
1470
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1471
|
+
grep -q 'reason=setup-token-invalid' "$MOP/acct-01/.expired" 2>/dev/null \
|
|
1472
|
+
&& t_ok "that run's auth failure parks the token (setup-token-invalid)" \
|
|
1473
|
+
|| t_fail "tagged auth failure" "$(tail -1 "$MOP/acct-01/.expired" 2>/dev/null)"
|
|
1474
|
+
[ ! -f "$MOP/acct-01/.server-token-verified" ] && t_ok "...and drops the token's proof" \
|
|
1475
|
+
|| t_fail "tagged auth failure" ".server-token-verified survived"
|
|
1476
|
+
# /clear (or /resume inside the TUI) has the SAME process run a new session id: 2.1.304
|
|
1477
|
+
# rewrites only the sessionId in sessions/<pid>.json, long after the capture's one look. The
|
|
1478
|
+
# shim keeps the credential by process (.sessions-via/<pid>, keyed by the registry's
|
|
1479
|
+
# startedAt), so any launch's refresh tags the new id too, and its auth failure retires the
|
|
1480
|
+
# token. Before, that id was sighted untagged: its failure parked the login (auth-error) of
|
|
1481
|
+
# an account with none, and kept the dead token's proof.
|
|
1482
|
+
rm -rf "$MOP/acct-01/.expired" "$MOP/acct-01/.sessions-index" "$MOP/acct-01/.sessions-via"
|
|
1483
|
+
printf '%s\n' "$(printf '%s' "$MOP_TOK" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)" \
|
|
1484
|
+
> "$MOP/acct-01/.server-token-verified"
|
|
1485
|
+
MSID3="cafecafe-3333-4ccc-8ddd-eeeeffff0003"
|
|
1486
|
+
MSID4="cafecafe-4444-4ccc-8ddd-eeeeffff0004"
|
|
1487
|
+
rm -f "$WORK/mop-switch-done"
|
|
1488
|
+
FAKE_SESSION_ID="$MSID3" FAKE_SESSION_UNTIL="$WORK/mop-switch-done" FAKE_SESSION_HOLD=180 \
|
|
1489
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1 &
|
|
1490
|
+
mswitch=$!
|
|
1491
|
+
for _ in $(seq 1 600); do
|
|
1492
|
+
[ -f "$MOP/acct-01/.sessions-via/$mswitch" ] \
|
|
1493
|
+
&& grep -q -- "^$MSID3 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null && break
|
|
1494
|
+
sleep 0.1
|
|
1495
|
+
done
|
|
1496
|
+
grep -q -- "^$MSID3 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1497
|
+
&& t_ok "the run's first session is tagged by its capture" \
|
|
1498
|
+
|| t_fail "switch: first session tag" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1499
|
+
[ -f "$MOP/acct-01/.sessions-via/$mswitch" ] \
|
|
1500
|
+
&& t_ok "...and its credential is on record by process (.sessions-via/<pid>)" \
|
|
1501
|
+
|| t_fail "switch: process record" "$(ls -a "$MOP/acct-01/.sessions-via" 2>&1 | tr '\n' ' ')"
|
|
1502
|
+
# What /clear does in 2.1.304: the same process and registry file, startedAt kept, a new
|
|
1503
|
+
# sessionId (and transcript), long after the capture is gone.
|
|
1504
|
+
mreg="$MOP/acct-01/sessions/$mswitch.json"
|
|
1505
|
+
sed "s/\"sessionId\":\"$MSID3\"/\"sessionId\":\"$MSID4\"/" "$mreg" > "$mreg.tmp" && mv -f "$mreg.tmp" "$mreg"
|
|
1506
|
+
printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$MSID4" \
|
|
1507
|
+
> "$MOP/acct-01/projects/-proj/$MSID4.jsonl"
|
|
1508
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1 # any launch's refresh sights the new id
|
|
1509
|
+
grep -q -- "^$MSID4 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1510
|
+
&& t_ok "a session the client switched the run to keeps the run's credential (token)" \
|
|
1511
|
+
|| t_fail "switch: switched-session tag" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1512
|
+
mop_auth "$MSID4" "$(date +%s)"
|
|
1513
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1514
|
+
grep -q 'reason=setup-token-invalid' "$MOP/acct-01/.expired" 2>/dev/null \
|
|
1515
|
+
&& t_ok "...so its auth failure parks the token (setup-token-invalid)" \
|
|
1516
|
+
|| t_fail "switch: auth failure" "$(tail -1 "$MOP/acct-01/.expired" 2>/dev/null)"
|
|
1517
|
+
[ ! -f "$MOP/acct-01/.server-token-verified" ] && t_ok "...and drops the token's proof" \
|
|
1518
|
+
|| t_fail "switch: auth failure" ".server-token-verified survived"
|
|
1519
|
+
: > "$WORK/mop-switch-done"
|
|
1520
|
+
wait "$mswitch" 2>/dev/null
|
|
1521
|
+
# The record names the process it was written for: under a reused pid (another startedAt)
|
|
1522
|
+
# it tags nothing; the process's own record does.
|
|
1523
|
+
rm -f "$MOP/acct-01/.expired" "$MOP/acct-01/.sessions-index"
|
|
1524
|
+
MSID5="cafecafe-5555-4ccc-8ddd-eeeeffff0005"
|
|
1525
|
+
mkdir -p "$MOP/acct-01/.sessions-via"
|
|
1526
|
+
printf '{"pid":99991,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":1790000000000}\n' \
|
|
1527
|
+
"$MSID5" > "$MOP/acct-01/sessions/99991.json"
|
|
1528
|
+
printf '1780000000000 token\n' > "$MOP/acct-01/.sessions-via/99991"
|
|
1529
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1530
|
+
{ grep -q -- "^$MSID5 " "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1531
|
+
&& ! grep -q -- "^$MSID5 .* token\$" "$MOP/acct-01/.sessions-index"; } \
|
|
1532
|
+
&& t_ok "a credential record of an earlier process under the same pid tags nothing" \
|
|
1533
|
+
|| t_fail "switch: reused pid" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1534
|
+
printf '1790000000000 token\n' > "$MOP/acct-01/.sessions-via/99991"
|
|
1535
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1536
|
+
grep -q -- "^$MSID5 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1537
|
+
&& t_ok "...while the process's own record tags a session it already knew" \
|
|
1538
|
+
|| t_fail "switch: own record" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1539
|
+
rm -rf "$MOP/acct-01/sessions/99991.json" "$MOP/acct-01/.sessions-via" "$MOP/acct-01/.sessions-index"
|
|
1540
|
+
# adopt turns the account dir into a symlink to ~/.claude: never over a dir that holds a
|
|
1541
|
+
# credential of its own — MCP sign-ins with no login among them, or a portable token. The
|
|
1542
|
+
# fake HOME carries a default login, so only that guard can refuse.
|
|
1543
|
+
AH="$WORK/adopt-home"
|
|
1544
|
+
mkdir -p "$AH/.claude"
|
|
1545
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-default","refreshToken":"r","expiresAt":9999999999999}}' \
|
|
1546
|
+
> "$AH/.claude/.credentials.json"
|
|
1547
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-02/.credentials.json"
|
|
1548
|
+
out="$(HOME="$AH" CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts adopt acct-02 2>&1)"; rc=$?
|
|
1549
|
+
{ [ "$rc" != 0 ] && [ ! -L "$MOP/acct-02" ] \
|
|
1550
|
+
&& [ "$(cat "$MOP/acct-02/.credentials.json" 2>/dev/null)" = "$MCP_ONLY" ]; } \
|
|
1551
|
+
&& t_ok "adopt refuses a dir whose credentials file holds MCP state alone, and keeps it" \
|
|
1552
|
+
|| t_fail "adopt over MCP state" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
1553
|
+
check "...saying it has credentials of its own" "already has its own credentials" "$out"
|
|
1554
|
+
rm -f "$MOP/acct-01/.credentials.json"
|
|
1555
|
+
out="$(HOME="$AH" CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts adopt acct-01 2>&1)"; rc=$?
|
|
1556
|
+
{ [ "$rc" != 0 ] && [ ! -L "$MOP/acct-01" ] \
|
|
1557
|
+
&& [ "$(cat "$MOP/acct-01/server.token" 2>/dev/null)" = "$MOP_TOK" ]; } \
|
|
1558
|
+
&& t_ok "adopt refuses a dir holding a portable token, and keeps the token" \
|
|
1559
|
+
|| t_fail "adopt over a token" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
1560
|
+
rm -rf "$MOP" "$AH" "$WORK"/mcponly-*.json "$WORK/login-import.json" "$WORK/mcp-token-server.py" "$WORK/mcp-token-port"
|
|
1561
|
+
|
|
1562
|
+
# ---- 9h. the limits pass's refresher writes under the client's lock --------------------
|
|
1563
|
+
# Claude Code takes proper-lockfile's <dir>/.storage-write.lock around every write of its
|
|
1564
|
+
# credential document (re-read, one change, the whole document back), and gives up after
|
|
1565
|
+
# about 7.5 s on a held lock. The refresher merges its rotated login into the same document:
|
|
1566
|
+
# a client lock held when a refresh would start skips it (no request, so nothing rotates);
|
|
1567
|
+
# after the POST the check-and-set and the write wait for the lock, so a client write in
|
|
1568
|
+
# flight is neither interleaved nor rolled back — and the rotated login is not lost to it.
|
|
1569
|
+
RL="$WORK/reflock"
|
|
1570
|
+
RL_HOME=mac; [ "$(uname -s)" = "Darwin" ] || RL_HOME=server
|
|
1571
|
+
mkdir -p "$RL/tmp" "$RL/acct-01"
|
|
1572
|
+
: > "$RL/.limits-kick"
|
|
1573
|
+
cat > "$RL/accounts.json" <<EOF
|
|
1574
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
1575
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
1576
|
+
"accounts": [ {"id": "acct-01", "email": "rlock@test", "home": "$RL_HOME", "added_at": "2026-10-20T00:00:00Z"} ] }
|
|
1577
|
+
EOF
|
|
1578
|
+
rl_stale() { # an expired login beside one MCP login: the refresher's work, the client's state
|
|
1579
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-rlstale","refreshToken":"sk-ant-ort01-rl%s","expiresAt":1000,"refreshTokenExpiresAt":9999999999999},"mcpOAuth":{"higgsfield|0123456789abcdef":{"serverName":"higgsfield","accessToken":"mcp-access-rl","refreshToken":"mcp-refresh-rl","expiresAt":9999999999999}}}' \
|
|
1580
|
+
"$1" > "$RL/acct-01/.credentials.json"
|
|
1581
|
+
chmod 600 "$RL/acct-01/.credentials.json"
|
|
1582
|
+
}
|
|
1583
|
+
cat > "$WORK/rlock-token-server.py" <<'EOF'
|
|
1584
|
+
import http.server, json, os, sys, threading, time
|
|
1585
|
+
target, mode_file, count_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
1586
|
+
lock = os.path.join(os.path.dirname(target), '.storage-write.lock')
|
|
1587
|
+
seen = [0]
|
|
1588
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
1589
|
+
def log_message(self, *a): pass
|
|
1590
|
+
def do_POST(self):
|
|
1591
|
+
self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
|
1592
|
+
seen[0] += 1
|
|
1593
|
+
with open(count_file, 'w') as f:
|
|
1594
|
+
f.write(str(seen[0]))
|
|
1595
|
+
if open(mode_file).read().strip() == 'hold':
|
|
1596
|
+
# a client takes its lock and reads the document now, and writes it back (an MCP
|
|
1597
|
+
# token of its own refreshed) 1.5 s later, from what it read
|
|
1598
|
+
os.mkdir(lock)
|
|
1599
|
+
doc = json.load(open(target))
|
|
1600
|
+
def later():
|
|
1601
|
+
time.sleep(1.5)
|
|
1602
|
+
doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] = 'mcp-refresh-CLIENT-HELD'
|
|
1603
|
+
with open(target + '.client', 'w') as f:
|
|
1604
|
+
json.dump(doc, f)
|
|
1605
|
+
os.replace(target + '.client', target)
|
|
1606
|
+
os.rmdir(lock)
|
|
1607
|
+
threading.Thread(target=later).start()
|
|
1608
|
+
raw = json.dumps({'access_token': 'sk-ant-oat01-rlrotated%d' % seen[0],
|
|
1609
|
+
'refresh_token': 'sk-ant-ort01-rlrotated%d' % seen[0],
|
|
1610
|
+
'expires_in': 28800}).encode()
|
|
1611
|
+
self.send_response(200)
|
|
1612
|
+
self.send_header('Content-Type', 'application/json')
|
|
1613
|
+
self.send_header('Content-Length', str(len(raw)))
|
|
1614
|
+
self.end_headers()
|
|
1615
|
+
self.wfile.write(raw)
|
|
1616
|
+
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
|
|
1617
|
+
print(srv.server_address[1], flush=True)
|
|
1618
|
+
srv.serve_forever()
|
|
1619
|
+
EOF
|
|
1620
|
+
printf 'plain\n' > "$WORK/rlock-mode"
|
|
1621
|
+
printf '0' > "$WORK/rlock-count"
|
|
1622
|
+
rl_stale a
|
|
1623
|
+
python3 "$WORK/rlock-token-server.py" "$RL/acct-01/.credentials.json" "$WORK/rlock-mode" "$WORK/rlock-count" \
|
|
1624
|
+
> "$WORK/rlock-port" 2>/dev/null &
|
|
1625
|
+
rlsrv_pid=$!
|
|
1626
|
+
STUB_PIDS="$STUB_PIDS $rlsrv_pid"
|
|
1627
|
+
rlport=""
|
|
1628
|
+
for _ in $(seq 1 20); do
|
|
1629
|
+
rlport="$(head -1 "$WORK/rlock-port" 2>/dev/null)"
|
|
1630
|
+
case "$rlport" in ''|*[!0-9]*) rlport=""; sleep 0.2 ;; *) break ;; esac
|
|
1631
|
+
done
|
|
1632
|
+
if [ -z "$rlport" ]; then
|
|
1633
|
+
t_ok "refresher lock tests skipped (cannot bind a loopback port here)"
|
|
1634
|
+
else
|
|
1635
|
+
rl_limits() { CLAUDE_ACCOUNTS_DIR="$RL" CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$rlport/token" \
|
|
1636
|
+
claude-accounts limits --force 2>&1; }
|
|
1637
|
+
cp "$RL/acct-01/.credentials.json" "$WORK/rlock-before.json"
|
|
1638
|
+
mkdir "$RL/acct-01/.storage-write.lock" # a client is writing right now
|
|
1639
|
+
out="$(rl_limits)"
|
|
1640
|
+
check "a held client lock skips the refresh" "acct-01: oauth refresh skipped" "$out"
|
|
1641
|
+
{ [ "$(cat "$WORK/rlock-count")" = 0 ] && cmp -s "$WORK/rlock-before.json" "$RL/acct-01/.credentials.json" \
|
|
1642
|
+
&& [ ! -f "$RL/acct-01/.oauth-refresh.json" ] && [ -d "$RL/acct-01/.storage-write.lock" ]; } \
|
|
1643
|
+
&& t_ok "...sending nothing (no rotation), writing nothing, noting no backoff, leaving the lock" \
|
|
1644
|
+
|| t_fail "refresh under a held lock" "requests=$(cat "$WORK/rlock-count") backoff=$([ -f "$RL/acct-01/.oauth-refresh.json" ] && echo yes || echo no)"
|
|
1645
|
+
touch -t 202001010101 "$RL/acct-01/.storage-write.lock" # its holder died long ago
|
|
1646
|
+
out="$(rl_limits)"
|
|
1647
|
+
check "a stale client lock is taken over and the refresh lands" "acct-01: oauth access token refreshed" "$out"
|
|
1648
|
+
[ ! -e "$RL/acct-01/.storage-write.lock" ] && [ "$(cat "$WORK/rlock-count")" = 1 ] \
|
|
1649
|
+
&& t_ok "...and the lock is released after the write" \
|
|
1650
|
+
|| t_fail "stale client lock" "requests=$(cat "$WORK/rlock-count") lock=$(ls -d "$RL/acct-01/.storage-write.lock" 2>/dev/null)"
|
|
1651
|
+
rl_stale b
|
|
1652
|
+
rmdir "$RL/acct-01/.storage-write.lock" 2>/dev/null || true # this case on its own
|
|
1653
|
+
printf 'hold\n' > "$WORK/rlock-mode"
|
|
1654
|
+
out="$(rl_limits)"
|
|
1655
|
+
check "a refresh whose write window a client holds still lands" "acct-01: oauth access token refreshed" "$out"
|
|
1656
|
+
for _ in $(seq 1 50); do # the client's write is done
|
|
1657
|
+
[ -e "$RL/acct-01/.storage-write.lock" ] || break
|
|
1658
|
+
sleep 0.1
|
|
1659
|
+
done
|
|
1660
|
+
python3 - "$RL/acct-01/.credentials.json" <<'EOF'
|
|
1661
|
+
import json, os, stat, sys
|
|
1662
|
+
doc = json.load(open(sys.argv[1]))
|
|
1663
|
+
o = doc['claudeAiOauth']
|
|
1664
|
+
assert o['accessToken'] == 'sk-ant-oat01-rlrotated2' and o['refreshToken'] == 'sk-ant-ort01-rlrotated2', 'the rotated login was rolled back'
|
|
1665
|
+
assert doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] == 'mcp-refresh-CLIENT-HELD', 'the client write was lost'
|
|
1666
|
+
assert stat.S_IMODE(os.stat(sys.argv[1]).st_mode) == 0o600
|
|
1667
|
+
EOF
|
|
1668
|
+
[ $? -eq 0 ] && [ ! -e "$RL/acct-01/.storage-write.lock" ] \
|
|
1669
|
+
&& t_ok "...after the client's write: its MCP token and the rotated login both survive" \
|
|
1670
|
+
|| t_fail "refresh vs a client write in flight" "see assertions above"
|
|
1671
|
+
fi
|
|
1672
|
+
kill "$rlsrv_pid" 2>/dev/null; wait "$rlsrv_pid" 2>/dev/null || true
|
|
1673
|
+
rm -rf "$RL" "$WORK"/rlock-*
|
|
1674
|
+
|
|
1191
1675
|
# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
|
|
1192
1676
|
mkdir -p "$ACC/acct-03"
|
|
1193
1677
|
printf 'sk-ant-oat01-tok-for-03' > "$ACC/acct-03/server.token"
|
|
@@ -1558,6 +2042,21 @@ claude >/dev/null 2>&1
|
|
|
1558
2042
|
[ ! -f "$ACC/acct-01/.limited" ] \
|
|
1559
2043
|
&& t_ok "a stale rival claim does not hide a newer one" \
|
|
1560
2044
|
|| t_fail "duplicate session claim" "only the first rival claim was inspected"
|
|
2045
|
+
# A line the shim tagged ` token` (a run it launched on the portable token, 9g) is the same
|
|
2046
|
+
# claim to every reader: to the conflict check above...
|
|
2047
|
+
rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan
|
|
2048
|
+
printf '%s 2090-01-01T00:00:00Z token\n' "$SIDD" > "$ACC/acct-02/.sessions-index"
|
|
2049
|
+
claude >/dev/null 2>&1
|
|
2050
|
+
[ ! -f "$ACC/acct-01/.limited" ] \
|
|
2051
|
+
&& t_ok "a newer rival claim tagged token still conflicts" \
|
|
2052
|
+
|| t_fail "token-tagged rival claim" "the token field hid acct-02's newer claim"
|
|
2053
|
+
# ...and to the scan that marks an account off its own session's rejection
|
|
2054
|
+
rm -f "$ACC/acct-02/.sessions-index" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan
|
|
2055
|
+
printf '%s token\n' "$CLAIM1" > "$ACC/acct-01/.sessions-index"
|
|
2056
|
+
claude >/dev/null 2>&1
|
|
2057
|
+
grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
|
|
2058
|
+
&& t_ok "a session line tagged token still attributes its own rejection" \
|
|
2059
|
+
|| t_fail "token-tagged index line" "client_limit_scan missed a rejection behind a tagged line"
|
|
1561
2060
|
rm -f "$ACC/acct-02/projects/-proj" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
|
|
1562
2061
|
rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
|
|
1563
2062
|
|
|
@@ -4939,10 +5438,67 @@ if command -v node >/dev/null 2>&1; then
|
|
|
4939
5438
|
# nothing and the nightly self-update logged "npm not found; skipping" for days while
|
|
4940
5439
|
# every Mac's copy silently froze. npm must be resolved by path — and specifically the
|
|
4941
5440
|
# one inside the prefix that owns the RUNNING copy, never whatever a stray PATH offers.
|
|
5441
|
+
# The model catalog after a self-update (lib/common.sh model_catalog_after_update): a real
|
|
5442
|
+
# update refreshes it ONCE, from the tree npm just wrote; "already latest" never does; and a
|
|
5443
|
+
# refresh that fails is logged while the update keeps exit 0.
|
|
5444
|
+
su_catalog_calls() { grep -c '^NEW-TREE model-catalog refresh --quiet$' "$WORK/catalog-calls-$_cli.log" 2>/dev/null; }
|
|
5445
|
+
su_catalog_checks() {
|
|
5446
|
+
[ "$(su_catalog_calls)" = 1 ] \
|
|
5447
|
+
&& t_ok "$_cli: a real update refreshes the model catalog once, from the NEW tree" \
|
|
5448
|
+
|| t_fail "$_cli catalog refresh" "calls: $(head -3 "$WORK/catalog-calls-$_cli.log" 2>/dev/null)"
|
|
5449
|
+
{ [ -f "$SU_CL/model-catalog.json" ] && [ -f "$SU_CX/model-catalog.json" ]; } \
|
|
5450
|
+
&& t_ok "$_cli: that refresh wrote model-catalog.json into both pools" \
|
|
5451
|
+
|| t_fail "$_cli catalog files" "$(ls "$SU_CL" "$SU_CX" 2>&1 | tr '\n' ' ')"
|
|
5452
|
+
grep -q "self-update: model catalog refreshed" "$SU_OWN/update.log" \
|
|
5453
|
+
&& t_ok "$_cli: update.log records the catalog refresh" \
|
|
5454
|
+
|| t_fail "$_cli catalog log" "$(tail -3 "$SU_OWN/update.log" 2>/dev/null)"
|
|
5455
|
+
out="$(_su)"
|
|
5456
|
+
check "$_cli: the next run is already latest" "already latest (9.9.9)" "$out"
|
|
5457
|
+
[ "$(su_catalog_calls)" = 1 ] && t_ok "$_cli: an 'already latest' run never refreshes the catalog" \
|
|
5458
|
+
|| t_fail "$_cli catalog on already-latest" "calls: $(su_catalog_calls)"
|
|
5459
|
+
python3 - "$NPMROOT/package.json" <<'PY'
|
|
5460
|
+
import json, sys
|
|
5461
|
+
doc = json.load(open(sys.argv[1])); doc['version'] = '0.0.1'
|
|
5462
|
+
json.dump(doc, open(sys.argv[1], 'w'))
|
|
5463
|
+
PY
|
|
5464
|
+
: > "$WORK/catalog-fail-$_cli"
|
|
5465
|
+
out="$(_su)"
|
|
5466
|
+
rc=$?
|
|
5467
|
+
[ "$rc" = 0 ] && t_ok "$_cli: a failing catalog refresh leaves self-update at exit 0" \
|
|
5468
|
+
|| t_fail "$_cli failing catalog rc" "rc=$rc: $(printf '%s' "$out" | head -c 200)"
|
|
5469
|
+
check "$_cli: the failed refresh is reported" "model catalog refresh FAILED (rc=3)" "$out"
|
|
5470
|
+
[ "$(su_catalog_calls)" = 2 ] && t_ok "$_cli: that update still ran the refresh once" \
|
|
5471
|
+
|| t_fail "$_cli failing catalog calls" "calls: $(su_catalog_calls)"
|
|
5472
|
+
}
|
|
5473
|
+
# What `npm install` does to the running copy, beyond the version: it ships a new tree. The
|
|
5474
|
+
# CLI it writes records every `model-catalog` call (and fails one on request), and it lands
|
|
5475
|
+
# under a NEW inode, as npm's extract does, so the bash running self-update keeps its old file.
|
|
5476
|
+
SHIP="$WORK/fake-npm-ship.py"
|
|
5477
|
+
cat > "$SHIP" <<'SHIPEOF'
|
|
5478
|
+
import json, os, sys
|
|
5479
|
+
root, cli, log, failflag = sys.argv[1:5]
|
|
5480
|
+
pkg = os.path.join(root, 'package.json')
|
|
5481
|
+
doc = json.load(open(pkg)); doc['version'] = '9.9.9'
|
|
5482
|
+
json.dump(doc, open(pkg, 'w'))
|
|
5483
|
+
path = os.path.join(root, 'bin', cli)
|
|
5484
|
+
text = open(path).read()
|
|
5485
|
+
mark = '# fake-npm: the new tree\n'
|
|
5486
|
+
if mark not in text:
|
|
5487
|
+
head, rest = text.split('\n', 1)
|
|
5488
|
+
probe = ('case "${1:-}" in model-catalog) printf "NEW-TREE %%s\\n" "$*" >> "%s"; '
|
|
5489
|
+
'[ -e "%s" ] && exit 3 ;; esac\n' % (log, failflag))
|
|
5490
|
+
text = head + '\n' + mark + probe + rest
|
|
5491
|
+
open(path + '.new', 'w').write(text)
|
|
5492
|
+
os.chmod(path + '.new', 0o755)
|
|
5493
|
+
os.replace(path + '.new', path)
|
|
5494
|
+
SHIPEOF
|
|
4942
5495
|
for _cli in claude-accounts codex-accounts; do
|
|
4943
5496
|
PFX="$WORK/pfx-$_cli"
|
|
4944
5497
|
NPMROOT="$PFX/lib/node_modules/claude-multiacc"
|
|
4945
|
-
|
|
5498
|
+
# Pools of its own: a real update now writes model-catalog.json into both of them.
|
|
5499
|
+
SU_CL="$WORK/su-claude-$_cli"; SU_CX="$WORK/su-codex-$_cli"
|
|
5500
|
+
mkdir -p "$NPMROOT" "$PFX/bin" "$SU_CL" "$SU_CX"
|
|
5501
|
+
if [ "$_cli" = claude-accounts ]; then SU_OWN="$SU_CL"; else SU_OWN="$SU_CX"; fi
|
|
4946
5502
|
cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$REPO_DIR/package.json" "$NPMROOT/"
|
|
4947
5503
|
# A fake npm that records how it was called and "installs" by bumping package.json.
|
|
4948
5504
|
# Its shebang names an interpreter that exists ONLY beside it, mirroring the real
|
|
@@ -4959,13 +5515,7 @@ NODEEOF
|
|
|
4959
5515
|
echo "npm \$*" >> "$WORK/npm-calls-$_cli.log"
|
|
4960
5516
|
case "\${1:-}" in
|
|
4961
5517
|
view) echo 9.9.9 ;;
|
|
4962
|
-
install) python3
|
|
4963
|
-
import json, sys
|
|
4964
|
-
p = sys.argv[1]
|
|
4965
|
-
doc = json.load(open(p)); doc['version'] = '9.9.9'
|
|
4966
|
-
json.dump(doc, open(p, 'w'))
|
|
4967
|
-
PY
|
|
4968
|
-
;;
|
|
5518
|
+
install) python3 "$SHIP" "$NPMROOT" "$_cli" "$WORK/catalog-calls-$_cli.log" "$WORK/catalog-fail-$_cli" ;;
|
|
4969
5519
|
esac
|
|
4970
5520
|
exit 0
|
|
4971
5521
|
NPMEOF
|
|
@@ -4975,9 +5525,11 @@ NPMEOF
|
|
|
4975
5525
|
printf '#!/usr/bin/env bash\necho "WRONG-NPM \$*" >> "%s"\nexit 0\n' "$WORK/npm-calls-$_cli.log" > "$WORK/wrongbin/npm"
|
|
4976
5526
|
chmod +x "$WORK/wrongbin/npm"
|
|
4977
5527
|
: > "$WORK/npm-calls-$_cli.log"
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
5528
|
+
_su() { # a scheduled agent's environment: no npm on PATH, nothing inherited
|
|
5529
|
+
env -i HOME="$HOME" PATH="$WORK/wrongbin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
|
5530
|
+
CLAUDE_ACCOUNTS_DIR="$SU_CL" CODEX_ACCOUNTS_DIR="$SU_CX" "$NPMROOT/bin/$_cli" self-update 2>&1
|
|
5531
|
+
}
|
|
5532
|
+
out="$(_su)"
|
|
4981
5533
|
rc=$?
|
|
4982
5534
|
[ "$rc" = "0" ] && t_ok "$_cli: self-update exits 0 with no npm on PATH" \
|
|
4983
5535
|
|| t_fail "$_cli self-update rc" "rc=$rc: $(printf '%s' "$out" | head -c 200)"
|
|
@@ -4994,6 +5546,56 @@ NPMEOF
|
|
|
4994
5546
|
grep -q "^npm view " "$WORK/npm-calls-$_cli.log" \
|
|
4995
5547
|
&& t_ok "$_cli: npm ran despite its interpreter being off PATH" \
|
|
4996
5548
|
|| t_fail "$_cli npm interpreter" "npm never executed — its shebang interpreter was not found"
|
|
5549
|
+
su_catalog_checks
|
|
5550
|
+
done
|
|
5551
|
+
# ---- 19a2. self-update only ever goes UP --------------------------------------------
|
|
5552
|
+
# This Mac runs builds packed by hand ahead of the registry, and app-robot runs self-update
|
|
5553
|
+
# every 10 minutes: installing `latest` over such a build swapped in one that misread what
|
|
5554
|
+
# the newer build had written. A newer install stays, an unreadable registry answer installs
|
|
5555
|
+
# nothing, and the versions compare as numbers (2.0.10 is newer than 2.0.9).
|
|
5556
|
+
for _cli in claude-accounts codex-accounts; do
|
|
5557
|
+
PFX="$WORK/pfx-down-$_cli"
|
|
5558
|
+
NPMROOT="$PFX/lib/node_modules/claude-multiacc"
|
|
5559
|
+
SU_CL="$WORK/su-down-claude-$_cli"; SU_CX="$WORK/su-down-codex-$_cli"
|
|
5560
|
+
mkdir -p "$NPMROOT" "$PFX/bin" "$SU_CL" "$SU_CX"
|
|
5561
|
+
cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$REPO_DIR/package.json" "$NPMROOT/"
|
|
5562
|
+
python3 - "$NPMROOT/package.json" <<'PY'
|
|
5563
|
+
import json, sys
|
|
5564
|
+
doc = json.load(open(sys.argv[1])); doc['version'] = '2.0.10'
|
|
5565
|
+
json.dump(doc, open(sys.argv[1], 'w'))
|
|
5566
|
+
PY
|
|
5567
|
+
cat > "$PFX/bin/npm" <<NPMEOF
|
|
5568
|
+
#!/bin/bash
|
|
5569
|
+
echo "npm \$*" >> "$WORK/npm-down-$_cli.log"
|
|
5570
|
+
case "\${1:-}" in view) cat "$WORK/npm-down-view-$_cli" 2>/dev/null ;; esac
|
|
5571
|
+
exit 0
|
|
5572
|
+
NPMEOF
|
|
5573
|
+
chmod +x "$PFX/bin/npm"
|
|
5574
|
+
: > "$WORK/npm-down-$_cli.log"
|
|
5575
|
+
_sud() {
|
|
5576
|
+
env -i HOME="$HOME" PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
|
|
5577
|
+
CLAUDE_ACCOUNTS_DIR="$SU_CL" CODEX_ACCOUNTS_DIR="$SU_CX" "$NPMROOT/bin/$_cli" self-update 2>&1
|
|
5578
|
+
}
|
|
5579
|
+
printf '2.0.9\n' > "$WORK/npm-down-view-$_cli"
|
|
5580
|
+
out="$(_sud)"; rc=$?
|
|
5581
|
+
check "$_cli: self-update never installs a registry version older than its own" \
|
|
5582
|
+
"this install (2.0.10) is newer than the registry's 2.0.9; not downgrading" "$out"
|
|
5583
|
+
{ [ "$rc" = 0 ] && ! grep -q '^npm install' "$WORK/npm-down-$_cli.log"; } \
|
|
5584
|
+
&& t_ok "$_cli: ...exits 0 and runs no npm install" \
|
|
5585
|
+
|| t_fail "$_cli downgrade" "rc=$rc calls: $(tr '\n' ' ' < "$WORK/npm-down-$_cli.log")"
|
|
5586
|
+
: > "$WORK/npm-down-view-$_cli"
|
|
5587
|
+
out="$(_sud)"; rc=$?
|
|
5588
|
+
check "$_cli: an unreadable registry version installs nothing" \
|
|
5589
|
+
"could not read the registry's version (npm view); not updating" "$out"
|
|
5590
|
+
{ [ "$rc" = 1 ] && ! grep -q '^npm install' "$WORK/npm-down-$_cli.log"; } \
|
|
5591
|
+
&& t_ok "$_cli: ...reports the failed check (exit 1) and runs no npm install" \
|
|
5592
|
+
|| t_fail "$_cli blind install" "rc=$rc calls: $(tr '\n' ' ' < "$WORK/npm-down-$_cli.log")"
|
|
5593
|
+
printf '2.0.11\n' > "$WORK/npm-down-view-$_cli"
|
|
5594
|
+
out="$(_sud)"
|
|
5595
|
+
check "$_cli: a newer registry version still installs" "npm 2.0.10 -> 2.0.11" "$out"
|
|
5596
|
+
grep -q '^npm install -g --prefix .* claude-multiacc@latest$' "$WORK/npm-down-$_cli.log" \
|
|
5597
|
+
&& t_ok "$_cli: ...through npm install" \
|
|
5598
|
+
|| t_fail "$_cli upgrade" "calls: $(tr '\n' ' ' < "$WORK/npm-down-$_cli.log")"
|
|
4997
5599
|
done
|
|
4998
5600
|
# The agents install.sh writes must carry a PATH for the same reason (a future tool
|
|
4999
5601
|
# that is not resolved by absolute path would hit exactly this again).
|
|
@@ -5042,7 +5644,7 @@ export CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-endpoint-missing.json"
|
|
|
5042
5644
|
export CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-endpoint-missing.json"
|
|
5043
5645
|
export CODEX_MULTIACC_FORCE_TTY=1
|
|
5044
5646
|
export FAKE_CTL2="$WORK/ctl2"
|
|
5045
|
-
|
|
5647
|
+
touch -t 203001010000 "$CX/.limits-kick" # armed in the future, like the claude pool's
|
|
5046
5648
|
|
|
5047
5649
|
# Codex auth fixtures are real-shaped: auth.json carrying JWTs whose exp/email
|
|
5048
5650
|
# claims the audit decodes offline (exactly what the CLI does).
|
|
@@ -5267,6 +5869,9 @@ out="$(CODEX_MULTIACC_DISABLE=1 codex 2>&1)"
|
|
|
5267
5869
|
check "codex: passthrough when disabled" "CFG=none" "$out"
|
|
5268
5870
|
|
|
5269
5871
|
# ---- C3. headroom selection: session gate first, then the WEEKLY headroom band ------
|
|
5872
|
+
# The codex shim counts telemetry older than 15 minutes as unknown (bin/codex STALE_AFTER), and
|
|
5873
|
+
# on a loaded machine the suite's $now is older than that by here: these fixtures need a fresh one.
|
|
5874
|
+
now="$(date +%s)"
|
|
5270
5875
|
cxlj() { printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":%s,"max_percent":%s,"buckets":[]}' "$now" "$1" "$2" "$3"; }
|
|
5271
5876
|
cxlj 80 10 80 > "$CX/acct-01/limits.json"
|
|
5272
5877
|
cxlj 20 10 20 > "$CX/acct-02/limits.json"
|
|
@@ -5703,6 +6308,7 @@ grep -q "all-limited fallback=acct-02 weekly=70%" "$CX/selection.log" \
|
|
|
5703
6308
|
rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
|
|
5704
6309
|
|
|
5705
6310
|
# ---- C6b. the fallback tells "still serving" from "rejected right now" ----------------
|
|
6311
|
+
now="$(date +%s)" # its markers lift in 10 minutes: never from a stale suite clock
|
|
5706
6312
|
# Ported from the claude shim (2026-08-29) when the session gate reached the fallback.
|
|
5707
6313
|
# acct-01: 7d window at 99% — worse headroom, but still answering. acct-02: 5h window at
|
|
5708
6314
|
# 100% — far better weekly (7%), but every request bounces until the reset.
|
|
@@ -6389,7 +6995,7 @@ case "$out" in *CFG=acct-04*) t_fail "codex apikey selection" "api-key account w
|
|
|
6389
6995
|
codex-accounts remove acct-04 --yes >/dev/null 2>&1
|
|
6390
6996
|
|
|
6391
6997
|
# ---- C13. limits: fixture endpoint, marking, classification -------------------------
|
|
6392
|
-
|
|
6998
|
+
touch -t 203001010000 "$CX/.limits-kick" # re-arm the shim-kick throttle: no background limits racing these
|
|
6393
6999
|
cat > "$WORK/cx-usage-high.json" <<EOF
|
|
6394
7000
|
{"email":"a@cx","plan_type":"pro",
|
|
6395
7001
|
"rate_limit":{"allowed":true,"limit_reached":false,
|
|
@@ -8346,6 +8952,96 @@ EOF2
|
|
|
8346
8952
|
out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
|
|
8347
8953
|
check "and its client can use it" "acct-02 lonly@test: PASS" "$out"
|
|
8348
8954
|
|
|
8955
|
+
# 17p. a Keychain item holding only MCP state is no login either (the 9g rule): a client in
|
|
8956
|
+
# a keychain-capable session saves its MCP servers' OAuth state there even when its login
|
|
8957
|
+
# came from a portable token. The shim, list and the marker heal all read it as none, and
|
|
8958
|
+
# every write of a login lands INTO the item, its MCP state kept.
|
|
8959
|
+
KMP="$WORK/kcmcp"
|
|
8960
|
+
KMP_HOME=mac; [ "$(uname -s)" = "Darwin" ] || KMP_HOME=server
|
|
8961
|
+
mkdir -p "$KMP/tmp" "$KMP/acct-01" "$KMP/acct-02" "$KMP/acct-03"
|
|
8962
|
+
: > "$KMP/.limits-kick"
|
|
8963
|
+
cat > "$KMP/accounts.json" <<EOF2
|
|
8964
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
8965
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
8966
|
+
"accounts": [
|
|
8967
|
+
{"id": "acct-01", "email": "kmtok@test", "home": "$KMP_HOME", "added_at": "2026-10-19T00:00:00Z"},
|
|
8968
|
+
{"id": "acct-02", "email": "kmnone@test", "home": "$KMP_HOME", "added_at": "2026-10-19T00:00:00Z"},
|
|
8969
|
+
{"id": "acct-03", "email": "kmlogin@test", "home": "$KMP_HOME", "added_at": "2026-10-19T00:00:00Z"}
|
|
8970
|
+
] }
|
|
8971
|
+
EOF2
|
|
8972
|
+
KC_MCP='{"mcpOAuth":{"higgsfield|0123456789abcdef":{"serverName":"higgsfield","accessToken":"mcp-access-kc","refreshToken":"mcp-refresh-kc","expiresAt":9999999999999}}}'
|
|
8973
|
+
KMP_TOK='sk-ant-oat01-KCMCPTOKENKCMCPTOKENKCMCPTOKENKCMCPTOKENKC01'
|
|
8974
|
+
kc_put "$KMP/acct-01" "$KC_MCP"
|
|
8975
|
+
kc_put "$KMP/acct-02" "$KC_MCP"
|
|
8976
|
+
printf '%s' "$KMP_TOK" > "$KMP/acct-01/server.token"
|
|
8977
|
+
ok1=1
|
|
8978
|
+
for _ in $(seq 1 10); do
|
|
8979
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" FAKE_KEYCHAIN_CLIENT=1 claude 2>&1)"
|
|
8980
|
+
case "$out" in *"CFG=acct-01 TOK=$KMP_TOK"*) ;; *) ok1=0; break ;; esac
|
|
8981
|
+
done
|
|
8982
|
+
[ "$ok1" = 1 ] && t_ok "an MCP-only Keychain item leaves the account to its token, which is exported" \
|
|
8983
|
+
|| t_fail "MCP-only keychain item + token" "got: $(printf '%s' "$out" | head -c 160)"
|
|
8984
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" claude-accounts list --json 2>&1)"
|
|
8985
|
+
python3 - "$out" <<'EOF2'
|
|
8986
|
+
import json, sys
|
|
8987
|
+
rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
|
|
8988
|
+
for aid in ('acct-01', 'acct-02'):
|
|
8989
|
+
c = rows[aid]['credentials']
|
|
8990
|
+
assert c['oauth'] is False and c['oauth_store'] is None and c['keychain'] is None, (aid, c)
|
|
8991
|
+
assert rows['acct-01']['selectable'] is True, rows['acct-01']
|
|
8992
|
+
assert rows['acct-02']['status'] == 'missing' and rows['acct-02']['needs_login'] is True, rows['acct-02']
|
|
8993
|
+
EOF2
|
|
8994
|
+
[ $? -eq 0 ] && t_ok "list --json: an MCP-only Keychain item is no oauth login" \
|
|
8995
|
+
|| t_fail "MCP-only keychain list --json" "see assertions above"
|
|
8996
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" python3 "$REPO_DIR/lib/keychain.py" probe "$KMP/acct-02" 2>&1)"
|
|
8997
|
+
check "keychain.py probe names the item nologin" '"state": "nologin"' "$out"
|
|
8998
|
+
case "$out" in *mcp-*) t_fail "keychain probe output" "an MCP token reached stdout" ;;
|
|
8999
|
+
*) t_ok "keychain probe output carries no MCP token" ;; esac
|
|
9000
|
+
# An auth park heals on a newer Keychain LOGIN, never on a newer item holding MCP state.
|
|
9001
|
+
printf '%s\nreason=auth-error marked_at=t detail=test\n' "$now" > "$KMP/acct-01/.expired"
|
|
9002
|
+
touch -t 202001010101 "$KMP/acct-01/server.token"
|
|
9003
|
+
touch -t 202001010202 "$KMP/acct-01/.expired"
|
|
9004
|
+
kc_put "$KMP/acct-01" "$KC_MCP" # rewritten after the park
|
|
9005
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" claude 2>&1)"
|
|
9006
|
+
[ -f "$KMP/acct-01/.expired" ] && t_ok "a newer MCP-only Keychain item does not heal an auth park" \
|
|
9007
|
+
|| t_fail "keychain MCP-only heal" ".expired was removed by an MCP-only item"
|
|
9008
|
+
check "...and the parked account is not run" "CFG=none" "$out"
|
|
9009
|
+
kc_put "$KMP/acct-01" "$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kmlogin","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999},%s' "${KC_MCP#?}")"
|
|
9010
|
+
CLAUDE_ACCOUNTS_DIR="$KMP" claude >/dev/null 2>&1
|
|
9011
|
+
[ ! -f "$KMP/acct-01/.expired" ] && t_ok "a newer Keychain login heals it" \
|
|
9012
|
+
|| t_fail "keychain login heal" ".expired survived a newer keychain login"
|
|
9013
|
+
# The refresher writes the rotated login INTO the item: its MCP state stays.
|
|
9014
|
+
kc_put "$KMP/acct-03" "$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kmstale","refreshToken":"sk-ant-ort01-km","expiresAt":1000,"refreshTokenExpiresAt":9999999999999},%s' "${KC_MCP#?}")"
|
|
9015
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" \
|
|
9016
|
+
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
9017
|
+
check "a Keychain login beside MCP state refreshes" "acct-03: oauth access token refreshed" "$out"
|
|
9018
|
+
kc_secret "$KMP/acct-03" | python3 -c '
|
|
9019
|
+
import json, sys
|
|
9020
|
+
doc = json.load(sys.stdin)
|
|
9021
|
+
assert doc["claudeAiOauth"]["accessToken"] == "sk-ant-oat01-refreshednew", doc["claudeAiOauth"]
|
|
9022
|
+
assert doc["mcpOAuth"]["higgsfield|0123456789abcdef"]["refreshToken"] == "mcp-refresh-kc", doc
|
|
9023
|
+
' && t_ok "the Keychain write-back keeps the item's MCP state" \
|
|
9024
|
+
|| t_fail "keychain refresh clobbered MCP state" "item: $(kc_secret "$KMP/acct-03" | head -c 80)"
|
|
9025
|
+
[ "$(kc_secret "$KMP/acct-02")" = "$KC_MCP" ] && [ ! -f "$KMP/acct-02/.expired" ] \
|
|
9026
|
+
&& t_ok "the limits pass leaves an MCP-only item alone and parks nothing" \
|
|
9027
|
+
|| t_fail "limits on an MCP-only keychain item" "item: $(kc_secret "$KMP/acct-02" | head -c 80)"
|
|
9028
|
+
# A login stranded under another name (a USER-less sign-in, older) moves INTO the item a
|
|
9029
|
+
# later client filed MCP state under the canonical name.
|
|
9030
|
+
security delete-generic-password -a "$KC_ME" -s "Claude Code-credentials-$(kc_hash "$KMP/acct-02")" >/dev/null 2>&1
|
|
9031
|
+
kc_put "$KMP/acct-02" "$KC_LIVE" unknown
|
|
9032
|
+
kc_put "$KMP/acct-02" "$KC_MCP"
|
|
9033
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
9034
|
+
check "the limits pass moves a stranded login beside MCP state" "acct-02: Keychain login moved under $KC_ME" "$out"
|
|
9035
|
+
kc_secret "$KMP/acct-02" | python3 -c '
|
|
9036
|
+
import json, sys
|
|
9037
|
+
doc = json.load(sys.stdin)
|
|
9038
|
+
assert doc["claudeAiOauth"]["accessToken"] == "sk-ant-oat01-slive", doc["claudeAiOauth"]
|
|
9039
|
+
assert doc["mcpOAuth"]["higgsfield|0123456789abcdef"]["refreshToken"] == "mcp-refresh-kc", doc
|
|
9040
|
+
' && [ "$(kc_items "$KMP/acct-02")" = "$KC_ME" ] \
|
|
9041
|
+
&& t_ok "...into the item, MCP state kept, the stranded copy gone" \
|
|
9042
|
+
|| t_fail "keychain move into MCP item" "items: $(kc_items "$KMP/acct-02" | tr '\n' ' ')"
|
|
9043
|
+
rm -rf "$KMP"
|
|
9044
|
+
|
|
8349
9045
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
8350
9046
|
unset FAKE_KEYCHAIN_DIR
|
|
8351
9047
|
|
|
@@ -8500,6 +9196,9 @@ case "$verb" in
|
|
|
8500
9196
|
-e "s/#{pane_current_command}/${FAKE_TMUX_PANE_CMD:-zsh}/g" \
|
|
8501
9197
|
-e "s/#{pane_in_mode}/${FAKE_TMUX_PANE_MODE:-0}/g" \
|
|
8502
9198
|
-e "s/#{synchronize-panes}/${FAKE_TMUX_PANE_SYNC:-0}/g" ;;
|
|
9199
|
+
capture-pane)
|
|
9200
|
+
# the pane's screen: what the fake TUI drew (FAKE_TMUX_SCREEN), else a blank one
|
|
9201
|
+
[ -n "${FAKE_TMUX_SCREEN:-}" ] && cat "$FAKE_TMUX_SCREEN" 2>/dev/null ;;
|
|
8503
9202
|
send-keys)
|
|
8504
9203
|
lit=0
|
|
8505
9204
|
while [ $# -gt 0 ]; do
|
|
@@ -9005,10 +9704,53 @@ grep -q '^depth=2$' "$st" 2>/dev/null && grep -q '^chain=CarryTst$' "$st" \
|
|
|
9005
9704
|
&& grep -q "^avoid=acct-01:$((nowc + 18000))\$" "$st" && grep -q '^cwd=/.*/ar-work dir$' "$st" \
|
|
9006
9705
|
&& t_ok "AR carry: the next watcher inherits depth, chain, AVOID and the session's cwd" \
|
|
9007
9706
|
|| t_fail "AR carry: state file" "$(cat "$st" 2>/dev/null)"
|
|
9707
|
+
grep -q '^via=login$' "$st" 2>/dev/null \
|
|
9708
|
+
&& t_ok "AR state: it names the credential the run carries (the login: via=login)" \
|
|
9709
|
+
|| t_fail "AR state: via=login" "$(grep '^via' "$st" 2>/dev/null)"
|
|
9008
9710
|
[ "$(grep -c 'autoresume relaunch chain=CarryTst from=acct-01 class=model depth=2$' "$ARC/selection.log")" = 1 ] \
|
|
9009
9711
|
&& t_ok "AR carry: the relaunch is logged once, not again by the re-entry" \
|
|
9010
9712
|
|| t_fail "AR carry: relaunch log" "$(grep autoresume "$ARC/selection.log")"
|
|
9011
9713
|
ar_reap "$ARC"
|
|
9714
|
+
# ...and the portable token when the run carries it: an auth verdict its watcher meets in
|
|
9715
|
+
# any session of that run (the client switches a run's session on /clear) is the token's.
|
|
9716
|
+
ar_claude_pool
|
|
9717
|
+
rm -f "$ARC/acct-01/.credentials.json" "$ARC/acct-02/.credentials.json"
|
|
9718
|
+
printf '%s' sk-ant-oat01-ARVIATOKEN > "$ARC/acct-01/server.token"
|
|
9719
|
+
printf '%s\n' "$(printf '%s' sk-ant-oat01-ARVIATOKEN | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)" \
|
|
9720
|
+
> "$ARC/acct-01/.server-token-verified"
|
|
9721
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
|
|
9722
|
+
claude --dangerously-skip-permissions </dev/null 2>&1)"
|
|
9723
|
+
check "AR state: a run on the portable token" "CFG=acct-01 TOK=sk-ant-oat01-ARVIATOKEN" "$out"
|
|
9724
|
+
st="$(ls "$ARC/tmp/autoresume/"*.state 2>/dev/null | head -1)"
|
|
9725
|
+
grep -q '^via=token$' "$st" 2>/dev/null \
|
|
9726
|
+
&& t_ok "AR state: ...names it (via=token)" \
|
|
9727
|
+
|| t_fail "AR state: via=token" "$(grep '^via' "$st" 2>/dev/null)"
|
|
9728
|
+
ar_reap "$ARC"
|
|
9729
|
+
|
|
9730
|
+
# ---- AR11b. a relaunch keeps its session's model on a Fable-spent account -----------------
|
|
9731
|
+
# An unpinned run on an account whose Fable bucket is full gets the fallback model pinned (9a4).
|
|
9732
|
+
# A relaunch names the model its session last ran on, which --resume restores: only a session
|
|
9733
|
+
# that IS on Fable is pinned off it; an Opus one resumes on its own model, not the fallback.
|
|
9734
|
+
ar_claude_pool
|
|
9735
|
+
nowc="$(date +%s)"
|
|
9736
|
+
printf '{"fetched_at":%s,"weekly_percent":5,"session_percent":10,"max_percent":100,"buckets":[%s]}' "$nowc" \
|
|
9737
|
+
'{"name":"session","percent":10},{"name":"weekly_all","percent":5},{"name":"weekly_scoped:Fable","percent":100}' \
|
|
9738
|
+
> "$ARC/acct-02/limits.json"
|
|
9739
|
+
for case_ in OpusModelTok0123:claude-opus-5-5:keeps FableModelTok012:claude-fable-5-1:falls; do
|
|
9740
|
+
tok="${case_%%:*}"; rest="${case_#*:}"; model="${rest%%:*}"
|
|
9741
|
+
ar_plant "$ARC" "$tok" claude crash acct-01 -- --effort max --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
|
|
9742
|
+
printf 'model=%s\n' "$model" >> "$ARC/tmp/autoresume/r-$tok.relaunch"
|
|
9743
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
|
|
9744
|
+
CLAUDE_MULTIACC_AR="$tok:$AR_SID" claude </dev/null 2>&1)"
|
|
9745
|
+
if [ "${rest#*:}" = keeps ]; then
|
|
9746
|
+
check "AR model: a $model session resumes on its own model on a Fable-spent account" \
|
|
9747
|
+
"CFG=acct-02 TOK=none ARGS=--effort max --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
|
|
9748
|
+
else
|
|
9749
|
+
check "AR model: a $model session on a Fable-spent account gets the fallback model" \
|
|
9750
|
+
"CFG=acct-02 TOK=none ARGS=--model claude-opus-5 --effort max --resume $AR_SID" "$out"
|
|
9751
|
+
fi
|
|
9752
|
+
ar_reap "$ARC"
|
|
9753
|
+
done
|
|
9012
9754
|
|
|
9013
9755
|
# ---- AR12. claude relaunch tokens: the auth park, dead tokens, the trust mirror ------------
|
|
9014
9756
|
# Planted directly (no watcher, no TUI), so each assertion can only come from the relaunch
|
|
@@ -9029,6 +9771,30 @@ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" C
|
|
|
9029
9771
|
|| t_fail "AR auth token: park" "$(ls -a "$ARC/acct-01" | tr '\n' ' ')"
|
|
9030
9772
|
check "AR auth token: ...and the run lands on the other account, resuming the session" \
|
|
9031
9773
|
"CFG=acct-02 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
|
|
9774
|
+
# The relaunch names the credential the run it left carried (that watcher's state, via=):
|
|
9775
|
+
# the session the watcher followed may be one the client switched the run to (/clear,
|
|
9776
|
+
# /resume), which no index line names (none here at all). via=token retires the token and
|
|
9777
|
+
# its proof; via=login parks the login and keeps the token's proof.
|
|
9778
|
+
for v in token:setup-token-invalid login:auth-error; do
|
|
9779
|
+
rm -f "$ARC/acct-01/.expired"
|
|
9780
|
+
echo proof > "$ARC/acct-01/.server-token-verified"
|
|
9781
|
+
ar_plant "$ARC" ViaTok0123456789A claude auth acct-01 -- \
|
|
9782
|
+
--dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
|
|
9783
|
+
printf 'via=%s\n' "${v%%:*}" >> "$ARC/tmp/autoresume/r-ViaTok0123456789A.relaunch"
|
|
9784
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
|
|
9785
|
+
CLAUDE_MULTIACC_AR="ViaTok0123456789A:$AR_SID" claude </dev/null 2>&1)"
|
|
9786
|
+
grep -q "reason=${v#*:} " "$ARC/acct-01/.expired" 2>/dev/null \
|
|
9787
|
+
&& t_ok "AR auth via=${v%%:*}: the relaunch parks the credential that run carried (${v#*:})" \
|
|
9788
|
+
|| t_fail "AR auth via=${v%%:*}" "$(tail -1 "$ARC/acct-01/.expired" 2>/dev/null) / $out"
|
|
9789
|
+
if [ "${v%%:*}" = token ]; then
|
|
9790
|
+
[ ! -f "$ARC/acct-01/.server-token-verified" ] && t_ok "AR auth via=token: ...and drops the token's proof" \
|
|
9791
|
+
|| t_fail "AR auth via=token" "the token's .server-token-verified survived"
|
|
9792
|
+
else
|
|
9793
|
+
[ -f "$ARC/acct-01/.server-token-verified" ] && t_ok "AR auth via=login: ...and keeps the token's proof" \
|
|
9794
|
+
|| t_fail "AR auth via=login" "the token's .server-token-verified was dropped"
|
|
9795
|
+
fi
|
|
9796
|
+
done
|
|
9797
|
+
rm -f "$ARC/acct-01/.server-token-verified"
|
|
9032
9798
|
# Dead tokens: missing, expired (older than 10 min), malformed, a FIFO in its place.
|
|
9033
9799
|
ar_dead claude "a missing relaunch file" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
|
|
9034
9800
|
CLAUDE_MULTIACC_AR="MissingTok0123456:$AR_SID"
|
|
@@ -9110,6 +9876,73 @@ cmp -s "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json" \
|
|
|
9110
9876
|
|| t_fail "AR trust: a plain launch edited .claude.json" "$(cat "$ARC"/acct-0*/.claude.json)"
|
|
9111
9877
|
ar_reap "$ARC"
|
|
9112
9878
|
|
|
9879
|
+
# ---- AR13. claude critical memory: restarted and resumed, nothing marked ---------------------
|
|
9880
|
+
# claude 2.1.304 shows "Critical memory usage (<size>) — restart and resume with claude
|
|
9881
|
+
# --continue" in its footer once its heap passes 2.5 GiB, and writes nothing of it to the
|
|
9882
|
+
# transcript; the watcher reads its own pane for it (capture-pane, never a key). A relaunch of
|
|
9883
|
+
# class=memory is about the PROCESS: the shim marks nothing and selects as it always does.
|
|
9884
|
+
# Planted first (no watcher, no TUI): acct-01 leads on headroom and is the account the session
|
|
9885
|
+
# left, and ordinary selection gives it straight back.
|
|
9886
|
+
ar_claude_pool
|
|
9887
|
+
nowc="$(date +%s)"
|
|
9888
|
+
for i in 01:5 02:20; do
|
|
9889
|
+
printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
|
|
9890
|
+
"$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
|
|
9891
|
+
done
|
|
9892
|
+
ar_plant "$ARC" MemTok0123456789AB claude memory acct-01 -- \
|
|
9893
|
+
--dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
|
|
9894
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
|
|
9895
|
+
CLAUDE_MULTIACC_AR="MemTok0123456789AB:$AR_SID" claude </dev/null 2>&1)"
|
|
9896
|
+
check "AR memory token: the relaunch selects normally, the account it left included" \
|
|
9897
|
+
"CFG=acct-01 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
|
|
9898
|
+
ls -a "$ARC"/acct-0*/ | grep -Eq '^\.(limited|expired)$' \
|
|
9899
|
+
&& t_fail "AR memory token: nothing is marked" "$(ls -a "$ARC"/acct-0*/ | tr '\n' ' ')" \
|
|
9900
|
+
|| t_ok "AR memory token: nothing is marked on the account it left (no .limited, no .expired)"
|
|
9901
|
+
ar_logged "$ARC" 'autoresume relaunch chain=PlantTst from=acct-01 class=memory depth=1$' \
|
|
9902
|
+
&& t_ok "AR memory token: the relaunch is logged with its class" \
|
|
9903
|
+
|| t_fail "AR memory token: relaunch log" "$(grep autoresume "$ARC/selection.log")"
|
|
9904
|
+
ar_reap "$ARC"
|
|
9905
|
+
# End to end: the fake TUI draws the banner in its footer; the real watcher (MEMORY_MIN_UPTIME
|
|
9906
|
+
# 0 here, a scan every 0.2 s) sees it, stops the client and types the relaunch into the shell.
|
|
9907
|
+
# FAKE_TUI_IGNORE_TERM: like the 8.3GB client of 2026-10-20 it ignores SIGTERM, so it is
|
|
9908
|
+
# KILLed after TERM_GRACE, and still gets its relaunch. The relaunched client's screen is its
|
|
9909
|
+
# own (no banner): it is not restarted again.
|
|
9910
|
+
ar_claude_pool
|
|
9911
|
+
rm -f "$ARB/screen"
|
|
9912
|
+
ar_start FAKE_TUI=memory FAKE_TUI_IGNORE_TERM=1 FAKE_TMUX_SCREEN="$ARB/screen" \
|
|
9913
|
+
CLAUDE_MULTIACC_AR_MEMORY_MIN_UPTIME=0 CLAUDE_MULTIACC_AR_MEMORY_SCAN=0.2 \
|
|
9914
|
+
-- claude --dangerously-skip-permissions "fix the bug please"
|
|
9915
|
+
first=$AR_PID
|
|
9916
|
+
if ar_wait 40 ar_lines 2 "$ARB/tui.log"; then
|
|
9917
|
+
l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
|
|
9918
|
+
from="$(ar_acct_of "$l1")"
|
|
9919
|
+
check "AR memory: resumed in place with the memory reason and its size" \
|
|
9920
|
+
"ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) This session was restarted automatically because the previous process reached critical memory usage (8.3GB)." "$l2"
|
|
9921
|
+
ar_wait 5 ar_stopped "$first" && t_ok "AR memory: the client that ignored SIGTERM was stopped (KILL)" \
|
|
9922
|
+
|| t_fail "AR memory: the client was stopped" "pid $first alive"
|
|
9923
|
+
ar_logged "$ARC" "autoresume detect provider=claude chain=[A-Za-z0-9]+ class=memory code=critical_memory sid=$AR_SID acct=$from reset=- size=8.3GB src=pane" \
|
|
9924
|
+
&& ar_logged "$ARC" "autoresume switch provider=claude chain=[A-Za-z0-9]+ from=$from class=memory sid=$AR_SID depth=1 probe=acct-0[12] size=8.3GB" \
|
|
9925
|
+
&& ar_logged "$ARC" "autoresume relaunch chain=[A-Za-z0-9]+ from=$from class=memory depth=1\$" \
|
|
9926
|
+
&& t_ok "AR memory: detected on the pane, switched, relaunched with class=memory" \
|
|
9927
|
+
|| t_fail "AR memory: log lines" "$(grep autoresume "$ARC/selection.log")"
|
|
9928
|
+
ls -a "$ARC"/acct-0*/ | grep -Eq '^\.(limited|expired)$' \
|
|
9929
|
+
&& t_fail "AR memory: nothing is marked" "$(ls -a "$ARC"/acct-0*/ | tr '\n' ' ')" \
|
|
9930
|
+
|| t_ok "AR memory: no account is marked"
|
|
9931
|
+
grep -q -- "capture-pane -p -J -t %1" "$ARB/tmux/calls" 2>/dev/null \
|
|
9932
|
+
&& ar_typed_ok CLAUDE "$AR_SID" "CLAUDE_ACCOUNTS_ROOT=$(cd "$ARC" && pwd)" "$REPO_DIR/bin/claude" \
|
|
9933
|
+
&& t_ok "AR memory: the pane was read (capture-pane), and the one line typed is the relaunch" \
|
|
9934
|
+
|| t_fail "AR memory: pane reads and the typed line" "$(grep -c capture-pane "$ARB/tmux/calls" 2>/dev/null) / $(cat "$ARB/tmux/typed" 2>/dev/null)"
|
|
9935
|
+
ar_wait 10 ar_logged "$ARC" "autoresume watch provider=claude chain=[A-Za-z0-9]+ pid=[0-9]+ acct=acct-0[12] depth=1\$"
|
|
9936
|
+
sleep 1.5
|
|
9937
|
+
[ "$(grep -c 'autoresume switch' "$ARC/selection.log")" = 1 ] && [ "$(($(wc -l < "$ARB/tui.log")))" = 2 ] \
|
|
9938
|
+
&& t_ok "AR memory: the relaunched session, its screen clear, is not restarted again" \
|
|
9939
|
+
|| t_fail "AR memory: restarted again" "$(grep autoresume "$ARC/selection.log")"
|
|
9940
|
+
else
|
|
9941
|
+
t_fail "AR memory: relaunched within 40 s" "tui=$(cat "$ARB/tui.log" 2>/dev/null) log=$(grep autoresume "$ARC/selection.log" 2>/dev/null)"
|
|
9942
|
+
fi
|
|
9943
|
+
ar_reap "$ARC"
|
|
9944
|
+
ar_no_watcher "$ARC" && t_ok "AR memory: no watcher left running" || t_fail "AR memory: watcher left running"
|
|
9945
|
+
|
|
9113
9946
|
# ---- AR10. codex: the same machinery through bin/codex ------------------------------------
|
|
9114
9947
|
ARX="$WORK/ar-codex"
|
|
9115
9948
|
ar_codex_pool() { # a fresh two-account codex pool sharing one sessions tree (the installed layout)
|
|
@@ -9280,6 +10113,12 @@ if [ -x /usr/bin/python3 ] && /usr/bin/python3 -c 'import sys; sys.exit(0 if sys
|
|
|
9280
10113
|
else
|
|
9281
10114
|
t_fail "MCP registry unit suite on stock python" "/usr/bin/python3 tests/test_mcp_registry.py failed"
|
|
9282
10115
|
fi
|
|
10116
|
+
# ...and so must the MCP OAuth merge the shim runs before exec (lib/mcp_oauth.py ensure).
|
|
10117
|
+
if /usr/bin/python3 "$REPO_DIR/tests/test_mcp_oauth.py" >/dev/null 2>&1; then
|
|
10118
|
+
t_ok "MCP OAuth sharing unit suite on stock python $(/usr/bin/python3 -c 'import sys;print("%d.%d"%sys.version_info[:2])')"
|
|
10119
|
+
else
|
|
10120
|
+
t_fail "MCP OAuth sharing unit suite on stock python" "/usr/bin/python3 tests/test_mcp_oauth.py failed"
|
|
10121
|
+
fi
|
|
9283
10122
|
fi
|
|
9284
10123
|
if CLAUDE_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_claude_reset*.py'; then
|
|
9285
10124
|
t_ok "claude: automatic limit-reset integration suite"
|
|
@@ -9318,6 +10157,172 @@ else
|
|
|
9318
10157
|
t_fail "claude organization recovery report suite" "see unittest output above"
|
|
9319
10158
|
fi
|
|
9320
10159
|
|
|
10160
|
+
# MCP OAuth logins shared by every claude account on this machine (lib/mcp_oauth.py):
|
|
10161
|
+
# harvest and winner rules, merges under the client's own lock, the Keychain read-back,
|
|
10162
|
+
# the refresh grant against a loopback authorization server, forget, and the shim's gate.
|
|
10163
|
+
if python3 "$REPO_DIR/tests/test_mcp_oauth.py"; then
|
|
10164
|
+
t_ok "claude: MCP OAuth sharing unit suite"
|
|
10165
|
+
else
|
|
10166
|
+
t_fail "claude MCP OAuth sharing unit suite" "see unittest output above"
|
|
10167
|
+
fi
|
|
10168
|
+
|
|
10169
|
+
# ---- 21. MCP OAuth sharing creates a credentials file for a setup-token account only ---
|
|
10170
|
+
# Every reader of .credentials.json — the shim (file_login), the limits pass (oauth_store),
|
|
10171
|
+
# the audit, report, verify — counts a document as a login only when it carries
|
|
10172
|
+
# claudeAiOauth (9g), so a file holding MCP state alone costs a setup-token account
|
|
10173
|
+
# nothing: it is the file its client writes itself once it meets an OAuth server from a
|
|
10174
|
+
# session that cannot open the Keychain, and all it has to read MCP logins from. Sharing
|
|
10175
|
+
# creates that file (0600, MCP state alone) for an account running on its token; any other
|
|
10176
|
+
# account keeps what it has. Own pool and keychain: acct-01 runs on a setup token, acct-02
|
|
10177
|
+
# holds the MCP login beside a file login, acct-03's Claude login lives in its Keychain
|
|
10178
|
+
# item, acct-04 has a Keychain login and a setup token, and acct-05 runs on a setup token
|
|
10179
|
+
# beside the file its client wrote first: a discovery stub, no login in it.
|
|
10180
|
+
MOP="$WORK/mcpoauth-pool"
|
|
10181
|
+
MOKC="$WORK/mcpoauth-keychain"
|
|
10182
|
+
mkdir -p "$MOP/tmp" "$MOP/acct-01" "$MOP/acct-02" "$MOP/acct-03" "$MOP/acct-04" "$MOP/acct-05" "$MOKC"
|
|
10183
|
+
: > "$MOP/.limits-kick"
|
|
10184
|
+
cat > "$MOP/accounts.json" <<EOF
|
|
10185
|
+
{ "version": 1, "threshold": 90,
|
|
10186
|
+
"accounts": [
|
|
10187
|
+
{"id": "acct-01", "email": "mo1@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10188
|
+
{"id": "acct-02", "email": "mo2@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10189
|
+
{"id": "acct-03", "email": "mo3@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10190
|
+
{"id": "acct-04", "email": "mo4@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10191
|
+
{"id": "acct-05", "email": "mo5@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"}
|
|
10192
|
+
] }
|
|
10193
|
+
EOF
|
|
10194
|
+
MO_TOK='sk-ant-oat01-MCPSHAREFAKEMCPSHAREFAKEMCPSHAREFAKEMCPSHARE00'
|
|
10195
|
+
MO_TOK4='sk-ant-oat01-MCPKCTOKFAKEMCPKCTOKFAKEMCPKCTOKFAKEMCPKCTOK04'
|
|
10196
|
+
MO_TOK5='sk-ant-oat01-MCPSTUBFAKEMCPSTUBFAKEMCPSTUBFAKEMCPSTUBFAKE05'
|
|
10197
|
+
printf '%s' "$MO_TOK" > "$MOP/acct-01/server.token"
|
|
10198
|
+
printf '%s' "$MO_TOK4" > "$MOP/acct-04/server.token"
|
|
10199
|
+
printf '%s' "$MO_TOK5" > "$MOP/acct-05/server.token"
|
|
10200
|
+
printf '{"mcpOAuth":{"higgsfield|0a1b2c3d4e5f6a7b":{"serverName":"higgsfield","serverUrl":"https://mcp.higgsfield.example/mcp","accessToken":"","discoveryState":{"authorizationServerUrl":"https://clerk.higgsfield.example"}}}}' \
|
|
10201
|
+
> "$MOP/acct-05/.credentials.json"
|
|
10202
|
+
out="$(env CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-05 claude 2>&1)"
|
|
10203
|
+
check "a discovery-stub file beside a setup token: the token is exported" "CFG=acct-05 TOK=$MO_TOK5" "$out"
|
|
10204
|
+
mo_exp="$(( ($(date +%s) + 72000) * 1000 ))"
|
|
10205
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-mofile","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999},"mcpOAuth":{"higgsfield|0a1b2c3d4e5f6a7b":{"serverName":"higgsfield","serverUrl":"https://mcp.higgsfield.example/mcp","accessToken":"fake-mcp-access-mo","refreshToken":"fake-mcp-refresh-mo","expiresAt":%s,"clientId":"https://claude.ai/oauth/claude-code-client-metadata","discoveryState":{"authorizationServerUrl":"http://auth.example.invalid"}}}}' \
|
|
10206
|
+
"$mo_exp" > "$MOP/acct-02/.credentials.json"
|
|
10207
|
+
mo_env() { env CLAUDE_ACCOUNTS_DIR="$MOP" CLAUDE_MULTIACC_KEYCHAIN=1 FAKE_KEYCHAIN_DIR="$MOKC" "$@"; }
|
|
10208
|
+
mo_svc() { printf 'Claude Code-credentials-%s' "$(printf '%s' "$MOP/$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"; }
|
|
10209
|
+
for a in acct-03 acct-04; do
|
|
10210
|
+
mo_env security add-generic-password -U -a "${USER:-$(id -un)}" -s "$(mo_svc "$a")" \
|
|
10211
|
+
-X "$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-mokc%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' "${a#acct-}" \
|
|
10212
|
+
| python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
10213
|
+
done
|
|
10214
|
+
mo_limits() { mo_env CLAUDE_MULTIACC_MCP_OAUTH=0 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" "$@" claude-accounts limits 2>&1; }
|
|
10215
|
+
out="$(mo_limits)"
|
|
10216
|
+
check "MCP sharing: the Keychain logins report before any sync" "acct-03: ok" "$out"
|
|
10217
|
+
check "...the one beside a setup token too" "acct-04: ok" "$out"
|
|
10218
|
+
out="$(mo_env claude-accounts mcp auth-sync 2>&1)"
|
|
10219
|
+
check "MCP sharing: auth-sync shares the login" "higgsfield [higgsfield|0a1b2c3d4e5f6a7b]: login from acct-02 (file)" "$out"
|
|
10220
|
+
mo_mcp_only() { # $1 acct: its .credentials.json is MCP state alone (the shared login), 0600
|
|
10221
|
+
python3 - "$MOP/$1/.credentials.json" <<'EOF'
|
|
10222
|
+
import json, os, stat, sys
|
|
10223
|
+
doc = json.load(open(sys.argv[1]))
|
|
10224
|
+
assert list(doc) == ['mcpOAuth'], list(doc)
|
|
10225
|
+
assert doc['mcpOAuth']['higgsfield|0a1b2c3d4e5f6a7b']['refreshToken'] == 'fake-mcp-refresh-mo', doc
|
|
10226
|
+
assert stat.S_IMODE(os.stat(sys.argv[1]).st_mode) == 0o600
|
|
10227
|
+
EOF
|
|
10228
|
+
}
|
|
10229
|
+
mo_mcp_only acct-01 && mo_mcp_only acct-04 \
|
|
10230
|
+
&& t_ok "MCP sharing creates a .credentials.json holding MCP state alone for each setup-token account" \
|
|
10231
|
+
|| t_fail "MCP sharing: the setup-token accounts' files" "$(ls -a "$MOP/acct-01" "$MOP/acct-04" | tr '\n' ' ')"
|
|
10232
|
+
[ ! -e "$MOP/acct-03/.credentials.json" ] \
|
|
10233
|
+
&& t_ok "...and none for an account whose login is in its Keychain item" \
|
|
10234
|
+
|| t_fail "MCP sharing created a file beside a Keychain login" "$(head -c 120 "$MOP/acct-03/.credentials.json")"
|
|
10235
|
+
for a in acct-03 acct-04; do
|
|
10236
|
+
mo_env security find-generic-password -a "${USER:-$(id -un)}" -s "$(mo_svc "$a")" -w 2>/dev/null \
|
|
10237
|
+
| python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["claudeAiOauth"]["accessToken"]=="sk-ant-oat01-mokc'"${a#acct-}"'", d.keys(); assert "higgsfield|0a1b2c3d4e5f6a7b" in d["mcpOAuth"]' 2>/dev/null \
|
|
10238
|
+
&& t_ok "MCP sharing merges into $a's existing Keychain item and keeps its login" \
|
|
10239
|
+
|| t_fail "MCP sharing keychain merge ($a)" "item missing the shared login or lost its own"
|
|
10240
|
+
done
|
|
10241
|
+
# The setup-token account keeps running on its token, beside the file it was given; a
|
|
10242
|
+
# client that needs a login (FAKE_KEYCHAIN_CLIENT) would say "Not logged in" without one.
|
|
10243
|
+
for i in 1 2; do
|
|
10244
|
+
out="$(mo_env FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
|
|
10245
|
+
check "setup-token account still exports its token beside its MCP-only file (launch $i)" \
|
|
10246
|
+
"CFG=acct-01 TOK=$MO_TOK" "$out"
|
|
10247
|
+
done
|
|
10248
|
+
out="$(mo_env claude-accounts list 2>&1)"
|
|
10249
|
+
case "$(printf '%s\n' "$out" | grep '^acct-01')" in
|
|
10250
|
+
*"auth=token "*) t_ok "list shows auth=token for the setup-token account beside its MCP-only file" ;;
|
|
10251
|
+
*) t_fail "list auth column beside an MCP-only file" "$(printf '%s\n' "$out" | grep '^acct-01')" ;;
|
|
10252
|
+
esac
|
|
10253
|
+
out="$(mo_env claude-accounts list --json 2>&1)"
|
|
10254
|
+
python3 - "$out" <<'EOF'
|
|
10255
|
+
import json, sys
|
|
10256
|
+
rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
|
|
10257
|
+
c = rows['acct-01']['credentials']
|
|
10258
|
+
assert c['oauth'] is False and c['oauth_store'] is None, c
|
|
10259
|
+
assert rows['acct-01']['credential_class'] == 'portable' and rows['acct-01']['selectable'] is True
|
|
10260
|
+
c = rows['acct-04']['credentials']
|
|
10261
|
+
assert c['oauth'] is True and c['oauth_store'] == 'keychain' and c['token'] is True, c
|
|
10262
|
+
EOF
|
|
10263
|
+
[ $? -eq 0 ] && t_ok "list --json: an MCP-only file is no login (token: portable; Keychain login: keychain)" \
|
|
10264
|
+
|| t_fail "list --json beside created MCP files" "see assertions above"
|
|
10265
|
+
# The account with a Keychain login AND a token, in a session that can open the Keychain:
|
|
10266
|
+
# it runs on that login, and its telemetry comes from it, as before the file existed.
|
|
10267
|
+
out="$(mo_env FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
|
|
10268
|
+
check "a Keychain login beside a created MCP file runs on that login (Keychain readable)" \
|
|
10269
|
+
"CFG=acct-04 TOK=none" "$out"
|
|
10270
|
+
rm -f "$MOP/acct-03/limits.json" "$MOP/acct-04/limits.json"
|
|
10271
|
+
out="$(mo_limits)"
|
|
10272
|
+
check "MCP sharing: the Keychain login still reports after a sync" "acct-03: ok" "$out"
|
|
10273
|
+
check "...and the one beside its token and created MCP file" "acct-04: ok" "$out"
|
|
10274
|
+
mo_kc_alive() { # $1 acct: not parked, its telemetry from its OAuth login
|
|
10275
|
+
[ ! -f "$MOP/$1/.expired" ] \
|
|
10276
|
+
&& python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("source")=="oauth", d' \
|
|
10277
|
+
"$MOP/$1/limits.json" 2>/dev/null
|
|
10278
|
+
}
|
|
10279
|
+
mo_kc_alive acct-03 && mo_kc_alive acct-04 \
|
|
10280
|
+
&& t_ok "a Keychain login beside a shared MCP login is neither parked nor dark" \
|
|
10281
|
+
|| t_fail "Keychain logins after MCP sharing" "$(cat "$MOP"/acct-0[34]/.expired 2>/dev/null)"
|
|
10282
|
+
# ...and in a session that cannot open it (ssh, tmux): the token runs, nothing is parked,
|
|
10283
|
+
# nothing is called dead, and the readings the readable session took stand.
|
|
10284
|
+
cp "$MOP/acct-04/limits.json" "$WORK/mo-limits4.json"
|
|
10285
|
+
out="$(mo_env FAKE_KEYCHAIN_LOCKED=1 FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
|
|
10286
|
+
check "with the Keychain locked, that account runs on its token (its client reads the MCP file)" \
|
|
10287
|
+
"CFG=acct-04 TOK=$MO_TOK4" "$out"
|
|
10288
|
+
out="$(mo_limits FAKE_KEYCHAIN_LOCKED=1)"
|
|
10289
|
+
check "a locked Keychain login is left for the next pass, its token never spent" \
|
|
10290
|
+
"acct-04: oauth credential not usable this pass" "$out"
|
|
10291
|
+
{ [ ! -f "$MOP/acct-03/.expired" ] && [ ! -f "$MOP/acct-04/.expired" ] \
|
|
10292
|
+
&& cmp -s "$WORK/mo-limits4.json" "$MOP/acct-04/limits.json"; } \
|
|
10293
|
+
&& t_ok "...parked nowhere, and its telemetry kept" \
|
|
10294
|
+
|| t_fail "locked Keychain logins after MCP sharing" "$(cat "$MOP"/acct-0[34]/.expired 2>/dev/null)"
|
|
10295
|
+
out="$(mo_env FAKE_KEYCHAIN_LOCKED=1 claude-accounts list 2>&1)"
|
|
10296
|
+
check "list, locked: the Keychain login is locked here, not missing" "KEYCHAIN-LOCKED-HERE" \
|
|
10297
|
+
"$(printf '%s\n' "$out" | grep '^acct-03')"
|
|
10298
|
+
case "$(printf '%s\n' "$out" | grep '^acct-04')" in
|
|
10299
|
+
*EXPIRED*|*NO-LOGIN*|*INVALID*) t_fail "list, locked, acct-04" "$(printf '%s\n' "$out" | grep '^acct-04')" ;;
|
|
10300
|
+
*"auth=keychain+token"*) t_ok "list, locked: the token account beside it reads keychain+token, nothing dead" ;;
|
|
10301
|
+
*) t_fail "list, locked, acct-04" "$(printf '%s\n' "$out" | grep '^acct-04')" ;;
|
|
10302
|
+
esac
|
|
10303
|
+
out="$(mo_env claude-accounts mcp auth-status 2>&1)"
|
|
10304
|
+
check "auth-status: only the Keychain-login account has no file" "absent acct-03" "$out"
|
|
10305
|
+
# The setup-token account whose client wrote its file first got the shared login merged
|
|
10306
|
+
# into that stub, and keeps exporting its token.
|
|
10307
|
+
python3 - "$MOP/acct-05/.credentials.json" <<'EOF'
|
|
10308
|
+
import json, sys
|
|
10309
|
+
doc = json.load(open(sys.argv[1]))
|
|
10310
|
+
assert list(doc) == ['mcpOAuth'], list(doc)
|
|
10311
|
+
assert doc['mcpOAuth']['higgsfield|0a1b2c3d4e5f6a7b']['refreshToken'] == 'fake-mcp-refresh-mo', doc
|
|
10312
|
+
EOF
|
|
10313
|
+
[ $? -eq 0 ] && t_ok "the shared login is merged into that existing stub file" \
|
|
10314
|
+
|| t_fail "merge into a stub file" "$(head -c 200 "$MOP/acct-05/.credentials.json")"
|
|
10315
|
+
for i in 1 2; do
|
|
10316
|
+
out="$(mo_env FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-05 claude 2>&1)"
|
|
10317
|
+
check "...and that account still exports its token (launch $i)" "CFG=acct-05 TOK=$MO_TOK5" "$out"
|
|
10318
|
+
done
|
|
10319
|
+
out="$(mo_env claude-accounts list 2>&1)"
|
|
10320
|
+
case "$(printf '%s\n' "$out" | grep '^acct-05')" in
|
|
10321
|
+
*"auth=token "*) t_ok "...and list shows it auth=token" ;;
|
|
10322
|
+
*) t_fail "list auth column beside a merged stub file" "$(printf '%s\n' "$out" | grep '^acct-05')" ;;
|
|
10323
|
+
esac
|
|
10324
|
+
rm -rf "$MOP" "$MOKC" "$WORK/mo-limits4.json"
|
|
10325
|
+
|
|
9321
10326
|
# ---- summary ---------------------------------------------------------------------
|
|
9322
10327
|
echo
|
|
9323
10328
|
echo "passed: $PASS failed: $FAIL"
|