claude-multiacc 2.0.59 → 2.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +14 -1
  2. package/bin/claude +66 -10
  3. package/bin/claude-accounts +3 -0
  4. package/bin/codex +54 -5
  5. package/bin/codex-accounts +2 -0
  6. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  40. package/lib/common.sh +15 -0
  41. package/lib/mcp_registry.py +230 -8
  42. package/package.json +1 -1
  43. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  100. package/tests/run-tests.sh +143 -0
  101. package/tests/test_mcp_registry.py +234 -3
@@ -49,6 +49,18 @@ mkdir -p "$ACC/tmp" "$FAKEBIN"
49
49
  cat > "$FAKEBIN/claude" <<'EOF'
50
50
  #!/usr/bin/env bash
51
51
  # fake real claude for tests (not a multiacc shim)
52
+ # Global options may precede the subcommand, as commander allows (the operator's alias
53
+ # `claude --dangerously-skip-permissions`, the shim's own `--model X`): the mcp stand-in
54
+ # skips the two the suite types, and nothing else — `--resume mcp add x` is no mcp run.
55
+ _fa=("$@")
56
+ while [ "${#_fa[@]}" -gt 0 ]; do
57
+ case "${_fa[0]}" in
58
+ --dangerously-skip-permissions) _fa=("${_fa[@]:1}") ;;
59
+ --model) _fa=("${_fa[@]:2}") ;;
60
+ *) break ;;
61
+ esac
62
+ done
63
+ if [ "${_fa[0]:-}" = "mcp" ] && [ "${_fa[0]:-}" != "${1:-}" ]; then set -- "${_fa[@]}"; fi
52
64
  if [ "${1:-}" = "mcp" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
53
65
  # A stand-in for the real `claude mcp add|add-json|remove`: `-s user` edits the
54
66
  # config dir's .claude.json mcpServers, the default `local` scope edits
@@ -438,6 +450,11 @@ unset TMUX TMUX_PANE 2>/dev/null || true
438
450
  # everywhere; section 19a turns it on against its own roots (and the limits-pass backstop is
439
451
  # exercised with the switch on in tests/test_autoresume_catalog_cli.py).
440
452
  export CLAUDE_MULTIACC_MODEL_CATALOG=0 CODEX_MULTIACC_MODEL_CATALOG=0
453
+ # MCP adoption (lib/mcp_registry.py adopt) would take whatever the operator's real
454
+ # ~/.claude.json names — seeding copies it into every test account, this HOME being the
455
+ # operator's — into the test pool's registry. tests/test_mcp_registry.py covers adoption in
456
+ # disposable pools.
457
+ export CLAUDE_MULTIACC_MCP_ADOPT=0 CODEX_MULTIACC_MCP_ADOPT=0
441
458
 
442
459
  now="$(date +%s)"
443
460
 
@@ -5200,6 +5217,69 @@ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_ACCOUNT=acct-01 claude 2>&1)"
5200
5217
  check "the next launch on that account still runs" "CFG=acct-01" "$out"
5201
5218
  mcp_has acct-01 nested && t_fail "nested remove undone" "the next launch re-added nested" \
5202
5219
  || t_ok "a nested remove is not undone by the next launch"
5220
+ # GLOBAL OPTIONS before `mcp` (2026-10-22): the operator's alias claude='claude
5221
+ # --dangerously-skip-permissions' put a flag in front of every typed `claude mcp add`,
5222
+ # the mirror looked at $1 alone, and motionsites landed in 2 of 11 accounts. The shim
5223
+ # finds the subcommand the way commander does: a flag is skipped, a value option skips
5224
+ # its value, and an optional-value one (--resume) takes the next word as its own.
5225
+ : > "$PYLOG"
5226
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" claude --dangerously-skip-permissions mcp add -s user dsp -- echo d 2>&1)"
5227
+ rc=$?
5228
+ [ "$rc" = "0" ] && check "a flag before mcp: the stock add still answers like the real client" "Added stdio MCP server dsp" "$out" \
5229
+ || t_fail "flag-first add rc" "rc=$rc: $out"
5230
+ mcp_has acct-01 dsp && mcp_has acct-02 dsp && t_ok "'claude --dangerously-skip-permissions mcp add' lands in EVERY account" \
5231
+ || t_fail "flag-first mirror" "$out $(cat "$MPOOL/acct-01/.claude.json" "$MPOOL/acct-02/.claude.json")"
5232
+ grep -q " learn " "$PYLOG" && t_ok "a flag-first add is mirrored through learn" || t_fail "flag-first learn" "$(cat "$PYLOG")"
5233
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
5234
+ python3 -c 'import json,sys; d=json.loads(sys.argv[1]); s=d["mcpServers"].get("dsp") or {}; sys.exit(0 if s.get("command")=="echo" and s.get("args")==["d"] else 1)' "$out" \
5235
+ && t_ok "a flag-first add reaches the registry with the client's own block" || t_fail "flag-first registry" "$out"
5236
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude --dangerously-skip-permissions mcp remove -s user dsp 2>&1)"
5237
+ rc=$?
5238
+ [ "$rc" = "0" ] && t_ok "'claude --dangerously-skip-permissions mcp remove' exits 0" || t_fail "flag-first remove rc" "rc=$rc: $out"
5239
+ mcp_has acct-01 dsp || mcp_has acct-02 dsp \
5240
+ && t_fail "flag-first remove mirror" "dsp survives in an account" || t_ok "a flag-first remove drops the server from EVERY account"
5241
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
5242
+ python3 -c 'import json,sys; d=json.loads(sys.argv[1]); sys.exit(0 if "dsp" in d.get("retiredUser",[]) and "dsp" not in d.get("mcpServers",{}) else 1)' "$out" \
5243
+ && t_ok "a flag-first remove leaves a user-scope tombstone" || t_fail "flag-first tombstone" "$out"
5244
+ # nested (an agent inside a pooled session, alias or not): same parse, same mirror
5245
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_CONFIG_DIR="$MPOOL/acct-01" CLAUDE_SHIM_ACTIVE=1 claude --dangerously-skip-permissions mcp add -s user dspn -- echo n 2>&1)"
5246
+ rc=$?
5247
+ [ "$rc" = "0" ] && t_ok "a nested flag-first 'claude mcp add' exits 0" || t_fail "nested flag-first rc" "rc=$rc: $out"
5248
+ mcp_has acct-01 dspn && mcp_has acct-02 dspn && t_ok "a nested 'claude --dangerously-skip-permissions mcp add' lands in EVERY account" \
5249
+ || t_fail "nested flag-first mirror" "$out $(cat "$MPOOL/acct-02/.claude.json")"
5250
+ # a value option: --model's value is skipped, and add-json is a mutation like add
5251
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude --model opus mcp add-json -s user mj '{"type":"stdio","command":"echo","args":["j"],"env":{}}' 2>&1)"
5252
+ rc=$?
5253
+ [ "$rc" = "0" ] && t_ok "'claude --model opus mcp add-json' exits 0" || t_fail "model-first add-json rc" "rc=$rc: $out"
5254
+ mcp_has acct-01 mj && mcp_has acct-02 mj && t_ok "'claude --model opus mcp add-json' lands in EVERY account" \
5255
+ || t_fail "model-first mirror" "$out $(cat "$MPOOL/acct-01/.claude.json" "$MPOOL/acct-02/.claude.json")"
5256
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
5257
+ python3 -c 'import json,sys; d=json.loads(sys.argv[1]); s=d["mcpServers"].get("mj") or {}; sys.exit(0 if s.get("args")==["j"] else 1)' "$out" \
5258
+ && t_ok "a --model-first add-json reaches the registry" || t_fail "model-first registry" "$out"
5259
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_CONFIG_DIR="$MPOOL/acct-02" CLAUDE_SHIM_ACTIVE=1 claude --model opus mcp add-json -s user mjn '{"type":"stdio","command":"echo","args":["n"],"env":{}}' 2>&1)"
5260
+ mcp_has acct-01 mjn && mcp_has acct-02 mjn && t_ok "a nested 'claude --model opus mcp add-json' lands in EVERY account" \
5261
+ || t_fail "nested model-first mirror" "$out $(cat "$MPOOL/acct-01/.claude.json")"
5262
+ # NOT a mutation: --resume takes the next word (a session id or search term) as its
5263
+ # value, and -p's prompt is one word that merely reads "mcp add …" — the shim must run
5264
+ # no snapshot and no learn, and nothing reaches any account or the registry
5265
+ : > "$PYLOG"
5266
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" claude --resume mcp add rsm 2>&1 </dev/null)"
5267
+ rc=$?
5268
+ [ "$rc" = "0" ] && check "'claude --resume mcp add rsm' runs as an ordinary launch" "CFG=acct-01" "$out" \
5269
+ || t_fail "resume-first rc" "rc=$rc: $out"
5270
+ grep -Eq " (snapshot|learn) " "$PYLOG" && t_fail "resume-first mirror attempt" "$(cat "$PYLOG")" \
5271
+ || t_ok "'claude --resume mcp add' is no mcp mutation (no snapshot, no learn)"
5272
+ : > "$PYLOG"
5273
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" CLAUDE_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" claude -p "mcp add psm" 2>&1 </dev/null)"
5274
+ rc=$?
5275
+ [ "$rc" = "0" ] && check "'claude -p \"mcp add psm\"' runs as an ordinary print" "CFG=acct-01" "$out" \
5276
+ || t_fail "print-prompt rc" "rc=$rc: $out"
5277
+ grep -Eq " (snapshot|learn) " "$PYLOG" && t_fail "print-prompt mirror attempt" "$(cat "$PYLOG")" \
5278
+ || t_ok "a -p prompt reading 'mcp add' is no mcp mutation (no snapshot, no learn)"
5279
+ out="$(CLAUDE_ACCOUNTS_ROOT="$MPOOL" claude-accounts mcp list --json 2>&1)"
5280
+ mcp_has acct-01 rsm || mcp_has acct-01 psm || mcp_has acct-02 rsm || mcp_has acct-02 psm \
5281
+ || python3 -c 'import json,sys; d=json.loads(sys.argv[1]); a=set(d.get("mcpServers",{}))|set(d.get("retired",[]))|set(d.get("retiredUser",[])); sys.exit(0 if a & {"rsm","psm","add","mcp"} else 1)' "$out" \
5282
+ && t_fail "non-mutation leak" "$out" || t_ok "neither non-mutation reaches an account or the registry"
5203
5283
  # a CLAUDE_CONFIG_DIR OUTSIDE the pool is plain passthrough: no python, no mirror
5204
5284
  mkdir -p "$WORK/mcp-outside"
5205
5285
  : > "$PYLOG"
@@ -5789,6 +5869,18 @@ cat > "$FAKEBIN/codex" <<EOF
5789
5869
  #!/usr/bin/env bash
5790
5870
  # fake real codex for tests (not a shim)
5791
5871
  if [ "\${1:-}" = --help ]; then echo 'Usage: legacy codex [OPTIONS]'; exit 0; fi
5872
+ # Global options may precede the subcommand, as clap allows (the operator's alias
5873
+ # \`codex --dangerously-bypass-approvals-and-sandbox\`, -c k=v, -m model): the mcp
5874
+ # stand-in skips the ones the suite types.
5875
+ _fa=("\$@")
5876
+ while [ "\${#_fa[@]}" -gt 0 ]; do
5877
+ case "\${_fa[0]}" in
5878
+ --dangerously-bypass-approvals-and-sandbox) _fa=("\${_fa[@]:1}") ;;
5879
+ -c|-m) _fa=("\${_fa[@]:2}") ;;
5880
+ *) break ;;
5881
+ esac
5882
+ done
5883
+ if [ "\${_fa[0]:-}" = "mcp" ] && [ "\${_fa[0]:-}" != "\${1:-}" ]; then set -- "\${_fa[@]}"; fi
5792
5884
  if [ "\${1:-}" = "mcp" ] && [ -n "\${CODEX_HOME:-}" ]; then
5793
5885
  python3 "$FAKE_CODEX_MCP" "\$CODEX_HOME/config.toml" "\$@"
5794
5886
  rc=\$?
@@ -8011,6 +8103,57 @@ out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_ACCOUNT=acct-01 codex 2>&1)"
8011
8103
  check "codex: the next launch on that account still runs" "CFG=acct-01" "$out"
8012
8104
  cx_has acct-01 nested && t_fail "codex nested remove undone" "the next launch re-added nested" \
8013
8105
  || t_ok "codex: a nested remove is not undone by the next launch"
8106
+ # GLOBAL OPTIONS before `mcp` (2026-10-22, parity with 16e): the operator's alias
8107
+ # codex='codex --dangerously-bypass-approvals-and-sandbox' put a flag in front of every
8108
+ # typed `codex mcp add`. The shim finds the subcommand the way clap does: a flag is
8109
+ # skipped, -c/-m skip their value.
8110
+ : > "$PYLOG"
8111
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex --dangerously-bypass-approvals-and-sandbox mcp add dbx -- echo d 2>&1)"
8112
+ rc=$?
8113
+ [ "$rc" = "0" ] && check "codex: a flag before mcp: the stock add still answers like the real client" "Added global MCP server 'dbx'" "$out" \
8114
+ || t_fail "codex flag-first add rc" "rc=$rc: $out"
8115
+ cx_has acct-01 dbx && cx_has acct-02 dbx && t_ok "codex: 'codex --dangerously-bypass-approvals-and-sandbox mcp add' lands in EVERY account" \
8116
+ || t_fail "codex flag-first mirror" "$out $(cat "$CXP/acct-01/config.toml" "$CXP/acct-02/config.toml")"
8117
+ grep -q " learn " "$PYLOG" && t_ok "codex: a flag-first add is mirrored through learn" || t_fail "codex flag-first learn" "$(cat "$PYLOG")"
8118
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" codex-accounts mcp list --json 2>&1)"
8119
+ python3 -c 'import json,sys; d=json.loads(sys.argv[1]); s=d["mcpServers"].get("dbx") or {}; sys.exit(0 if s.get("command")=="echo" and s.get("args")==["d"] else 1)' "$out" \
8120
+ && t_ok "codex: a flag-first add reaches the registry" || t_fail "codex flag-first registry" "$out"
8121
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" codex -c a=b -m o3 mcp remove dbx 2>&1)"
8122
+ rc=$?
8123
+ [ "$rc" = "0" ] && t_ok "codex: 'codex -c a=b -m o3 mcp remove' exits 0" || t_fail "codex value-first remove rc" "rc=$rc: $out"
8124
+ cx_has acct-01 dbx || cx_has acct-02 dbx \
8125
+ && t_fail "codex value-first remove mirror" "dbx survives in an account" \
8126
+ || t_ok "codex: 'codex -c a=b -m o3 mcp remove' drops the server from EVERY account"
8127
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" codex-accounts mcp list --json 2>&1)"
8128
+ python3 -c 'import json,sys; d=json.loads(sys.argv[1]); sys.exit(0 if ("dbx" in d.get("retiredUser",[]) or "dbx" in d.get("retired",[])) and "dbx" not in d.get("mcpServers",{}) else 1)' "$out" \
8129
+ && t_ok "codex: a value-first remove is retired in the registry" || t_fail "codex value-first tombstone" "$out"
8130
+ # nested: the same two shapes from inside a pooled codex session
8131
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_HOME="$CXP/acct-01" CODEX_SHIM_ACTIVE=1 codex --dangerously-bypass-approvals-and-sandbox mcp add dbxn -- echo n 2>&1)"
8132
+ rc=$?
8133
+ [ "$rc" = "0" ] && t_ok "codex: a nested flag-first 'codex mcp add' exits 0" || t_fail "codex nested flag-first rc" "rc=$rc: $out"
8134
+ cx_has acct-01 dbxn && cx_has acct-02 dbxn && t_ok "codex: a nested flag-first add lands in EVERY account" \
8135
+ || t_fail "codex nested flag-first mirror" "$out $(cat "$CXP/acct-02/config.toml")"
8136
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_HOME="$CXP/acct-01" CODEX_SHIM_ACTIVE=1 codex -c a=b -m o3 mcp remove dbxn 2>&1)"
8137
+ rc=$?
8138
+ [ "$rc" = "0" ] && t_ok "codex: a nested 'codex -c a=b -m o3 mcp remove' exits 0" || t_fail "codex nested value-first remove rc" "rc=$rc: $out"
8139
+ cx_has acct-01 dbxn || cx_has acct-02 dbxn \
8140
+ && t_fail "codex nested value-first remove mirror" "dbxn survives in an account" \
8141
+ || t_ok "codex: a nested value-first remove drops the server from EVERY account"
8142
+ # NOT a mutation (parity with 16e's --resume / -p): a one-word prompt that reads
8143
+ # "mcp add …", and -i, whose variadic value takes every word up to the next option
8144
+ : > "$PYLOG"
8145
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex "mcp add psx" 2>&1 </dev/null)"
8146
+ rc=$?
8147
+ [ "$rc" = "0" ] && check "codex: a prompt reading 'mcp add' runs as an ordinary launch" "CFG=acct-01" "$out" \
8148
+ || t_fail "codex prompt rc" "rc=$rc: $out"
8149
+ out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_ACCOUNT=acct-01 CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex -i shot.png mcp add psx 2>&1 </dev/null)"
8150
+ rc=$?
8151
+ [ "$rc" = "0" ] && check "codex: 'codex -i shot.png mcp add psx' runs as an ordinary launch" "CFG=acct-01" "$out" \
8152
+ || t_fail "codex image-first rc" "rc=$rc: $out"
8153
+ grep -Eq " (snapshot|learn) " "$PYLOG" && t_fail "codex non-mutation mirror attempt" "$(cat "$PYLOG")" \
8154
+ || t_ok "codex: neither a prompt nor -i's words are an mcp mutation (no snapshot, no learn)"
8155
+ cx_has acct-01 psx || cx_has acct-02 psx && t_fail "codex non-mutation leak" "psx in an account" \
8156
+ || t_ok "codex: neither non-mutation reaches an account"
8014
8157
  mkdir -p "$WORK/cx-outside"
8015
8158
  : > "$PYLOG"
8016
8159
  out="$(CODEX_ACCOUNTS_ROOT="$CXP" CODEX_HOME="$WORK/cx-outside" CLAUDE_MULTIACC_PYTHON="$WORK/pylog.sh" codex mcp add outside -- echo o 2>&1)"
@@ -88,7 +88,7 @@ class EffectiveMergeTests(RegistryCase):
88
88
 
89
89
 
90
90
  class ClaudeReconcileTests(RegistryCase):
91
- def test_upsert_purges_tombstones_everywhere_and_keeps_extras(self):
91
+ def test_upsert_purges_tombstones_everywhere_and_adopts_extras(self):
92
92
  (self.root / "acct-01" / ".claude.json").write_text(json.dumps({
93
93
  "numStartups": 3,
94
94
  "mcpServers": {"adspower-local-api": STDIO, "mine": STDIO,
@@ -107,7 +107,10 @@ class ClaudeReconcileTests(RegistryCase):
107
107
  {"type": "stdio", "command": "npx",
108
108
  "args": ["-y", "appinspire-mcp@latest", "serve"], "env": {"K": "V"}})
109
109
  self.assertEqual(sorted(doc["projects"]["/p"]["mcpServers"]), ["keep"])
110
- self.assertEqual(sorted(self.claude_doc("acct-02")["mcpServers"]), ["appinspire-mcp"])
110
+ # an extra no section names is adopted: every account gets it (2026-10-22)
111
+ self.assertEqual(sorted(self.claude_doc("acct-02")["mcpServers"]), ["appinspire-mcp", "mine"])
112
+ self.assertEqual(sorted(self.claude_doc("acct-02")["projects"]["/p"]["mcpServers"]), ["keep"])
113
+ self.assertEqual(sorted(self.registry()["mcpServers"]), ["appinspire-mcp", "mine"])
111
114
  self.assertEqual(self.registry()["retired"], ["adspower-local-api"])
112
115
  self.assertTrue((self.root / "acct-01" / ".mcp-applied").exists())
113
116
 
@@ -514,7 +517,9 @@ class CodexReconcileTests(RegistryCase):
514
517
  self.assertEqual(rc, 0)
515
518
  doc = self.parsed()
516
519
  self.assertEqual(doc["mcp_servers"]["old"]["command"], "new")
517
- self.assertEqual(doc["mcp_servers"]["other"], {"command": "c"})
520
+ # the extra is adopted, so it now comes back in the registry's own translation
521
+ self.assertEqual(doc["mcp_servers"]["other"]["command"], "c")
522
+ self.assertIn("other", self.registry()["mcpServers"])
518
523
 
519
524
  def test_an_unsupported_layout_is_refused_and_the_file_survives(self):
520
525
  original = 'mcp_servers = { old = { command = "a" } }\n'
@@ -593,5 +598,231 @@ class CodexReconcileTests(RegistryCase):
593
598
  self.assertIn("acct-01: unchanged", out)
594
599
 
595
600
 
601
+ class AdoptTests(RegistryCase):
602
+ """A server some route around the shims' mirror put into ONE account (2026-10-22: the
603
+ operator's `alias claude='claude --dangerously-skip-permissions'` hid every typed
604
+ `claude mcp add` from it) is adopted by the next reconcile, and every account gets it."""
605
+
606
+ MOTION = {"type": "http", "url": "https://xgdzyqfalbibzelpdpvr.supabase.co/functions/v1/mcp"}
607
+
608
+ def write(self, account, doc):
609
+ (self.root / account).mkdir(exist_ok=True)
610
+ (self.root / account / ".claude.json").write_text(json.dumps(doc))
611
+
612
+ def test_one_accounts_server_reaches_every_account_from_a_single_account_apply(self):
613
+ self.write("acct-01", {"mcpServers": {"motionsites": self.MOTION}})
614
+ self.write("acct-02", {"mcpServers": {}})
615
+ # what the shim runs before exec, for the picked account alone
616
+ rc, _, _ = self.run_cli("claude", "apply", "--account-dir", self.acct("acct-01"),
617
+ "--fail-open", "--quiet")
618
+ self.assertEqual(rc, 0)
619
+ self.assertEqual(self.registry()["mcpServers"], {"motionsites": self.MOTION})
620
+ self.assertEqual(self.claude_doc("acct-02")["mcpServers"], {"motionsites": self.MOTION})
621
+ self.assertIn('mcp adopt "motionsites" into the registry',
622
+ (self.root / "ops.log").read_text())
623
+ # nothing left to adopt: a second pass changes nothing
624
+ before = (self.root / "mcp-servers.json").read_text()
625
+ rc, out, _ = self.run_cli("claude", "apply", "--all")
626
+ self.assertEqual((rc, before), (0, (self.root / "mcp-servers.json").read_text()))
627
+ self.assertNotIn("adopted", out)
628
+
629
+ def test_the_definition_most_accounts_hold_wins(self):
630
+ headless = {"type": "stdio", "command": "npx", "env": {},
631
+ "args": ["-y", "@playwright/mcp@latest", "--headless"]}
632
+ plain = {"type": "stdio", "command": "npx", "args": ["-y", "@playwright/mcp@latest"],
633
+ "env": {}}
634
+ (self.root / "accounts.json").write_text(json.dumps({"version": 1, "accounts": [
635
+ {"id": "acct-01"}, {"id": "acct-02"}, {"id": "acct-03"}]}))
636
+ self.write("acct-01", {"mcpServers": {"playwright": plain}}) # the newest file
637
+ self.write("acct-02", {"mcpServers": {"playwright": headless}})
638
+ self.write("acct-03", {"mcpServers": {"playwright": headless}})
639
+ os.utime(self.root / "acct-02" / ".claude.json", (1, 1))
640
+ os.utime(self.root / "acct-03" / ".claude.json", (1, 1))
641
+ rc, out, _ = self.run_cli("claude", "apply", "--all")
642
+ self.assertEqual(rc, 0)
643
+ self.assertIn("the definition 2 of 3 accounts hold", out)
644
+ for account in ("acct-01", "acct-02", "acct-03"):
645
+ self.assertEqual(self.claude_doc(account)["mcpServers"]["playwright"], headless)
646
+
647
+ def test_a_tie_goes_to_the_newest_config(self):
648
+ old = {"type": "http", "url": "https://old.example/mcp"}
649
+ new = {"type": "http", "url": "https://new.example/mcp"}
650
+ self.write("acct-01", {"mcpServers": {"x": old}})
651
+ self.write("acct-02", {"mcpServers": {"x": new}})
652
+ os.utime(self.root / "acct-01" / ".claude.json", (1, 1))
653
+ self.run_cli("claude", "apply", "--all")
654
+ self.assertEqual(self.registry()["mcpServers"]["x"], new)
655
+
656
+ def test_retired_servers_are_never_adopted(self):
657
+ self.run_cli("claude", "add-json", "gone", json.dumps(HTTP))
658
+ self.run_cli("claude", "remove", "gone")
659
+ self.run_cli("claude", "add-json", "useronly", json.dumps(HTTP))
660
+ self.run_cli("claude", "remove", "useronly", "--user-only")
661
+ self.write("acct-01", {"mcpServers": {"gone": HTTP, "useronly": HTTP},
662
+ "projects": {"/p": {"mcpServers": {"gone": HTTP}}}})
663
+ rc, _, _ = self.run_cli("claude", "apply", "--all")
664
+ self.assertEqual(rc, 0)
665
+ self.assertEqual(self.registry()["mcpServers"], {})
666
+ self.assertEqual(self.claude_doc("acct-01")["mcpServers"], {})
667
+ self.assertEqual(self.claude_doc("acct-01")["projects"]["/p"]["mcpServers"], {})
668
+
669
+ def test_an_overlay_owners_servers_and_tombstones_stay_out_of_the_registry(self):
670
+ self.run_cli("claude", "import-local", "--owner", "app-robot",
671
+ stdin=json.dumps({"mcpServers": {"robot": STDIO}, "retired": ["dropped"]}))
672
+ self.write("acct-01", {"mcpServers": {"robot": STDIO, "dropped": HTTP}})
673
+ self.run_cli("claude", "apply", "--all")
674
+ self.assertFalse((self.root / "mcp-servers.json").exists()) # nothing adopted
675
+ self.assertEqual(sorted(self.claude_doc("acct-02")["mcpServers"]), ["robot"])
676
+
677
+ def test_project_servers_are_adopted_but_mcpjson_approvals_are_not(self):
678
+ # one account's click on a project's .mcp.json server must not override another
679
+ # account's rejection everywhere (disabledMcpjsonServers)
680
+ self.write("acct-01", {"projects": {"/p": {"mcpServers": {"local": STDIO},
681
+ "enabledMcpjsonServers": ["team"]}}})
682
+ self.write("acct-02", {"projects": {"/p": {"disabledMcpjsonServers": ["team"]}}})
683
+ self.run_cli("claude", "apply", "--all")
684
+ project = self.claude_doc("acct-02")["projects"]["/p"]
685
+ self.assertEqual(project["mcpServers"], {"local": STDIO})
686
+ self.assertNotIn("team", project.get("enabledMcpjsonServers", []))
687
+ self.assertEqual(project["disabledMcpjsonServers"], ["team"])
688
+
689
+ def test_a_single_account_apply_chooses_what_every_account_would(self):
690
+ old = {"type": "http", "url": "https://old.example/mcp"}
691
+ new = {"type": "http", "url": "https://new.example/mcp"}
692
+ (self.root / "accounts.json").write_text(json.dumps({"version": 1, "accounts": [
693
+ {"id": "acct-01"}, {"id": "acct-02"}, {"id": "acct-03"}]}))
694
+ self.write("acct-01", {"mcpServers": {"x": old}}) # the account launched first
695
+ self.write("acct-02", {"mcpServers": {"x": new}})
696
+ self.write("acct-03", {"mcpServers": {"x": new}})
697
+ self.run_cli("claude", "apply", "--account-dir", self.acct("acct-01"),
698
+ "--fail-open", "--quiet")
699
+ for account in ("acct-01", "acct-02", "acct-03"):
700
+ self.assertEqual(self.claude_doc(account)["mcpServers"]["x"], new)
701
+
702
+ def test_an_account_being_seeded_gets_the_registry_too(self):
703
+ # seeding applies to a dir the manifest does not list yet
704
+ self.run_cli("claude", "add-json", "reg1", json.dumps(HTTP))
705
+ self.write("acct-01", {"mcpServers": {"reg1": HTTP, "newone": self.MOTION}})
706
+ self.write("acct-07", {"mcpServers": {}})
707
+ self.run_cli("claude", "apply", "--account-dir", self.acct("acct-07"))
708
+ self.assertEqual(sorted(self.claude_doc("acct-07")["mcpServers"]), ["newone", "reg1"])
709
+
710
+ def test_a_server_carrying_values_stays_on_this_machine(self):
711
+ secret = {"type": "http", "url": "https://api.example/mcp",
712
+ "headers": {"Authorization": "Bearer abc"}}
713
+ referenced = {"type": "http", "url": "https://api.example/mcp2",
714
+ "headers": {"Authorization": "${API_TOKEN}"}}
715
+ self.write("acct-01", {"mcpServers": {"secret": secret, "referenced": referenced}})
716
+ self.run_cli("claude", "apply", "--all")
717
+ self.assertEqual(list(self.registry()["mcpServers"]), ["referenced"])
718
+ self.assertEqual(self.overlay()["owners"]["local"]["mcpServers"], {"secret": secret})
719
+ self.assertEqual(self.claude_doc("acct-02")["mcpServers"]["secret"], secret)
720
+
721
+ def test_an_overlay_owners_drop_stays_retired_across_imports(self):
722
+ self.write("acct-01", {"mcpServers": {}})
723
+ for payload in ({"mcpServers": {"robot": STDIO}}, {"mcpServers": {}},
724
+ {"mcpServers": {"other": HTTP}}):
725
+ self.run_cli("claude", "import-local", "--owner", "app-robot", stdin=json.dumps(payload))
726
+ self.assertIn("robot", self.overlay()["owners"]["app-robot"]["retired"])
727
+ # a copy that never went through the ledger (a dir copied from an old account)
728
+ self.write("acct-02", {"mcpServers": {"robot": STDIO}})
729
+ self.run_cli("claude", "apply", "--all")
730
+ self.assertFalse((self.root / "mcp-servers.json").exists())
731
+ self.assertNotIn("robot", self.claude_doc("acct-02")["mcpServers"])
732
+ # publishing it again lifts the tombstone
733
+ self.run_cli("claude", "import-local", "--owner", "app-robot",
734
+ stdin=json.dumps({"mcpServers": {"robot": STDIO}}))
735
+ self.assertNotIn("robot", self.overlay()["owners"]["app-robot"]["retired"])
736
+
737
+ def test_a_replica_adopts_into_its_own_overlay(self):
738
+ (self.root / "sync-role").write_text("replica\n")
739
+ self.write("acct-01", {"mcpServers": {"motionsites": self.MOTION}})
740
+ self.run_cli("claude", "apply", "--all")
741
+ self.assertFalse((self.root / "mcp-servers.json").exists())
742
+ self.assertEqual(self.overlay()["owners"]["local"]["mcpServers"],
743
+ {"motionsites": self.MOTION})
744
+ self.assertEqual(self.claude_doc("acct-02")["mcpServers"], {"motionsites": self.MOTION})
745
+
746
+ def test_a_copy_the_pool_wrote_itself_is_never_adopted_back(self):
747
+ # README's recovery for a stray synced entry: drop it WITHOUT a tombstone. The copies
748
+ # the reconcile wrote stay in the accounts, and must not come back as "new" servers.
749
+ self.write("acct-01", {"mcpServers": {}})
750
+ self.write("acct-02", {"mcpServers": {}})
751
+ self.run_cli("claude", "add-json", "stray", json.dumps(HTTP))
752
+ self.assertIn("stray", self.claude_doc("acct-02")["mcpServers"])
753
+ registry = self.registry()
754
+ del registry["mcpServers"]["stray"]
755
+ (self.root / "mcp-servers.json").write_text(json.dumps(registry))
756
+ self.run_cli("claude", "apply", "--all")
757
+ self.assertNotIn("stray", self.registry()["mcpServers"])
758
+ self.assertNotIn("adopt", (self.root / "ops.log").read_text())
759
+
760
+ def test_a_vanished_overlay_owners_server_stays_out_of_the_synced_registry(self):
761
+ # app-robot publishes a machine-specific appinspire-mcp into the overlay; were the
762
+ # overlay ever gone, every account's copy must not be pushed to the whole fleet
763
+ self.write("acct-01", {"mcpServers": {}})
764
+ self.run_cli("claude", "import-local", "--owner", "app-robot",
765
+ stdin=json.dumps({"mcpServers": {"appinspire-mcp": STDIO}}))
766
+ self.assertIn("appinspire-mcp", self.claude_doc("acct-01")["mcpServers"])
767
+ (self.root / "mcp-servers.local.json").unlink()
768
+ self.run_cli("claude", "apply", "--all")
769
+ self.assertFalse((self.root / "mcp-servers.json").exists())
770
+ ledger = json.loads((self.root / "acct-01" / ".mcp-managed.json").read_text())
771
+ self.assertIn("appinspire-mcp", ledger["user"])
772
+
773
+ def test_the_kill_switch_leaves_extras_where_they_are(self):
774
+ self.write("acct-01", {"mcpServers": {"motionsites": self.MOTION}})
775
+ with mock_env(CLAUDE_MULTIACC_MCP_ADOPT="0"):
776
+ self.run_cli("claude", "apply", "--all")
777
+ self.assertFalse((self.root / "mcp-servers.json").exists())
778
+ self.assertFalse((self.root / "acct-02" / ".claude.json").exists())
779
+
780
+ def test_an_unreadable_account_never_stops_the_others(self):
781
+ (self.root / "acct-01" / ".claude.json").write_text("{not json")
782
+ self.write("acct-02", {"mcpServers": {"motionsites": self.MOTION}})
783
+ rc, _, _ = self.run_cli("claude", "apply", "--all")
784
+ self.assertEqual(rc, R.EXIT_PARTIAL) # acct-01 itself still fails to reconcile
785
+ self.assertEqual(self.registry()["mcpServers"], {"motionsites": self.MOTION})
786
+
787
+ @unittest.skipUnless(HAS_TOMLLIB, "codex reconcile needs tomllib (Python 3.11+)")
788
+ def test_a_codex_table_the_registry_cannot_express_as_it_is_is_left_alone(self):
789
+ text = ('[mcp_servers.odd]\ncommand = "c"\nenabled = false\n'
790
+ 'env_vars = [{ name = "K", source = "remote" }]\n')
791
+ (self.root / "acct-01" / "config.toml").write_text(text)
792
+ rc, _, err = self.run_cli("codex", "apply", "--all")
793
+ self.assertEqual(rc, 0)
794
+ self.assertEqual((self.root / "acct-01" / "config.toml").read_text(), text)
795
+ self.assertFalse((self.root / "mcp-servers.json").exists())
796
+
797
+ @unittest.skipUnless(HAS_TOMLLIB, "codex reconcile needs tomllib (Python 3.11+)")
798
+ def test_a_codex_accounts_server_is_adopted_for_every_codex_account(self):
799
+ (self.root / "acct-01" / "config.toml").write_text(
800
+ '[mcp_servers.motionsites]\nurl = "https://xgdzyqfalbibzelpdpvr.supabase.co/functions/v1/mcp"\n')
801
+ rc, _, _ = self.run_cli("codex", "apply", "--all")
802
+ self.assertEqual(rc, 0)
803
+ import tomllib
804
+ doc = tomllib.loads((self.root / "acct-02" / "config.toml").read_text())
805
+ self.assertEqual(doc["mcp_servers"]["motionsites"]["url"], self.MOTION["url"])
806
+ # and the round trip is stable: the next pass rewrites nothing
807
+ text = (self.root / "acct-01" / "config.toml").read_text()
808
+ rc, out, _ = self.run_cli("codex", "apply", "--all")
809
+ self.assertIn("acct-01: unchanged", out)
810
+ self.assertEqual(text, (self.root / "acct-01" / "config.toml").read_text())
811
+
812
+
813
+ @contextlib.contextmanager
814
+ def mock_env(**values):
815
+ saved = {key: os.environ.get(key) for key in values}
816
+ os.environ.update(values)
817
+ try:
818
+ yield
819
+ finally:
820
+ for key, value in saved.items():
821
+ if value is None:
822
+ os.environ.pop(key, None)
823
+ else:
824
+ os.environ[key] = value
825
+
826
+
596
827
  if __name__ == "__main__":
597
828
  unittest.main()