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.
Files changed (151) hide show
  1. package/README.md +125 -4
  2. package/bin/claude +389 -94
  3. package/bin/claude-accounts +276 -42
  4. package/bin/codex +69 -5
  5. package/bin/codex-accounts +33 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +81 -9
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +146 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/mcp_registry.py +230 -8
  59. package/lib/model_catalog.json +404 -0
  60. package/lib/model_catalog.py +201 -0
  61. package/lib/model_match.py +104 -0
  62. package/lib/report.py +8 -5
  63. package/lib/session_settings.py +123 -0
  64. package/package.json +2 -2
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  122. package/tests/fake_codex_tui.py +25 -0
  123. package/tests/fake_security.sh +24 -2
  124. package/tests/fixtures/cross_provider_cli.py +109 -0
  125. package/tests/run-tests.sh +1171 -23
  126. package/tests/test_autoresume.py +215 -8
  127. package/tests/test_autoresume_catalog.py +226 -0
  128. package/tests/test_autoresume_catalog_cli.py +185 -0
  129. package/tests/test_autoresume_codex_records.py +16 -0
  130. package/tests/test_autoresume_codex_trust.py +247 -0
  131. package/tests/test_autoresume_cross_guards.py +28 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +14 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_mcp_registry.py +234 -3
  151. package/tests/test_packaged_commands.py +41 -0
@@ -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
- trap 'rm -f "$reg"; exit 143' TERM
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 Keychain item under its FA() name ONLY
258
- # ($USER; Bun's "unknown" without it), then .credentials.json — nothing else.
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
- if ! security find-generic-password -a "${USER:-unknown}" -s "Claude Code-credentials-$h" -w >/dev/null 2>&1 \
261
- && [ ! -f "$CLAUDE_CONFIG_DIR/.credentials.json" ]; then
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
- sleep "${FAKE_SESSION_HOLD:-4}"
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
- unset CLAUDE_CONFIG_DIR CLAUDE_CODE_OAUTH_TOKEN CLAUDE_ACCOUNT CLAUDE_SHIM_ACTIVE 2>/dev/null || true
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
- : > "$ACC/.limits-kick"
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
- mkdir -p "$NPMROOT" "$PFX/bin"
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 - "$NPMROOT/package.json" <<'PY'
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
- out="$(env -i HOME="$HOME" PATH="$WORK/wrongbin:/usr/bin:/bin:/usr/sbin:/sbin" \
4979
- CLAUDE_ACCOUNTS_DIR="$ACC" CODEX_ACCOUNTS_DIR="$WORK/codex-accounts" \
4980
- "$NPMROOT/bin/$_cli" self-update 2>&1)"
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
- : > "$CX/.limits-kick"
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
- : > "$CX/.limits-kick" # re-arm the shim-kick throttle: no background limits racing these
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"