claude-multiacc 2.0.58 → 2.0.60
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 +125 -4
- package/bin/claude +389 -94
- package/bin/claude-accounts +276 -42
- package/bin/codex +69 -5
- package/bin/codex-accounts +33 -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 +146 -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/mcp_registry.py +230 -8
- 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 +1171 -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_mcp_registry.py +234 -3
- package/tests/test_packaged_commands.py +41 -0
package/tests/run-tests.sh
CHANGED
|
@@ -49,6 +49,18 @@ mkdir -p "$ACC/tmp" "$FAKEBIN"
|
|
|
49
49
|
cat > "$FAKEBIN/claude" <<'EOF'
|
|
50
50
|
#!/usr/bin/env bash
|
|
51
51
|
# fake real claude for tests (not a multiacc shim)
|
|
52
|
+
# Global options may precede the subcommand, as commander allows (the operator's alias
|
|
53
|
+
# `claude --dangerously-skip-permissions`, the shim's own `--model X`): the mcp stand-in
|
|
54
|
+
# skips the two the suite types, and nothing else — `--resume mcp add x` is no mcp run.
|
|
55
|
+
_fa=("$@")
|
|
56
|
+
while [ "${#_fa[@]}" -gt 0 ]; do
|
|
57
|
+
case "${_fa[0]}" in
|
|
58
|
+
--dangerously-skip-permissions) _fa=("${_fa[@]:1}") ;;
|
|
59
|
+
--model) _fa=("${_fa[@]:2}") ;;
|
|
60
|
+
*) break ;;
|
|
61
|
+
esac
|
|
62
|
+
done
|
|
63
|
+
if [ "${_fa[0]:-}" = "mcp" ] && [ "${_fa[0]:-}" != "${1:-}" ]; then set -- "${_fa[@]}"; fi
|
|
52
64
|
if [ "${1:-}" = "mcp" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
53
65
|
# A stand-in for the real `claude mcp add|add-json|remove`: `-s user` edits the
|
|
54
66
|
# config dir's .claude.json mcpServers, the default `local` scope edits
|
|
@@ -186,6 +198,10 @@ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
186
198
|
# exits 143, and a crash (SIGKILL) leaves the registry behind. The scripted error only
|
|
187
199
|
# fires on a fresh launch; a `--resume <sid>` launch appends what the real client does
|
|
188
200
|
# after the auto-resume prompt — a normal reply — unless FAKE_TUI_QUIET is set.
|
|
201
|
+
# FAKE_TMUX_SCREEN is the pane's screen (the fake tmux's capture-pane prints it): every
|
|
202
|
+
# launch draws its own, and FAKE_TUI=memory puts the client's critical-memory banner in
|
|
203
|
+
# its footer. FAKE_TUI_IGNORE_TERM=1 is a client that SIGTERM does not stop (only KILL).
|
|
204
|
+
[ -n "${FAKE_TMUX_SCREEN:-}" ] && : > "$FAKE_TMUX_SCREEN"
|
|
189
205
|
acct="$(basename "$CLAUDE_CONFIG_DIR")"
|
|
190
206
|
sid=""; prev=""
|
|
191
207
|
for a in "$@"; do
|
|
@@ -212,7 +228,11 @@ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
212
228
|
reg="$CLAUDE_CONFIG_DIR/sessions/$$.json"
|
|
213
229
|
printf '{"pid":%s,"sessionId":"%s","cwd":"%s","kind":"interactive","status":"busy","startedAt":%s000}\n' \
|
|
214
230
|
"$$" "$sid" "$PWD" "$(date +%s)" > "$reg"
|
|
215
|
-
|
|
231
|
+
if [ -n "${FAKE_TUI_IGNORE_TERM:-}" ]; then
|
|
232
|
+
trap '' TERM
|
|
233
|
+
else
|
|
234
|
+
trap 'rm -f "$reg"; exit 143' TERM
|
|
235
|
+
fi
|
|
216
236
|
trap 'rm -f "$reg"; exit 129' HUP
|
|
217
237
|
if [ "$resumed" = 0 ]; then
|
|
218
238
|
sleep "${FAKE_TUI_DELAY:-0.3}"
|
|
@@ -221,6 +241,14 @@ if [ -n "${FAKE_TUI:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
221
241
|
auth) apierr authentication_failed ;;
|
|
222
242
|
invalid) apierr invalid_request ;;
|
|
223
243
|
crash) sleep 0.3; kill -9 $$ ;;
|
|
244
|
+
memory)
|
|
245
|
+
printf '{"type":"assistant","timestamp":"%s","isSidechain":false,"sessionId":"%s","message":{"model":"claude-opus-5-5","content":[{"type":"text","text":"Started the workflows."}]}}\n' \
|
|
246
|
+
"$(ts)" "$sid" >> "$tr"
|
|
247
|
+
# the prompt's row as 2.1.304 draws it: the mode indicator, then a no-break space
|
|
248
|
+
rule="────────────────────────────────────────────────────────────"
|
|
249
|
+
printf '%s\n' "⏺ Started the workflows." "" "$rule" "❯$(printf '\302\240')" "$rule" \
|
|
250
|
+
" ⏵⏵ bypass permissions on Critical memory usage (8.3GB) — restart and resume with claude --continue, or run /compact" \
|
|
251
|
+
> "${FAKE_TMUX_SCREEN:-/dev/null}" ;;
|
|
224
252
|
esac
|
|
225
253
|
fi
|
|
226
254
|
# Bounded, in short sleeps so a TERM is handled within a tenth of a second.
|
|
@@ -254,11 +282,14 @@ case "${CLAUDE_CODE_OAUTH_TOKEN:-}" in
|
|
|
254
282
|
esac
|
|
255
283
|
if [ -n "${FAKE_KEYCHAIN_CLIENT:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ] \
|
|
256
284
|
&& [ -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
|
|
257
|
-
# The real macOS client's login lookup: the
|
|
258
|
-
# ($USER; Bun's "unknown" without it)
|
|
285
|
+
# The real macOS client's login lookup (2.1.303): the document in the Keychain item
|
|
286
|
+
# under its FA() name ONLY ($USER; Bun's "unknown" without it) when it can read one,
|
|
287
|
+
# else .credentials.json — nothing else — and that document is a login only when it
|
|
288
|
+
# carries claudeAiOauth: MCP OAuth state alone (mcpOAuth) is "Not logged in".
|
|
259
289
|
h="$(printf '%s' "$CLAUDE_CONFIG_DIR" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"
|
|
260
|
-
|
|
261
|
-
|
|
290
|
+
doc="$(security find-generic-password -a "${USER:-unknown}" -s "Claude Code-credentials-$h" -w 2>/dev/null)" \
|
|
291
|
+
|| doc="$(cat "$CLAUDE_CONFIG_DIR/.credentials.json" 2>/dev/null)"
|
|
292
|
+
if ! printf '%s' "$doc" | grep -Eq '"claudeAiOauth"[[:space:]]*:[[:space:]]*[{]'; then
|
|
262
293
|
echo "Not logged in · Please run /login" >&2
|
|
263
294
|
exit 1
|
|
264
295
|
fi
|
|
@@ -341,7 +372,15 @@ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
341
372
|
"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" "$FAKE_LIMIT_RESET" "$FAKE_SESSION_ID" \
|
|
342
373
|
>> "$CLAUDE_CONFIG_DIR/projects/-proj/$FAKE_SESSION_ID.jsonl"
|
|
343
374
|
fi
|
|
344
|
-
|
|
375
|
+
if [ -n "${FAKE_SESSION_UNTIL:-}" ]; then
|
|
376
|
+
# held until the test says so (a loaded machine runs the steps slower than any timer)
|
|
377
|
+
fake_n=0
|
|
378
|
+
while [ ! -e "$FAKE_SESSION_UNTIL" ] && [ "$fake_n" -lt $(( ${FAKE_SESSION_HOLD:-4} * 10 )) ]; do
|
|
379
|
+
sleep 0.1; fake_n=$((fake_n + 1))
|
|
380
|
+
done
|
|
381
|
+
else
|
|
382
|
+
sleep "${FAKE_SESSION_HOLD:-4}"
|
|
383
|
+
fi
|
|
345
384
|
rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
|
|
346
385
|
fi
|
|
347
386
|
# what an interactive client would inherit from the shim (auto-resume must leak nothing)
|
|
@@ -363,8 +402,12 @@ chmod +x "$FAKEBIN/security"
|
|
|
363
402
|
|
|
364
403
|
export PATH="$REPO_DIR/bin:$FAKEBIN:$PATH"
|
|
365
404
|
export FAKE_CTL="$WORK/ctl"
|
|
366
|
-
# Neutralize any ambient state from the invoking environment.
|
|
367
|
-
|
|
405
|
+
# Neutralize any ambient state from the invoking environment. A pooled session exports
|
|
406
|
+
# its pool roots and config dir to every child: run from inside one, the suite's shim
|
|
407
|
+
# calls resolved the REAL pools (2026-10-14: a sandbox test printed a live OAuth token),
|
|
408
|
+
# so the roots go too — every test that needs one sets it explicitly.
|
|
409
|
+
unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN CLAUDE_ACCOUNT CLAUDE_SHIM_ACTIVE \
|
|
410
|
+
CLAUDE_ACCOUNTS_ROOT CODEX_ACCOUNTS_ROOT CODEX_HOME CODEX_ACCOUNT CODEX_SHIM_ACTIVE 2>/dev/null || true
|
|
368
411
|
export CLAUDE_MULTIACC_NO_SYNC=1
|
|
369
412
|
# Fixtures are local file:// URLs with no rate limit, so the anti-429 fetch throttle
|
|
370
413
|
# is off by default here; the throttle test re-enables it explicitly.
|
|
@@ -379,12 +422,14 @@ export CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-endpoint-missing.json"
|
|
|
379
422
|
# Same for the usage endpoint: the SHIM's opportunistic background `limits --quiet`
|
|
380
423
|
# kick inherits this default, so it can never reach a real endpoint from tests
|
|
381
424
|
# (every explicit limits test overrides the URL inline). The pre-armed .limits-kick
|
|
382
|
-
# throttle keeps those background kicks from racing explicit limits runs
|
|
425
|
+
# throttle keeps those background kicks from racing explicit limits runs: armed in the
|
|
426
|
+
# FUTURE, since a throttle armed "now" lapses after 10 minutes, and on a loaded machine a pool
|
|
427
|
+
# outlives that (a kicked pass then held the lock and left a backoff behind).
|
|
383
428
|
export CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-endpoint-missing.json"
|
|
384
429
|
# Organization checks must never send synthetic tokens to a real provider. The
|
|
385
430
|
# dedicated identity suite overrides this with its loopback HTTP contract server.
|
|
386
431
|
export CLAUDE_MULTIACC_TOKEN_IDENTITY_URL="file://$WORK/identity-endpoint-missing.json"
|
|
387
|
-
|
|
432
|
+
touch -t 203001010000 "$ACC/.limits-kick"
|
|
388
433
|
# Keychain lookups are OFF for the legacy sections (their pools are file-based and the
|
|
389
434
|
# extra `security` process per account would only slow them down); section 17 turns
|
|
390
435
|
# them on explicitly against the fake `security` above.
|
|
@@ -399,6 +444,17 @@ export CLAUDE_MULTIACC_PATH_PROBE=0
|
|
|
399
444
|
# tmux — a watcher here would otherwise type into the developer's REAL pane.
|
|
400
445
|
export CLAUDE_MULTIACC_AUTORESUME=0 CODEX_MULTIACC_AUTORESUME=0
|
|
401
446
|
unset TMUX TMUX_PANE 2>/dev/null || true
|
|
447
|
+
# The model catalog refresh (lib/model_catalog.py) writes <pool>/model-catalog.json: every
|
|
448
|
+
# limits pass for its own pool, the verb and self-update for BOTH. Until the codex section the
|
|
449
|
+
# suite has no codex root, so the claude side's "other pool" is the operator's real one. Off
|
|
450
|
+
# everywhere; section 19a turns it on against its own roots (and the limits-pass backstop is
|
|
451
|
+
# exercised with the switch on in tests/test_autoresume_catalog_cli.py).
|
|
452
|
+
export CLAUDE_MULTIACC_MODEL_CATALOG=0 CODEX_MULTIACC_MODEL_CATALOG=0
|
|
453
|
+
# MCP adoption (lib/mcp_registry.py adopt) would take whatever the operator's real
|
|
454
|
+
# ~/.claude.json names — seeding copies it into every test account, this HOME being the
|
|
455
|
+
# operator's — into the test pool's registry. tests/test_mcp_registry.py covers adoption in
|
|
456
|
+
# disposable pools.
|
|
457
|
+
export CLAUDE_MULTIACC_MCP_ADOPT=0 CODEX_MULTIACC_MCP_ADOPT=0
|
|
402
458
|
|
|
403
459
|
now="$(date +%s)"
|
|
404
460
|
|
|
@@ -1188,6 +1244,451 @@ check "a model 404 reaches the caller as it is" "not available on your firstPart
|
|
|
1188
1244
|
|| t_fail "model 404 park" "$(tail -1 "$ACC/acct-01/.expired")"
|
|
1189
1245
|
rm -f "$FAKE_CTL" "$ACC"/acct-*/limits.json "$ACC"/acct-*/.limited "$ACC"/acct-*/.expired
|
|
1190
1246
|
|
|
1247
|
+
# ---- 9g. a credentials file holding only MCP state is NO login --------------------------
|
|
1248
|
+
# Claude Code 2.1.303 keeps the OAuth state of the MCP servers an account signed into
|
|
1249
|
+
# (mcpOAuth, every entry with its own expiresAt and refreshToken) in the same document as
|
|
1250
|
+
# the login, and from a session that cannot open the Keychain (ssh/tmux) it writes a
|
|
1251
|
+
# .credentials.json holding ONLY that. Counted as the login, such accounts were picked
|
|
1252
|
+
# with no token exported, so every run said "Not logged in · Run /login" (2026-10-19):
|
|
1253
|
+
# the greedy expiry scrape read the MCP entry's future expiresAt as the login's. Own pool
|
|
1254
|
+
# root; FAKE_KEYCHAIN_CLIENT makes the fake client refuse a run with no login at all.
|
|
1255
|
+
MOP="$WORK/mcponly"
|
|
1256
|
+
MOP_HOME=mac; [ "$(uname -s)" = "Darwin" ] || MOP_HOME=server
|
|
1257
|
+
mkdir -p "$MOP/tmp" "$MOP/acct-01" "$MOP/acct-02"
|
|
1258
|
+
: > "$MOP/.limits-kick"
|
|
1259
|
+
cat > "$MOP/accounts.json" <<EOF
|
|
1260
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
1261
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
1262
|
+
"accounts": [
|
|
1263
|
+
{"id": "acct-01", "email": "mtok@test", "home": "$MOP_HOME", "added_at": "2026-10-19T00:00:00Z"},
|
|
1264
|
+
{"id": "acct-02", "email": "mnone@test", "home": "$MOP_HOME", "added_at": "2026-10-19T00:00:00Z"}
|
|
1265
|
+
] }
|
|
1266
|
+
EOF
|
|
1267
|
+
MCP_KEY='higgsfield|0123456789abcdef'
|
|
1268
|
+
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}}'
|
|
1269
|
+
MCP_ONLY="{\"mcpOAuth\":{$MCP_ENTRY}}"
|
|
1270
|
+
MOP_TOK='sk-ant-oat01-MCPONLYTOKENMCPONLYTOKENMCPONLYTOKENMCPONLY01'
|
|
1271
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1272
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-02/.credentials.json"
|
|
1273
|
+
printf '%s' "$MOP_TOK" > "$MOP/acct-01/server.token"
|
|
1274
|
+
ok1=1
|
|
1275
|
+
for _ in $(seq 1 10); do
|
|
1276
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude 2>&1)"
|
|
1277
|
+
case "$out" in *"CFG=acct-01 TOK=$MOP_TOK"*) ;; *) ok1=0; break ;; esac
|
|
1278
|
+
done
|
|
1279
|
+
[ "$ok1" = 1 ] && t_ok "an MCP-only credentials file leaves the account to its token, which is exported" \
|
|
1280
|
+
|| t_fail "MCP-only file + token" "got: $(printf '%s' "$out" | head -c 160)"
|
|
1281
|
+
mv "$MOP/acct-01/server.token" "$WORK/mcponly-token.aside"
|
|
1282
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1283
|
+
check "with no token and no Keychain login, an MCP-only account is not selectable" "CFG=none" "$out"
|
|
1284
|
+
mv "$WORK/mcponly-token.aside" "$MOP/acct-01/server.token"
|
|
1285
|
+
# A DEAD login beside a live MCP entry is dead, in either key order: the scrape reads the
|
|
1286
|
+
# login object alone, never the entry's expiresAt or refreshToken.
|
|
1287
|
+
for order in login-first mcp-first; do
|
|
1288
|
+
if [ "$order" = login-first ]; then
|
|
1289
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-expired","expiresAt":1000000000000,"scopes":["user:inference"]},"mcpOAuth":{%s}}' \
|
|
1290
|
+
"$MCP_ENTRY" > "$MOP/acct-01/.credentials.json"
|
|
1291
|
+
else
|
|
1292
|
+
printf '{"mcpOAuth":{%s},"claudeAiOauth":{"accessToken":"sk-ant-oat01-expired","expiresAt":1000000000000}}' \
|
|
1293
|
+
"$MCP_ENTRY" > "$MOP/acct-01/.credentials.json"
|
|
1294
|
+
fi
|
|
1295
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1296
|
+
check "a dead login beside a live MCP entry is dead ($order): the token is exported" \
|
|
1297
|
+
"CFG=acct-01 TOK=$MOP_TOK" "$out"
|
|
1298
|
+
done
|
|
1299
|
+
mv "$MOP/acct-01/server.token" "$WORK/mcponly-token.aside"
|
|
1300
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1301
|
+
check "...and with no token it is not selected" "CFG=none" "$out"
|
|
1302
|
+
grep -q "skipped-expired: acct-01" "$MOP/selection.log" 2>/dev/null \
|
|
1303
|
+
&& t_ok "...but listed as a dead login (skipped-expired)" \
|
|
1304
|
+
|| t_fail "dead login beside MCP state" "no skipped-expired line for acct-01"
|
|
1305
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list 2>&1)"
|
|
1306
|
+
check "list flags the dead login beside MCP state" "EXPIRED-LOGIN" "$(printf '%s\n' "$out" | grep '^acct-01')"
|
|
1307
|
+
mv "$WORK/mcponly-token.aside" "$MOP/acct-01/server.token"
|
|
1308
|
+
# A credential park heals on a newer LOGIN; an MCP-only rewrite (the client saves one on
|
|
1309
|
+
# every MCP token refresh) is no such evidence.
|
|
1310
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1311
|
+
printf '%s\nreason=auth-error marked_at=t detail=test\n' "$now" > "$MOP/acct-01/.expired"
|
|
1312
|
+
touch -t 202001010101 "$MOP/acct-01/server.token" # the token is OLDER than the park
|
|
1313
|
+
touch -t 202001010202 "$MOP/acct-01/.expired"
|
|
1314
|
+
touch "$MOP/acct-01/.credentials.json" # MCP state rewritten after it
|
|
1315
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1316
|
+
check "an MCP-only rewrite does not heal an auth park" "CFG=none" "$out"
|
|
1317
|
+
[ -f "$MOP/acct-01/.expired" ] && t_ok "...the park stays in force" \
|
|
1318
|
+
|| t_fail "MCP-only rewrite healed a park" ".expired was removed"
|
|
1319
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list 2>&1)"
|
|
1320
|
+
check "...for the audit too" "EXPIRED-LOGIN" "$(printf '%s\n' "$out" | grep '^acct-01')"
|
|
1321
|
+
printf '{"mcpOAuth":{%s},"claudeAiOauth":{"accessToken":"sk-ant-oat01-relogged","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' \
|
|
1322
|
+
"$MCP_ENTRY" > "$MOP/acct-01/.credentials.json"
|
|
1323
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude 2>&1)"
|
|
1324
|
+
check "a login written after the park heals it, and runs on the login" "CFG=acct-01 TOK=none" "$out"
|
|
1325
|
+
[ ! -f "$MOP/acct-01/.expired" ] && t_ok "...and the park is gone" \
|
|
1326
|
+
|| t_fail "login write did not heal" ".expired survived a newer login"
|
|
1327
|
+
# list / list --json / verify: an MCP-only file is no oauth login anywhere.
|
|
1328
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1329
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list 2>&1)"
|
|
1330
|
+
line="$(printf '%s\n' "$out" | grep '^acct-01')"
|
|
1331
|
+
case "$line" in
|
|
1332
|
+
*"auth=token "*) t_ok "list shows auth=token, not oauth+token, for an MCP-only file" ;;
|
|
1333
|
+
*) t_fail "list auth column for an MCP-only file" "$line" ;;
|
|
1334
|
+
esac
|
|
1335
|
+
check "list calls the token-less MCP-only account NO-LOGIN" "auth=NONE" "$(printf '%s\n' "$out" | grep '^acct-02')"
|
|
1336
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts list --json 2>&1)"
|
|
1337
|
+
python3 - "$out" <<'EOF'
|
|
1338
|
+
import json, sys
|
|
1339
|
+
rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
|
|
1340
|
+
a1, a2 = rows['acct-01'], rows['acct-02']
|
|
1341
|
+
assert a1['credentials']['oauth'] is False and a1['credentials']['oauth_store'] is None, a1['credentials']
|
|
1342
|
+
assert a1['credential_class'] == 'portable' and a1['selectable'] is True, a1
|
|
1343
|
+
assert a2['credentials']['oauth'] is False and a2['credential_class'] == 'none', a2
|
|
1344
|
+
assert a2['status'] == 'missing' and a2['needs_login'] is True, a2
|
|
1345
|
+
assert 'no Claude login' in a2['reason'], a2['reason']
|
|
1346
|
+
EOF
|
|
1347
|
+
[ $? -eq 0 ] && t_ok "list --json: an MCP-only file is no oauth login (token-backed: portable; else missing)" \
|
|
1348
|
+
|| t_fail "MCP-only list --json" "see assertions above"
|
|
1349
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude-accounts verify 2>&1)"
|
|
1350
|
+
check "verify runs an MCP-only account on its token" "acct-01 mtok@test: PASS" "$out"
|
|
1351
|
+
check "verify skips an MCP-only account with nothing to authenticate by" \
|
|
1352
|
+
"acct-02 mnone@test: SKIP (no auth on this machine)" "$out"
|
|
1353
|
+
[ ! -f "$MOP/acct-02/.expired" ] && t_ok "verify parks nothing over an MCP-only file" \
|
|
1354
|
+
|| t_fail "verify parked an MCP-only account" "$(tail -1 "$MOP/acct-02/.expired")"
|
|
1355
|
+
# limits: an MCP-only file is no login to refresh — never a dead grant to park — and the
|
|
1356
|
+
# pass leaves it byte-for-byte alone.
|
|
1357
|
+
cp "$MOP/acct-02/.credentials.json" "$WORK/mcponly-before.json"
|
|
1358
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts limits --force >/dev/null 2>&1
|
|
1359
|
+
[ ! -f "$MOP/acct-02/.expired" ] && t_ok "limits never parks an MCP-only file as a dead grant" \
|
|
1360
|
+
|| t_fail "limits parked an MCP-only account" "$(tail -1 "$MOP/acct-02/.expired")"
|
|
1361
|
+
cmp -s "$WORK/mcponly-before.json" "$MOP/acct-02/.credentials.json" \
|
|
1362
|
+
&& t_ok "limits leaves the MCP state exactly as it was" \
|
|
1363
|
+
|| t_fail "limits rewrote an MCP-only file" "$(head -c 160 "$MOP/acct-02/.credentials.json")"
|
|
1364
|
+
# A refresh writes the login INTO the file as it is when it lands: the MCP token the client
|
|
1365
|
+
# saved while the grant was in flight survives (the endpoint stub saves one mid-flight).
|
|
1366
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-stale","refreshToken":"sk-ant-ort01-mcpkeep","expiresAt":1000,"refreshTokenExpiresAt":9999999999999},"mcpOAuth":{%s},"mcpOAuthClientConfig":{"%s":{"clientId":"cid-kept"}}}' \
|
|
1367
|
+
"$MCP_ENTRY" "$MCP_KEY" > "$MOP/acct-02/.credentials.json"
|
|
1368
|
+
cat > "$WORK/mcp-token-server.py" <<'EOF'
|
|
1369
|
+
import http.server, json, os, sys
|
|
1370
|
+
target = sys.argv[1]
|
|
1371
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
1372
|
+
def log_message(self, *a): pass
|
|
1373
|
+
def do_POST(self):
|
|
1374
|
+
try:
|
|
1375
|
+
self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
|
1376
|
+
except Exception:
|
|
1377
|
+
pass
|
|
1378
|
+
doc = json.load(open(target))
|
|
1379
|
+
doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] = 'mcp-refresh-SAVED-MIDFLIGHT'
|
|
1380
|
+
with open(target + '.mid', 'w') as f:
|
|
1381
|
+
json.dump(doc, f)
|
|
1382
|
+
os.replace(target + '.mid', target)
|
|
1383
|
+
raw = json.dumps({'access_token': 'sk-ant-oat01-mcprefreshed', 'refresh_token': 'sk-ant-ort01-mcprotated',
|
|
1384
|
+
'expires_in': 28800, 'refresh_token_expires_in': 2592000}).encode()
|
|
1385
|
+
self.send_response(200)
|
|
1386
|
+
self.send_header('Content-Type', 'application/json')
|
|
1387
|
+
self.send_header('Content-Length', str(len(raw)))
|
|
1388
|
+
self.end_headers()
|
|
1389
|
+
self.wfile.write(raw)
|
|
1390
|
+
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
|
|
1391
|
+
print(srv.server_address[1], flush=True)
|
|
1392
|
+
srv.serve_forever()
|
|
1393
|
+
EOF
|
|
1394
|
+
mport=""
|
|
1395
|
+
python3 "$WORK/mcp-token-server.py" "$MOP/acct-02/.credentials.json" > "$WORK/mcp-token-port" 2>/dev/null &
|
|
1396
|
+
msrv_pid=$!
|
|
1397
|
+
STUB_PIDS="$STUB_PIDS $msrv_pid"
|
|
1398
|
+
for _ in $(seq 1 20); do
|
|
1399
|
+
mport="$(head -1 "$WORK/mcp-token-port" 2>/dev/null)"
|
|
1400
|
+
case "$mport" in ''|*[!0-9]*) mport=""; sleep 0.2 ;; *) break ;; esac
|
|
1401
|
+
done
|
|
1402
|
+
if [ -z "$mport" ]; then
|
|
1403
|
+
t_ok "mid-flight MCP save test skipped (cannot bind a loopback port here)"
|
|
1404
|
+
else
|
|
1405
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$mport/token" \
|
|
1406
|
+
claude-accounts limits --force 2>&1)"
|
|
1407
|
+
check "a login beside MCP state refreshes" "acct-02: oauth access token refreshed" "$out"
|
|
1408
|
+
python3 - "$MOP/acct-02/.credentials.json" <<'EOF'
|
|
1409
|
+
import json, os, stat, sys
|
|
1410
|
+
doc = json.load(open(sys.argv[1]))
|
|
1411
|
+
o = doc['claudeAiOauth']
|
|
1412
|
+
assert o['accessToken'] == 'sk-ant-oat01-mcprefreshed' and o['refreshToken'] == 'sk-ant-ort01-mcprotated', 'login not rotated'
|
|
1413
|
+
entry = doc['mcpOAuth']['higgsfield|0123456789abcdef']
|
|
1414
|
+
assert entry['refreshToken'] == 'mcp-refresh-SAVED-MIDFLIGHT', 'the MCP token saved mid-flight was rolled back'
|
|
1415
|
+
assert entry['accessToken'] == 'mcp-access-higgsfield', entry
|
|
1416
|
+
assert doc['mcpOAuthClientConfig'] == {'higgsfield|0123456789abcdef': {'clientId': 'cid-kept'}}, doc
|
|
1417
|
+
assert stat.S_IMODE(os.stat(sys.argv[1]).st_mode) == 0o600
|
|
1418
|
+
EOF
|
|
1419
|
+
[ $? -eq 0 ] && t_ok "the refresh writes the login INTO the file: MCP state saved mid-flight survives" \
|
|
1420
|
+
|| t_fail "refresh clobbered MCP state" "see assertions above"
|
|
1421
|
+
fi
|
|
1422
|
+
kill "$msrv_pid" 2>/dev/null; wait "$msrv_pid" 2>/dev/null || true
|
|
1423
|
+
# import --creds: the login lands INTO the account's file, and a file without one is refused.
|
|
1424
|
+
printf '%s' "$MCP_ONLY" > "$WORK/mcponly-import.json"
|
|
1425
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts import mimp@test --id acct-03 --creds "$WORK/mcponly-import.json" --no-sync 2>&1)"; rc=$?
|
|
1426
|
+
[ "$rc" != 0 ] && [ ! -d "$MOP/acct-03" ] && t_ok "import refuses a credentials file holding no login" \
|
|
1427
|
+
|| t_fail "import of an MCP-only file" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
1428
|
+
check "...and says why" "holds no Claude login" "$out"
|
|
1429
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1430
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-imported","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' > "$WORK/login-import.json"
|
|
1431
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts import mtok@test --id acct-01 --creds "$WORK/login-import.json" --no-sync 2>&1)"
|
|
1432
|
+
check "import updates an account whose file holds MCP state" "Imported acct-01" "$out"
|
|
1433
|
+
python3 - "$MOP/acct-01/.credentials.json" <<'EOF'
|
|
1434
|
+
import json, sys
|
|
1435
|
+
doc = json.load(open(sys.argv[1]))
|
|
1436
|
+
assert doc['claudeAiOauth']['accessToken'] == 'sk-ant-oat01-imported', doc
|
|
1437
|
+
assert doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] == 'mcp-refresh-higgsfield', doc
|
|
1438
|
+
EOF
|
|
1439
|
+
[ $? -eq 0 ] && t_ok "import writes the login INTO the file, keeping its MCP state" \
|
|
1440
|
+
|| t_fail "import clobbered MCP state" "see assertions above"
|
|
1441
|
+
# A transcript's auth failure is charged to the credential THAT run carried, never to the
|
|
1442
|
+
# one the account would get now. The pre-fix shim ran these accounts with no token at all
|
|
1443
|
+
# ("Not logged in"), so blaming their token for it parked a working token durably — and a
|
|
1444
|
+
# verify PASS could not stick: the very next run re-read the same record and re-parked it.
|
|
1445
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-01/.credentials.json"
|
|
1446
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-02/.credentials.json" # acct-01 is the only candidate
|
|
1447
|
+
rm -f "$MOP"/acct-0*/.expired "$MOP"/acct-0*/.sessions-index "$MOP"/acct-0*/.client-org-cleared
|
|
1448
|
+
printf '%s\n' "$(printf '%s' "$MOP_TOK" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)" \
|
|
1449
|
+
> "$MOP/acct-01/.server-token-verified"
|
|
1450
|
+
MSID="cafecafe-1111-4222-8333-444455556666"
|
|
1451
|
+
MSID2="cafecafe-7777-4888-8999-aaaabbbbcccc"
|
|
1452
|
+
mkdir -p "$MOP/acct-01/sessions" "$MOP/acct-01/projects/-proj"
|
|
1453
|
+
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; }
|
|
1454
|
+
mop_auth() { # $1 session id, $2 epoch: the client's own authentication_failed record
|
|
1455
|
+
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' \
|
|
1456
|
+
"$(mop_iso "$2")" "$1" >> "$MOP/acct-01/projects/-proj/$1.jsonl"
|
|
1457
|
+
}
|
|
1458
|
+
mnow="$(date +%s)"
|
|
1459
|
+
printf '%s %s\n' "$MSID" "$(mop_iso $((mnow - 600)))" > "$MOP/acct-01/.sessions-index"
|
|
1460
|
+
mop_auth "$MSID" $((mnow - 300))
|
|
1461
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1462
|
+
check "an untagged run's auth failure takes the account out of the pool" "CFG=none" "$out"
|
|
1463
|
+
grep -q 'reason=auth-error' "$MOP/acct-01/.expired" 2>/dev/null \
|
|
1464
|
+
&& t_ok "...as the login's failure (auth-error), never the token's" \
|
|
1465
|
+
|| t_fail "untagged auth failure blamed the token" "$(tail -1 "$MOP/acct-01/.expired" 2>/dev/null)"
|
|
1466
|
+
[ -f "$MOP/acct-01/.server-token-verified" ] && t_ok "...and the token's proof survives it" \
|
|
1467
|
+
|| t_fail "untagged auth failure" "the token's .server-token-verified was dropped"
|
|
1468
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 claude-accounts verify 2>&1)"
|
|
1469
|
+
check "verify passes the parked MCP-only account on its token" "acct-01 mtok@test: PASS" "$out"
|
|
1470
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1471
|
+
check "the PASS sticks: the record before it re-parks nothing, the token is exported" \
|
|
1472
|
+
"CFG=acct-01 TOK=$MOP_TOK" "$out"
|
|
1473
|
+
[ ! -f "$MOP/acct-01/.expired" ] && t_ok "...and no park is left behind" \
|
|
1474
|
+
|| t_fail "verify PASS re-parked" "$(tail -1 "$MOP/acct-01/.expired")"
|
|
1475
|
+
# A run the shim launched on the token is tagged in the index, and ITS failure retires it.
|
|
1476
|
+
rm -f "$MOP/acct-01/.sessions-index"
|
|
1477
|
+
FAKE_SESSION_ID="$MSID2" FAKE_SESSION_HOLD=2 CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1478
|
+
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
1479
|
+
grep -q -- "^$MSID2 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null && break
|
|
1480
|
+
sleep 0.2
|
|
1481
|
+
done
|
|
1482
|
+
grep -q -- "^$MSID2 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1483
|
+
&& t_ok "the shim tags the session of a run it launched on the portable token" \
|
|
1484
|
+
|| t_fail "token-run tag" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1485
|
+
sleep 1 # a real failure postdates verify's watermark
|
|
1486
|
+
mop_auth "$MSID2" "$(date +%s)"
|
|
1487
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$MOP" claude 2>&1)"
|
|
1488
|
+
grep -q 'reason=setup-token-invalid' "$MOP/acct-01/.expired" 2>/dev/null \
|
|
1489
|
+
&& t_ok "that run's auth failure parks the token (setup-token-invalid)" \
|
|
1490
|
+
|| t_fail "tagged auth failure" "$(tail -1 "$MOP/acct-01/.expired" 2>/dev/null)"
|
|
1491
|
+
[ ! -f "$MOP/acct-01/.server-token-verified" ] && t_ok "...and drops the token's proof" \
|
|
1492
|
+
|| t_fail "tagged auth failure" ".server-token-verified survived"
|
|
1493
|
+
# /clear (or /resume inside the TUI) has the SAME process run a new session id: 2.1.304
|
|
1494
|
+
# rewrites only the sessionId in sessions/<pid>.json, long after the capture's one look. The
|
|
1495
|
+
# shim keeps the credential by process (.sessions-via/<pid>, keyed by the registry's
|
|
1496
|
+
# startedAt), so any launch's refresh tags the new id too, and its auth failure retires the
|
|
1497
|
+
# token. Before, that id was sighted untagged: its failure parked the login (auth-error) of
|
|
1498
|
+
# an account with none, and kept the dead token's proof.
|
|
1499
|
+
rm -rf "$MOP/acct-01/.expired" "$MOP/acct-01/.sessions-index" "$MOP/acct-01/.sessions-via"
|
|
1500
|
+
printf '%s\n' "$(printf '%s' "$MOP_TOK" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)" \
|
|
1501
|
+
> "$MOP/acct-01/.server-token-verified"
|
|
1502
|
+
MSID3="cafecafe-3333-4ccc-8ddd-eeeeffff0003"
|
|
1503
|
+
MSID4="cafecafe-4444-4ccc-8ddd-eeeeffff0004"
|
|
1504
|
+
rm -f "$WORK/mop-switch-done"
|
|
1505
|
+
FAKE_SESSION_ID="$MSID3" FAKE_SESSION_UNTIL="$WORK/mop-switch-done" FAKE_SESSION_HOLD=180 \
|
|
1506
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1 &
|
|
1507
|
+
mswitch=$!
|
|
1508
|
+
for _ in $(seq 1 600); do
|
|
1509
|
+
[ -f "$MOP/acct-01/.sessions-via/$mswitch" ] \
|
|
1510
|
+
&& grep -q -- "^$MSID3 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null && break
|
|
1511
|
+
sleep 0.1
|
|
1512
|
+
done
|
|
1513
|
+
grep -q -- "^$MSID3 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1514
|
+
&& t_ok "the run's first session is tagged by its capture" \
|
|
1515
|
+
|| t_fail "switch: first session tag" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1516
|
+
[ -f "$MOP/acct-01/.sessions-via/$mswitch" ] \
|
|
1517
|
+
&& t_ok "...and its credential is on record by process (.sessions-via/<pid>)" \
|
|
1518
|
+
|| t_fail "switch: process record" "$(ls -a "$MOP/acct-01/.sessions-via" 2>&1 | tr '\n' ' ')"
|
|
1519
|
+
# What /clear does in 2.1.304: the same process and registry file, startedAt kept, a new
|
|
1520
|
+
# sessionId (and transcript), long after the capture is gone.
|
|
1521
|
+
mreg="$MOP/acct-01/sessions/$mswitch.json"
|
|
1522
|
+
sed "s/\"sessionId\":\"$MSID3\"/\"sessionId\":\"$MSID4\"/" "$mreg" > "$mreg.tmp" && mv -f "$mreg.tmp" "$mreg"
|
|
1523
|
+
printf '{"type":"mode","mode":"normal","sessionId":"%s"}\n' "$MSID4" \
|
|
1524
|
+
> "$MOP/acct-01/projects/-proj/$MSID4.jsonl"
|
|
1525
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1 # any launch's refresh sights the new id
|
|
1526
|
+
grep -q -- "^$MSID4 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1527
|
+
&& t_ok "a session the client switched the run to keeps the run's credential (token)" \
|
|
1528
|
+
|| t_fail "switch: switched-session tag" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1529
|
+
mop_auth "$MSID4" "$(date +%s)"
|
|
1530
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1531
|
+
grep -q 'reason=setup-token-invalid' "$MOP/acct-01/.expired" 2>/dev/null \
|
|
1532
|
+
&& t_ok "...so its auth failure parks the token (setup-token-invalid)" \
|
|
1533
|
+
|| t_fail "switch: auth failure" "$(tail -1 "$MOP/acct-01/.expired" 2>/dev/null)"
|
|
1534
|
+
[ ! -f "$MOP/acct-01/.server-token-verified" ] && t_ok "...and drops the token's proof" \
|
|
1535
|
+
|| t_fail "switch: auth failure" ".server-token-verified survived"
|
|
1536
|
+
: > "$WORK/mop-switch-done"
|
|
1537
|
+
wait "$mswitch" 2>/dev/null
|
|
1538
|
+
# The record names the process it was written for: under a reused pid (another startedAt)
|
|
1539
|
+
# it tags nothing; the process's own record does.
|
|
1540
|
+
rm -f "$MOP/acct-01/.expired" "$MOP/acct-01/.sessions-index"
|
|
1541
|
+
MSID5="cafecafe-5555-4ccc-8ddd-eeeeffff0005"
|
|
1542
|
+
mkdir -p "$MOP/acct-01/.sessions-via"
|
|
1543
|
+
printf '{"pid":99991,"sessionId":"%s","cwd":"/proj","kind":"interactive","startedAt":1790000000000}\n' \
|
|
1544
|
+
"$MSID5" > "$MOP/acct-01/sessions/99991.json"
|
|
1545
|
+
printf '1780000000000 token\n' > "$MOP/acct-01/.sessions-via/99991"
|
|
1546
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1547
|
+
{ grep -q -- "^$MSID5 " "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1548
|
+
&& ! grep -q -- "^$MSID5 .* token\$" "$MOP/acct-01/.sessions-index"; } \
|
|
1549
|
+
&& t_ok "a credential record of an earlier process under the same pid tags nothing" \
|
|
1550
|
+
|| t_fail "switch: reused pid" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1551
|
+
printf '1790000000000 token\n' > "$MOP/acct-01/.sessions-via/99991"
|
|
1552
|
+
CLAUDE_ACCOUNTS_DIR="$MOP" claude >/dev/null 2>&1
|
|
1553
|
+
grep -q -- "^$MSID5 .* token\$" "$MOP/acct-01/.sessions-index" 2>/dev/null \
|
|
1554
|
+
&& t_ok "...while the process's own record tags a session it already knew" \
|
|
1555
|
+
|| t_fail "switch: own record" "index: $(cat "$MOP/acct-01/.sessions-index" 2>/dev/null)"
|
|
1556
|
+
rm -rf "$MOP/acct-01/sessions/99991.json" "$MOP/acct-01/.sessions-via" "$MOP/acct-01/.sessions-index"
|
|
1557
|
+
# adopt turns the account dir into a symlink to ~/.claude: never over a dir that holds a
|
|
1558
|
+
# credential of its own — MCP sign-ins with no login among them, or a portable token. The
|
|
1559
|
+
# fake HOME carries a default login, so only that guard can refuse.
|
|
1560
|
+
AH="$WORK/adopt-home"
|
|
1561
|
+
mkdir -p "$AH/.claude"
|
|
1562
|
+
printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-default","refreshToken":"r","expiresAt":9999999999999}}' \
|
|
1563
|
+
> "$AH/.claude/.credentials.json"
|
|
1564
|
+
printf '%s' "$MCP_ONLY" > "$MOP/acct-02/.credentials.json"
|
|
1565
|
+
out="$(HOME="$AH" CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts adopt acct-02 2>&1)"; rc=$?
|
|
1566
|
+
{ [ "$rc" != 0 ] && [ ! -L "$MOP/acct-02" ] \
|
|
1567
|
+
&& [ "$(cat "$MOP/acct-02/.credentials.json" 2>/dev/null)" = "$MCP_ONLY" ]; } \
|
|
1568
|
+
&& t_ok "adopt refuses a dir whose credentials file holds MCP state alone, and keeps it" \
|
|
1569
|
+
|| t_fail "adopt over MCP state" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
1570
|
+
check "...saying it has credentials of its own" "already has its own credentials" "$out"
|
|
1571
|
+
rm -f "$MOP/acct-01/.credentials.json"
|
|
1572
|
+
out="$(HOME="$AH" CLAUDE_ACCOUNTS_DIR="$MOP" claude-accounts adopt acct-01 2>&1)"; rc=$?
|
|
1573
|
+
{ [ "$rc" != 0 ] && [ ! -L "$MOP/acct-01" ] \
|
|
1574
|
+
&& [ "$(cat "$MOP/acct-01/server.token" 2>/dev/null)" = "$MOP_TOK" ]; } \
|
|
1575
|
+
&& t_ok "adopt refuses a dir holding a portable token, and keeps the token" \
|
|
1576
|
+
|| t_fail "adopt over a token" "rc=$rc: $(printf '%s' "$out" | head -c 160)"
|
|
1577
|
+
rm -rf "$MOP" "$AH" "$WORK"/mcponly-*.json "$WORK/login-import.json" "$WORK/mcp-token-server.py" "$WORK/mcp-token-port"
|
|
1578
|
+
|
|
1579
|
+
# ---- 9h. the limits pass's refresher writes under the client's lock --------------------
|
|
1580
|
+
# Claude Code takes proper-lockfile's <dir>/.storage-write.lock around every write of its
|
|
1581
|
+
# credential document (re-read, one change, the whole document back), and gives up after
|
|
1582
|
+
# about 7.5 s on a held lock. The refresher merges its rotated login into the same document:
|
|
1583
|
+
# a client lock held when a refresh would start skips it (no request, so nothing rotates);
|
|
1584
|
+
# after the POST the check-and-set and the write wait for the lock, so a client write in
|
|
1585
|
+
# flight is neither interleaved nor rolled back — and the rotated login is not lost to it.
|
|
1586
|
+
RL="$WORK/reflock"
|
|
1587
|
+
RL_HOME=mac; [ "$(uname -s)" = "Darwin" ] || RL_HOME=server
|
|
1588
|
+
mkdir -p "$RL/tmp" "$RL/acct-01"
|
|
1589
|
+
: > "$RL/.limits-kick"
|
|
1590
|
+
cat > "$RL/accounts.json" <<EOF
|
|
1591
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
1592
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
1593
|
+
"accounts": [ {"id": "acct-01", "email": "rlock@test", "home": "$RL_HOME", "added_at": "2026-10-20T00:00:00Z"} ] }
|
|
1594
|
+
EOF
|
|
1595
|
+
rl_stale() { # an expired login beside one MCP login: the refresher's work, the client's state
|
|
1596
|
+
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}}}' \
|
|
1597
|
+
"$1" > "$RL/acct-01/.credentials.json"
|
|
1598
|
+
chmod 600 "$RL/acct-01/.credentials.json"
|
|
1599
|
+
}
|
|
1600
|
+
cat > "$WORK/rlock-token-server.py" <<'EOF'
|
|
1601
|
+
import http.server, json, os, sys, threading, time
|
|
1602
|
+
target, mode_file, count_file = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
1603
|
+
lock = os.path.join(os.path.dirname(target), '.storage-write.lock')
|
|
1604
|
+
seen = [0]
|
|
1605
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
1606
|
+
def log_message(self, *a): pass
|
|
1607
|
+
def do_POST(self):
|
|
1608
|
+
self.rfile.read(int(self.headers.get('Content-Length') or 0))
|
|
1609
|
+
seen[0] += 1
|
|
1610
|
+
with open(count_file, 'w') as f:
|
|
1611
|
+
f.write(str(seen[0]))
|
|
1612
|
+
if open(mode_file).read().strip() == 'hold':
|
|
1613
|
+
# a client takes its lock and reads the document now, and writes it back (an MCP
|
|
1614
|
+
# token of its own refreshed) 1.5 s later, from what it read
|
|
1615
|
+
os.mkdir(lock)
|
|
1616
|
+
doc = json.load(open(target))
|
|
1617
|
+
def later():
|
|
1618
|
+
time.sleep(1.5)
|
|
1619
|
+
doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] = 'mcp-refresh-CLIENT-HELD'
|
|
1620
|
+
with open(target + '.client', 'w') as f:
|
|
1621
|
+
json.dump(doc, f)
|
|
1622
|
+
os.replace(target + '.client', target)
|
|
1623
|
+
os.rmdir(lock)
|
|
1624
|
+
threading.Thread(target=later).start()
|
|
1625
|
+
raw = json.dumps({'access_token': 'sk-ant-oat01-rlrotated%d' % seen[0],
|
|
1626
|
+
'refresh_token': 'sk-ant-ort01-rlrotated%d' % seen[0],
|
|
1627
|
+
'expires_in': 28800}).encode()
|
|
1628
|
+
self.send_response(200)
|
|
1629
|
+
self.send_header('Content-Type', 'application/json')
|
|
1630
|
+
self.send_header('Content-Length', str(len(raw)))
|
|
1631
|
+
self.end_headers()
|
|
1632
|
+
self.wfile.write(raw)
|
|
1633
|
+
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
|
|
1634
|
+
print(srv.server_address[1], flush=True)
|
|
1635
|
+
srv.serve_forever()
|
|
1636
|
+
EOF
|
|
1637
|
+
printf 'plain\n' > "$WORK/rlock-mode"
|
|
1638
|
+
printf '0' > "$WORK/rlock-count"
|
|
1639
|
+
rl_stale a
|
|
1640
|
+
python3 "$WORK/rlock-token-server.py" "$RL/acct-01/.credentials.json" "$WORK/rlock-mode" "$WORK/rlock-count" \
|
|
1641
|
+
> "$WORK/rlock-port" 2>/dev/null &
|
|
1642
|
+
rlsrv_pid=$!
|
|
1643
|
+
STUB_PIDS="$STUB_PIDS $rlsrv_pid"
|
|
1644
|
+
rlport=""
|
|
1645
|
+
for _ in $(seq 1 20); do
|
|
1646
|
+
rlport="$(head -1 "$WORK/rlock-port" 2>/dev/null)"
|
|
1647
|
+
case "$rlport" in ''|*[!0-9]*) rlport=""; sleep 0.2 ;; *) break ;; esac
|
|
1648
|
+
done
|
|
1649
|
+
if [ -z "$rlport" ]; then
|
|
1650
|
+
t_ok "refresher lock tests skipped (cannot bind a loopback port here)"
|
|
1651
|
+
else
|
|
1652
|
+
rl_limits() { CLAUDE_ACCOUNTS_DIR="$RL" CLAUDE_MULTIACC_TOKEN_URL="http://127.0.0.1:$rlport/token" \
|
|
1653
|
+
claude-accounts limits --force 2>&1; }
|
|
1654
|
+
cp "$RL/acct-01/.credentials.json" "$WORK/rlock-before.json"
|
|
1655
|
+
mkdir "$RL/acct-01/.storage-write.lock" # a client is writing right now
|
|
1656
|
+
out="$(rl_limits)"
|
|
1657
|
+
check "a held client lock skips the refresh" "acct-01: oauth refresh skipped" "$out"
|
|
1658
|
+
{ [ "$(cat "$WORK/rlock-count")" = 0 ] && cmp -s "$WORK/rlock-before.json" "$RL/acct-01/.credentials.json" \
|
|
1659
|
+
&& [ ! -f "$RL/acct-01/.oauth-refresh.json" ] && [ -d "$RL/acct-01/.storage-write.lock" ]; } \
|
|
1660
|
+
&& t_ok "...sending nothing (no rotation), writing nothing, noting no backoff, leaving the lock" \
|
|
1661
|
+
|| t_fail "refresh under a held lock" "requests=$(cat "$WORK/rlock-count") backoff=$([ -f "$RL/acct-01/.oauth-refresh.json" ] && echo yes || echo no)"
|
|
1662
|
+
touch -t 202001010101 "$RL/acct-01/.storage-write.lock" # its holder died long ago
|
|
1663
|
+
out="$(rl_limits)"
|
|
1664
|
+
check "a stale client lock is taken over and the refresh lands" "acct-01: oauth access token refreshed" "$out"
|
|
1665
|
+
[ ! -e "$RL/acct-01/.storage-write.lock" ] && [ "$(cat "$WORK/rlock-count")" = 1 ] \
|
|
1666
|
+
&& t_ok "...and the lock is released after the write" \
|
|
1667
|
+
|| t_fail "stale client lock" "requests=$(cat "$WORK/rlock-count") lock=$(ls -d "$RL/acct-01/.storage-write.lock" 2>/dev/null)"
|
|
1668
|
+
rl_stale b
|
|
1669
|
+
rmdir "$RL/acct-01/.storage-write.lock" 2>/dev/null || true # this case on its own
|
|
1670
|
+
printf 'hold\n' > "$WORK/rlock-mode"
|
|
1671
|
+
out="$(rl_limits)"
|
|
1672
|
+
check "a refresh whose write window a client holds still lands" "acct-01: oauth access token refreshed" "$out"
|
|
1673
|
+
for _ in $(seq 1 50); do # the client's write is done
|
|
1674
|
+
[ -e "$RL/acct-01/.storage-write.lock" ] || break
|
|
1675
|
+
sleep 0.1
|
|
1676
|
+
done
|
|
1677
|
+
python3 - "$RL/acct-01/.credentials.json" <<'EOF'
|
|
1678
|
+
import json, os, stat, sys
|
|
1679
|
+
doc = json.load(open(sys.argv[1]))
|
|
1680
|
+
o = doc['claudeAiOauth']
|
|
1681
|
+
assert o['accessToken'] == 'sk-ant-oat01-rlrotated2' and o['refreshToken'] == 'sk-ant-ort01-rlrotated2', 'the rotated login was rolled back'
|
|
1682
|
+
assert doc['mcpOAuth']['higgsfield|0123456789abcdef']['refreshToken'] == 'mcp-refresh-CLIENT-HELD', 'the client write was lost'
|
|
1683
|
+
assert stat.S_IMODE(os.stat(sys.argv[1]).st_mode) == 0o600
|
|
1684
|
+
EOF
|
|
1685
|
+
[ $? -eq 0 ] && [ ! -e "$RL/acct-01/.storage-write.lock" ] \
|
|
1686
|
+
&& t_ok "...after the client's write: its MCP token and the rotated login both survive" \
|
|
1687
|
+
|| t_fail "refresh vs a client write in flight" "see assertions above"
|
|
1688
|
+
fi
|
|
1689
|
+
kill "$rlsrv_pid" 2>/dev/null; wait "$rlsrv_pid" 2>/dev/null || true
|
|
1690
|
+
rm -rf "$RL" "$WORK"/rlock-*
|
|
1691
|
+
|
|
1191
1692
|
# ---- 10. token-only account exports CLAUDE_CODE_OAUTH_TOKEN ------------------
|
|
1192
1693
|
mkdir -p "$ACC/acct-03"
|
|
1193
1694
|
printf 'sk-ant-oat01-tok-for-03' > "$ACC/acct-03/server.token"
|
|
@@ -1558,6 +2059,21 @@ claude >/dev/null 2>&1
|
|
|
1558
2059
|
[ ! -f "$ACC/acct-01/.limited" ] \
|
|
1559
2060
|
&& t_ok "a stale rival claim does not hide a newer one" \
|
|
1560
2061
|
|| t_fail "duplicate session claim" "only the first rival claim was inspected"
|
|
2062
|
+
# A line the shim tagged ` token` (a run it launched on the portable token, 9g) is the same
|
|
2063
|
+
# claim to every reader: to the conflict check above...
|
|
2064
|
+
rm -f "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan
|
|
2065
|
+
printf '%s 2090-01-01T00:00:00Z token\n' "$SIDD" > "$ACC/acct-02/.sessions-index"
|
|
2066
|
+
claude >/dev/null 2>&1
|
|
2067
|
+
[ ! -f "$ACC/acct-01/.limited" ] \
|
|
2068
|
+
&& t_ok "a newer rival claim tagged token still conflicts" \
|
|
2069
|
+
|| t_fail "token-tagged rival claim" "the token field hid acct-02's newer claim"
|
|
2070
|
+
# ...and to the scan that marks an account off its own session's rejection
|
|
2071
|
+
rm -f "$ACC/acct-02/.sessions-index" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.client-scan
|
|
2072
|
+
printf '%s token\n' "$CLAIM1" > "$ACC/acct-01/.sessions-index"
|
|
2073
|
+
claude >/dev/null 2>&1
|
|
2074
|
+
grep -q 'reason=client-rate-limit' "$ACC/acct-01/.limited" 2>/dev/null \
|
|
2075
|
+
&& t_ok "a session line tagged token still attributes its own rejection" \
|
|
2076
|
+
|| t_fail "token-tagged index line" "client_limit_scan missed a rejection behind a tagged line"
|
|
1561
2077
|
rm -f "$ACC/acct-02/projects/-proj" "$ACC"/acct-0*/.limited "$ACC"/acct-0*/.sessions-index "$ACC"/acct-0*/.client-scan
|
|
1562
2078
|
rm -rf "$ACC"/acct-0*/projects "$ACC"/acct-0*/sessions
|
|
1563
2079
|
|
|
@@ -4701,6 +5217,69 @@ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
|
|
|
4701
5217
|
check "the next launch on that account still runs" "CFG=acct-01" "$out"
|
|
4702
5218
|
mcp_has acct-01 nested && t_fail "nested remove undone" "the next launch re-added nested" \
|
|
4703
5219
|
|| t_ok "a nested remove is not undone by the next launch"
|
|
5220
|
+
# GLOBAL OPTIONS before `mcp` (2026-10-22): the operator's alias claude='claude
|
|
5221
|
+
# --dangerously-skip-permissions' put a flag in front of every typed `claude mcp add`,
|
|
5222
|
+
# the mirror looked at $1 alone, and motionsites landed in 2 of 11 accounts. The shim
|
|
5223
|
+
# finds the subcommand the way commander does: a flag is skipped, a value option skips
|
|
5224
|
+
# its value, and an optional-value one (--resume) takes the next word as its own.
|
|
5225
|
+
: > "$PYLOG"
|
|
5226
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" claude --dangerously-skip-permissions mcp add -s user dsp -- echo d 2>&1)"
|
|
5227
|
+
rc=$?
|
|
5228
|
+
[ "$rc" = "0" ] && check "a flag before mcp: the stock add still answers like the real client" "Added stdio MCP server dsp" "$out" \
|
|
5229
|
+
|| t_fail "flag-first add rc" "rc=$rc: $out"
|
|
5230
|
+
mcp_has acct-01 dsp && mcp_has acct-02 dsp && t_ok "'claude --dangerously-skip-permissions mcp add' lands in EVERY account" \
|
|
5231
|
+
|| t_fail "flag-first mirror" "$out $(cat "$MPOOL/acct-01/.claude.json" "$MPOOL/acct-02/.claude.json")"
|
|
5232
|
+
grep -q " learn " "$PYLOG" && t_ok "a flag-first add is mirrored through learn" || t_fail "flag-first learn" "$(cat "$PYLOG")"
|
|
5233
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
|
|
5234
|
+
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); s=d["mcpServers"].get("dsp") or {}; sys.exit(0 if s.get("command")=="echo" and s.get("args")==["d"] else 1)' "$out" \
|
|
5235
|
+
&& t_ok "a flag-first add reaches the registry with the client's own block" || t_fail "flag-first registry" "$out"
|
|
5236
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude --dangerously-skip-permissions mcp remove -s user dsp 2>&1)"
|
|
5237
|
+
rc=$?
|
|
5238
|
+
[ "$rc" = "0" ] && t_ok "'claude --dangerously-skip-permissions mcp remove' exits 0" || t_fail "flag-first remove rc" "rc=$rc: $out"
|
|
5239
|
+
mcp_has acct-01 dsp || mcp_has acct-02 dsp \
|
|
5240
|
+
&& t_fail "flag-first remove mirror" "dsp survives in an account" || t_ok "a flag-first remove drops the server from EVERY account"
|
|
5241
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
|
|
5242
|
+
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); sys.exit(0 if "dsp" in d.get("retiredUser",[]) and "dsp" not in d.get("mcpServers",{}) else 1)' "$out" \
|
|
5243
|
+
&& t_ok "a flag-first remove leaves a user-scope tombstone" || t_fail "flag-first tombstone" "$out"
|
|
5244
|
+
# nested (an agent inside a pooled session, alias or not): same parse, same mirror
|
|
5245
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_CONFIG_DIR="$MPOOL/acct-01" CLAUDE_SHIM_ACTIVE=1 claude --dangerously-skip-permissions mcp add -s user dspn -- echo n 2>&1)"
|
|
5246
|
+
rc=$?
|
|
5247
|
+
[ "$rc" = "0" ] && t_ok "a nested flag-first 'claude mcp add' exits 0" || t_fail "nested flag-first rc" "rc=$rc: $out"
|
|
5248
|
+
mcp_has acct-01 dspn && mcp_has acct-02 dspn && t_ok "a nested 'claude --dangerously-skip-permissions mcp add' lands in EVERY account" \
|
|
5249
|
+
|| t_fail "nested flag-first mirror" "$out $(cat "$MPOOL/acct-02/.claude.json")"
|
|
5250
|
+
# a value option: --model's value is skipped, and add-json is a mutation like add
|
|
5251
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude --model opus mcp add-json -s user mj '{"type":"stdio","command":"echo","args":["j"],"env":{}}' 2>&1)"
|
|
5252
|
+
rc=$?
|
|
5253
|
+
[ "$rc" = "0" ] && t_ok "'claude --model opus mcp add-json' exits 0" || t_fail "model-first add-json rc" "rc=$rc: $out"
|
|
5254
|
+
mcp_has acct-01 mj && mcp_has acct-02 mj && t_ok "'claude --model opus mcp add-json' lands in EVERY account" \
|
|
5255
|
+
|| t_fail "model-first mirror" "$out $(cat "$MPOOL/acct-01/.claude.json" "$MPOOL/acct-02/.claude.json")"
|
|
5256
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
|
|
5257
|
+
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); s=d["mcpServers"].get("mj") or {}; sys.exit(0 if s.get("args")==["j"] else 1)' "$out" \
|
|
5258
|
+
&& t_ok "a --model-first add-json reaches the registry" || t_fail "model-first registry" "$out"
|
|
5259
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_CONFIG_DIR="$MPOOL/acct-02" CLAUDE_SHIM_ACTIVE=1 claude --model opus mcp add-json -s user mjn '{"type":"stdio","command":"echo","args":["n"],"env":{}}' 2>&1)"
|
|
5260
|
+
mcp_has acct-01 mjn && mcp_has acct-02 mjn && t_ok "a nested 'claude --model opus mcp add-json' lands in EVERY account" \
|
|
5261
|
+
|| t_fail "nested model-first mirror" "$out $(cat "$MPOOL/acct-01/.claude.json")"
|
|
5262
|
+
# NOT a mutation: --resume takes the next word (a session id or search term) as its
|
|
5263
|
+
# value, and -p's prompt is one word that merely reads "mcp add …" — the shim must run
|
|
5264
|
+
# no snapshot and no learn, and nothing reaches any account or the registry
|
|
5265
|
+
: > "$PYLOG"
|
|
5266
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" claude --resume mcp add rsm 2>&1 </dev/null)"
|
|
5267
|
+
rc=$?
|
|
5268
|
+
[ "$rc" = "0" ] && check "'claude --resume mcp add rsm' runs as an ordinary launch" "CFG=acct-01" "$out" \
|
|
5269
|
+
|| t_fail "resume-first rc" "rc=$rc: $out"
|
|
5270
|
+
grep -Eq " (snapshot|learn) " "$PYLOG" && t_fail "resume-first mirror attempt" "$(cat "$PYLOG")" \
|
|
5271
|
+
|| t_ok "'claude --resume mcp add' is no mcp mutation (no snapshot, no learn)"
|
|
5272
|
+
: > "$PYLOG"
|
|
5273
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" claude -p "mcp add psm" 2>&1 </dev/null)"
|
|
5274
|
+
rc=$?
|
|
5275
|
+
[ "$rc" = "0" ] && check "'claude -p \"mcp add psm\"' runs as an ordinary print" "CFG=acct-01" "$out" \
|
|
5276
|
+
|| t_fail "print-prompt rc" "rc=$rc: $out"
|
|
5277
|
+
grep -Eq " (snapshot|learn) " "$PYLOG" && t_fail "print-prompt mirror attempt" "$(cat "$PYLOG")" \
|
|
5278
|
+
|| t_ok "a -p prompt reading 'mcp add' is no mcp mutation (no snapshot, no learn)"
|
|
5279
|
+
out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
|
|
5280
|
+
mcp_has acct-01 rsm || mcp_has acct-01 psm || mcp_has acct-02 rsm || mcp_has acct-02 psm \
|
|
5281
|
+
|| python3 -c 'import json,sys; d=json.loads(sys.argv[1]); a=set(d.get("mcpServers",{}))|set(d.get("retired",[]))|set(d.get("retiredUser",[])); sys.exit(0 if a & {"rsm","psm","add","mcp"} else 1)' "$out" \
|
|
5282
|
+
&& t_fail "non-mutation leak" "$out" || t_ok "neither non-mutation reaches an account or the registry"
|
|
4704
5283
|
# a CLAUDE_CONFIG_DIR OUTSIDE the pool is plain passthrough: no python, no mirror
|
|
4705
5284
|
mkdir -p "$WORK/mcp-outside"
|
|
4706
5285
|
: > "$PYLOG"
|
|
@@ -4939,10 +5518,67 @@ if command -v node >/dev/null 2>&1; then
|
|
|
4939
5518
|
# nothing and the nightly self-update logged "npm not found; skipping" for days while
|
|
4940
5519
|
# every Mac's copy silently froze. npm must be resolved by path — and specifically the
|
|
4941
5520
|
# one inside the prefix that owns the RUNNING copy, never whatever a stray PATH offers.
|
|
5521
|
+
# The model catalog after a self-update (lib/common.sh model_catalog_after_update): a real
|
|
5522
|
+
# update refreshes it ONCE, from the tree npm just wrote; "already latest" never does; and a
|
|
5523
|
+
# refresh that fails is logged while the update keeps exit 0.
|
|
5524
|
+
su_catalog_calls() { grep -c '^NEW-TREE model-catalog refresh --quiet$' "$WORK/catalog-calls-$_cli.log" 2>/dev/null; }
|
|
5525
|
+
su_catalog_checks() {
|
|
5526
|
+
[ "$(su_catalog_calls)" = 1 ] \
|
|
5527
|
+
&& t_ok "$_cli: a real update refreshes the model catalog once, from the NEW tree" \
|
|
5528
|
+
|| t_fail "$_cli catalog refresh" "calls: $(head -3 "$WORK/catalog-calls-$_cli.log" 2>/dev/null)"
|
|
5529
|
+
{ [ -f "$SU_CL/model-catalog.json" ] && [ -f "$SU_CX/model-catalog.json" ]; } \
|
|
5530
|
+
&& t_ok "$_cli: that refresh wrote model-catalog.json into both pools" \
|
|
5531
|
+
|| t_fail "$_cli catalog files" "$(ls "$SU_CL" "$SU_CX" 2>&1 | tr '\n' ' ')"
|
|
5532
|
+
grep -q "self-update: model catalog refreshed" "$SU_OWN/update.log" \
|
|
5533
|
+
&& t_ok "$_cli: update.log records the catalog refresh" \
|
|
5534
|
+
|| t_fail "$_cli catalog log" "$(tail -3 "$SU_OWN/update.log" 2>/dev/null)"
|
|
5535
|
+
out="$(_su)"
|
|
5536
|
+
check "$_cli: the next run is already latest" "already latest (9.9.9)" "$out"
|
|
5537
|
+
[ "$(su_catalog_calls)" = 1 ] && t_ok "$_cli: an 'already latest' run never refreshes the catalog" \
|
|
5538
|
+
|| t_fail "$_cli catalog on already-latest" "calls: $(su_catalog_calls)"
|
|
5539
|
+
python3 - "$NPMROOT/package.json" <<'PY'
|
|
5540
|
+
import json, sys
|
|
5541
|
+
doc = json.load(open(sys.argv[1])); doc['version'] = '0.0.1'
|
|
5542
|
+
json.dump(doc, open(sys.argv[1], 'w'))
|
|
5543
|
+
PY
|
|
5544
|
+
: > "$WORK/catalog-fail-$_cli"
|
|
5545
|
+
out="$(_su)"
|
|
5546
|
+
rc=$?
|
|
5547
|
+
[ "$rc" = 0 ] && t_ok "$_cli: a failing catalog refresh leaves self-update at exit 0" \
|
|
5548
|
+
|| t_fail "$_cli failing catalog rc" "rc=$rc: $(printf '%s' "$out" | head -c 200)"
|
|
5549
|
+
check "$_cli: the failed refresh is reported" "model catalog refresh FAILED (rc=3)" "$out"
|
|
5550
|
+
[ "$(su_catalog_calls)" = 2 ] && t_ok "$_cli: that update still ran the refresh once" \
|
|
5551
|
+
|| t_fail "$_cli failing catalog calls" "calls: $(su_catalog_calls)"
|
|
5552
|
+
}
|
|
5553
|
+
# What `npm install` does to the running copy, beyond the version: it ships a new tree. The
|
|
5554
|
+
# CLI it writes records every `model-catalog` call (and fails one on request), and it lands
|
|
5555
|
+
# under a NEW inode, as npm's extract does, so the bash running self-update keeps its old file.
|
|
5556
|
+
SHIP="$WORK/fake-npm-ship.py"
|
|
5557
|
+
cat > "$SHIP" <<'SHIPEOF'
|
|
5558
|
+
import json, os, sys
|
|
5559
|
+
root, cli, log, failflag = sys.argv[1:5]
|
|
5560
|
+
pkg = os.path.join(root, 'package.json')
|
|
5561
|
+
doc = json.load(open(pkg)); doc['version'] = '9.9.9'
|
|
5562
|
+
json.dump(doc, open(pkg, 'w'))
|
|
5563
|
+
path = os.path.join(root, 'bin', cli)
|
|
5564
|
+
text = open(path).read()
|
|
5565
|
+
mark = '# fake-npm: the new tree\n'
|
|
5566
|
+
if mark not in text:
|
|
5567
|
+
head, rest = text.split('\n', 1)
|
|
5568
|
+
probe = ('case "${1:-}" in model-catalog) printf "NEW-TREE %%s\\n" "$*" >> "%s"; '
|
|
5569
|
+
'[ -e "%s" ] && exit 3 ;; esac\n' % (log, failflag))
|
|
5570
|
+
text = head + '\n' + mark + probe + rest
|
|
5571
|
+
open(path + '.new', 'w').write(text)
|
|
5572
|
+
os.chmod(path + '.new', 0o755)
|
|
5573
|
+
os.replace(path + '.new', path)
|
|
5574
|
+
SHIPEOF
|
|
4942
5575
|
for _cli in claude-accounts codex-accounts; do
|
|
4943
5576
|
PFX="$WORK/pfx-$_cli"
|
|
4944
5577
|
NPMROOT="$PFX/lib/node_modules/claude-multiacc"
|
|
4945
|
-
|
|
5578
|
+
# Pools of its own: a real update now writes model-catalog.json into both of them.
|
|
5579
|
+
SU_CL="$WORK/su-claude-$_cli"; SU_CX="$WORK/su-codex-$_cli"
|
|
5580
|
+
mkdir -p "$NPMROOT" "$PFX/bin" "$SU_CL" "$SU_CX"
|
|
5581
|
+
if [ "$_cli" = claude-accounts ]; then SU_OWN="$SU_CL"; else SU_OWN="$SU_CX"; fi
|
|
4946
5582
|
cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$REPO_DIR/package.json" "$NPMROOT/"
|
|
4947
5583
|
# A fake npm that records how it was called and "installs" by bumping package.json.
|
|
4948
5584
|
# Its shebang names an interpreter that exists ONLY beside it, mirroring the real
|
|
@@ -4959,13 +5595,7 @@ NODEEOF
|
|
|
4959
5595
|
echo "npm \$*" >> "$WORK/npm-calls-$_cli.log"
|
|
4960
5596
|
case "\${1:-}" in
|
|
4961
5597
|
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
|
-
;;
|
|
5598
|
+
install) python3 "$SHIP" "$NPMROOT" "$_cli" "$WORK/catalog-calls-$_cli.log" "$WORK/catalog-fail-$_cli" ;;
|
|
4969
5599
|
esac
|
|
4970
5600
|
exit 0
|
|
4971
5601
|
NPMEOF
|
|
@@ -4975,9 +5605,11 @@ NPMEOF
|
|
|
4975
5605
|
printf '#!/usr/bin/env bash\necho "WRONG-NPM \$*" >> "%s"\nexit 0\n' "$WORK/npm-calls-$_cli.log" > "$WORK/wrongbin/npm"
|
|
4976
5606
|
chmod +x "$WORK/wrongbin/npm"
|
|
4977
5607
|
: > "$WORK/npm-calls-$_cli.log"
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
5608
|
+
_su() { # a scheduled agent's environment: no npm on PATH, nothing inherited
|
|
5609
|
+
env -i HOME="$HOME" PATH="$WORK/wrongbin:/usr/bin:/bin:/usr/sbin:/sbin" \
|
|
5610
|
+
CLAUDE_ACCOUNTS_DIR="$SU_CL" CODEX_ACCOUNTS_DIR="$SU_CX" "$NPMROOT/bin/$_cli" self-update 2>&1
|
|
5611
|
+
}
|
|
5612
|
+
out="$(_su)"
|
|
4981
5613
|
rc=$?
|
|
4982
5614
|
[ "$rc" = "0" ] && t_ok "$_cli: self-update exits 0 with no npm on PATH" \
|
|
4983
5615
|
|| t_fail "$_cli self-update rc" "rc=$rc: $(printf '%s' "$out" | head -c 200)"
|
|
@@ -4994,6 +5626,56 @@ NPMEOF
|
|
|
4994
5626
|
grep -q "^npm view " "$WORK/npm-calls-$_cli.log" \
|
|
4995
5627
|
&& t_ok "$_cli: npm ran despite its interpreter being off PATH" \
|
|
4996
5628
|
|| t_fail "$_cli npm interpreter" "npm never executed — its shebang interpreter was not found"
|
|
5629
|
+
su_catalog_checks
|
|
5630
|
+
done
|
|
5631
|
+
# ---- 19a2. self-update only ever goes UP --------------------------------------------
|
|
5632
|
+
# This Mac runs builds packed by hand ahead of the registry, and app-robot runs self-update
|
|
5633
|
+
# every 10 minutes: installing `latest` over such a build swapped in one that misread what
|
|
5634
|
+
# the newer build had written. A newer install stays, an unreadable registry answer installs
|
|
5635
|
+
# nothing, and the versions compare as numbers (2.0.10 is newer than 2.0.9).
|
|
5636
|
+
for _cli in claude-accounts codex-accounts; do
|
|
5637
|
+
PFX="$WORK/pfx-down-$_cli"
|
|
5638
|
+
NPMROOT="$PFX/lib/node_modules/claude-multiacc"
|
|
5639
|
+
SU_CL="$WORK/su-down-claude-$_cli"; SU_CX="$WORK/su-down-codex-$_cli"
|
|
5640
|
+
mkdir -p "$NPMROOT" "$PFX/bin" "$SU_CL" "$SU_CX"
|
|
5641
|
+
cp -R "$REPO_DIR/bin" "$REPO_DIR/lib" "$REPO_DIR/package.json" "$NPMROOT/"
|
|
5642
|
+
python3 - "$NPMROOT/package.json" <<'PY'
|
|
5643
|
+
import json, sys
|
|
5644
|
+
doc = json.load(open(sys.argv[1])); doc['version'] = '2.0.10'
|
|
5645
|
+
json.dump(doc, open(sys.argv[1], 'w'))
|
|
5646
|
+
PY
|
|
5647
|
+
cat > "$PFX/bin/npm" <<NPMEOF
|
|
5648
|
+
#!/bin/bash
|
|
5649
|
+
echo "npm \$*" >> "$WORK/npm-down-$_cli.log"
|
|
5650
|
+
case "\${1:-}" in view) cat "$WORK/npm-down-view-$_cli" 2>/dev/null ;; esac
|
|
5651
|
+
exit 0
|
|
5652
|
+
NPMEOF
|
|
5653
|
+
chmod +x "$PFX/bin/npm"
|
|
5654
|
+
: > "$WORK/npm-down-$_cli.log"
|
|
5655
|
+
_sud() {
|
|
5656
|
+
env -i HOME="$HOME" PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
|
|
5657
|
+
CLAUDE_ACCOUNTS_DIR="$SU_CL" CODEX_ACCOUNTS_DIR="$SU_CX" "$NPMROOT/bin/$_cli" self-update 2>&1
|
|
5658
|
+
}
|
|
5659
|
+
printf '2.0.9\n' > "$WORK/npm-down-view-$_cli"
|
|
5660
|
+
out="$(_sud)"; rc=$?
|
|
5661
|
+
check "$_cli: self-update never installs a registry version older than its own" \
|
|
5662
|
+
"this install (2.0.10) is newer than the registry's 2.0.9; not downgrading" "$out"
|
|
5663
|
+
{ [ "$rc" = 0 ] && ! grep -q '^npm install' "$WORK/npm-down-$_cli.log"; } \
|
|
5664
|
+
&& t_ok "$_cli: ...exits 0 and runs no npm install" \
|
|
5665
|
+
|| t_fail "$_cli downgrade" "rc=$rc calls: $(tr '\n' ' ' < "$WORK/npm-down-$_cli.log")"
|
|
5666
|
+
: > "$WORK/npm-down-view-$_cli"
|
|
5667
|
+
out="$(_sud)"; rc=$?
|
|
5668
|
+
check "$_cli: an unreadable registry version installs nothing" \
|
|
5669
|
+
"could not read the registry's version (npm view); not updating" "$out"
|
|
5670
|
+
{ [ "$rc" = 1 ] && ! grep -q '^npm install' "$WORK/npm-down-$_cli.log"; } \
|
|
5671
|
+
&& t_ok "$_cli: ...reports the failed check (exit 1) and runs no npm install" \
|
|
5672
|
+
|| t_fail "$_cli blind install" "rc=$rc calls: $(tr '\n' ' ' < "$WORK/npm-down-$_cli.log")"
|
|
5673
|
+
printf '2.0.11\n' > "$WORK/npm-down-view-$_cli"
|
|
5674
|
+
out="$(_sud)"
|
|
5675
|
+
check "$_cli: a newer registry version still installs" "npm 2.0.10 -> 2.0.11" "$out"
|
|
5676
|
+
grep -q '^npm install -g --prefix .* claude-multiacc@latest$' "$WORK/npm-down-$_cli.log" \
|
|
5677
|
+
&& t_ok "$_cli: ...through npm install" \
|
|
5678
|
+
|| t_fail "$_cli upgrade" "calls: $(tr '\n' ' ' < "$WORK/npm-down-$_cli.log")"
|
|
4997
5679
|
done
|
|
4998
5680
|
# The agents install.sh writes must carry a PATH for the same reason (a future tool
|
|
4999
5681
|
# that is not resolved by absolute path would hit exactly this again).
|
|
@@ -5042,7 +5724,7 @@ export CODEX_MULTIACC_TOKEN_URL="file://$WORK/cx-token-endpoint-missing.json"
|
|
|
5042
5724
|
export CODEX_MULTIACC_USAGE_URL="file://$WORK/cx-usage-endpoint-missing.json"
|
|
5043
5725
|
export CODEX_MULTIACC_FORCE_TTY=1
|
|
5044
5726
|
export FAKE_CTL2="$WORK/ctl2"
|
|
5045
|
-
|
|
5727
|
+
touch -t 203001010000 "$CX/.limits-kick" # armed in the future, like the claude pool's
|
|
5046
5728
|
|
|
5047
5729
|
# Codex auth fixtures are real-shaped: auth.json carrying JWTs whose exp/email
|
|
5048
5730
|
# claims the audit decodes offline (exactly what the CLI does).
|
|
@@ -5187,6 +5869,18 @@ cat > "$FAKEBIN/codex" <<EOF
|
|
|
5187
5869
|
#!/usr/bin/env bash
|
|
5188
5870
|
# fake real codex for tests (not a shim)
|
|
5189
5871
|
if [ "\${1:-}" = --help ]; then echo 'Usage: legacy codex [OPTIONS]'; exit 0; fi
|
|
5872
|
+
# Global options may precede the subcommand, as clap allows (the operator's alias
|
|
5873
|
+
# \`codex --dangerously-bypass-approvals-and-sandbox\`, -c k=v, -m model): the mcp
|
|
5874
|
+
# stand-in skips the ones the suite types.
|
|
5875
|
+
_fa=("\$@")
|
|
5876
|
+
while [ "\${#_fa[@]}" -gt 0 ]; do
|
|
5877
|
+
case "\${_fa[0]}" in
|
|
5878
|
+
--dangerously-bypass-approvals-and-sandbox) _fa=("\${_fa[@]:1}") ;;
|
|
5879
|
+
-c|-m) _fa=("\${_fa[@]:2}") ;;
|
|
5880
|
+
*) break ;;
|
|
5881
|
+
esac
|
|
5882
|
+
done
|
|
5883
|
+
if [ "\${_fa[0]:-}" = "mcp" ] && [ "\${_fa[0]:-}" != "\${1:-}" ]; then set -- "\${_fa[@]}"; fi
|
|
5190
5884
|
if [ "\${1:-}" = "mcp" ] && [ -n "\${CODEX_HOME:-}" ]; then
|
|
5191
5885
|
python3 "$FAKE_CODEX_MCP" "\$CODEX_HOME/config.toml" "\$@"
|
|
5192
5886
|
rc=\$?
|
|
@@ -5267,6 +5961,9 @@ out="$(CODEX_MULTIACC_DISABLE=1 codex 2>&1)"
|
|
|
5267
5961
|
check "codex: passthrough when disabled" "CFG=none" "$out"
|
|
5268
5962
|
|
|
5269
5963
|
# ---- C3. headroom selection: session gate first, then the WEEKLY headroom band ------
|
|
5964
|
+
# The codex shim counts telemetry older than 15 minutes as unknown (bin/codex STALE_AFTER), and
|
|
5965
|
+
# on a loaded machine the suite's $now is older than that by here: these fixtures need a fresh one.
|
|
5966
|
+
now="$(date +%s)"
|
|
5270
5967
|
cxlj() { printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":%s,"max_percent":%s,"buckets":[]}' "$now" "$1" "$2" "$3"; }
|
|
5271
5968
|
cxlj 80 10 80 > "$CX/acct-01/limits.json"
|
|
5272
5969
|
cxlj 20 10 20 > "$CX/acct-02/limits.json"
|
|
@@ -5703,6 +6400,7 @@ grep -q "all-limited fallback=acct-02 weekly=70%" "$CX/selection.log" \
|
|
|
5703
6400
|
rm -f "$CX"/acct-*/.limited "$CX"/acct-*/limits.json
|
|
5704
6401
|
|
|
5705
6402
|
# ---- C6b. the fallback tells "still serving" from "rejected right now" ----------------
|
|
6403
|
+
now="$(date +%s)" # its markers lift in 10 minutes: never from a stale suite clock
|
|
5706
6404
|
# Ported from the claude shim (2026-08-29) when the session gate reached the fallback.
|
|
5707
6405
|
# acct-01: 7d window at 99% — worse headroom, but still answering. acct-02: 5h window at
|
|
5708
6406
|
# 100% — far better weekly (7%), but every request bounces until the reset.
|
|
@@ -6389,7 +7087,7 @@ case "$out" in *CFG=acct-04*) t_fail "codex apikey selection" "api-key account w
|
|
|
6389
7087
|
codex-accounts remove acct-04 --yes >/dev/null 2>&1
|
|
6390
7088
|
|
|
6391
7089
|
# ---- C13. limits: fixture endpoint, marking, classification -------------------------
|
|
6392
|
-
|
|
7090
|
+
touch -t 203001010000 "$CX/.limits-kick" # re-arm the shim-kick throttle: no background limits racing these
|
|
6393
7091
|
cat > "$WORK/cx-usage-high.json" <<EOF
|
|
6394
7092
|
{"email":"a@cx","plan_type":"pro",
|
|
6395
7093
|
"rate_limit":{"allowed":true,"limit_reached":false,
|
|
@@ -7405,6 +8103,57 @@ out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_ACCOUNT=acct-01 codex 2>&1)"
|
|
|
7405
8103
|
check "codex: the next launch on that account still runs" "CFG=acct-01" "$out"
|
|
7406
8104
|
cx_has acct-01 nested && t_fail "codex nested remove undone" "the next launch re-added nested" \
|
|
7407
8105
|
|| t_ok "codex: a nested remove is not undone by the next launch"
|
|
8106
|
+
# GLOBAL OPTIONS before `mcp` (2026-10-22, parity with 16e): the operator's alias
|
|
8107
|
+
# codex='codex --dangerously-bypass-approvals-and-sandbox' put a flag in front of every
|
|
8108
|
+
# typed `codex mcp add`. The shim finds the subcommand the way clap does: a flag is
|
|
8109
|
+
# skipped, -c/-m skip their value.
|
|
8110
|
+
: > "$PYLOG"
|
|
8111
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex --dangerously-bypass-approvals-and-sandbox mcp add dbx -- echo d 2>&1)"
|
|
8112
|
+
rc=$?
|
|
8113
|
+
[ "$rc" = "0" ] && check "codex: a flag before mcp: the stock add still answers like the real client" "Added global MCP server 'dbx'" "$out" \
|
|
8114
|
+
|| t_fail "codex flag-first add rc" "rc=$rc: $out"
|
|
8115
|
+
cx_has acct-01 dbx && cx_has acct-02 dbx && t_ok "codex: 'codex --dangerously-bypass-approvals-and-sandbox mcp add' lands in EVERY account" \
|
|
8116
|
+
|| t_fail "codex flag-first mirror" "$out $(cat "$CXP/acct-01/config.toml" "$CXP/acct-02/config.toml")"
|
|
8117
|
+
grep -q " learn " "$PYLOG" && t_ok "codex: a flag-first add is mirrored through learn" || t_fail "codex flag-first learn" "$(cat "$PYLOG")"
|
|
8118
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" codex-accounts mcp list --json 2>&1)"
|
|
8119
|
+
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); s=d["mcpServers"].get("dbx") or {}; sys.exit(0 if s.get("command")=="echo" and s.get("args")==["d"] else 1)' "$out" \
|
|
8120
|
+
&& t_ok "codex: a flag-first add reaches the registry" || t_fail "codex flag-first registry" "$out"
|
|
8121
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" codex -c a=b -m o3 mcp remove dbx 2>&1)"
|
|
8122
|
+
rc=$?
|
|
8123
|
+
[ "$rc" = "0" ] && t_ok "codex: 'codex -c a=b -m o3 mcp remove' exits 0" || t_fail "codex value-first remove rc" "rc=$rc: $out"
|
|
8124
|
+
cx_has acct-01 dbx || cx_has acct-02 dbx \
|
|
8125
|
+
&& t_fail "codex value-first remove mirror" "dbx survives in an account" \
|
|
8126
|
+
|| t_ok "codex: 'codex -c a=b -m o3 mcp remove' drops the server from EVERY account"
|
|
8127
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" codex-accounts mcp list --json 2>&1)"
|
|
8128
|
+
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); sys.exit(0 if ("dbx" in d.get("retiredUser",[]) or "dbx" in d.get("retired",[])) and "dbx" not in d.get("mcpServers",{}) else 1)' "$out" \
|
|
8129
|
+
&& t_ok "codex: a value-first remove is retired in the registry" || t_fail "codex value-first tombstone" "$out"
|
|
8130
|
+
# nested: the same two shapes from inside a pooled codex session
|
|
8131
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_HOME="$CXP/acct-01" CODEX_SHIM_ACTIVE=1 codex --dangerously-bypass-approvals-and-sandbox mcp add dbxn -- echo n 2>&1)"
|
|
8132
|
+
rc=$?
|
|
8133
|
+
[ "$rc" = "0" ] && t_ok "codex: a nested flag-first 'codex mcp add' exits 0" || t_fail "codex nested flag-first rc" "rc=$rc: $out"
|
|
8134
|
+
cx_has acct-01 dbxn && cx_has acct-02 dbxn && t_ok "codex: a nested flag-first add lands in EVERY account" \
|
|
8135
|
+
|| t_fail "codex nested flag-first mirror" "$out $(cat "$CXP/acct-02/config.toml")"
|
|
8136
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_HOME="$CXP/acct-01" CODEX_SHIM_ACTIVE=1 codex -c a=b -m o3 mcp remove dbxn 2>&1)"
|
|
8137
|
+
rc=$?
|
|
8138
|
+
[ "$rc" = "0" ] && t_ok "codex: a nested 'codex -c a=b -m o3 mcp remove' exits 0" || t_fail "codex nested value-first remove rc" "rc=$rc: $out"
|
|
8139
|
+
cx_has acct-01 dbxn || cx_has acct-02 dbxn \
|
|
8140
|
+
&& t_fail "codex nested value-first remove mirror" "dbxn survives in an account" \
|
|
8141
|
+
|| t_ok "codex: a nested value-first remove drops the server from EVERY account"
|
|
8142
|
+
# NOT a mutation (parity with 16e's --resume / -p): a one-word prompt that reads
|
|
8143
|
+
# "mcp add …", and -i, whose variadic value takes every word up to the next option
|
|
8144
|
+
: > "$PYLOG"
|
|
8145
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex "mcp add psx" 2>&1 </dev/null)"
|
|
8146
|
+
rc=$?
|
|
8147
|
+
[ "$rc" = "0" ] && check "codex: a prompt reading 'mcp add' runs as an ordinary launch" "CFG=acct-01" "$out" \
|
|
8148
|
+
|| t_fail "codex prompt rc" "rc=$rc: $out"
|
|
8149
|
+
out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex -i shot.png mcp add psx 2>&1 </dev/null)"
|
|
8150
|
+
rc=$?
|
|
8151
|
+
[ "$rc" = "0" ] && check "codex: 'codex -i shot.png mcp add psx' runs as an ordinary launch" "CFG=acct-01" "$out" \
|
|
8152
|
+
|| t_fail "codex image-first rc" "rc=$rc: $out"
|
|
8153
|
+
grep -Eq " (snapshot|learn) " "$PYLOG" && t_fail "codex non-mutation mirror attempt" "$(cat "$PYLOG")" \
|
|
8154
|
+
|| t_ok "codex: neither a prompt nor -i's words are an mcp mutation (no snapshot, no learn)"
|
|
8155
|
+
cx_has acct-01 psx || cx_has acct-02 psx && t_fail "codex non-mutation leak" "psx in an account" \
|
|
8156
|
+
|| t_ok "codex: neither non-mutation reaches an account"
|
|
7408
8157
|
mkdir -p "$WORK/cx-outside"
|
|
7409
8158
|
: > "$PYLOG"
|
|
7410
8159
|
out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_HOME="$WORK/cx-outside" CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex mcp add outside -- echo o 2>&1)"
|
|
@@ -8346,6 +9095,96 @@ EOF2
|
|
|
8346
9095
|
out="$(env -u USER -u LOGNAME FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNTS_DIR="$KLP" claude-accounts verify 2>&1)"
|
|
8347
9096
|
check "and its client can use it" "acct-02 lonly@test: PASS" "$out"
|
|
8348
9097
|
|
|
9098
|
+
# 17p. a Keychain item holding only MCP state is no login either (the 9g rule): a client in
|
|
9099
|
+
# a keychain-capable session saves its MCP servers' OAuth state there even when its login
|
|
9100
|
+
# came from a portable token. The shim, list and the marker heal all read it as none, and
|
|
9101
|
+
# every write of a login lands INTO the item, its MCP state kept.
|
|
9102
|
+
KMP="$WORK/kcmcp"
|
|
9103
|
+
KMP_HOME=mac; [ "$(uname -s)" = "Darwin" ] || KMP_HOME=server
|
|
9104
|
+
mkdir -p "$KMP/tmp" "$KMP/acct-01" "$KMP/acct-02" "$KMP/acct-03"
|
|
9105
|
+
: > "$KMP/.limits-kick"
|
|
9106
|
+
cat > "$KMP/accounts.json" <<EOF2
|
|
9107
|
+
{ "version": 1, "server": "root@203.0.113.1", "server_root": "/root/.claude-accounts",
|
|
9108
|
+
"server_repo": "/root/claude-multiacc", "threshold": 90,
|
|
9109
|
+
"accounts": [
|
|
9110
|
+
{"id": "acct-01", "email": "kmtok@test", "home": "$KMP_HOME", "added_at": "2026-10-19T00:00:00Z"},
|
|
9111
|
+
{"id": "acct-02", "email": "kmnone@test", "home": "$KMP_HOME", "added_at": "2026-10-19T00:00:00Z"},
|
|
9112
|
+
{"id": "acct-03", "email": "kmlogin@test", "home": "$KMP_HOME", "added_at": "2026-10-19T00:00:00Z"}
|
|
9113
|
+
] }
|
|
9114
|
+
EOF2
|
|
9115
|
+
KC_MCP='{"mcpOAuth":{"higgsfield|0123456789abcdef":{"serverName":"higgsfield","accessToken":"mcp-access-kc","refreshToken":"mcp-refresh-kc","expiresAt":9999999999999}}}'
|
|
9116
|
+
KMP_TOK='sk-ant-oat01-KCMCPTOKENKCMCPTOKENKCMCPTOKENKCMCPTOKENKC01'
|
|
9117
|
+
kc_put "$KMP/acct-01" "$KC_MCP"
|
|
9118
|
+
kc_put "$KMP/acct-02" "$KC_MCP"
|
|
9119
|
+
printf '%s' "$KMP_TOK" > "$KMP/acct-01/server.token"
|
|
9120
|
+
ok1=1
|
|
9121
|
+
for _ in $(seq 1 10); do
|
|
9122
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" FAKE_KEYCHAIN_CLIENT=1 claude 2>&1)"
|
|
9123
|
+
case "$out" in *"CFG=acct-01 TOK=$KMP_TOK"*) ;; *) ok1=0; break ;; esac
|
|
9124
|
+
done
|
|
9125
|
+
[ "$ok1" = 1 ] && t_ok "an MCP-only Keychain item leaves the account to its token, which is exported" \
|
|
9126
|
+
|| t_fail "MCP-only keychain item + token" "got: $(printf '%s' "$out" | head -c 160)"
|
|
9127
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" claude-accounts list --json 2>&1)"
|
|
9128
|
+
python3 - "$out" <<'EOF2'
|
|
9129
|
+
import json, sys
|
|
9130
|
+
rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
|
|
9131
|
+
for aid in ('acct-01', 'acct-02'):
|
|
9132
|
+
c = rows[aid]['credentials']
|
|
9133
|
+
assert c['oauth'] is False and c['oauth_store'] is None and c['keychain'] is None, (aid, c)
|
|
9134
|
+
assert rows['acct-01']['selectable'] is True, rows['acct-01']
|
|
9135
|
+
assert rows['acct-02']['status'] == 'missing' and rows['acct-02']['needs_login'] is True, rows['acct-02']
|
|
9136
|
+
EOF2
|
|
9137
|
+
[ $? -eq 0 ] && t_ok "list --json: an MCP-only Keychain item is no oauth login" \
|
|
9138
|
+
|| t_fail "MCP-only keychain list --json" "see assertions above"
|
|
9139
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" python3 "$REPO_DIR/lib/keychain.py" probe "$KMP/acct-02" 2>&1)"
|
|
9140
|
+
check "keychain.py probe names the item nologin" '"state": "nologin"' "$out"
|
|
9141
|
+
case "$out" in *mcp-*) t_fail "keychain probe output" "an MCP token reached stdout" ;;
|
|
9142
|
+
*) t_ok "keychain probe output carries no MCP token" ;; esac
|
|
9143
|
+
# An auth park heals on a newer Keychain LOGIN, never on a newer item holding MCP state.
|
|
9144
|
+
printf '%s\nreason=auth-error marked_at=t detail=test\n' "$now" > "$KMP/acct-01/.expired"
|
|
9145
|
+
touch -t 202001010101 "$KMP/acct-01/server.token"
|
|
9146
|
+
touch -t 202001010202 "$KMP/acct-01/.expired"
|
|
9147
|
+
kc_put "$KMP/acct-01" "$KC_MCP" # rewritten after the park
|
|
9148
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" claude 2>&1)"
|
|
9149
|
+
[ -f "$KMP/acct-01/.expired" ] && t_ok "a newer MCP-only Keychain item does not heal an auth park" \
|
|
9150
|
+
|| t_fail "keychain MCP-only heal" ".expired was removed by an MCP-only item"
|
|
9151
|
+
check "...and the parked account is not run" "CFG=none" "$out"
|
|
9152
|
+
kc_put "$KMP/acct-01" "$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kmlogin","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999},%s' "${KC_MCP#?}")"
|
|
9153
|
+
CLAUDE_ACCOUNTS_DIR="$KMP" claude >/dev/null 2>&1
|
|
9154
|
+
[ ! -f "$KMP/acct-01/.expired" ] && t_ok "a newer Keychain login heals it" \
|
|
9155
|
+
|| t_fail "keychain login heal" ".expired survived a newer keychain login"
|
|
9156
|
+
# The refresher writes the rotated login INTO the item: its MCP state stays.
|
|
9157
|
+
kc_put "$KMP/acct-03" "$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-kmstale","refreshToken":"sk-ant-ort01-km","expiresAt":1000,"refreshTokenExpiresAt":9999999999999},%s' "${KC_MCP#?}")"
|
|
9158
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" CLAUDE_MULTIACC_TOKEN_URL="file://$WORK/token-ok.json" \
|
|
9159
|
+
CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
9160
|
+
check "a Keychain login beside MCP state refreshes" "acct-03: oauth access token refreshed" "$out"
|
|
9161
|
+
kc_secret "$KMP/acct-03" | python3 -c '
|
|
9162
|
+
import json, sys
|
|
9163
|
+
doc = json.load(sys.stdin)
|
|
9164
|
+
assert doc["claudeAiOauth"]["accessToken"] == "sk-ant-oat01-refreshednew", doc["claudeAiOauth"]
|
|
9165
|
+
assert doc["mcpOAuth"]["higgsfield|0123456789abcdef"]["refreshToken"] == "mcp-refresh-kc", doc
|
|
9166
|
+
' && t_ok "the Keychain write-back keeps the item's MCP state" \
|
|
9167
|
+
|| t_fail "keychain refresh clobbered MCP state" "item: $(kc_secret "$KMP/acct-03" | head -c 80)"
|
|
9168
|
+
[ "$(kc_secret "$KMP/acct-02")" = "$KC_MCP" ] && [ ! -f "$KMP/acct-02/.expired" ] \
|
|
9169
|
+
&& t_ok "the limits pass leaves an MCP-only item alone and parks nothing" \
|
|
9170
|
+
|| t_fail "limits on an MCP-only keychain item" "item: $(kc_secret "$KMP/acct-02" | head -c 80)"
|
|
9171
|
+
# A login stranded under another name (a USER-less sign-in, older) moves INTO the item a
|
|
9172
|
+
# later client filed MCP state under the canonical name.
|
|
9173
|
+
security delete-generic-password -a "$KC_ME" -s "Claude Code-credentials-$(kc_hash "$KMP/acct-02")" >/dev/null 2>&1
|
|
9174
|
+
kc_put "$KMP/acct-02" "$KC_LIVE" unknown
|
|
9175
|
+
kc_put "$KMP/acct-02" "$KC_MCP"
|
|
9176
|
+
out="$(CLAUDE_ACCOUNTS_DIR="$KMP" CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" claude-accounts limits --force 2>&1)"
|
|
9177
|
+
check "the limits pass moves a stranded login beside MCP state" "acct-02: Keychain login moved under $KC_ME" "$out"
|
|
9178
|
+
kc_secret "$KMP/acct-02" | python3 -c '
|
|
9179
|
+
import json, sys
|
|
9180
|
+
doc = json.load(sys.stdin)
|
|
9181
|
+
assert doc["claudeAiOauth"]["accessToken"] == "sk-ant-oat01-slive", doc["claudeAiOauth"]
|
|
9182
|
+
assert doc["mcpOAuth"]["higgsfield|0123456789abcdef"]["refreshToken"] == "mcp-refresh-kc", doc
|
|
9183
|
+
' && [ "$(kc_items "$KMP/acct-02")" = "$KC_ME" ] \
|
|
9184
|
+
&& t_ok "...into the item, MCP state kept, the stranded copy gone" \
|
|
9185
|
+
|| t_fail "keychain move into MCP item" "items: $(kc_items "$KMP/acct-02" | tr '\n' ' ')"
|
|
9186
|
+
rm -rf "$KMP"
|
|
9187
|
+
|
|
8349
9188
|
export CLAUDE_MULTIACC_KEYCHAIN=0
|
|
8350
9189
|
unset FAKE_KEYCHAIN_DIR
|
|
8351
9190
|
|
|
@@ -8500,6 +9339,9 @@ case "$verb" in
|
|
|
8500
9339
|
-e "s/#{pane_current_command}/${FAKE_TMUX_PANE_CMD:-zsh}/g" \
|
|
8501
9340
|
-e "s/#{pane_in_mode}/${FAKE_TMUX_PANE_MODE:-0}/g" \
|
|
8502
9341
|
-e "s/#{synchronize-panes}/${FAKE_TMUX_PANE_SYNC:-0}/g" ;;
|
|
9342
|
+
capture-pane)
|
|
9343
|
+
# the pane's screen: what the fake TUI drew (FAKE_TMUX_SCREEN), else a blank one
|
|
9344
|
+
[ -n "${FAKE_TMUX_SCREEN:-}" ] && cat "$FAKE_TMUX_SCREEN" 2>/dev/null ;;
|
|
8503
9345
|
send-keys)
|
|
8504
9346
|
lit=0
|
|
8505
9347
|
while [ $# -gt 0 ]; do
|
|
@@ -9005,10 +9847,53 @@ grep -q '^depth=2$' "$st" 2>/dev/null && grep -q '^chain=CarryTst$' "$st" \
|
|
|
9005
9847
|
&& grep -q "^avoid=acct-01:$((nowc + 18000))\$" "$st" && grep -q '^cwd=/.*/ar-work dir$' "$st" \
|
|
9006
9848
|
&& t_ok "AR carry: the next watcher inherits depth, chain, AVOID and the session's cwd" \
|
|
9007
9849
|
|| t_fail "AR carry: state file" "$(cat "$st" 2>/dev/null)"
|
|
9850
|
+
grep -q '^via=login$' "$st" 2>/dev/null \
|
|
9851
|
+
&& t_ok "AR state: it names the credential the run carries (the login: via=login)" \
|
|
9852
|
+
|| t_fail "AR state: via=login" "$(grep '^via' "$st" 2>/dev/null)"
|
|
9008
9853
|
[ "$(grep -c 'autoresume relaunch chain=CarryTst from=acct-01 class=model depth=2$' "$ARC/selection.log")" = 1 ] \
|
|
9009
9854
|
&& t_ok "AR carry: the relaunch is logged once, not again by the re-entry" \
|
|
9010
9855
|
|| t_fail "AR carry: relaunch log" "$(grep autoresume "$ARC/selection.log")"
|
|
9011
9856
|
ar_reap "$ARC"
|
|
9857
|
+
# ...and the portable token when the run carries it: an auth verdict its watcher meets in
|
|
9858
|
+
# any session of that run (the client switches a run's session on /clear) is the token's.
|
|
9859
|
+
ar_claude_pool
|
|
9860
|
+
rm -f "$ARC/acct-01/.credentials.json" "$ARC/acct-02/.credentials.json"
|
|
9861
|
+
printf '%s' sk-ant-oat01-ARVIATOKEN > "$ARC/acct-01/server.token"
|
|
9862
|
+
printf '%s\n' "$(printf '%s' sk-ant-oat01-ARVIATOKEN | { shasum -a 256 2>/dev/null || sha256sum; } | cut -d ' ' -f1)" \
|
|
9863
|
+
> "$ARC/acct-01/.server-token-verified"
|
|
9864
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" FAKE_STUB_PY_LOG="$ARB/stub.log" \
|
|
9865
|
+
claude --dangerously-skip-permissions </dev/null 2>&1)"
|
|
9866
|
+
check "AR state: a run on the portable token" "CFG=acct-01 TOK=sk-ant-oat01-ARVIATOKEN" "$out"
|
|
9867
|
+
st="$(ls "$ARC/tmp/autoresume/"*.state 2>/dev/null | head -1)"
|
|
9868
|
+
grep -q '^via=token$' "$st" 2>/dev/null \
|
|
9869
|
+
&& t_ok "AR state: ...names it (via=token)" \
|
|
9870
|
+
|| t_fail "AR state: via=token" "$(grep '^via' "$st" 2>/dev/null)"
|
|
9871
|
+
ar_reap "$ARC"
|
|
9872
|
+
|
|
9873
|
+
# ---- AR11b. a relaunch keeps its session's model on a Fable-spent account -----------------
|
|
9874
|
+
# An unpinned run on an account whose Fable bucket is full gets the fallback model pinned (9a4).
|
|
9875
|
+
# A relaunch names the model its session last ran on, which --resume restores: only a session
|
|
9876
|
+
# that IS on Fable is pinned off it; an Opus one resumes on its own model, not the fallback.
|
|
9877
|
+
ar_claude_pool
|
|
9878
|
+
nowc="$(date +%s)"
|
|
9879
|
+
printf '{"fetched_at":%s,"weekly_percent":5,"session_percent":10,"max_percent":100,"buckets":[%s]}' "$nowc" \
|
|
9880
|
+
'{"name":"session","percent":10},{"name":"weekly_all","percent":5},{"name":"weekly_scoped:Fable","percent":100}' \
|
|
9881
|
+
> "$ARC/acct-02/limits.json"
|
|
9882
|
+
for case_ in OpusModelTok0123:claude-opus-5-5:keeps FableModelTok012:claude-fable-5-1:falls; do
|
|
9883
|
+
tok="${case_%%:*}"; rest="${case_#*:}"; model="${rest%%:*}"
|
|
9884
|
+
ar_plant "$ARC" "$tok" claude crash acct-01 -- --effort max --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
|
|
9885
|
+
printf 'model=%s\n' "$model" >> "$ARC/tmp/autoresume/r-$tok.relaunch"
|
|
9886
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
|
|
9887
|
+
CLAUDE_MULTIACC_AR="$tok:$AR_SID" claude </dev/null 2>&1)"
|
|
9888
|
+
if [ "${rest#*:}" = keeps ]; then
|
|
9889
|
+
check "AR model: a $model session resumes on its own model on a Fable-spent account" \
|
|
9890
|
+
"CFG=acct-02 TOK=none ARGS=--effort max --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
|
|
9891
|
+
else
|
|
9892
|
+
check "AR model: a $model session on a Fable-spent account gets the fallback model" \
|
|
9893
|
+
"CFG=acct-02 TOK=none ARGS=--model claude-opus-5 --effort max --resume $AR_SID" "$out"
|
|
9894
|
+
fi
|
|
9895
|
+
ar_reap "$ARC"
|
|
9896
|
+
done
|
|
9012
9897
|
|
|
9013
9898
|
# ---- AR12. claude relaunch tokens: the auth park, dead tokens, the trust mirror ------------
|
|
9014
9899
|
# Planted directly (no watcher, no TUI), so each assertion can only come from the relaunch
|
|
@@ -9029,6 +9914,30 @@ out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" C
|
|
|
9029
9914
|
|| t_fail "AR auth token: park" "$(ls -a "$ARC/acct-01" | tr '\n' ' ')"
|
|
9030
9915
|
check "AR auth token: ...and the run lands on the other account, resuming the session" \
|
|
9031
9916
|
"CFG=acct-02 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
|
|
9917
|
+
# The relaunch names the credential the run it left carried (that watcher's state, via=):
|
|
9918
|
+
# the session the watcher followed may be one the client switched the run to (/clear,
|
|
9919
|
+
# /resume), which no index line names (none here at all). via=token retires the token and
|
|
9920
|
+
# its proof; via=login parks the login and keeps the token's proof.
|
|
9921
|
+
for v in token:setup-token-invalid login:auth-error; do
|
|
9922
|
+
rm -f "$ARC/acct-01/.expired"
|
|
9923
|
+
echo proof > "$ARC/acct-01/.server-token-verified"
|
|
9924
|
+
ar_plant "$ARC" ViaTok0123456789A claude auth acct-01 -- \
|
|
9925
|
+
--dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
|
|
9926
|
+
printf 'via=%s\n' "${v%%:*}" >> "$ARC/tmp/autoresume/r-ViaTok0123456789A.relaunch"
|
|
9927
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
|
|
9928
|
+
CLAUDE_MULTIACC_AR="ViaTok0123456789A:$AR_SID" claude </dev/null 2>&1)"
|
|
9929
|
+
grep -q "reason=${v#*:} " "$ARC/acct-01/.expired" 2>/dev/null \
|
|
9930
|
+
&& t_ok "AR auth via=${v%%:*}: the relaunch parks the credential that run carried (${v#*:})" \
|
|
9931
|
+
|| t_fail "AR auth via=${v%%:*}" "$(tail -1 "$ARC/acct-01/.expired" 2>/dev/null) / $out"
|
|
9932
|
+
if [ "${v%%:*}" = token ]; then
|
|
9933
|
+
[ ! -f "$ARC/acct-01/.server-token-verified" ] && t_ok "AR auth via=token: ...and drops the token's proof" \
|
|
9934
|
+
|| t_fail "AR auth via=token" "the token's .server-token-verified survived"
|
|
9935
|
+
else
|
|
9936
|
+
[ -f "$ARC/acct-01/.server-token-verified" ] && t_ok "AR auth via=login: ...and keeps the token's proof" \
|
|
9937
|
+
|| t_fail "AR auth via=login" "the token's .server-token-verified was dropped"
|
|
9938
|
+
fi
|
|
9939
|
+
done
|
|
9940
|
+
rm -f "$ARC/acct-01/.server-token-verified"
|
|
9032
9941
|
# Dead tokens: missing, expired (older than 10 min), malformed, a FIFO in its place.
|
|
9033
9942
|
ar_dead claude "a missing relaunch file" "claude-multiacc: $AR_DEAD_MSG Resume with: claude --resume $AR_SID" \
|
|
9034
9943
|
CLAUDE_MULTIACC_AR="MissingTok0123456:$AR_SID"
|
|
@@ -9110,6 +10019,73 @@ cmp -s "$ARC/acct-01/.claude.json" "$ARB/acct-01.claude.json" \
|
|
|
9110
10019
|
|| t_fail "AR trust: a plain launch edited .claude.json" "$(cat "$ARC"/acct-0*/.claude.json)"
|
|
9111
10020
|
ar_reap "$ARC"
|
|
9112
10021
|
|
|
10022
|
+
# ---- AR13. claude critical memory: restarted and resumed, nothing marked ---------------------
|
|
10023
|
+
# claude 2.1.304 shows "Critical memory usage (<size>) — restart and resume with claude
|
|
10024
|
+
# --continue" in its footer once its heap passes 2.5 GiB, and writes nothing of it to the
|
|
10025
|
+
# transcript; the watcher reads its own pane for it (capture-pane, never a key). A relaunch of
|
|
10026
|
+
# class=memory is about the PROCESS: the shim marks nothing and selects as it always does.
|
|
10027
|
+
# Planted first (no watcher, no TUI): acct-01 leads on headroom and is the account the session
|
|
10028
|
+
# left, and ordinary selection gives it straight back.
|
|
10029
|
+
ar_claude_pool
|
|
10030
|
+
nowc="$(date +%s)"
|
|
10031
|
+
for i in 01:5 02:20; do
|
|
10032
|
+
printf '{"fetched_at":%s,"weekly_percent":%s,"session_percent":10,"max_percent":%s,"buckets":[]}' \
|
|
10033
|
+
"$nowc" "${i#*:}" "${i#*:}" > "$ARC/acct-${i%%:*}/limits.json"
|
|
10034
|
+
done
|
|
10035
|
+
ar_plant "$ARC" MemTok0123456789AB claude memory acct-01 -- \
|
|
10036
|
+
--dangerously-skip-permissions --resume "$AR_SID" "(claude-multiacc auto-resume) carry on please"
|
|
10037
|
+
out="$(cd "$ARH" && env "${AREC[@]}" CLAUDE_MULTIACC_PYTHON="$ARB/stub-python" CLAUDE_MULTIACC_HEADROOM_BAND=0 \
|
|
10038
|
+
CLAUDE_MULTIACC_AR="MemTok0123456789AB:$AR_SID" claude </dev/null 2>&1)"
|
|
10039
|
+
check "AR memory token: the relaunch selects normally, the account it left included" \
|
|
10040
|
+
"CFG=acct-01 TOK=none ARGS=--dangerously-skip-permissions --resume $AR_SID (claude-multiacc auto-resume) carry on please" "$out"
|
|
10041
|
+
ls -a "$ARC"/acct-0*/ | grep -Eq '^\.(limited|expired)$' \
|
|
10042
|
+
&& t_fail "AR memory token: nothing is marked" "$(ls -a "$ARC"/acct-0*/ | tr '\n' ' ')" \
|
|
10043
|
+
|| t_ok "AR memory token: nothing is marked on the account it left (no .limited, no .expired)"
|
|
10044
|
+
ar_logged "$ARC" 'autoresume relaunch chain=PlantTst from=acct-01 class=memory depth=1$' \
|
|
10045
|
+
&& t_ok "AR memory token: the relaunch is logged with its class" \
|
|
10046
|
+
|| t_fail "AR memory token: relaunch log" "$(grep autoresume "$ARC/selection.log")"
|
|
10047
|
+
ar_reap "$ARC"
|
|
10048
|
+
# End to end: the fake TUI draws the banner in its footer; the real watcher (MEMORY_MIN_UPTIME
|
|
10049
|
+
# 0 here, a scan every 0.2 s) sees it, stops the client and types the relaunch into the shell.
|
|
10050
|
+
# FAKE_TUI_IGNORE_TERM: like the 8.3GB client of 2026-10-20 it ignores SIGTERM, so it is
|
|
10051
|
+
# KILLed after TERM_GRACE, and still gets its relaunch. The relaunched client's screen is its
|
|
10052
|
+
# own (no banner): it is not restarted again.
|
|
10053
|
+
ar_claude_pool
|
|
10054
|
+
rm -f "$ARB/screen"
|
|
10055
|
+
ar_start FAKE_TUI=memory FAKE_TUI_IGNORE_TERM=1 FAKE_TMUX_SCREEN="$ARB/screen" \
|
|
10056
|
+
CLAUDE_MULTIACC_AR_MEMORY_MIN_UPTIME=0 CLAUDE_MULTIACC_AR_MEMORY_SCAN=0.2 \
|
|
10057
|
+
-- claude --dangerously-skip-permissions "fix the bug please"
|
|
10058
|
+
first=$AR_PID
|
|
10059
|
+
if ar_wait 40 ar_lines 2 "$ARB/tui.log"; then
|
|
10060
|
+
l1="$(sed -n 1p "$ARB/tui.log")"; l2="$(sed -n 2p "$ARB/tui.log")"
|
|
10061
|
+
from="$(ar_acct_of "$l1")"
|
|
10062
|
+
check "AR memory: resumed in place with the memory reason and its size" \
|
|
10063
|
+
"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"
|
|
10064
|
+
ar_wait 5 ar_stopped "$first" && t_ok "AR memory: the client that ignored SIGTERM was stopped (KILL)" \
|
|
10065
|
+
|| t_fail "AR memory: the client was stopped" "pid $first alive"
|
|
10066
|
+
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" \
|
|
10067
|
+
&& 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" \
|
|
10068
|
+
&& ar_logged "$ARC" "autoresume relaunch chain=[A-Za-z0-9]+ from=$from class=memory depth=1\$" \
|
|
10069
|
+
&& t_ok "AR memory: detected on the pane, switched, relaunched with class=memory" \
|
|
10070
|
+
|| t_fail "AR memory: log lines" "$(grep autoresume "$ARC/selection.log")"
|
|
10071
|
+
ls -a "$ARC"/acct-0*/ | grep -Eq '^\.(limited|expired)$' \
|
|
10072
|
+
&& t_fail "AR memory: nothing is marked" "$(ls -a "$ARC"/acct-0*/ | tr '\n' ' ')" \
|
|
10073
|
+
|| t_ok "AR memory: no account is marked"
|
|
10074
|
+
grep -q -- "capture-pane -p -J -t %1" "$ARB/tmux/calls" 2>/dev/null \
|
|
10075
|
+
&& ar_typed_ok CLAUDE "$AR_SID" "CLAUDE_ACCOUNTS_ROOT=$(cd "$ARC" && pwd)" "$REPO_DIR/bin/claude" \
|
|
10076
|
+
&& t_ok "AR memory: the pane was read (capture-pane), and the one line typed is the relaunch" \
|
|
10077
|
+
|| 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)"
|
|
10078
|
+
ar_wait 10 ar_logged "$ARC" "autoresume watch provider=claude chain=[A-Za-z0-9]+ pid=[0-9]+ acct=acct-0[12] depth=1\$"
|
|
10079
|
+
sleep 1.5
|
|
10080
|
+
[ "$(grep -c 'autoresume switch' "$ARC/selection.log")" = 1 ] && [ "$(($(wc -l < "$ARB/tui.log")))" = 2 ] \
|
|
10081
|
+
&& t_ok "AR memory: the relaunched session, its screen clear, is not restarted again" \
|
|
10082
|
+
|| t_fail "AR memory: restarted again" "$(grep autoresume "$ARC/selection.log")"
|
|
10083
|
+
else
|
|
10084
|
+
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)"
|
|
10085
|
+
fi
|
|
10086
|
+
ar_reap "$ARC"
|
|
10087
|
+
ar_no_watcher "$ARC" && t_ok "AR memory: no watcher left running" || t_fail "AR memory: watcher left running"
|
|
10088
|
+
|
|
9113
10089
|
# ---- AR10. codex: the same machinery through bin/codex ------------------------------------
|
|
9114
10090
|
ARX="$WORK/ar-codex"
|
|
9115
10091
|
ar_codex_pool() { # a fresh two-account codex pool sharing one sessions tree (the installed layout)
|
|
@@ -9280,6 +10256,12 @@ if [ -x /usr/bin/python3 ] && /usr/bin/python3 -c 'import sys; sys.exit(0 if sys
|
|
|
9280
10256
|
else
|
|
9281
10257
|
t_fail "MCP registry unit suite on stock python" "/usr/bin/python3 tests/test_mcp_registry.py failed"
|
|
9282
10258
|
fi
|
|
10259
|
+
# ...and so must the MCP OAuth merge the shim runs before exec (lib/mcp_oauth.py ensure).
|
|
10260
|
+
if /usr/bin/python3 "$REPO_DIR/tests/test_mcp_oauth.py" >/dev/null 2>&1; then
|
|
10261
|
+
t_ok "MCP OAuth sharing unit suite on stock python $(/usr/bin/python3 -c 'import sys;print("%d.%d"%sys.version_info[:2])')"
|
|
10262
|
+
else
|
|
10263
|
+
t_fail "MCP OAuth sharing unit suite on stock python" "/usr/bin/python3 tests/test_mcp_oauth.py failed"
|
|
10264
|
+
fi
|
|
9283
10265
|
fi
|
|
9284
10266
|
if CLAUDE_MULTIACC_AUTO_RESET=1 python3 -m unittest discover -s "$REPO_DIR/tests" -p 'test_claude_reset*.py'; then
|
|
9285
10267
|
t_ok "claude: automatic limit-reset integration suite"
|
|
@@ -9318,6 +10300,172 @@ else
|
|
|
9318
10300
|
t_fail "claude organization recovery report suite" "see unittest output above"
|
|
9319
10301
|
fi
|
|
9320
10302
|
|
|
10303
|
+
# MCP OAuth logins shared by every claude account on this machine (lib/mcp_oauth.py):
|
|
10304
|
+
# harvest and winner rules, merges under the client's own lock, the Keychain read-back,
|
|
10305
|
+
# the refresh grant against a loopback authorization server, forget, and the shim's gate.
|
|
10306
|
+
if python3 "$REPO_DIR/tests/test_mcp_oauth.py"; then
|
|
10307
|
+
t_ok "claude: MCP OAuth sharing unit suite"
|
|
10308
|
+
else
|
|
10309
|
+
t_fail "claude MCP OAuth sharing unit suite" "see unittest output above"
|
|
10310
|
+
fi
|
|
10311
|
+
|
|
10312
|
+
# ---- 21. MCP OAuth sharing creates a credentials file for a setup-token account only ---
|
|
10313
|
+
# Every reader of .credentials.json — the shim (file_login), the limits pass (oauth_store),
|
|
10314
|
+
# the audit, report, verify — counts a document as a login only when it carries
|
|
10315
|
+
# claudeAiOauth (9g), so a file holding MCP state alone costs a setup-token account
|
|
10316
|
+
# nothing: it is the file its client writes itself once it meets an OAuth server from a
|
|
10317
|
+
# session that cannot open the Keychain, and all it has to read MCP logins from. Sharing
|
|
10318
|
+
# creates that file (0600, MCP state alone) for an account running on its token; any other
|
|
10319
|
+
# account keeps what it has. Own pool and keychain: acct-01 runs on a setup token, acct-02
|
|
10320
|
+
# holds the MCP login beside a file login, acct-03's Claude login lives in its Keychain
|
|
10321
|
+
# item, acct-04 has a Keychain login and a setup token, and acct-05 runs on a setup token
|
|
10322
|
+
# beside the file its client wrote first: a discovery stub, no login in it.
|
|
10323
|
+
MOP="$WORK/mcpoauth-pool"
|
|
10324
|
+
MOKC="$WORK/mcpoauth-keychain"
|
|
10325
|
+
mkdir -p "$MOP/tmp" "$MOP/acct-01" "$MOP/acct-02" "$MOP/acct-03" "$MOP/acct-04" "$MOP/acct-05" "$MOKC"
|
|
10326
|
+
: > "$MOP/.limits-kick"
|
|
10327
|
+
cat > "$MOP/accounts.json" <<EOF
|
|
10328
|
+
{ "version": 1, "threshold": 90,
|
|
10329
|
+
"accounts": [
|
|
10330
|
+
{"id": "acct-01", "email": "mo1@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10331
|
+
{"id": "acct-02", "email": "mo2@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10332
|
+
{"id": "acct-03", "email": "mo3@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10333
|
+
{"id": "acct-04", "email": "mo4@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"},
|
|
10334
|
+
{"id": "acct-05", "email": "mo5@test", "home": "mac", "added_at": "2026-10-19T00:00:00Z"}
|
|
10335
|
+
] }
|
|
10336
|
+
EOF
|
|
10337
|
+
MO_TOK='sk-ant-oat01-MCPSHAREFAKEMCPSHAREFAKEMCPSHAREFAKEMCPSHARE00'
|
|
10338
|
+
MO_TOK4='sk-ant-oat01-MCPKCTOKFAKEMCPKCTOKFAKEMCPKCTOKFAKEMCPKCTOK04'
|
|
10339
|
+
MO_TOK5='sk-ant-oat01-MCPSTUBFAKEMCPSTUBFAKEMCPSTUBFAKEMCPSTUBFAKE05'
|
|
10340
|
+
printf '%s' "$MO_TOK" > "$MOP/acct-01/server.token"
|
|
10341
|
+
printf '%s' "$MO_TOK4" > "$MOP/acct-04/server.token"
|
|
10342
|
+
printf '%s' "$MO_TOK5" > "$MOP/acct-05/server.token"
|
|
10343
|
+
printf '{"mcpOAuth":{"higgsfield|0a1b2c3d4e5f6a7b":{"serverName":"higgsfield","serverUrl":"https://mcp.higgsfield.example/mcp","accessToken":"","discoveryState":{"authorizationServerUrl":"https://clerk.higgsfield.example"}}}}' \
|
|
10344
|
+
> "$MOP/acct-05/.credentials.json"
|
|
10345
|
+
out="$(env CLAUDE_ACCOUNTS_DIR="$MOP" FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-05 claude 2>&1)"
|
|
10346
|
+
check "a discovery-stub file beside a setup token: the token is exported" "CFG=acct-05 TOK=$MO_TOK5" "$out"
|
|
10347
|
+
mo_exp="$(( ($(date +%s) + 72000) * 1000 ))"
|
|
10348
|
+
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"}}}}' \
|
|
10349
|
+
"$mo_exp" > "$MOP/acct-02/.credentials.json"
|
|
10350
|
+
mo_env() { env CLAUDE_ACCOUNTS_DIR="$MOP" CLAUDE_MULTIACC_KEYCHAIN=1 FAKE_KEYCHAIN_DIR="$MOKC" "$@"; }
|
|
10351
|
+
mo_svc() { printf 'Claude Code-credentials-%s' "$(printf '%s' "$MOP/$1" | { shasum -a 256 2>/dev/null || sha256sum; } | cut -c1-8)"; }
|
|
10352
|
+
for a in acct-03 acct-04; do
|
|
10353
|
+
mo_env security add-generic-password -U -a "${USER:-$(id -un)}" -s "$(mo_svc "$a")" \
|
|
10354
|
+
-X "$(printf '{"claudeAiOauth":{"accessToken":"sk-ant-oat01-mokc%s","refreshToken":"r","expiresAt":9999999999999,"refreshTokenExpiresAt":9999999999999}}' "${a#acct-}" \
|
|
10355
|
+
| python3 -c 'import sys;print(sys.stdin.buffer.read().hex())')"
|
|
10356
|
+
done
|
|
10357
|
+
mo_limits() { mo_env CLAUDE_MULTIACC_MCP_OAUTH=0 CLAUDE_MULTIACC_USAGE_URL="file://$WORK/usage-low.json" "$@" claude-accounts limits 2>&1; }
|
|
10358
|
+
out="$(mo_limits)"
|
|
10359
|
+
check "MCP sharing: the Keychain logins report before any sync" "acct-03: ok" "$out"
|
|
10360
|
+
check "...the one beside a setup token too" "acct-04: ok" "$out"
|
|
10361
|
+
out="$(mo_env claude-accounts mcp auth-sync 2>&1)"
|
|
10362
|
+
check "MCP sharing: auth-sync shares the login" "higgsfield [higgsfield|0a1b2c3d4e5f6a7b]: login from acct-02 (file)" "$out"
|
|
10363
|
+
mo_mcp_only() { # $1 acct: its .credentials.json is MCP state alone (the shared login), 0600
|
|
10364
|
+
python3 - "$MOP/$1/.credentials.json" <<'EOF'
|
|
10365
|
+
import json, os, stat, sys
|
|
10366
|
+
doc = json.load(open(sys.argv[1]))
|
|
10367
|
+
assert list(doc) == ['mcpOAuth'], list(doc)
|
|
10368
|
+
assert doc['mcpOAuth']['higgsfield|0a1b2c3d4e5f6a7b']['refreshToken'] == 'fake-mcp-refresh-mo', doc
|
|
10369
|
+
assert stat.S_IMODE(os.stat(sys.argv[1]).st_mode) == 0o600
|
|
10370
|
+
EOF
|
|
10371
|
+
}
|
|
10372
|
+
mo_mcp_only acct-01 && mo_mcp_only acct-04 \
|
|
10373
|
+
&& t_ok "MCP sharing creates a .credentials.json holding MCP state alone for each setup-token account" \
|
|
10374
|
+
|| t_fail "MCP sharing: the setup-token accounts' files" "$(ls -a "$MOP/acct-01" "$MOP/acct-04" | tr '\n' ' ')"
|
|
10375
|
+
[ ! -e "$MOP/acct-03/.credentials.json" ] \
|
|
10376
|
+
&& t_ok "...and none for an account whose login is in its Keychain item" \
|
|
10377
|
+
|| t_fail "MCP sharing created a file beside a Keychain login" "$(head -c 120 "$MOP/acct-03/.credentials.json")"
|
|
10378
|
+
for a in acct-03 acct-04; do
|
|
10379
|
+
mo_env security find-generic-password -a "${USER:-$(id -un)}" -s "$(mo_svc "$a")" -w 2>/dev/null \
|
|
10380
|
+
| 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 \
|
|
10381
|
+
&& t_ok "MCP sharing merges into $a's existing Keychain item and keeps its login" \
|
|
10382
|
+
|| t_fail "MCP sharing keychain merge ($a)" "item missing the shared login or lost its own"
|
|
10383
|
+
done
|
|
10384
|
+
# The setup-token account keeps running on its token, beside the file it was given; a
|
|
10385
|
+
# client that needs a login (FAKE_KEYCHAIN_CLIENT) would say "Not logged in" without one.
|
|
10386
|
+
for i in 1 2; do
|
|
10387
|
+
out="$(mo_env FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
|
|
10388
|
+
check "setup-token account still exports its token beside its MCP-only file (launch $i)" \
|
|
10389
|
+
"CFG=acct-01 TOK=$MO_TOK" "$out"
|
|
10390
|
+
done
|
|
10391
|
+
out="$(mo_env claude-accounts list 2>&1)"
|
|
10392
|
+
case "$(printf '%s\n' "$out" | grep '^acct-01')" in
|
|
10393
|
+
*"auth=token "*) t_ok "list shows auth=token for the setup-token account beside its MCP-only file" ;;
|
|
10394
|
+
*) t_fail "list auth column beside an MCP-only file" "$(printf '%s\n' "$out" | grep '^acct-01')" ;;
|
|
10395
|
+
esac
|
|
10396
|
+
out="$(mo_env claude-accounts list --json 2>&1)"
|
|
10397
|
+
python3 - "$out" <<'EOF'
|
|
10398
|
+
import json, sys
|
|
10399
|
+
rows = {a['id']: a for a in json.loads(sys.argv[1])['accounts']}
|
|
10400
|
+
c = rows['acct-01']['credentials']
|
|
10401
|
+
assert c['oauth'] is False and c['oauth_store'] is None, c
|
|
10402
|
+
assert rows['acct-01']['credential_class'] == 'portable' and rows['acct-01']['selectable'] is True
|
|
10403
|
+
c = rows['acct-04']['credentials']
|
|
10404
|
+
assert c['oauth'] is True and c['oauth_store'] == 'keychain' and c['token'] is True, c
|
|
10405
|
+
EOF
|
|
10406
|
+
[ $? -eq 0 ] && t_ok "list --json: an MCP-only file is no login (token: portable; Keychain login: keychain)" \
|
|
10407
|
+
|| t_fail "list --json beside created MCP files" "see assertions above"
|
|
10408
|
+
# The account with a Keychain login AND a token, in a session that can open the Keychain:
|
|
10409
|
+
# it runs on that login, and its telemetry comes from it, as before the file existed.
|
|
10410
|
+
out="$(mo_env FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
|
|
10411
|
+
check "a Keychain login beside a created MCP file runs on that login (Keychain readable)" \
|
|
10412
|
+
"CFG=acct-04 TOK=none" "$out"
|
|
10413
|
+
rm -f "$MOP/acct-03/limits.json" "$MOP/acct-04/limits.json"
|
|
10414
|
+
out="$(mo_limits)"
|
|
10415
|
+
check "MCP sharing: the Keychain login still reports after a sync" "acct-03: ok" "$out"
|
|
10416
|
+
check "...and the one beside its token and created MCP file" "acct-04: ok" "$out"
|
|
10417
|
+
mo_kc_alive() { # $1 acct: not parked, its telemetry from its OAuth login
|
|
10418
|
+
[ ! -f "$MOP/$1/.expired" ] \
|
|
10419
|
+
&& python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert d.get("source")=="oauth", d' \
|
|
10420
|
+
"$MOP/$1/limits.json" 2>/dev/null
|
|
10421
|
+
}
|
|
10422
|
+
mo_kc_alive acct-03 && mo_kc_alive acct-04 \
|
|
10423
|
+
&& t_ok "a Keychain login beside a shared MCP login is neither parked nor dark" \
|
|
10424
|
+
|| t_fail "Keychain logins after MCP sharing" "$(cat "$MOP"/acct-0[34]/.expired 2>/dev/null)"
|
|
10425
|
+
# ...and in a session that cannot open it (ssh, tmux): the token runs, nothing is parked,
|
|
10426
|
+
# nothing is called dead, and the readings the readable session took stand.
|
|
10427
|
+
cp "$MOP/acct-04/limits.json" "$WORK/mo-limits4.json"
|
|
10428
|
+
out="$(mo_env FAKE_KEYCHAIN_LOCKED=1 FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-04 claude 2>&1)"
|
|
10429
|
+
check "with the Keychain locked, that account runs on its token (its client reads the MCP file)" \
|
|
10430
|
+
"CFG=acct-04 TOK=$MO_TOK4" "$out"
|
|
10431
|
+
out="$(mo_limits FAKE_KEYCHAIN_LOCKED=1)"
|
|
10432
|
+
check "a locked Keychain login is left for the next pass, its token never spent" \
|
|
10433
|
+
"acct-04: oauth credential not usable this pass" "$out"
|
|
10434
|
+
{ [ ! -f "$MOP/acct-03/.expired" ] && [ ! -f "$MOP/acct-04/.expired" ] \
|
|
10435
|
+
&& cmp -s "$WORK/mo-limits4.json" "$MOP/acct-04/limits.json"; } \
|
|
10436
|
+
&& t_ok "...parked nowhere, and its telemetry kept" \
|
|
10437
|
+
|| t_fail "locked Keychain logins after MCP sharing" "$(cat "$MOP"/acct-0[34]/.expired 2>/dev/null)"
|
|
10438
|
+
out="$(mo_env FAKE_KEYCHAIN_LOCKED=1 claude-accounts list 2>&1)"
|
|
10439
|
+
check "list, locked: the Keychain login is locked here, not missing" "KEYCHAIN-LOCKED-HERE" \
|
|
10440
|
+
"$(printf '%s\n' "$out" | grep '^acct-03')"
|
|
10441
|
+
case "$(printf '%s\n' "$out" | grep '^acct-04')" in
|
|
10442
|
+
*EXPIRED*|*NO-LOGIN*|*INVALID*) t_fail "list, locked, acct-04" "$(printf '%s\n' "$out" | grep '^acct-04')" ;;
|
|
10443
|
+
*"auth=keychain+token"*) t_ok "list, locked: the token account beside it reads keychain+token, nothing dead" ;;
|
|
10444
|
+
*) t_fail "list, locked, acct-04" "$(printf '%s\n' "$out" | grep '^acct-04')" ;;
|
|
10445
|
+
esac
|
|
10446
|
+
out="$(mo_env claude-accounts mcp auth-status 2>&1)"
|
|
10447
|
+
check "auth-status: only the Keychain-login account has no file" "absent acct-03" "$out"
|
|
10448
|
+
# The setup-token account whose client wrote its file first got the shared login merged
|
|
10449
|
+
# into that stub, and keeps exporting its token.
|
|
10450
|
+
python3 - "$MOP/acct-05/.credentials.json" <<'EOF'
|
|
10451
|
+
import json, sys
|
|
10452
|
+
doc = json.load(open(sys.argv[1]))
|
|
10453
|
+
assert list(doc) == ['mcpOAuth'], list(doc)
|
|
10454
|
+
assert doc['mcpOAuth']['higgsfield|0a1b2c3d4e5f6a7b']['refreshToken'] == 'fake-mcp-refresh-mo', doc
|
|
10455
|
+
EOF
|
|
10456
|
+
[ $? -eq 0 ] && t_ok "the shared login is merged into that existing stub file" \
|
|
10457
|
+
|| t_fail "merge into a stub file" "$(head -c 200 "$MOP/acct-05/.credentials.json")"
|
|
10458
|
+
for i in 1 2; do
|
|
10459
|
+
out="$(mo_env FAKE_KEYCHAIN_CLIENT=1 CLAUDE_ACCOUNT=acct-05 claude 2>&1)"
|
|
10460
|
+
check "...and that account still exports its token (launch $i)" "CFG=acct-05 TOK=$MO_TOK5" "$out"
|
|
10461
|
+
done
|
|
10462
|
+
out="$(mo_env claude-accounts list 2>&1)"
|
|
10463
|
+
case "$(printf '%s\n' "$out" | grep '^acct-05')" in
|
|
10464
|
+
*"auth=token "*) t_ok "...and list shows it auth=token" ;;
|
|
10465
|
+
*) t_fail "list auth column beside a merged stub file" "$(printf '%s\n' "$out" | grep '^acct-05')" ;;
|
|
10466
|
+
esac
|
|
10467
|
+
rm -rf "$MOP" "$MOKC" "$WORK/mo-limits4.json"
|
|
10468
|
+
|
|
9321
10469
|
# ---- summary ---------------------------------------------------------------------
|
|
9322
10470
|
echo
|
|
9323
10471
|
echo "passed: $PASS failed: $FAIL"
|