claude-multiacc 2.0.58 → 2.0.59

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